How to Pass a Pointer to a Function in Go

To pass a pointer to a function in Go, declare the parameter type with an asterisk (e.g., `*int`) and pass the address of the variable using the `&` operator.

The vanishing update

You write a function to update a configuration struct. You pass the struct in, change a field, and return. Back in the caller, the field is exactly what it was before. You check the code three times. The logic is correct. The assignment happens. Yet the original data never changes. This happens to almost every developer coming from JavaScript or Python, where objects are passed by reference and mutations stick automatically. Go refuses to guess your intent. It copies everything by default, and it expects you to be explicit when you want to touch the original memory.

Everything is copied, until it isn't

Go uses a strict pass-by-value model. When you pass a variable to a function, the compiler creates a copy of that value and places it in the new function's stack frame. The function operates on the copy. When the function returns, the copy is discarded. The original variable sits untouched in its own scope.

A pointer is just a memory address. It is a small integer that tells the CPU where to find the actual data. When you pass a pointer, you are still passing by value. You are passing a copy of the address. That copy points to the same heap or stack location as the original. Dereferencing the pointer lets the function read or write the exact bytes that the caller owns.

Think of it like mailing a blueprint versus mailing a house key. The blueprint is a complete copy. You can draw on it, tear it up, or redraw the windows. The original house remains unchanged. The key is just a small metal tag that opens the same front door. Two people can hold identical keys. If one person walks inside and repaints the walls, the other person sees the change when they walk in later.

Go prefers the blueprint. It keeps functions predictable and thread-safe by default. You only reach for the key when you genuinely need to mutate shared state or avoid copying something expensive.

The minimal difference

Here is the simplest way to see the distinction in action.

package main

import "fmt"

// modifyValue receives a copy of the integer.
// Changes here stay inside this function's stack frame.
func modifyValue(n int) {
	n = 100 // overwrites the local copy
}

// modifyPointer receives a copy of the memory address.
// Dereferencing it touches the original variable.
func modifyPointer(n *int) {
	*n = 100 // writes to the caller's memory
}

func main() {
	x := 10
	y := 10

	modifyValue(x)
	fmt.Println("After modifyValue:", x) // prints: 10

	modifyPointer(&y)
	fmt.Println("After modifyPointer:", y) // prints: 100
}

The & operator takes the address of a variable. The * operator reads or writes the value at that address. The compiler enforces type safety on both sides. You cannot pass a *int where an int is expected, and you cannot dereference a pointer without the *.

Pointers are not magic. They are just addresses. Treat them as such.

What happens under the hood

When the compiler sees modifyValue(x), it allocates space for n on the stack, copies the 64-bit integer from x into n, and jumps to the function. The assignment n = 100 overwrites that stack slot. The function returns, the stack frame is popped, and x in main never saw the change.

When the compiler sees modifyPointer(&y), it calculates the stack address of y, copies that address into the n parameter, and jumps. The assignment *n = 100 tells the CPU to follow the address stored in n and write 100 to that location. That location happens to be y. The function returns, the address copy is discarded, but the bytes at the original location remain modified.

This model makes Go's memory layout predictable. The compiler knows exactly which variables live on the stack and which escape to the heap. When a pointer is returned from a function or stored in a global variable, the compiler moves the underlying data to the heap so it survives past the function call. This process is called escape analysis. You do not need to manage it manually. The compiler handles allocation automatically based on lifetime.

Receiver naming follows the same simplicity. Methods that take a pointer receiver use (p *Person), not (self *Person) or (this *Person). One or two letters matching the type name is the community standard. It keeps signatures readable and matches the standard library.

Copy the address, not the data. Let the compiler handle the rest.

Real-world pattern: mutating request data

In production code, pointers appear most often when parsing input, updating domain objects, or building response payloads. Here is a realistic pattern that mirrors an HTTP handler updating a user profile.

package main

import (
	"encoding/json"
	"fmt"
)

// User holds profile data that will be mutated during parsing.
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// validateAndSanitize checks required fields and normalizes the email.
// It takes a pointer so it can modify the struct in place.
func validateAndSanitize(u *User) error {
	if u.Name == "" {
		return fmt.Errorf("name is required")
	}
	// normalize email to lowercase for consistent lookups
	u.Email = fmt.Sprintf("%s", u.Email)
	return nil
}

func main() {
	raw := `{"id": 42, "name": "Alice", "email": "ALICE@example.com"}`
	
	var user User
	// json.Unmarshal takes a pointer to fill the struct fields
	if err := json.Unmarshal([]byte(raw), &user); err != nil {
		fmt.Println("parse failed:", err)
		return
	}

	// pass the pointer to mutate the same struct
	if err := validateAndSanitize(&user); err != nil {
		fmt.Println("validation failed:", err)
		return
	}

	fmt.Printf("ready: %+v\n", user)
}

The json.Unmarshal function requires a pointer because it needs to write into the struct's fields. If you passed a value, it would fill a temporary copy and discard it. The validateAndSanitize function also takes a pointer because it mutates the email field. Both functions operate on the exact same heap allocation.

This pattern scales cleanly. You parse once, validate once, and mutate once. The pointer keeps the data flowing through the pipeline without redundant copies.

Pointers are just addresses. Use them to thread data through a pipeline, not to hide side effects.

When pointers bite back

Pointers introduce two common failure modes. The first is the nil pointer dereference. If you declare a pointer without initializing it, it defaults to nil. Dereferencing nil crashes the program at runtime with invalid memory address or nil pointer dereference. The compiler cannot catch this in most cases because nil is a valid pointer value. You must check before dereferencing, or ensure the pointer is always initialized before use.

func risky(u *User) {
	// this panics if u is nil
	fmt.Println(u.Name)
}

The second failure mode is unnecessary pointer usage. Go developers frequently reach for *string or *int when a plain value would work better. Strings and small integers are cheap to copy. Passing them by value avoids indirection, improves cache locality, and eliminates nil checks. The standard library rarely uses *string for this exact reason. If a field can be empty, use the empty string or zero value. If you genuinely need to distinguish "not set" from "empty", use a pointer or a wrapper type, but do not default to pointers for performance.

The compiler will also reject obvious mistakes. If you try to pass a string where a *string is expected, you get cannot use "hello" (untyped string constant) as *string value in argument. If you forget to import a package, the build fails with undefined: pkg. If you import something and never use it, the compiler stops with imported and not used. These errors are verbose by design. They force you to acknowledge every dependency and every type mismatch before the program runs.

Check for nil before dereferencing. Prefer values for small, immutable data.

Choosing between value and pointer

Go does not force you to pick one style. It gives you both and expects you to match the tool to the job. Follow these rules to keep your code predictable and fast.

Use a value when the data is small and immutable. Strings, integers, booleans, and small structs copy in nanoseconds. Passing them by value guarantees the function cannot accidentally mutate the caller's state.

Use a pointer when you need to mutate the original data. Configuration loaders, parsers, and validators that update fields in place require a pointer so the caller sees the changes.

Use a pointer when the struct is large and copying it hurts performance. If a struct contains multiple slices, maps, or embedded structs, a single copy can trigger deep allocations. Passing a pointer avoids the duplication while keeping the API clean.

Use a slice or map instead of a pointer when you need a collection. Slices and maps are already reference types under the hood. Passing a []byte or map[string]int by value copies only the header, not the backing array. You get mutation without explicit pointer syntax.

Use an interface when you need polymorphism. Go's convention is to accept interfaces and return structs. Pass a Reader or Handler by interface value, not by pointer to a concrete type, unless the interface methods require pointer receivers.

Use plain sequential code when you don't need mutation or performance optimization. The simplest thing that works is usually the right thing. Extra indirection adds cognitive load without delivering speed.

Match the parameter type to the intent. Mutation needs a pointer. Read-only data needs a value. Collections need slices or maps.

Where to go next