Skip to content
CalliCoder

Introduction to Functions in Golang

Golang 14 min read

Multiple return values are the reason Go has no exceptions, named results interact with defer in a way that enables error wrapping, and a closure over a loop variable behaves differently since Go 1.22.

Go’s function design is shaped by one decision: there are no exceptions, so errors come back as values. Everything else: multiple returns, named results, the if err != nil shape of Go code — follows from that, and understanding it as a consequence rather than a quirk makes the language read better.

Written against Go 1.22.

Declaration

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

func add(a, b int) int {          // shared type, written once
	return a + b
}

func greet(name string) {         // no return value
	fmt.Println("Hello,", name)
}

The type follows the name, which reads left to right, “a is an int”, and matters more in a declaration like func f(g func(int) error) []byte, where the C-style ordering becomes unreadable.

Parameters are always passed by value. Passing a struct copies it; passing a slice copies the header (the pointer, length and capacity) which is why writing to s[0] inside a function is visible to the caller while append may not be. Maps and channels are reference types and behave the same way.

Multiple return values

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

result, err := divide(10, 3)
if err != nil {
	return fmt.Errorf("calculating ratio: %w", err)
}

This is the language’s replacement for exceptions. An error is an ordinary value in an ordinary return slot, so it cannot be ignored by accident: the compiler rejects an unused variable, and skipping it requires writing _ deliberately.

The convention is that the error comes last, and that the other values are unusable when it is non-nil. Returning both a useful value and an error is legal and confusing; the two exceptions are io.Reader, which returns bytes alongside io.EOF, and functions returning a partial result by documented contract.

%w in fmt.Errorf wraps the original so errors.Is and errors.As can still see it. %v formats it as text and breaks the chain.

Named return values

func divide(a, b float64) (result float64, err error) {
	if b == 0 {
		err = errors.New("division by zero")
		return                     // naked return — returns the current values
	}
	result = a / b
	return
}

Named results are pre-declared and zero-valued. The naked return sends whatever they currently hold.

Use them sparingly. In a function longer than a few lines, a naked return forces the reader back to the signature to find out what is being returned, and the values can be modified between assignment and return.

The case where they earn their place is defer modifying the result:

func process(path string) (err error) {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer func() {
		if cerr := f.Close(); cerr != nil && err == nil {
			err = cerr           // only possible because err is named
		}
	}()
	return doWork(f)
}

A deferred closure can assign to a named result and change what the caller sees. With an anonymous result there is nothing to assign to, and a Close error on a write is silently lost, which for a buffered writer means losing data with no report.

The same mechanism converts a panic into an error:

defer func() {
	if r := recover(); r != nil {
		err = fmt.Errorf("recovered: %v", r)
	}
}()

Variadic functions

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

sum(1, 2, 3)

values := []int{1, 2, 3}
sum(values...)                    // spread — required

Inside the function nums is an []int. It is nil when no arguments are passed, which ranges zero times, so no length check is needed.

Only one variadic parameter and it must be last. The spread form passes the slice directly without copying it, which means the function can modify the caller’s slice: a real difference from the non-spread call, which builds a fresh slice. A variadic function that keeps or mutates its argument should copy it first, or document that it takes ownership.

Functions are values

type Transform func(string) string

func apply(items []string, t Transform) []string {
	out := make([]string, len(items))
	for i, item := range items {
		out[i] = t(item)
	}
	return out
}

apply(names, strings.ToUpper)
apply(names, func(s string) string { return strings.TrimSpace(s) })

A function type can be a parameter, a return value, a struct field or a map value. The named type Transform is worth declaring when the signature appears more than once.

Returning a function creates a closure over the enclosing scope:

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

next := counter()
next()    // 1
next()    // 2

count outlives counter, because the closure holds it: the variable escapes to the heap, which the compiler decides automatically. That is the mechanism behind middleware, functional options and rate limiters in Go, and it is worth knowing that each call to counter produces an independent count.

Closures over a loop variable

for _, v := range items {
	go func() {
		process(v)          // Go < 1.22: probably the last item, repeatedly
	}()
}

Before Go 1.22, the loop variable was one variable reused across iterations, so every closure captured the same one. Since 1.22 each iteration gets its own, and the code above does what it looks like.

The behaviour follows the go directive in go.mod, not the installed toolchain: a module declaring go 1.21 still gets the old semantics on a new compiler. In a codebase spanning the change, v := v at the top of the body is correct under both and costs nothing.

Methods are functions with a receiver

type Counter struct{ n int }

func (c *Counter) Increment() { c.n++ }      // pointer receiver — can modify
func (c Counter) Value() int  { return c.n } // value receiver — operates on a copy

A value receiver gets a copy, so func (c Counter) Increment() { c.n++ } compiles and does nothing — one of the most common Go bugs, and silent.

Pick one kind per type rather than mixing them. A type with any pointer receiver should use pointer receivers throughout, because only *Counter then satisfies an interface the methods define, and a mixed set makes which one does confusing.

defer runs at function exit

func handler() {
	defer fmt.Println("done")      // runs when handler returns
	for i := 0; i < 1000; i++ {
		f, _ := os.Open(paths[i])
		defer f.Close()            // accumulates 1000 deferred calls
	}
}

Deferred calls run last-in-first-out when the function returns, not at the end of a block. A defer inside a loop holds every resource until the whole function finishes; give the loop body its own function to fix it.

Arguments are evaluated at the point of the defer, not when it runs, defer fmt.Println(i) captures i immediately.

More in the Golang guides, including interfaces and control flow.

Frequently asked questions

Why does Go return errors instead of throwing?

So that failure is an ordinary value in an ordinary return slot. It cannot be ignored accidentally, and the control flow is visible at the call site.

Where does the error go in the return list?

Last, by convention, and the other values should be treated as unusable when it is non-nil.

What is the difference between %w and %v when wrapping?

%w keeps the original error reachable by errors.Is and errors.As; %v formats it as text and breaks the chain.

Should I use named return values?

Sparingly. They are genuinely useful when a deferred closure must modify the result (capturing a Close error, or recovering a panic) and hurt readability otherwise.

Are arguments passed by value?

Always. A struct is copied; a slice’s header is copied, so element writes are visible to the caller but append may not be.

How do I pass a slice to a variadic function?

With the spread: sum(values...). That passes the slice directly rather than copying, so the function can modify the caller’s data.

Why do all my goroutines see the last loop value?

On Go 1.21 and earlier the loop variable is shared. Since 1.22 each iteration has its own, but the semantics follow the go directive in go.mod, not the compiler version.

Why does my method not change the struct?

It has a value receiver, so it operates on a copy. Use a pointer receiver.

Should I mix value and pointer receivers?

No. Pick one per type: with pointer receivers, only the pointer type satisfies the interface, and a mixed set makes that unpredictable.

When do deferred calls run?

When the enclosing function returns, last-in-first-out. A defer inside a loop accumulates until then; wrap the loop body in a function to release per iteration.