Functions in Go

A function in Go is a named, reusable block of code that takes zero or more parameters, does some work, and can hand back zero or more results. Functions are the basic building block of every Go program — a package is nothing more than a collection of types, variables, and functions, and execution always begins at func main(). Because Go treats functions as first-class values, you can store them in variables, pass them as arguments, and return them from other functions, which unlocks patterns like callbacks and closures that you will use constantly once you get comfortable with the language.

Overview / How it works

Every Go function starts with the func keyword, followed by a name, a parenthesized parameter list, an optional return type (or list of return types), and a body in braces. A complete Go program is really just a package of declarations — types, variables, constants, and functions — and the runtime starts by calling the special func main() in package main. Everything else, including all of the standard library, is just functions calling other functions.

Go passes every argument by value: when you call add(3, 5), Go copies the values 3 and 5 into new local variables inside add‘s stack frame. If add reassigned its parameter, the caller would never see it, because the caller’s variable and the parameter are different memory locations holding a copy of the same value. This is true for every type, including structs — passing a large struct by value copies the whole thing, which is one reason large structs are often passed by pointer instead.

Slices, maps, channels, and functions look like they are passed “by reference”, but they are not — they are passed by value too, it’s just that their value is a small header, not the underlying data. A slice header holds a pointer to a backing array, a length, and a capacity; a map or channel header holds a pointer to internal runtime bookkeeping. When you pass a slice into a function, the function gets its own copy of the header, but that copy still points at the same backing array. Modifying an element of the slice inside the function is therefore visible to the caller, but calling append on the parameter, or reassigning the parameter to a new slice, only changes the function’s local copy of the header and is invisible outside — this is one of the most common sources of confusion for people coming from other languages, and it’s covered in Common Mistakes below.

Go functions can return more than one value, which is baked directly into the language — there is no tuple type involved; the compiler just knows how to hand back multiple values and how to receive them on the caller’s side with a, b := f(). The overwhelmingly common use of this is returning a result alongside an error, since Go has no exceptions: routine failures are ordinary values that the caller is expected to check with if err != nil immediately after the call, rather than being thrown and caught somewhere else in the program.

Return values can also be named in the function signature, for example func sum(nums ...int) (total int). A named return value is declared and zero-initialized as soon as the function starts, behaves like an ordinary local variable throughout the body, and is what gets sent back by a bare return statement with no arguments (a “naked return”). Named returns are useful for documenting what each result means, and for cases where a deferred function needs to inspect or modify the result before it is handed back to the caller — but overusing naked returns in long functions can make code harder to follow, which is why many style guides reserve them for short functions.

A parameter list can end with a variadic parameter, written as ...Type, which lets the caller pass any number of trailing arguments (including zero). Inside the function, a variadic parameter is just an ordinary slice of that type. If you already have a slice and want to pass its elements as individual variadic arguments, you spread it with slice... at the call site.

Finally, functions in Go are first-class values: a function has a type such as func(int, int) int, and you can assign a function to a variable, pass it as an argument, store it in a struct field, or return it from another function. A function literal defined inside another function that references variables from the enclosing scope is called a closure. Go’s compiler performs escape analysis to determine that a captured variable’s lifetime must now extend beyond the enclosing function’s return, and it automatically allocates that variable on the heap instead of the stack so the closure can keep using it safely.

Syntax

The general form of a function declaration looks like this:

func name(param1 Type1, param2 Type2) (ReturnType1, ReturnType2) {
    // function body
    return value1, value2
}
Part Meaning
func Keyword that begins every function declaration.
name The function’s identifier; omit it entirely for an anonymous function literal.
(param1 Type1, param2 Type2) Parameter list. Parameters that share a type can be grouped, e.g. (a, b int).
(ReturnType1, ReturnType2) Return type list. A single return type doesn’t need parentheses; zero return types omit this section entirely.
(result Type) A named return value; when used, a bare return sends back its current value.
...Type A variadic parameter, always last, received inside the function as []Type.
{ ... } The function body, with the opening brace on the same line as the signature.

Examples

Example 1: A simple function with one return value

package main

import "fmt"

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

func main() {
	sum := add(3, 5)
	fmt.Println("Sum:", sum)
}

Output:

Sum: 8

add takes two int parameters and returns a single int. Inside main, the two literal values 3 and 5 are copied into add‘s parameters a and b; the function computes their sum and returns it, and main stores that returned value in a new local variable called sum.

Example 2: Multiple return values for a result and an error

package main

import (
	"errors"
	"fmt"
)

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

func main() {
	result, err := divide(10, 2)
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println("Result:", result)

	_, err = divide(5, 0)
	if err != nil {
		fmt.Println("Error:", err)
	}
}

Output:

Result: 5
Error: division by zero

divide returns two values every time: an int result and an error. When the divisor is 0, it returns the zero value 0 alongside a non-nil error instead of crashing; the caller is expected to check err immediately, which is exactly what Go’s idiomatic if err != nil pattern does. On the first call the division succeeds so err is nil and the result prints; on the second call the divisor is 0, so the error branch runs instead.

Example 3: Variadic parameters and a named return value

package main

import "fmt"

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

func main() {
	fmt.Println("Total:", sum(1, 2, 3, 4, 5))
	fmt.Println("Total:", sum())
}

Output:

Total: 15
Total: 0

nums ...int lets callers pass any number of int arguments; inside the function, nums is an ordinary []int slice. The return value total is named directly in the signature, so it starts at its zero value (0), and the bare return statement sends back whatever total holds at that point. Calling sum() with no arguments is perfectly legal — nums is simply an empty slice, the loop body never executes, and total stays at 0.

Example 4: Functions as values — a closure

package main

import "fmt"

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

func main() {
	counter := makeCounter()
	fmt.Println(counter())
	fmt.Println(counter())
	fmt.Println(counter())
}

Output:

1
2
3

makeCounter returns a function value of type func() int. That returned function is a closure: it references count, a variable declared in makeCounter‘s own scope, even after makeCounter has returned. Each call to counter() increments that same count variable and returns the new value, which is why three successive calls print 1, 2, and 3 instead of 1 three times — every closure produced by a single call to makeCounter shares its own private count.

How it works step by step

When main calls add(3, 5) in Example 1, several things happen in order:

  1. Go evaluates the argument expressions left to right — here just the literals 3 and 5.
  2. The runtime sets up a new stack frame for add and copies those values into its parameters a and b.
  3. add‘s body executes, computing a + b.
  4. The result is copied out of add‘s stack frame and back to the call site in main.
  5. add‘s stack frame is discarded, and main resumes, assigning the copied result to sum.

Multiple return values follow the same pattern, just with more than one value copied back at once — that’s why result, err := divide(10, 2) can unpack two independent values from a single call in one statement.

Closures work a little differently under the hood. In Example 4, the Go compiler notices that the anonymous function returned by makeCounter refers to count, a variable that would normally live on makeCounter‘s stack frame and disappear when it returns. Through escape analysis, the compiler detects that count‘s lifetime must outlive the call to makeCounter, so it allocates count on the heap instead of the stack. The returned closure carries a reference to that heap-allocated count, so every call to counter() reads and writes the same memory, even though makeCounter itself returned long ago.

Common Mistakes

Mistake 1: Discarding the error return value

It’s tempting to ignore the second return value with _ when you’re in a hurry:

result, _ := divide(10, 0)
fmt.Println("Result:", result)

This silently prints Result: 0 even though the division actually failed — you’ve thrown away the exact information that would have told you something went wrong. Always check the error immediately after the call:

package main

import (
	"errors"
	"fmt"
)

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

func main() {
	result, err := divide(10, 0)
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println("Result:", result)
}

Output:

Error: division by zero

Mistake 2: Expecting a slice parameter to grow for the caller

Because slices look reference-like, it’s easy to assume that appending to a slice parameter changes the caller’s slice too:

func appendItem(s []int) {
	s = append(s, 99)
}

func main() {
	nums := []int{1, 2, 3}
	appendItem(nums)
	fmt.Println(nums)
}

This prints [1 2 3], not [1 2 3 99], because s inside appendItem is a separate copy of the slice header; reassigning s with append only updates that local copy. Since the underlying array often doesn’t have room to grow in place, append may also allocate a brand-new backing array the caller’s header knows nothing about. To make the change visible to the caller, return the new slice and reassign it:

package main

import "fmt"

func appendItem(s []int) []int {
	return append(s, 99)
}

func main() {
	nums := []int{1, 2, 3}
	nums = appendItem(nums)
	fmt.Println(nums)
}

Output:

[1 2 3 99]

Mistake 3: Shadowing a named return value with :=

Named return values are ordinary variables, so it’s surprisingly easy to accidentally shadow one instead of assigning to it:

func compute(x int) (result int) {
	if x > 0 {
		result := x * 2
		fmt.Println(result)
	}
	return
}

Using := inside the if block declares a brand-new local result that only exists inside that block; the outer named return value is never touched, so the bare return at the end always sends back 0. The fix is to use plain assignment (=) so you write to the named return value itself:

package main

import "fmt"

func compute(x int) (result int) {
	if x > 0 {
		result = x * 2
	}
	return
}

func main() {
	fmt.Println(compute(5))
	fmt.Println(compute(-3))
}

Output:

10
0

Best Practices

  • Keep functions small and focused on a single task; if you can’t summarize what a function does in one short sentence, consider splitting it.
  • Return an error as the last result from any operation that can fail, and check it with if err != nil immediately after the call — never further down the function.
  • Reassign the result of append back to the slice variable (s = append(s, x)); never assume a slice argument will reflect changes made inside a called function.
  • Use named return values mainly for documentation on short functions, or when a deferred call needs to adjust the result; prefer explicit return value statements in longer functions to avoid shadowing bugs.
  • Group more than two or three related parameters of the same kind into a struct instead of a long parameter list.
  • Prefer accepting narrow interfaces as parameters and returning concrete types, so callers get precise information while you keep implementation flexibility.
  • Use variadic parameters for genuinely optional trailing arguments, not as a substitute for an ordinary slice parameter when argument order or count matters semantically.
  • Start exported function doc comments with the function’s own name, e.g. // Divide returns..., since that’s the convention go doc and package documentation tools rely on.

Practice Exercises

  1. Write a function max3(a, b, c int) int that returns the largest of three integers without using the built-in max function. Call it with max3(4, 9, 2) and print the result; it should print 9.
  2. Write a function divmod(a, b int) (int, int) that returns both the quotient and the remainder of a / b in one call. Calling divmod(17, 5) should let you print Quotient: 3 Remainder: 2.
  3. Write a function makeMultiplier(factor int) func(int) int that returns a closure multiplying its argument by factor. Create two multipliers, one for 2 and one for 10, and show that calling each with the same input (say 5) produces different, independent results (10 and 50).

Summary

  • A Go function is declared with func, a name, a parameter list, an optional return type list, and a body.
  • Arguments are always passed by value; for slices, maps, and channels the “value” is a small header, so element mutations are visible to the caller but reassignment and appends usually are not.
  • Functions can return multiple values, most commonly a result paired with an error that must be checked with if err != nil.
  • Named return values are zero-initialized local variables that a bare return sends back — assign to them with =, not :=, or you’ll shadow them.
  • A variadic parameter (...Type) lets callers pass any number of trailing arguments and is received as a slice inside the function.
  • Functions are first-class values in Go; a closure defined inside another function keeps working references to variables from its enclosing scope even after that function returns.