Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, you wrote functions that return several values, accept other functions, and take variadic arguments. You also saw a function return an updated slice rather than assume its caller would see a new slice length. Today you’ll examine the rule behind that choice: Go passes every function argument by value, but some copied values still refer to shared data.
By the end, you should be able to predict whether an assignment or function call copies data, shares data, or changes only a local variable—and choose deliberately when a function should mutate its caller’s state. Allow about 40 minutes, including the short readings, video segments, and a run in your existing module.
What exactly gets copied?
When you pass a value to a Go function, the parameter receives a copy. For an int, that copy is another integer. For a pointer, it is another pointer to the same object.
func bumpValue(n int) {
n++
}
func bumpPointer(p *int) {
(*p)++
}
n := 4
bumpValue(n)
fmt.Println(n) // 4
bumpPointer(&n)
fmt.Println(n) // 5
The expression &n takes the address of n; its type is *int. Inside bumpPointer, *p accesses the integer at that address. The pointer parameter p was copied, but both the original pointer value and its copy identify the same integer. Reassigning p itself would change only the local parameter, not the caller’s variable.
A pointer can be nil, meaning it does not point to a value. Dereferencing a nil pointer panics, so a function that permits nil must handle it before using *p. Go does not require pointers for ordinary local variables, nor do you need a pointer simply to avoid writing a return statement.
Read the official Go FAQ’s answer to “When are function parameters passed by value?” It states the rule precisely and then applies it to maps and slices—the distinction we will use throughout this lesson.
Frequently Asked Questions (FAQ)
Read the Go FAQ to establish what a function actually receives when you pass an integer, pointer, map, or slice.
Under “Pointers and Allocation,” find “When are function parameters passed by value?” Read the full answer. Focus on the difference between copying a value and copying the data to which that value refers; you can leave the interface example for later.
The opening of Go Class: 14 Reference & Value Semantics, by Matt KØDVB, frames the practical decision as sharing versus duplicating data. Its later example shows another legitimate choice: copy a small value, change the copy, and return it.
Go Class: 14 Reference & Value Semantics
Watch these brief portions of “Go Class: 14 Reference & Value Semantics” for the design choice between sharing an object and returning a modified copy.
Watch sharing versus copying, then skip to returning a copy. Focus on which version allows a caller to observe a change without sharing the original object.
Values can protect state—or copy more than you intended
An array is a value containing its elements. Assigning or passing an array copies those elements:
func changeFirst(a [2]int) {
a[0] = 99
}
original := [2]int{10, 20}
duplicate := original
duplicate[0] = 30
changeFirst(original)
fmt.Println(original) // [10 20]
fmt.Println(duplicate) // [30 20]
A struct also has value semantics. That makes passing a small struct and returning a changed version a useful design when the original should remain unchanged:
type Tally struct {
Label string
Count int
}
func increased(t Tally) Tally {
t.Count++
return t
}
old := Tally{Label: "visits", Count: 2}
updated := increased(old)
fmt.Println(old.Count, updated.Count) // 2 3
If the function instead needs to change the caller’s tally, accept *Tally and modify its fields through the pointer. That choice communicates intended mutation as well as making the change visible. Copying a large struct or array may cost more than copying a pointer, but a pointer is not automatically faster: it also introduces sharing and may have other runtime costs. Choose the semantics you need first; measure performance when it matters.
There is an important limit to what a struct copy isolates. Copying a struct copies its fields as values. If one of those fields is a slice, the new struct gets a copy of the slice descriptor, not a copy of its underlying elements. We will use that fact shortly.
For the array rule, read the “Arrays” subsection of Effective Go. It also explains why slices, rather than pointers to arrays, are the usual choice for general sequences.
Read the official guide’s array discussion to connect the copy rule to the arrays and slices you studied earlier.
In “Data” → “Arrays,” start with the bullet beginning “Arrays are values.” Read the array discussion through its closing recommendation. Notice that passing an array copies it, while passing a pointer to an array does not—but that slices are normally the better sequence parameter.
A copied slice can still change shared elements
A slice value describes a view of an underlying array. Conceptually, it contains a reference to the array, a length, and a capacity. Assigning a slice or passing it to a function copies that descriptor. The copied descriptors initially view the same elements.

This explains two behaviors that initially seem contradictory:
func editAndExtend(values []int) {
values[0] = 7
values = append(values, 9)
}
numbers := make([]int, 1, 1)
numbers[0] = 4
editAndExtend(numbers)
fmt.Println(numbers) // [7]
Changing values[0] changes a shared array element, so the caller sees 7. But append returns a new slice value. Assigning that value to the local parameter values does not replace the caller’s slice descriptor. Here the original capacity is exactly one, so extending the slice requires another underlying array; regardless, the caller still has its original length of one.
To let the caller receive a changed length—or a potentially new underlying array—return the slice and assign the result:
func editAndExtend(values []int) []int {
values[0] = 7
return append(values, 9)
}
numbers = editAndExtend(numbers)
fmt.Println(numbers) // [7 9]
This is why functions such as last lesson’s collectLabels return the slice they build. A pointer to a slice can also let a function replace the caller’s descriptor, but returning the new slice is usually simpler.
Read Effective Go’s “Slices” subsection, stopping after its explanation of why its Append function must return a slice. The example implements more machinery than you need to memorize; its final reasoning is the point.
Read the official guide’s slice explanation to see element mutation and slice-header replacement handled in one example.
In “Data” → “Slices,” begin at “Slices hold references to an underlying array.” Follow the slice explanation through the paragraph beginning “We must return the slice afterwards.” Pay particular attention to what the example copies into a new array and why it returns the resulting slice.
For another explanation of these two common pitfalls, return to the same Go Class video. This segment also notes that a range value is a copy of the element; if you want to modify a struct in a slice, use its index to access the element rather than modifying the range variable.
Go Class: 14 Reference & Value Semantics
This part of the Go Class video connects copied range values with the need to return a slice after appending.
Watch range and append. Focus on the difference between changing an element through its index and changing a copied range variable, then on why appending inside a function requires returning the new slice.
When you need an independent copy of slice elements, allocate storage and copy into it:
independent := make([]int, len(numbers))
copy(independent, numbers)
Now changing independent[0] will not change numbers[0]. This copies the elements one level deep: if an element itself contains a slice, map, or pointer, further copying may be needed to isolate the data it refers to.
Maps share entries without needing a pointer parameter
A map value is also a small descriptor referring to map data. Copying it does not duplicate its entries:
counts := map[string]int{"visits": 1}
other := counts
other["visits"]++
fmt.Println(counts["visits"]) // 2

The same rule holds across a function call:
func recordVisit(counts map[string]int, label string) {
counts[label]++
}
recordVisit does not need *map[string]int to change an entry. On the other hand, assigning a new map to its local counts parameter would not replace the caller’s map variable. If replacing the map is the goal, return the new map and let the caller assign it. Also remember that a nil map can be read but cannot be written to; initialize a map before adding entries.
Sharing a map may be exactly what you intend for a visit counter. If a function must own independent entries, allocate a new map and copy them:
independentCounts := make(map[string]int, len(counts))
for key, value := range counts {
independentCounts[key] = value
}
As with a slice, this is a one-level copy: a map whose values contain pointers or slices can still share the data behind those values.
Make mutation a deliberate part of a function’s contract
Use this decision rule when writing a function:
- Pass a value when it should work with its own copy, or when it only needs to read the value. Return a modified copy if the caller needs the result.
- Pass a pointer when the function must change the caller’s ordinary value, or when copying a large value is demonstrably undesirable. A copied pointer still shares its target.
- Pass a slice or map normally when the function needs to read or change existing elements or entries. For a slice whose length may change, return the resulting slice. Make an explicit copy when sharing would violate the function’s contract.
Try those predictions in your existing module. Add the following to a new file in package main, call showSemantics() from your existing main, and run go run .:
package main
import "fmt"
type VisitTally struct {
Label string
Count int
}
func changedCopy(t VisitTally) VisitTally {
t.Count++
return t
}
func changeOriginal(t *VisitTally) {
t.Count++
}
func extend(values []int) []int {
values[0]++
return append(values, 9)
}
func record(counts map[string]int, label string) {
counts[label]++
}
func showSemantics() {
tally := VisitTally{Label: "visits", Count: 2}
next := changedCopy(tally)
fmt.Println("copies:", tally.Count, next.Count) // 2 3
changeOriginal(&tally)
fmt.Println("pointer:", tally.Count) // 3
values := make([]int, 1, 1)
values[0] = 4
extended := extend(values)
fmt.Println("slices:", values, extended) // [5] [5 9]
counts := map[string]int{"visits": 1}
record(counts, "visits")
fmt.Println("map:", counts["visits"]) // 2
}
The slice line is the most useful one to inspect: values sees the changed existing element but retains its old length; extended has the returned length of two. The tally lines contrast returning a changed copy with changing the original through a pointer.
Takeaways
Go always copies the value passed to a function. An array or plain struct copy can give you independent data; a pointer copy still identifies the same target; and copied slice or map values still refer to shared underlying data. Separate “can this function change an element?” from “can it replace the caller’s variable?” That distinction tells you when to pass a pointer, return a new slice, or make an explicit copy.
Next, you’ll use defer to manage cleanup and reason about when deferred work runs. The same attention to function boundaries and values will help you understand what a deferred call receives.