Cristhian Villegas
Backend10 min read2 views

Go Course #4: Functions and Error Handling in Go

Go Course #4: Functions and Error Handling in Go

Introduction: functions and errors in Go

Welcome to Part 4 of the Go course for beginners. In the previous article you learned how to control program flow with if, switch, and for. Now we are going to learn how to organize your code into reusable functions and handle errors the idiomatic Go way.

Go Logo

Functions are the fundamental building blocks of any Go program. But what makes Go truly special is its unique approach to error handling: instead of exceptions, Go uses multiple return values and the if err != nil pattern. This approach is explicit, predictable, and forces you to think about what can go wrong at every step.

Basic function syntax

A function in Go is declared with the func keyword, followed by the name, parameters in parentheses, and the return type.

go
1package main
2
3import "fmt"
4
5// Function without return value
6func greet(name string) {
7    fmt.Println("Hello,", name)
8}
9
10// Function with return value
11func add(a int, b int) int {
12    return a + b
13}
14
15// Parameters of the same type can be shortened
16func multiply(a, b int) int {
17    return a * b
18}
19
20func main() {
21    greet("Alice")
22    result := add(5, 3)
23    fmt.Println("5 + 3 =", result)
24    fmt.Println("4 * 7 =", multiply(4, 7))
25}
Note: In Go, functions that start with an uppercase letter are exported (visible from other packages). Functions that start with a lowercase letter are private to the package. This also applies to variables, types, and constants.

Multiple return values: Go's signature feature

One of Go's most distinctive features is that functions can return multiple values. This is fundamental for error handling.

go
1package main
2
3import (
4    "errors"
5    "fmt"
6)
7
8// Returns two values: result and error
9func divide(a, b float64) (float64, error) {
10    if b == 0 {
11        return 0, errors.New("cannot divide by zero")
12    }
13    return a / b, nil
14}
15
16func main() {
17    result, err := divide(10, 3)
18    if err != nil {
19        fmt.Println("Error:", err)
20        return
21    }
22    fmt.Printf("10 / 3 = %.2f
23", result)
24
25    result, err = divide(10, 0)
26    if err != nil {
27        fmt.Println("Error:", err)
28        return
29    }
30    fmt.Printf("10 / 0 = %.2f
31", result)
32}

Another practical example: a function that searches for an element in a slice and returns its position.

go
1package main
2
3import "fmt"
4
5func search(names []string, target string) (int, bool) {
6    for i, name := range names {
7        if name == target {
8            return i, true
9        }
10    }
11    return -1, false
12}
13
14func main() {
15    list := []string{"Alice", "Bob", "Carol", "Dave"}
16
17    if pos, found := search(list, "Carol"); found {
18        fmt.Printf("Carol is at position %d
19", pos)
20    } else {
21        fmt.Println("Carol was not found")
22    }
23}
Tip: If you do not need one of the returned values, use the blank identifier _ to discard it: _, err := divide(10, 0).

Named return values

Go allows you to name the return values in the function signature. This declares them as local variables and enables using return without arguments (naked return).

go
1package main
2
3import "fmt"
4
5func calculateStats(numbers []float64) (sum, average float64, count int) {
6    count = len(numbers)
7    if count == 0 {
8        return // returns zero values for each type
9    }
10
11    for _, n := range numbers {
12        sum += n
13    }
14    average = sum / float64(count)
15    return // returns sum, average, count
16}
17
18func main() {
19    data := []float64{85.5, 92.0, 78.3, 95.1, 88.7}
20    sum, average, count := calculateStats(data)
21
22    fmt.Printf("Count: %d
23", count)
24    fmt.Printf("Sum: %.2f
25", sum)
26    fmt.Printf("Average: %.2f
27", average)
28}
Warning: Named return values are useful in short functions, but in longer functions they can hurt readability. The Go team recommends using them primarily to document the meaning of return values.

Variadic functions

A variadic function accepts a variable number of arguments of the same type. It is declared with ... before the type of the last parameter.

go
1package main
2
3import "fmt"
4
5func sumAll(numbers ...int) int {
6    total := 0
7    for _, n := range numbers {
8        total += n
9    }
10    return total
11}
12
13func printLine(separator string, values ...string) {
14    for i, v := range values {
15        if i > 0 {
16            fmt.Print(separator)
17        }
18        fmt.Print(v)
19    }
20    fmt.Println()
21}
22
23func main() {
24    fmt.Println(sumAll(1, 2, 3))          // 6
25    fmt.Println(sumAll(10, 20, 30, 40))   // 100
26
27    // Pass a slice to a variadic function
28    nums := []int{5, 10, 15}
29    fmt.Println(sumAll(nums...))           // 30
30
31    printLine(" - ", "Go", "is", "awesome") // Go - is - awesome
32}
Note: The fmt.Println function you have been using throughout the course is, in fact, a variadic function. Its signature is func Println(a ...any) (n int, err error).

Anonymous functions and closures

Go supports anonymous functions (functions without a name) that can be assigned to variables or executed immediately. When an anonymous function captures variables from its surrounding scope, it becomes a closure.

go
1package main
2
3import "fmt"
4
5func main() {
6    // Anonymous function assigned to a variable
7    double := func(n int) int {
8        return n * 2
9    }
10    fmt.Println(double(5)) // 10
11
12    // Immediately invoked anonymous function (IIFE)
13    result := func(a, b int) int {
14        return a + b
15    }(3, 7)
16    fmt.Println(result) // 10
17
18    // Closure: captures the counter variable
19    counter := 0
20    increment := func() int {
21        counter++
22        return counter
23    }
24
25    fmt.Println(increment()) // 1
26    fmt.Println(increment()) // 2
27    fmt.Println(increment()) // 3
28    fmt.Println("Final counter:", counter) // 3
29}

A practical example of closures: an ID generator.

go
1package main
2
3import "fmt"
4
5func createIDGenerator(prefix string) func() string {
6    id := 0
7    return func() string {
8        id++
9        return fmt.Sprintf("%s-%04d", prefix, id)
10    }
11}
12
13func main() {
14    userGen := createIDGenerator("USR")
15    orderGen := createIDGenerator("ORD")
16
17    fmt.Println(userGen())  // USR-0001
18    fmt.Println(userGen())  // USR-0002
19    fmt.Println(orderGen()) // ORD-0001
20    fmt.Println(userGen())  // USR-0003
21    fmt.Println(orderGen()) // ORD-0002
22}

defer, panic, and recover

Go has three special mechanisms to control flow in exceptional situations.

defer postpones the execution of a function until the surrounding function returns. It is ideal for releasing resources.

go
1package main
2
3import (
4    "fmt"
5    "os"
6)
7
8func readFile(name string) {
9    file, err := os.Open(name)
10    if err != nil {
11        fmt.Println("Error opening:", err)
12        return
13    }
14    defer file.Close() // Runs when the function exits
15
16    fmt.Println("File opened successfully")
17    // ... process the file ...
18    // file.Close() is called automatically at the end
19}
20
21func main() {
22    // Defers execute in LIFO order (last in, first out)
23    defer fmt.Println("1 - First deferred, last to run")
24    defer fmt.Println("2 - Second deferred")
25    defer fmt.Println("3 - Third deferred, first to run")
26
27    fmt.Println("Main code")
28}
bash
1Main code
23 - Third deferred, first to run
32 - Second deferred
41 - First deferred, last to run

panic stops normal program execution. recover allows you to recover from a panic inside a defer.

go
1package main
2
3import "fmt"
4
5func riskyOperation() {
6    defer func() {
7        if r := recover(); r != nil {
8            fmt.Println("Recovered from panic:", r)
9        }
10    }()
11
12    fmt.Println("Starting operation...")
13    panic("something went terribly wrong")
14    fmt.Println("This line never executes")
15}
16
17func main() {
18    riskyOperation()
19    fmt.Println("Program continues after recovered panic")
20}
Warning: In Go, panic and recover are NOT the equivalent of try/catch from other languages. You should only use panic for truly unrecoverable errors (like corrupted program state). For normal errors, always use the error return pattern.

The if err != nil pattern: error handling in Go

Error handling in Go is explicit and based on returning values of type error. This is the most important pattern you need to master.

go
1package main
2
3import (
4    "errors"
5    "fmt"
6    "strconv"
7)
8
9func convertToAge(text string) (int, error) {
10    age, err := strconv.Atoi(text)
11    if err != nil {
12        return 0, fmt.Errorf("invalid value '%s': %w", text, err)
13    }
14    if age < 0 || age > 150 {
15        return 0, fmt.Errorf("age out of range: %d", age)
16    }
17    return age, nil
18}
19
20func main() {
21    inputs := []string{"25", "abc", "-5", "200", "42"}
22
23    for _, input := range inputs {
24        age, err := convertToAge(input)
25        if err != nil {
26            fmt.Printf("Error with '%s': %s
27", input, err)
28            continue
29        }
30        fmt.Printf("Valid age: %d
31", age)
32    }
33}

errors.New creates simple errors. fmt.Errorf creates formatted errors. The %w verb wraps the original error to maintain the error chain.

go
1package main
2
3import (
4    "errors"
5    "fmt"
6)
7
8// Custom errors as package-level variables (sentinel errors)
9var (
10    ErrNotFound    = errors.New("resource not found")
11    ErrUnauthorized = errors.New("unauthorized")
12    ErrNoConnection = errors.New("no database connection")
13)
14
15func findUser(id int) (string, error) {
16    if id <= 0 {
17        return "", fmt.Errorf("findUser: invalid id %d: %w", id, ErrNotFound)
18    }
19    // Simulation: only user with id 1 exists
20    if id != 1 {
21        return "", fmt.Errorf("findUser: id %d: %w", id, ErrNotFound)
22    }
23    return "Alice Johnson", nil
24}
25
26func main() {
27    name, err := findUser(99)
28    if err != nil {
29        // errors.Is checks if the error (or any wrapped error) matches
30        if errors.Is(err, ErrNotFound) {
31            fmt.Println("User does not exist:", err)
32        } else {
33            fmt.Println("Unexpected error:", err)
34        }
35        return
36    }
37    fmt.Println("User found:", name)
38}

Custom error types

For more complex errors, you can create types that implement the error interface (which only requires the Error() string method).

go
1package main
2
3import (
4    "errors"
5    "fmt"
6)
7
8// Custom error type
9type ValidationError struct {
10    Field   string
11    Message string
12}
13
14func (e *ValidationError) Error() string {
15    return fmt.Sprintf("validation failed on '%s': %s", e.Field, e.Message)
16}
17
18func validateUser(name string, age int) error {
19    if name == "" {
20        return &ValidationError{Field: "name", Message: "cannot be empty"}
21    }
22    if age < 0 || age > 150 {
23        return &ValidationError{Field: "age", Message: "must be between 0 and 150"}
24    }
25    return nil
26}
27
28func main() {
29    err := validateUser("", 25)
30    if err != nil {
31        // errors.As extracts the concrete type from the error
32        var valErr *ValidationError
33        if errors.As(err, &valErr) {
34            fmt.Printf("Field with error: %s
35", valErr.Field)
36            fmt.Printf("Detail: %s
37", valErr.Message)
38        }
39        return
40    }
41
42    err = validateUser("Ana", -5)
43    if err != nil {
44        fmt.Println(err)
45    }
46}
Tip: Use errors.Is to compare errors by value (like sentinel errors such as ErrNotFound) and errors.As to compare by type (like *ValidationError). Both functions traverse the wrapped error chain automatically.

Best practices for error handling

Error handling in Go has clear conventions you should follow:

  • Always check errors: never ignore a return value of type error.
  • Add context: use fmt.Errorf with %w to wrap errors with additional information.
  • Sentinel errors: define known errors as package-level variables so consumers can use errors.Is.
  • Do not use panic for normal errors: reserve it for truly unrecoverable situations.
  • Return errors, do not print them: let the caller decide what to do with the error.
go
1// BAD: printing and continuing
2func process(data string) int {
3    result, err := convert(data)
4    if err != nil {
5        fmt.Println("Error:", err) // Caller does not know it failed
6        return 0
7    }
8    return result
9}
10
11// GOOD: returning the error with context
12func process(data string) (int, error) {
13    result, err := convert(data)
14    if err != nil {
15        return 0, fmt.Errorf("process '%s': %w", data, err)
16    }
17    return result, nil
18}

Summary and next article

In this article you learned the pillars of Go programming:

  • Functions: basic syntax, shortened parameters, and uppercase export convention
  • Multiple return values: Go's most distinctive feature, essential for error handling
  • Named return values: useful for documentation and in short functions
  • Variadic functions: accept a variable number of arguments with ...
  • Anonymous functions and closures: capture environment variables to create generators and callbacks
  • defer, panic, recover: for releasing resources and handling critical situations
  • The if err != nil pattern: the explicit error handling that defines Go
  • Custom error types: types implementing the error interface with errors.Is and errors.As

In the next article (Part 5) we will learn about structs and interfaces: how to define composite types, attach methods to them, and use interfaces to write flexible and decoupled code.

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles