How to Define and Call Functions in Go

Define Go functions with the func keyword and parameters, then call them by name with matching arguments.

The copy-paste trap

You have a script that parses log lines. You copy-paste the parsing block into three different places. You find a bug in the regex, fix it in one spot, and forget the other two. The script starts misreporting errors at 3 AM. This is the moment you stop copy-pasting and start defining functions. Functions let you write logic once and run it from anywhere. They also give you a name for what the code does, which is often more valuable than the code itself.

Functions are reusable blocks

A function is a named block of code that takes inputs, does work, and returns outputs. Think of a function like a coffee machine. You don't need to know how the heating element works or how the grinder rotates. You just know that if you put in beans and water and press the button, you get coffee. The machine encapsulates the complexity.

In Go, the "beans and water" are parameters, the "button" is the function call, and the "coffee" is the return value. Functions hide implementation details behind a clear interface. The caller only cares about what goes in and what comes out.

Minimal example

Here's the simplest function: two integers in, one integer out.

package main

import "fmt"

// Add takes two integers and returns their sum.
func Add(a, b int) int {
	// Parameters share the type when adjacent.
	// The return type sits after the parameter list.
	return a + b
}

func main() {
	// Call Add with arguments matching the parameter types.
	// Assign the result to a variable.
	sum := Add(2, 3)
	fmt.Println(sum)
}

How definitions and calls work

The definition starts with func. The name follows. Go uses PascalCase for exported names (visible to other packages) and camelCase for unexported names (private to the package). The parameter list goes in parentheses. If adjacent parameters have the same type, you can list the type once at the end: (a, b int) means both a and b are int. The return type comes after the closing parenthesis. If a function returns nothing, you omit the return type entirely.

When you call Add(2, 3), Go checks that 2 and 3 are integers. It creates copies of the values and passes them to the function. Go passes arguments by value. The function gets a copy of the data. Changing a inside the function never changes the variable outside. This is a safety feature: functions can't accidentally mutate data they didn't ask for. If you need to modify the original value, pass a pointer. For small types like int, string, or small structs, pass by value is faster and safer.

Every type in Go has a zero value. Integers are 0, booleans are false, strings are "", pointers are nil. When you declare a variable without initializing it, it gets the zero value. This applies to function parameters and return values too. You don't need to initialize every variable explicitly. The compiler guarantees they start at a known state.

Trust gofmt. The Go community uses a single formatting tool. Don't argue about indentation or brace placement. Let the tool decide. Most editors run gofmt on save. Your code will look like everyone else's code, which reduces cognitive load when reading other people's work.

Functions are the atoms of Go. Name them well.

Real code returns errors

Real Go code rarely returns a single value. You almost always return a result and an error. Go functions can return multiple values. This is idiomatic for error handling. The convention is to return the result first, then the error.

package main

import (
	"errors"
	"fmt"
)

// Divide returns the quotient of a and b.
// It returns an error if b is zero.
func Divide(a, b float64) (float64, error) {
	// Check for division by zero before performing the operation.
	if b == 0 {
		// Return zero value for the result and a descriptive error.
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	// Capture both return values.
	result, err := Divide(10, 2)
	if err != nil {
		// Handle the error case immediately.
		fmt.Println("Error:", err)
		return
	}
	fmt.Println("Result:", result)
}

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You can't accidentally ignore an error. If you don't care about the error, you must explicitly discard it using the blank identifier _. This forces you to make a conscious decision about every error.

Errors are values. Handle them where you can do something useful.

Variadic functions for flexible arguments

Sometimes you don't know how many arguments you'll get. Go supports variadic functions. The ... syntax before the last parameter type allows passing zero or more arguments of that type. Inside the function, the parameter becomes a slice.

package main

import "fmt"

// Sum adds all integers in the slice.
// The ...int syntax allows passing zero or more arguments.
func Sum(nums ...int) int {
	total := 0
	// Iterate over the slice of arguments.
	for _, n := range nums {
		total += n
	}
	return total
}

func main() {
	// Call Sum with a variable number of arguments.
	fmt.Println(Sum(1, 2, 3))
	fmt.Println(Sum(10, 20))
	fmt.Println(Sum())
}

You can also pass a slice to a variadic function using the ... operator. Sum(nums...) unpacks the slice into individual arguments. This is useful when you're building up a list of arguments dynamically.

Defer for cleanup

defer schedules a function call to run after the surrounding function returns. It's essential for resource cleanup. You use defer to close files, release locks, or flush buffers. The deferred function runs even if the surrounding function panics or returns early.

package main

import (
	"fmt"
	"os"
)

// ProcessFile reads the first line of a file.
func ProcessFile(name string) (string, error) {
	// Open the file.
	file, err := os.Open(name)
	if err != nil {
		return "", err
	}
	// Defer ensures the file closes even if an error occurs later.
	defer file.Close()

	// Read the first line.
	var line string
	_, err = fmt.Fscanln(file, &line)
	if err != nil {
		// Return early. The deferred Close still runs.
		return "", err
	}
	return line, nil
}

func main() {
	line, err := ProcessFile("test.txt")
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println("Line:", line)
}

Deferred functions run in LIFO order. If you defer three functions, they run in reverse order. This matches how resources are acquired and released. Defer is idiomatic for cleanup. Use it liberally for resources that must be released.

Pitfalls and compiler errors

Go is strict about types and return values. If you pass a string where an int is expected, the compiler rejects the program with cannot use "hello" (untyped string constant) as int value in argument. If a function returns two values and you only capture one, you get Divide returns 2 values but 1 expected. You must capture all return values or discard the ones you don't need using _.

Named return values are a trap. Go allows you to name the return variables in the signature. They are initialized to zero values and returned automatically if you use a bare return. This looks convenient but leads to bugs. It's easy to forget to update a variable before returning, or to shadow a variable inside the function body.

// BadDivide uses named return values.
// The return variables are declared in the signature.
func BadDivide(a, b float64) (result float64, err error) {
	if b == 0 {
		// err is already declared. Assign to it directly.
		err = errors.New("division by zero")
		return
	}
	// result is assigned. A bare return sends both values back.
	result = a / b
	return
}

The community generally avoids named return values. They make it harder to see what is being returned and can obscure the flow of the function. Stick to explicit returns. The extra typing is worth the clarity.

Shadowing is another common issue. If you declare a variable with := inside a block, it can shadow an outer variable with the same name. This often happens with err. If you have err in the outer scope and write err := someCall() inside an if, you create a new err that disappears when the block ends. The outer err remains unchanged. Use = instead of := if you want to update the existing variable, or use a linter to catch shadowing.

Named returns are a shortcut that often leads to bugs. Stick to explicit returns.

Decision matrix

Use a function when you have logic that repeats or needs a descriptive name to clarify intent. Use a method when the operation is tightly coupled to a struct or type, and you want to call it on an instance like obj.DoWork(). Use a closure when you need a function that captures variables from its surrounding scope, such as in event handlers or goroutines. Use inline code when the operation is trivial and appears only once. Don't wrap a single assignment in a function just to be "functional."

Keep functions small. If a function does three things, split it into three.

Where to go next