Not sure how to structure your Go web application?

My new book guides you through the start-to-finish build of a real world web application in Go — covering topics like how to structure your code, manage dependencies, create dynamic database-driven pages, and how to authenticate and authorize users securely.

Take a look!

Demystifying function parameters in Go

Published on:

In this post we're going to talk about how (and why!) different types of function parameters behave differently in Go.

If you're new (or even not-so-new) to Go, this can be a common source of confusion and questions. Why do functions generally mutate maps and slices, but not other data types? Why isn't my slice being mutated when I append to it in a function? Why doesn't assigning a new value to a pointer parameter have any effect outside the function?

Once you understand how functions and the different Go types work, the answers to these kind of questions becomes clearer. You'll discover that Go's behavior consistently follows a few fairly straightforward rules, which I'll aim to highlight in this post.

(If you just want the actionable takeaways, you can skip to the summary.)

Parameters and arguments

Before we dive into this post, I'd like to quickly explain the difference between parameters and arguments. People sometimes use these terms interchangeably – but for this tutorial it's important that we're precise on the terminology.

  • Parameters are the variables that you define in a function declaration.
  • Arguments are the values that get passed to the function for execution.

(A neat way to remember this is arguments = actual values.)

Functions operate on copies of the arguments

It's important to understand that when you call a function in Go, the function always operates on a copy of the arguments. That is, the parameters contain a copy of the argument values.

We can illustrate this with the following short example:

package main

import "fmt"

func incrementScore(s int) {
    s += 10
}

func main() {
    score := 20
    incrementScore(score)
    fmt.Println("The score is", score) // Prints: "The score is 20"
}

When you run this program it will print "The score is 20", not "The score is 30". That's because the parameter s in incrementScore() contains a copy of the score argument, and when we increment the value with s += 10 we are updating this copy, not the original score variable in the main() function.

We can confirm this behavior by using the reference operator & to get the memory addresses of the score argument and s parameter, like so:

package main

import "fmt"

func incrementScore(s int) {
    fmt.Println("has address", &s) // Prints: "has address 0xc000012040"
    s += 10
}

func main() {
    score := 20
    fmt.Println("has address", &score) // Prints: "has address 0xc000012028"
    incrementScore(score)
}

If you run this, you'll see that the printed memory addresses are different – in my case 0xc000012028 for the score argument and 0xc000012040 for the parameter s. That confirms that they are truly different variables, with their values stored at different locations in memory. With that in mind, it's not surprising that changing one doesn't change the other.

Just to hammer home the point one more time: in Go, functions always operate on a copy of the arguments. There are no exceptions to this.

Pointer parameters

So, what can we do if we want incrementScore() to actually change the score variable?

The answer is to change the signature of incrementScore() so that the parameter s is a pointer, like func incrementScore(s *int). Let's take a look at a working example and then talk it through.

package main

import "fmt"

func incrementScore(s *int) {
    newScore := *s + 10
    *s = newScore
}

func main() {
    score := 20
    incrementScore(&score)
    fmt.Println("The score is", score) // Prints: "The score is 30"
}

In this code:

  • We declare the score variable normally in main() with the line score := 20.
  • Then in the line incrementScore(&score) we use the reference operator & to get a pointer to the score variable, and pass this pointer as the argument to incrementScore(). Remember, a pointer just contains a memory address – in this case it's the memory address of the score variable.
  • When incrementScore() is executed, the parameter s contains a copy of this pointer. But this copy still holds the same memory address – the memory address of the score variable.
  • In the line newScore := *s + 10 we use the dereference operator *s to 'read through' and get the underlying value at that memory address, and add ten to it.
  • Then in the next line *s = newScore we use the dereference operator again to 'write through' and set newScore as the value at that memory address.
  • The end result is that we've mutated the value at the memory address of the score variable. So when the program executes the final line of code, we get the output "The score is 30".

I should point out that I made the code here a bit more verbose than it needs to be. You can simplify incrementScore() to use the += operator like so:

func incrementScore(s *int) {
    *s += 10
}

Write-though vs reassignment

In the example above, we used the deference operator *s to read-through and then write-through to the underlying memory address. But what would happen if we didn't write-through, and assigned a completely new pointer value to s instead? Let's take a look.

package main

import "fmt"

func incrementScore(s *int) {
    newScore := *s + 10
    s = &newScore
}

func main() {
    score := 20
    incrementScore(&score)
    fmt.Println("The score is", score) // Prints: "The score is 20"
}

If you run this, you'll see we're back to the situation where the score value isn't being mutated, and the program is printing "The score is 20" again.

The only thing that's changed here is the body of the incrementScore() function. In this code:

  • The line newScore := *s + 10 is exactly the same as before. It reads through to get the underlying score value from the s parameter, adds ten to it, and assigns the result to the newScore variable.
  • But the line s = &newScore is different. Here we use the reference operator &newScore to get a pointer to the newScore variable, and assign this to s. This means that the variable s no longer contains the memory address of the score variable from main() – it now contains the memory address of the newScore variable.

So, in this example, incrementScore() doesn't ever 'write-through' and change anything at the memory address of the score variable. All it does is replace s with a completely different pointer, which is then discarded when the function returns.

This is just one example of a more general rule. Assigning a new value to a parameter with the = operator won't affect the argument in any way (unless the parameter is a pointer and you are dereferencing it and 'writing-through' a new value). Remember, the parameter is just a copy of the argument.

Automatic dereferencing

Let's continue with the same example, but update the incrementScore() function so that it accepts a pointer to a custom player struct, containing the player's name and score.

package main

import "fmt"

type player struct {
    name  string
    score int
}

// Make the parameter a pointer to a player struct.
func incrementScore(p *player) {
    p.score += 10
}

func main() {
    // Initialize a player struct and assign it to the variable p1.
    p1 := player{name: "Alice", score: 20}
    // Pass a pointer to p1 to incrementScore().
    incrementScore(&p1)
    fmt.Printf("The score for %s is %d", p1.name, p1.score) // Prints: "The score for Alice is 30"
}

So as you might expect, because the parameter p in incrementScore() is a pointer, the changes that we make to p affect the data at the underlying memory address of p1 and the program prints "The score for Alice is 30".

But the most interesting part here is the line of code p.score += 10 in the incrementScore() function. p is a pointer, but we appears that we don't have to dereference it using the * operator in order to write-through the new value.

You could – if you wanted to – change this line to be (*p).score += 10. That's perfectly valid and will compile fine. But it's not necessary. If you have a pointer to a struct (which is what the p parameter is here), then Go will automatically dereference the pointer for you when you use the dot operator . on it to access a field or call a method.

You can also use index expressions on a pointer to an array without dereferencing it. (Note that this will only work on arrays, not slices). For example:

a := &[3]string{"a", "b", "c"}
fmt.Println(a[1]) // Instead of having to write (*a)[1]

"Reference types"

Everything we've illustrated in this tutorial so far is true when the parameter type is a basic type, a struct, an array, a function, or a pointer to any of those things.

However, the behavior that you get when a parameter is a map, slice or channel type needs some further discussion. Once you realize how these types are implemented at runtime behind the scenes, you'll see that their behavior actually follows the same rules as the other Go types – but nonetheless it can be a bit confusing at first.

If you've been programming for a while, you might be familiar with the terms pass-by-value and pass-by-reference from other languages. You might have also heard or read people in the Go world saying things like "maps, slices and channels are reference types", or "maps, slices and channels are passed by reference".

Well... the sentiment there is sort of right, but the wording isn't correct and needs tightening up.

Firstly, Go does not support pass-by-reference behavior. I've probably banged this drum enough already now, but parameters are always a copy of the arguments. That is, they are always passed by value. Even pointers are passed by value; a pointer parameter will contain a copy of the pointer.

Strictly speaking, there's also no such group of things in Go known as "reference types". To be fair, the Go spec did use "reference types" as an umbrella term for maps, slices and channels in one sentence, but this was removed over a decade ago (with the commit message Go has no 'reference types'). Basically, I recommend forgetting hearing the term "reference types" in relation to Go, and replacing it with an understanding of how maps, channels and slices are actually implemented.

Maps and Channels

The important thing to understand is that behind-the-scenes when your code is running, the Go runtime implements a map as a pointer to a runtime.hmap struct, and a channel as a pointer to a runtime.hchan struct.

This means that map and channel parameters behave in a similar way to regular pointer parameters. The parameter will contain a copy of the map or channel, but this copy will still point to the same underlying memory location that holds the runtime.hmap or runtime.hchan struct. In turn, that means that any changes you make to a map or channel parameter will also mutate the argument.

Let's look at an example, where we create a scores map containing the names and scores for multiple players like map[string]int{"Alice": 20, "Bob": 160}, and then pass it to a function that increments the score by ten for all players.

package main

import "fmt"

func incrementAllScores(sm map[string]int) {
    for name := range sm {
        sm[name] += 10
    }
}

func main() {
    scores := map[string]int{"Alice": 20, "Bob": 160}
    incrementAllScores(scores)
    fmt.Println(scores) // Prints: map[Alice:30 Bob:170]
}

When you run this, you'll see that incrementAllScores() mutates the scores argument and the program prints map[Alice:30 Bob:170] as the output.

Because of this behavior, you normally won't need to use a pointer to a map or channel as a function parameter.

On the other hand, if you don't want a function to mutate a map, you can use the maps.Clone() function to create a clone that points to a different memory location, and work on the clone instead.

func example(m map[string]int) {
    cm := maps.Clone(m)
    // ... do something with the cloned map.
}

Slices

How slices work behind the scenes in Go can be pretty difficult to grok, and if you'd like a detailed explanation I recommend reading the Go Slices: usage and internals post on the official blog.

But as a high-level summary, the Go runtime implements slices as a runtime.slice struct. This struct wraps a pointer to a (fixed-size) array that actually stores the slice data. You can think of a slice as being a bit like a 'window through' to a segment of this underlying array.

So when you have a function with a slice parameter, the parameter will contain a copy of the slice argument you pass it. Effectively, it will have a copy of the runtime.slice struct. But the pointer in this copy of runtime.slice will still point to the same underlying array, meaning that any changes you make to a slice parameter will also mutate the argument.

To demonstrate this, let's say that we have a slice containing some player scores, and pass it to a addBonus() function that adds fifty to each score in the slice.

package main

import "fmt"

func addBonus(s []int) {
    for i := range s {
        s[i] += 50
    }
}

func main() {
    scores := []int{10, 20, 30}
    addBonus(scores)
    fmt.Println(scores) // Prints: [60 70 80]
}

When you run this code it will print [60 70 80], demonstrating that the changes made in addBonus() mutated the elements in the scores slice.

If you don't want a function to mutate a slice, you can make a clone of it using slices.Clone() and work on that instead.

func example(s []int) {
    cs := slices.Clone(s)
    // ... do something with the cloned slice.
}

So far, so good. Slices generally behave pretty much like maps and channels, in the sense that changing a slice parameter will mutate the argument. If you don't want that, you can make a clone at the start of the function and use the clone instead.

But using append() on a slice parameter can sometimes be a source of confusion. Consider the following code, where we create a variadic addScores() function that appends some new values to a scores slice.

package main

import "fmt"

func addScores(s []int, values ...int) {
    s = append(s, values...)
}

func main() {
    scores := []int{10, 20, 30}
    addScores(scores, 40, 50, 60)
    fmt.Println(scores) // Prints: [10 20 30]
}

(Yes, this is a bit of a silly example, but it illustrates the point in a simple way.)

When you run this program, it will print out [10 20 30] – demonstrating that the append() operation in addScores() has not affected the scores argument.

This actually makes sense and is consistent with the other behavior we've seen in this post. Earlier on I said:

Assigning a new value to a parameter with the = operator won't affect the argument in any way (unless the parameter is a pointer and you are dereferencing it and 'writing-through' a new value). Remember, the parameter is just a copy of the argument.

The code s = append(s, values...) is no different. We're replacing the s parameter with a new value, and this operation doesn't touch the argument in any way.

So what about when you want an append() operation in a function to mutate the argument? The answer here is to make the parameter a pointer to a slice, like so:

package main

import "fmt"

func addScores(s *[]int, values ...int) {
    *s = append(*s, values...)
}

func main() {
    scores := []int{10, 20, 30}
    addScores(&scores, 40, 50, 60)
    fmt.Println(scores) // Prints: [10 20 30 40 50 60]
}

Now with the line *s = append(*s, values...), whatever is returned by the append() function will be 'written-through' to the memory address of the scores argument.

Exactly the same logic applies for operations to 'reslice' a slice and assign the result back to the parameter, like s = s[0:1]. If you want this operation to mutate the argument, you should make the parameter a pointer and dereference it like *s = (*s)[0:1].

Summary

We've covered a lot of ground in this post, so I'll try to summarize everything into a handful of take-away points.

  • Parameters always contain a copy of the argument. Go doesn't have "reference types" or support pass-by-reference semantics.

  • For the basic Go types, as well as structs, arrays and functions, changing the value of a parameter in the function body won’t change the value of the argument. But if you do want to mutate the argument, you can use a pointer parameter instead and dereference it inside the function to ‘write-through’ a new value to the argument's memory address. For common operations on structs and arrays, Go will automatically dereference the pointer for you.

  • Because of the way that they're implemented by the Go runtime, changes you make to map, slice, channel parameters in a function will mutate the argument. If you don't want this, make a clone at the start of the function and use that instead.

  • Using the = operator to assign a new value to a parameter does not affect the argument (unless you are manually-or-automatically dereferencing a pointer and 'writing-through' a new value). So for slices, if you want a function to perform an append or reslice operation that mutates the argument, you should use a pointer to a slice as the function parameter and dereference it as necessary.