Skip to content
Codeloom
Go

Functions in Go: Declarations, Multiple Returns, and Errors

A complete guide to Go functions — declarations, parameters, multiple and named returns, the error idiom that defines Go, variadic parameters, and functions as values.

·10 min read · By Codeloom
Beginner 11 min read

What you'll learn

  • How to declare a function with parameters and a return type
  • How Go returns multiple values, and why that shapes the language
  • The error return convention every Go program uses
  • Named return values and naked returns — when they help, when they hurt
  • Variadic parameters with ...T
  • Functions as values and how to pass them around

Prerequisites

Functions are the workhorse of Go. The language has very little ceremony around them, but several conventions — multiple return values, errors as values, exported vs unexported names — shape every Go program you’ll ever read. This post covers the syntax in full and the idioms that go with it.

Declaring a function

The func keyword introduces a function, followed by a name, a parenthesised parameter list with types, and an optional return type:

func greet(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

greet("Alice")
// output: Hello, Alice!

A function with no parameters and no return is the smallest form:

func banner() {
    fmt.Println(strings.Repeat("=", 40))
}

A function that returns a value declares its return type after the parameters:

func add(a int, b int) int {
    return a + b
}

fmt.Println(add(3, 4))
// output: 7

When consecutive parameters share a type, you can collapse them:

func add(a, b int) int {
    return a + b
}

The signature reads left to right exactly as you would say it: “function add takes a and b, both int, and returns an int.”

Exported vs unexported

A function name starting with a capital letter is exported — visible outside its package. A name starting with a lowercase letter is unexported — visible only within the package that declares it.

func PublicHelper() { ... }    // callable from any package that imports this one
func privateHelper() { ... }   // package-private

This is the only visibility mechanism in Go. There is no public, private, or protected keyword. The first letter of the name does the work.

Multiple return values

Go functions can return more than one value, and this isn’t a niche feature — it’s how the language handles errors, secondary results, and unpacking. The classic example is a function that returns a value alongside an error:

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("divide by zero")
    }
    return a / b, nil
}

The caller receives both values and decides how to handle them:

result, err := divide(10, 2)
if err != nil {
    fmt.Println("error:", err)
    return
}
fmt.Println("result:", result)
// output: result: 5

If you only want one of the values, use _ (the blank identifier) to discard the other:

result, _ := divide(10, 2)        // ignore the error (don't do this in real code)
_, err := divide(10, 0)            // only care if it failed

Many standard-library calls return a value plus an error this way: os.Open, strconv.Atoi, http.Get, json.Marshal. You will write hundreds of result, err := ... lines in any real Go program.

The error convention

Go has no exceptions in the Java/Python sense. Instead, errors are values returned from functions, and you handle them explicitly. The convention is:

  1. The error is the last return value.
  2. Its type is the built-in interface error.
  3. A nil error means success. A non-nil error means failure, and the other return values should be treated as undefined.
func loadConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, err
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return Config{}, err
    }
    return cfg, nil
}

The repeated if err != nil { return ..., err } pattern is famous and intentional. Yes, it’s verbose. The trade-off is that every error appears in the code path. You can’t accidentally ignore one. After a few weeks of writing Go, the pattern reads as a single line.

To create an error, use one of:

errors.New("something went wrong")
fmt.Errorf("failed to open %s: %w", path, err)

fmt.Errorf with %w wraps an underlying error so callers can inspect it with errors.Is and errors.As. We cover error wrapping in detail later in the series.

Named return values

You can name return values in the function signature. The names become pre-declared variables inside the function, initialised to their zero values:

func split(sum int) (x, y int) {
    x = sum * 4 / 9
    y = sum - x
    return
}

A return statement with no values — a naked return — sends back the current values of the named results. It saves a few characters at the cost of clarity.

Used well, named returns document what each value means in the signature:

func divmod(a, b int) (quotient, remainder int) {
    quotient = a / b
    remainder = a % b
    return
}

Used badly, in a 60-line function, naked returns become a hazard — the reader has to scroll to remember what variables you’re returning. Convention: use named returns when the signature is self-documenting, prefer explicit return x, y once a function grows past about 20 lines.

Variadic parameters

A function can accept any number of arguments of a given type with ...T:

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

fmt.Println(sum(1, 2, 3))           // 6
fmt.Println(sum(1, 2, 3, 4, 5))     // 15
fmt.Println(sum())                  // 0

Inside the function, nums is a regular []int slice. To pass an existing slice into a variadic parameter, use ... at the call site:

nums := []int{1, 2, 3, 4, 5}
fmt.Println(sum(nums...))           // 15

The most famous variadic function is fmt.Printf:

fmt.Printf("%s is %d years old\n", "Alice", 30)

A variadic parameter must be the last parameter of the function.

Functions as values

Functions in Go are first-class values. You can store them in variables, pass them as arguments, and return them from other functions.

add := func(a, b int) int {
    return a + b
}
fmt.Println(add(3, 4))    // 7

The literal func(a, b int) int { return a + b } is an anonymous function, also called a function literal.

Passing a function to another function is the basis of higher-order utilities like Map and Filter:

func apply(nums []int, f func(int) int) []int {
    out := make([]int, len(nums))
    for i, n := range nums {
        out[i] = f(n)
    }
    return out
}

double := func(n int) int { return n * 2 }
fmt.Println(apply([]int{1, 2, 3}, double))
// output: [2 4 6]

The type func(int) int is the type of a function taking one int and returning one int. You read function types the same way you read function declarations.

Closures

An anonymous function captures the variables in scope where it’s defined. This makes a closure:

func counter() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}

next := counter()
fmt.Println(next())    // 1
fmt.Println(next())    // 2
fmt.Println(next())    // 3

Each call to counter() produces a new closure with its own n. Closures are the engine behind many standard-library patterns — for example, http.HandlerFunc and the sort.Slice comparator.

Try this. Write a function mostFrequent(words []string) (string, error) that returns the word that appears most often in the slice, plus an error if the slice is empty. Test it on []string{"go", "rust", "go", "python", "go", "rust"} and on an empty slice. This exercise combines slices, maps, multiple returns, and the error convention — everything in this post.

Defer, briefly

A defer statement schedules a function call to run when the surrounding function returns. The most common use is to release a resource:

func readAll(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()        // runs when readAll returns

    return io.ReadAll(f)
}

defer f.Close() guarantees the file is closed no matter how readAll exits — early return, normal return, or even a panic. We cover defer properly in Control Flow in Go.

Method declarations (a preview)

Go functions can be attached to a type, becoming methods:

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

r := Rectangle{Width: 3, Height: 4}
fmt.Println(r.Area())    // 12

The (r Rectangle) between func and the method name is the receiver. It’s syntactically a parameter that comes before the function name. Methods are how Go does the equivalent of object-oriented design without classes. We cover structs and methods in detail later in the series.

A worked example

A small program that fetches and parses a list of integers from a string, demonstrating most of this post:

package main

import (
    "errors"
    "fmt"
    "strconv"
    "strings"
)

// parseInts splits s on commas and parses each piece as an int.
// Returns an error if any piece fails to parse.
func parseInts(s string) ([]int, error) {
    if strings.TrimSpace(s) == "" {
        return nil, errors.New("empty input")
    }
    parts := strings.Split(s, ",")
    out := make([]int, 0, len(parts))
    for i, p := range parts {
        n, err := strconv.Atoi(strings.TrimSpace(p))
        if err != nil {
            return nil, fmt.Errorf("position %d: %w", i, err)
        }
        out = append(out, n)
    }
    return out, nil
}

// sum is a variadic helper.
func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

func main() {
    nums, err := parseInts("3, 5, 8, 13")
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println("numbers:", nums)
    fmt.Println("sum:", sum(nums...))
}
// output:
// numbers: [3 5 8 13]
// sum: 29

Almost every Go program is built out of pieces like this: a small function that returns a value and an error, a caller that checks the error, and a helper or two. Functions are not glamorous in Go. That’s the point.

Try this. Extend the worked example: add a function average(nums []int) (float64, error) that returns an error if the slice is empty. Call it from main and print the result formatted to two decimal places. Notice how natural the error convention starts to feel by the second time you use it.

Recap

You now know:

  • func name(params) returnType { ... } is the basic declaration; consecutive same-typed parameters collapse.
  • An identifier starting with a capital letter is exported; lowercase is package-private.
  • Functions can return multiple values, and the standard pattern is (result, error).
  • Errors are values: check them with if err != nil, create them with errors.New or fmt.Errorf.
  • Named return values document intent; naked returns are tolerable in short functions only.
  • ...T declares a variadic parameter; slice... expands a slice at a call site.
  • Functions are first-class values — store them, pass them, return them; anonymous functions form closures.

Next steps

The next post covers Go’s control flow — if with an init statement, the one and only for loop in four flavours, switch without break, and defer properly.

→ Next: Control Flow in Go

Questions or feedback? Email codeloomdevv@gmail.com.