JavaRush /Courses /Go SELF /In Go, everything is passed by value

In Go, everything is passed by value

Go SELF
Level 19, Lesson 1
Available

1. Values, parameters, and pointers

If you’ve already written in languages where “everything is by reference by default” or “everything is by value by default,” Go can seem tricky: you pass a slice into a function—and it changes; you pass a map—and it changes; you pass an int—and it doesn’t change. It’s tempting to say, “well, see, not everything is by value!” But in Go everything is honest—you just need to distinguish changing a variable from changing the data the variable points to.

Imagine a variable is a note on a desk. Passing “by value” means making a copy of the note and handing it to a function. But if the note contains a warehouse address, then the copy of the note still points to the same warehouse. The function can modify the warehouse contents, and you’ll see it “from the outside.” The note itself (your variable) does not have to change.

Assignment copies the value

Before talking about functions, it’s useful to lock in a very down-to-earth fact: when you write b = a, Go copies the value of a into b. For simple types this is as obvious as it gets: you copy a number—you get another number. For “composite” values (a pointer, a slice, a map), the value is also copied; it’s just “not simple” and contains references to data inside.

Let’s warm up with numbers: there’s no magic here, and that’s good—we’ll leave magic to magicians and some JavaScript frameworks.

package main

import "fmt"

func main() {
	a := 10
	b := a
	b = 99

	fmt.Println(a) // 10
	fmt.Println(b) // 99
}

This is exactly the “thinking style” you need to keep in mind going forward: values are copied. The difference begins when the value itself is an “address.”

Function parameters are local copies of arguments

The key point: when you call a function, Go creates new parameter variables and copies the argument values into them. That is, the parameter x inside a function is not a “magic portal” into a variable in the caller’s code. It’s a separate variable, simply initialized with a copy.

The most straightforward example is incrementing a number.

package main

import "fmt"

func incVal(x int) {
	x++
	fmt.Println("inside:", x) // inside: 11
}

func main() {
	a := 10
	incVal(a)
	fmt.Println("outside:", a) // outside: 10
}

This is the fundamental model: a parameter is a copy.

If you need to “change a from the outside,” you have two normal options (and they are not mutually exclusive): either return a new value and assign it, or pass a pointer. Next we’ll break down why this does not contradict “pass by value.”

Pointers are also passed by value: the address is copied

This is where students usually have a small cognitive explosion: “if a pointer is passed by value, why can we change a through *p inside the function?” The answer is simple: the pointer itself is copied—i.e., the address. And that address (in both copies) points to the same memory location. So a change at that address is visible to everyone holding a copy of that address.

package main

import "fmt"

func incPtr(p *int) {
	*p++
}

func main() {
	a := 10
	incPtr(&a)
	fmt.Println(a) // 11
}

Notice an important subtlety: we did not “pass variable a by reference,” we passed a value that is the address of a. And that value was copied into the parameter p.

To feel the difference, let’s try changing the pointer itself inside the function (i.e., make it “look at” a different location). This will not change a, because you will change only the local copy of the pointer.

package main

import "fmt"

func pointToOther(p *int) {
	x := 777
	p = &x // change only the local copy of p
}

func main() {
	a := 10
	pointToOther(&a)
	fmt.Println(a) // 10
}

This is an important observation: you can modify data at an address, but reassigning the pointer itself does not affect what was outside, because the pointer was passed by value.

3. Slices and map: “reference-like” behavior without pass-by-reference

Slices are more interesting because a slice is not an array. A slice is a “window” (view) onto an array of data. Inside a slice there is a small header: a pointer to the data, a length (len), and a capacity (cap). That’s why a slice behaves “by reference”: two different slices can look at the same underlying array.

This internal idea is worth not just memorizing, but “seeing” as a diagram:

flowchart LR
    A[Variable s: slice header] -->|ptr| B[(Underlying array)]
    A -->|len| L[len]
    A -->|cap| C[cap]

The “ptr + len + cap” fact matters not as theory for theory’s sake—it explains 80% of slice weirdness.

Why changing s[0] is visible outside

If a function receives a copy of the slice header, then both headers point to the same underlying array. Therefore, changing an element modifies shared data.

package main

import "fmt"

func setFirst(s []int) {
	if len(s) > 0 {
		s[0] = 99
	}
}

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

The change is visible from the outside, but this is not “breaking the rule.” It’s a consequence of the fact that the header copy stores the same pointer to the same data.

Why append often requires return

This is where real bugs begin. When you do append, Go may grow the slice within the same array, or it may allocate a new array and copy the data there. If the slice length changes, the function that does it usually must return the new slice—otherwise the caller continues living with the old header.

This is not “style advice,” it’s a necessity that follows from how slices work and from the fact that changing length means changing the header, and our header in the function is a local copy.

Let’s make an example of incorrect code (a classic):

package main

import "fmt"

func addTaskBad(tasks []string, title string) {
	tasks = append(tasks, title) // modified the local header copy
}

func main() {
	tasks := make([]string, 0, 1)
	addTaskBad(tasks, "learn Go")
	fmt.Println(tasks) // []
}

Almost every student at this point says, “But I added it!” Yes, you added it—but to the local variable tasks inside the function, because assigning the result of append changes the header (len/cap/ptr).

The correct way is to return the slice:

package main

import "fmt"

func addTask(tasks []string, title string) []string {
	tasks = append(tasks, title)
	return tasks
}

func main() {
	tasks := make([]string, 0, 1)
	tasks = addTask(tasks, "learn Go")
	fmt.Println(tasks) // [learn Go]
}

In practice this becomes a useful rule for reading code: if you see a function that “adds to a slice” and it returns nothing, you should almost automatically be suspicious.

By the way, many standard library functions and the slices package return a new slice for exactly this reason: they change length/content and want the caller to work with a correct result, not with an old “invalidated” header.

map: content changes are visible, reassignment is not

With map it feels even trickier: you almost always see “changes from the outside,” even if you return nothing. That makes it easy to draw the wrong conclusion: “map is passed by reference.” In practice the model is the same as with slices: a map value contains a reference to internal storage (a table), and when passing it you copy a “header” that points to the same data.

So when you do m["x"] = 1 inside a function, you modify shared storage, and it’s visible outside.

package main

import "fmt"

func markDone(done map[string]bool, title string) {
	done[title] = true
}

func main() {
	done := make(map[string]bool)
	markDone(done, "learn Go")
	fmt.Println(done["learn Go"]) // true
}

But here too it’s important to distinguish “change the contents” vs “reassign the variable.” If inside the function you do done = make(map[string]bool), you create a new map and assign it to the local variable done, and nothing changes outside.

package main

import "fmt"

func resetMapBad(done map[string]bool) {
	done = make(map[string]bool) // reassigned only the local variable
	done["x"] = true
}

func main() {
	done := map[string]bool{"learn Go": true}
	resetMapBad(done)
	fmt.Println(done["learn Go"]) // true
	fmt.Println(done["x"])        // false
}

If you want to “reset the map,” do it like with a slice: either return a new map, or clear the existing one (clearing is changing shared storage, so it will be visible outside).

Cheat sheet: what is copied and what can change

When you read code, it’s useful for your brain to hold not 30 exceptions but one supporting table: what happens if a function reassigns a parameter, and what happens if a function changes the “inside” data.

Argument type What is copied on call Does x = ... inside the function affect the outside? Do “internal” mutations affect the outside?
int, bool, string
value no not applicable
*T
address no (reassigning
p
)
yes (
*p = ...
)
[]T
header (ptr/len/cap) no (reassigning
s
)
yes (
s[i] = ...
)
map[K]V
header (reference to the table) no (reassigning
m
)
yes (
m[k] = v
,
delete(m, k)
)

The key meaning of this table: the rule “everything is passed by value” does not break in any of these cases. It’s just that sometimes the value contains a “reference” to shared data.

4. Mini app “Task List” without structs

Let’s carefully continue the learning project (let’s call it tasklite). We are not using structs yet, so we’ll store the task list as []string, and the completion status as map[string]bool.

The idea is simple: we’ll have small functions that either modify shared data (for example, a map), or return a new value (for example, an updated slice).

Adding a task: the function returns a slice

Let’s start with safely adding a task. We already saw that the variant without return very easily “loses” the addition.

package main

import "fmt"

func addTask(tasks []string, title string) []string {
	return append(tasks, title)
}

func main() {
	tasks := make([]string, 0)
	tasks = addTask(tasks, "learn Go")
	tasks = addTask(tasks, "drink water")

	fmt.Println(tasks) // [learn Go drink water]
}

What matters here is that we always assign the result. This is the same principle as why append returns a value, and why many functions from slices return a new slice: length changes require an updated header.

Mark as “done”: you can do it without return, but you must understand the contract

With done status it’s simpler: writing into a map changes shared storage, so returning the map is not required.

package main

import "fmt"

func markDone(done map[string]bool, title string) {
	done[title] = true
}

func main() {
	done := make(map[string]bool)

	markDone(done, "learn Go")
	fmt.Println(done["learn Go"]) // true
}

But this is where a typical beginner bug is born: “if you don’t need to return a map, then you don’t need to return a slice either.” No: with slices it depends on whether you only change elements or you change length/capacity.

Renaming a task: a slice can be modified “in place”

If a task is stored as a string in an array, you can change an element by index—this changes shared data in the underlying array.

package main

import "fmt"

func renameTask(tasks []string, i int, newTitle string) {
	if i < 0 || i >= len(tasks) {
		return
	}
	tasks[i] = newTitle
}

func main() {
	tasks := []string{"learn Go", "drink water"}
	renameTask(tasks, 0, "learn Go seriously")

	fmt.Println(tasks) // [learn Go seriously drink water]
}

Here return is not needed because the length did not change, the header did not change—we changed the array contents, and both headers point to the same array.

Resetting the task list: “reassign” vs “clear”

Sometimes you want to say “reset the task list.” If you write this:

package main

import "fmt"

func resetTasksBad(tasks []string) {
	tasks = nil // this is reassignment of a local variable
}

func main() {
	tasks := []string{"a", "b"}
	resetTasksBad(tasks)
	fmt.Println(tasks) // [a b]
}

…then nothing will happen outside, because you reassigned the local copy of the header.

A normal “Go-ish” option for a beginner is to return a new slice:

package main

import "fmt"

func resetTasks(tasks []string) []string {
	return tasks[:0] // length 0, but memory may remain (this is fine)
}

func main() {
	tasks := []string{"a", "b"}
	tasks = resetTasks(tasks)

	fmt.Println(tasks)      // []
	fmt.Println(len(tasks)) // 0
}

5. Common mistakes

Mistake #1: expecting func inc(x int) to change the original variable.
This is one of the most common “beginner” bugs. It almost always comes from habits from other languages or from the feeling that “the function works with the variable.” In Go, a parameter is a copy. Want a new value—return it and assign it. Want to modify “in place”—pass a pointer, but only if it really makes the code simpler, not scarier.

Mistake #2: calling append inside a function and not returning the result.
This bug is especially nasty because sometimes it “seems to work” (if cap allows growth without reallocation) and sometimes it “suddenly doesn’t work.” The correct mental model is: append returns a new slice, and if you change the length, you must use the returned value. This follows from the fact that a slice is a header (ptr/len/cap), and changing it cannot magically update the external header.

Mistake #3: mixing up “change an element” and “reassign a variable.”
The phrase “I changed the slice” can mean two different things: “I changed s[0]” and “I did s = append(s, ...)”. In the first case, shared data changes; in the second, the slice header changes, and the header inside the function is local. With map it’s similar: m[k] = v changes shared state, while m = make(map[K]V) just reassigns a local variable.

Mistake #4: trying to “reset a map” by doing m = make(...) inside a function and expecting it to become empty outside.
It’s the same logic as with a pointer: you only change the local copy of the value. If you need to replace the map, return a new map and assign it outside. If you need to clear the current one, delete keys (that is a change to shared state).

Mistake #5: treating pointers as a cure-all.
When someone first realizes that a pointer “makes changes visible outside,” the temptation is to pass *int, *[]string, *map[string]bool everywhere. Usually this makes code more fragile: nil appears, more aliasing effects appear, it’s harder to read who owns the data. Good style is to first try to make the function pure (return a new value), and only if that is truly inconvenient switch to mutation via a pointer.

1
Task
Go SELF, level 19, lesson 1
Locked
Copy of the argument
Copy of the argument
1
Task
Go SELF, level 19, lesson 1
Locked
Stubborn Pointer
Stubborn Pointer
1
Task
Go SELF, level 19, lesson 1
Locked
Slice title
Slice title
1
Task
Go SELF, level 19, lesson 1
Locked
Map and Mirage
Map and Mirage
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION