AlgoMaster Logo

Pointers & Functions

Last Updated: May 22, 2026

High Priority
12 min read

Every value you hand to a Go function gets copied. That's a fine default for small things like a price or a name, but when you want a function to actually update the caller's product, cart, or order, copying gets in the way. This lesson shows how pointer parameters let a function reach back and modify the caller's value, when returning a pointer makes sense, and a small set of pitfalls (loop-variable pointers, pointers to reassigned locals) that come up when mixing pointers with functions.

Go Always Passes By Value

Before talking about pointer parameters, it helps to nail down what Go actually does when you call a function. Go passes every argument by value, with no exceptions. A function gets its own copy of every parameter, and changes to that copy don't leak back to the caller.

The discount function got a copy of cartTotal, modified its copy, and threw the copy away when it returned. The caller's cartTotal is untouched. This is the same behavior as with structs: a struct passed to a function gets copied wholesale, and the function works on the copy.

sellOne decremented its own copy of the product's stock. The caller's p.Stock is still 12. This surprises people the first time, because the function looked like it was doing the right thing.

There's one place where it can look like Go is passing by reference, and it's worth being precise about. Slices, maps, and channels are themselves small "header" values that point at a shared piece of memory. When you pass a slice into a function, Go copies the header (a pointer, a length, a capacity), but both copies still point at the same backing array. Writing to a slice element through the function's copy is visible to the caller, because they share the storage.

The function still got a copy. It just happened to be a copy of a slice header that points at the same array the caller's slice points at. That's pass-by-value of a header, not pass-by-reference. The distinction matters because reassigning the slice inside the function (prices = append(prices, 99)) does not affect the caller, even though writing to elements does.

Pointer Parameters: Letting a Function Mutate

When you want a function to update the caller's value, you pass a pointer to it. The function takes a parameter of pointer type (*T), the caller passes &x instead of x, and the function dereferences the pointer with *p to read or write the target. Here we focus on what they mean in the context of function calls.

Two things changed compared to the earlier discount function. The parameter type is now *float64 instead of float64, and the call site uses &cartTotal instead of cartTotal. Inside the function, *price reads or writes the float64 that the caller's variable lives in. The assignment *price = *price * ... writes through the pointer, so the caller sees the new value.

The same pattern works for structs, and this is where it really starts to pay off. Updating a single field of a Product through a value parameter does nothing useful, but a pointer parameter lets the function update the caller's product directly.

Notice that inside sellOne, the code writes p.Stock and not (*p).Stock. Go automatically inserts the dereference when you access a field through a struct pointer. Both forms work, but the short form is what you'll see in real Go code. The dot operator on a struct pointer is syntactic sugar that Go added because writing (*p).Stock everywhere is noisy.

The diagram captures what's happening at the call. The caller's p lives somewhere in memory, and &p produces a pointer to that location. When you call sellOne(&p), Go copies that pointer into the function's parameter. The parameter is a brand new variable inside the function, but its value (an address) points at the same Product. Writing through the parameter reaches into the caller's memory.

A pointer parameter does not change the fundamental rule. Go still passes by value. What got copied is the pointer itself, not the thing it points at. The function receives its own private pointer variable, which happens to hold the same address as the caller's. That's enough for the function to read and modify the target.

Pointer Parameters vs Returning New Values

There are two natural ways to "update" a value in Go. The first is to pass a pointer and have the function write through it. The second is to take a value, compute the new version, and return it. Both work. The choice changes the shape of the call site and the function's contract.

Here's the return-a-new-value style applied to the same discount logic:

The function takes a copy, edits the copy, and returns it. The caller reassigns the result back to book. No pointers involved. This style is sometimes called the "transform" or "value-in, value-out" pattern, and it's very common in Go for small structs and immutable-feeling code.

Now compare it to the pointer-mutating style:

Same end state, different mechanics. The pointer version writes in place; the value version returns a new copy. The trade-offs come down to four things:

ConcernPointer parameterReturn new value
Mutates caller's value?Yes, in placeNo, caller reassigns
Function signaturefunc f(p *T, ...)func f(p T, ...) T
Call sitef(&x, ...)x = f(x, ...)
Allocation pressureNone for the parameterCopies on every call
ComposabilityHarder to chainEasy to chain (f(g(x)))
Reads in concurrent codeRisky without lockingSafe (each goroutine gets a copy)

The return-new-value style composes well. You can write cart = applyTax(applyDiscount(cart, 10), 8) in one line and it reads top to bottom. The pointer style needs two separate calls and a temporary, which is more code but makes the mutation explicit. The compiler also has an easier time keeping value-returning code on the stack, and value-returning functions are naturally safer when multiple goroutines might be looking at the same data.

In idiomatic Go, prefer returning new values for small types and short pipelines, and use pointer parameters when the struct is large, when the function genuinely owns the mutation (think "update this in place"), or when you want to signal that the function modifies the input. Both styles are tools, and most codebases use both.

Returning a Pointer From a Function

You can also return a pointer from a function. This is common for "constructor" functions that build a new value and hand the caller a pointer to it:

The function creates a Product value, takes its address with &, and returns the pointer. The caller gets a *Product it can pass around, mutate, or share with other functions. This is the standard Go pattern for "give me a new product" or "give me a new cart"; you'll see it in standard library code as NewBuffer, NewReader, NewRequest, and so on.

If you came from a language with manual memory management, the next thing you'd want to ask is whether returning &Product{...} is safe. In a language like C, taking the address of a local variable and returning it is a bug, because the local variable's memory is reclaimed when the function returns. Go has no such bug. The address you return remains valid for as long as anyone holds onto it.

What's going on is something called escape analysis, and it's worth a short look so the magic doesn't feel like magic.

Escape Analysis: Stack vs Heap, Briefly

Go has two places to put values: the stack and the heap. The stack is a per-goroutine region of memory that grows and shrinks as functions are called and return. Values on the stack are fast to allocate (just bump a pointer) and reclaimed automatically when the function returns. The heap is a shared region of memory managed by the garbage collector. Values on the heap stick around as long as anything still references them, and the GC eventually frees the unreferenced ones.

You don't pick where a value goes. The Go compiler does, through escape analysis. The rule is roughly: if the compiler can prove that no reference to a value outlives the function that created it, the value goes on the stack. If a reference escapes (gets returned, stored in a longer-lived structure, or captured by a goroutine), the value gets allocated on the heap instead, so it survives.

That's why return &Product{...} is safe. The compiler sees the address escaping, decides "this needs to outlive the function", and allocates the Product on the heap. The function returns, its stack frame disappears, but the Product survives on the heap. The GC tracks it through the returned pointer and frees it only when no one is referencing it anymore.

You can ask the compiler to explain its decisions with -gcflags="-m":

The output tells you which allocations escaped and why. You don't usually need this in everyday work; the takeaway is that Go's safety story for pointers is "the compiler figures out where it has to live, and the GC keeps it alive as long as you need it." That's why returning a pointer to a freshly built value just works.

A second subtle point: even values created with var inside a function can escape if you take their address and return it.

The variable p looks like a local. In a C-like language, returning &p would be a use-after-free bug. In Go, the compiler notices that the address escapes and allocates p on the heap from the start. You write the code as if p were local, and the runtime does the right thing.

Common Patterns: Update In Place and Optional Output Parameters

Two patterns come up often once you start writing functions that take pointers.

The first is the "update in place" function. The function takes a pointer to something, mutates it, and returns nothing (or just an error). The function's purpose is the mutation; the value of the return slot is the side effect on the input.

Each call modifies the caller's book directly. The chain reads as a sequence of imperative steps: restock, then discount. This style fits well when you have one canonical object that goes through a series of transitions, like a product, an order, or a cart.

The second pattern is using a pointer parameter as an "optional output" slot. The function's primary return value is something else (a boolean, an error), and the pointer is where the function deposits an additional piece of information if it's relevant.

The function's main signal is the boolean "did it succeed". The reserved pointer is where the actual count goes when the answer is yes. The caller passes &got, the function writes through it on success, and the caller reads the result after the call.

This pattern is less common in Go than in C, because Go has multiple return values and most "optional output" use cases are cleaner as (value, ok) or (value, error) pairs. The standard library uses the pointer-output pattern in a few places (json.Unmarshal(data, &result) is a famous one, because the result type isn't known to the function in advance). When in doubt, use multiple return values first; pointer outputs are for the cases where multiple returns don't fit cleanly.

Pitfall: Pointer to a Loop Variable

One of the classic bugs in Go used to be taking the address of a loop variable and storing it somewhere. Before Go 1.22, the loop variable was a single variable reused across iterations, and pointing at it gave you a pointer that changed value as the loop ran.

The bug looked like this:

On Go 1.21 and earlier, this prints the last product three times, because every iteration wrote into the same p variable, and every appended pointer was a pointer to that single, ever-changing variable. By the time the loop finished, p held the last product, and all three pointers pointed at it.

Go 1.22 changed the semantics. Each iteration now gets its own copy of the loop variable, so &p differs on each iteration. Running the same code on Go 1.22 or later prints all three products correctly:

That's the version of Go this course targets, so the snippet above is correct as written today. The reason this pitfall is still worth knowing is twofold. First, there's a lot of pre-1.22 code out there, and recognizing the pattern matters when you read or maintain it. Second, the older workaround (rebinding the variable inside the loop body) still works, and it's a defensive habit some teams keep regardless of Go version:

The line p := p creates a fresh p inside each iteration's scope. &p then refers to that fresh variable, which lives independently of the next iteration's p. With Go 1.22+, this is redundant but harmless. With pre-1.22 code, it's the fix.

A cleaner alternative when you have a slice and want pointers into it is to take the address of the slice element directly:

&products[i] is a pointer into the slice's backing array, one for each index. There's no copy involved, and the pointers are stable as long as the slice isn't resliced or reallocated. This is the right answer when you actually want pointers to the elements of an existing collection, rather than pointers to per-iteration copies.

Pitfall: Pointer to a Local That's Reassigned

A related trap shows up when you take the address of a variable and then reassign the variable. The pointer still points where it always pointed; it doesn't follow the new assignment.

That one works the way most people expect: p points at price, and reassigning price updates the same memory cell p points at. Reading *p gives the new value. No bug.

The trap appears when the "reassignment" actually replaces the value the variable holds with a brand new value, while another pointer is sitting on the old one. This is most visible with pointers to pointers, or with slices and maps, but the cleanest example uses two locals:

The variable current started pointing at book, then was reassigned to point at mug. Mutating book after that has no effect on what current points at. People sometimes write code expecting current to "track" the latest assignment to book, but a pointer is a snapshot of an address. Reassigning the pointer changes where it points; mutating the original value changes the contents at the original address. The two are independent.

A subtler version of this trap is taking the address of a parameter inside a function. The parameter is a fresh local variable, populated from the argument. Storing &parameter somewhere gives you a pointer to the function's parameter slot, not to the caller's original.

remember got a copy of book in its parameter p. It stored the address of that copy in the global saved. Mutating the caller's book after the call doesn't reach the saved copy, because saved points at the parameter slot (which escaped to the heap when the address was taken), not at the caller's variable. If you want saved to track the caller's product, the function should take a *Product and store the pointer directly: func remember(p *Product) { saved = p }. Then saved and the caller's book share the same underlying memory.

Putting It Together

A small end-to-end example brings the pieces together. The program creates a product with a constructor that returns a pointer, mutates the product in place through pointer-parameter functions, and reads it back from the original variable to confirm the changes stuck.

Three of the functions take or return pointers. newProduct returns a *Product, and applyDiscount and restock accept *Product so they can update the caller's product. The fourth, summary, takes a Product by value because it only reads, and value semantics signal that intent. The *book at the call site dereferences the pointer to hand summary a copy of the underlying struct.

Notice the natural division of labor. Functions that produce or mutate use pointers. Functions that read use values. That's not a hard rule, but it's a reasonable starting point.