← All blogs
  • Go
  • Function
  • Defer

Understanding Functions in Go: Parameters, Slices, Maps, Recursion, and defer

Explore how Go functions handle parameters, slices, maps, recursion, and defer, with examples of copying and shared state.

Originally published on Medium ↗. Read the full article below.

When I started learning functions in Go, I thought they would be pretty straightforward.

Define a function, pass some values, get something back, and move on.

But once I got into arrays, slices, maps, multiple return values, recursion, and defer, I realized there are a few details that are really worth understanding properly.

The part that confused me the most was parameter passing.

Go says that arguments are passed by value. But then you can change something inside a slice or map and see the change outside the function.

So what exactly is being copied?

That question helped connect a lot of the concepts for me. Here’s how I understand it now.

Understanding Functions in Go: Parameters, Slices, Maps, Recursion, and defer

Functions are values too

One thing I like about Go is that functions aren’t limited to just being something we call.

A function can also be stored in a variable.

For example:

package main

import "fmt"

func add(a int, b int) int {
 return a + b
}

func main() {

 var operation func(int, int) int
 operation = add

 fmt.Println(operation(2, 3))
}

Output

5

Here, operation is a variable that can hold a function with this signature:

func(int, int) int

So this:

operation(2, 3)

calls the function stored in operation.

We can also create a function without giving it a name:

package main

import "fmt"

func main() {
 operation := func(a int, b int) int {
  return a + b
 }

 fmt.Println(operation(2, 3))
}

Output

5

This becomes useful when we want to pass functions around or use a small function only in one place.

Function signatures

A function’s signature describes the types of its parameters and its return values.

For example:

func add(a int, b int) int

The parameter names a and b aren't what make the signature.

These two functions have the same parameter and return types:

func add(a int, b int) int

and:

func sum(x int, y int) int

The names are different, but both take two int values and return an int.

This becomes especially important when working with function variables.

The part that confused me: how parameters are passed

Here’s the rule I kept coming back to:

Go passes arguments by value.

That means the function receives a copy of the argument.

But the interesting question is:

What exactly is being copied?

This becomes much easier to understand when we look at arrays, slices, and maps separately.

Arrays are copied

Let’s start with an array.

package main

import "fmt"

func change(arr [3]int) {
 arr[0] = 100
 fmt.Println("Inside function:", arr)
}

func main() {
 arr := [3]int{1, 2, 3}
 fmt.Println("Before:", arr)

 change(arr)

 fmt.Println("After:", arr)
}

Output

Before: [1 2 3]
Inside function: [100 2 3]
After: [1 2 3]

Why didn’t the original array change?

Because the entire array was copied when we passed it to the function.

So we can think of it like this:

main's array
[1 2 3]
      ↓ copy
function's array
[1 2 3]

The function changes its own copy.

That’s why the array in main remains:

[1 2 3]

Slices are different

This is where things get more interesting.

Consider:

package main

import "fmt"

func change(s []int) {
 s[0] = 100
 fmt.Println("Inside function:", s)
}

func main() {
 s := []int{1, 2, 3}

 fmt.Println("Before:", s)

 change(s)

 fmt.Println("After:", s)
}

Output

Before: [1 2 3]
Inside function: [100 2 3]
After: [100 2 3]

Wait.

If Go passes the slice by value, why did the original slice change?

The important detail is that a slice is not the entire underlying array.

A slice contains information that describes a portion of an underlying array, including its pointer to the data, length, and capacity.

When we pass a slice to a function, that slice value is copied.

But both slice values can refer to the same underlying array.

Conceptually:

main's slice
     |
     v
[1 2 3]
     ^
     |
function's copied slice

So when the function does:

s[0] = 100

it’s changing the shared underlying array.

That’s why main() sees:

[100 2 3]

The important distinction is:

The slice itself is copied, but the underlying array can be shared.

This is one of the places where saying “slices are passed by reference” can cause confusion.

Technically, the slice value is still passed by value.

Maps work in a similar way

Maps can be confusing for a similar reason.

Let’s look at an example:

package main

import "fmt"

func change(m map[int]int) {
 m[3] = 1

 fmt.Println("Inside function:", m)
}

func main() {
 m := map[int]int{4: 1}

 fmt.Println("Before:", m)

 change(m)

 fmt.Println("After:", m)
}

Output

Before: map[4:1]
Inside function: map[3:1 4:1]
After: map[3:1 4:1]

The map was passed by value, but the copied map value refers to the same underlying map data.

So this:

m[3] = 1

changes the underlying map.

That’s why the caller sees the change.

But replacing the map is different

Here’s an example that makes the distinction clearer:

package main

import "fmt"

func do(m map[int]int) {
 m[3] = 1

 m = make(map[int]int)

 m[4] = 4

 fmt.Println("Inside do():", m)
}

func main() {
 m := map[int]int{4: 1}

 fmt.Println("Before do():", m)

 do(m)

 fmt.Println("After do():", m)
}

Output

Before do(): map[4:1]
Inside do(): map[4:4]
After do(): map[3:1 4:1]

This looks strange at first.

Let’s go through it.

First:

m[3] = 1

changes the original map.

So the map in main() now contains:

map[3:1 4:1]

Then we do:

m = make(map[int]int)

This creates a completely new map and assigns it to the local variable m.

The m in main() is still pointing to the original map.

So now we can think of it like this:

Before make():
main's m  ──────> original map
                    [3:1 4:1]
After make():
main's m  ──────> original map
                    [3:1 4:1]
function's m ────> new map
                    [ ]

Then:

m[4] = 4

changes the new map.

That’s why:

Inside do(): map[4:4]

but after the function finishes:

After do(): map[3:1 4:1]

The important distinction is:

Changing the contents of a map is different from replacing the map itself.

What if we want to replace the caller’s map?

We can pass a pointer to the map.

package main

import "fmt"

func do(m *map[int]int) {
 (*m)[3] = 1

 *m = make(map[int]int)

 (*m)[4] = 4
}

func main() {
 m := map[int]int{4: 1}

 fmt.Println("Before do():", m)

 do(&m)

 fmt.Println("After do():", m)
}

Output

Before do(): map[4:1]
After do(): map[4:4]

This time:

*m = make(map[int]int)

actually replaces the map stored in the caller’s variable.

So the difference becomes:

map passed normally
        ↓
function gets a copy of the map value
        ↓
both values can refer to the same map data

With a pointer:

pointer to the caller's map variable
        ↓
function can change which map that variable refers to

This is why the more precise statement is:

Go passes the map value by value, but that value refers to shared map data.

The mental model that finally made sense to me

When I was confused about parameter passing, this simple breakdown helped:

| Type      | What gets copied | Can changes be visible outside? |
|-----------|------------------|----------------------------------|
| `Array`   | Entire array     | No                               |
| `Slice`   | Slice value      | Yes, when modifying shared underlying elements |
| `Map`     | Map value        | Yes, when modifying map contents |
| `Pointer` | Pointer value    | Yes, because it can point to the same data |

The bigger lesson is that everything is still passed by value.

The difference is what that value represents.

Multiple return values

Another thing I really like about Go is that functions can return multiple values.

For example:

package main

import "fmt"

func divide(a int, b int) (int, int) {
 return a / b, a % b
}

func main() {
 quotient, remainder := divide(10, 3)

 fmt.Println("Quotient:", quotient)
 fmt.Println("Remainder:", remainder)
}

Output

Quotient: 3
Remainder: 1

This is especially useful when a function needs to return both a result and some additional information.

A very common pattern in Go is returning a value along with an error.

The important thing for me was simply getting comfortable with the fact that a function doesn’t have to return only one value.

Recursion

Recursion is when a function calls itself.

Here’s a simple example:

package main

import "fmt"

func countdown(n int) {
 if n == 0 {
  return
 }

 fmt.Println(n)

 countdown(n - 1)
}

func main() {
 countdown(3)
}

Output

3
2
1

The important part of recursion is having a condition that eventually stops the function.

Without this:

if n == 0 {
	return
}

the function would keep calling itself.

Each function call gets its own stack frame.

For:

countdown(3)

the calls look roughly like:

countdown(3)
    |
    +-- countdown(2)
            |
            +-- countdown(1)
                    |
                    +-- countdown(0)

Once countdown(0) returns, the previous calls can finish too.

So recursion isn’t magic. It’s basically a function building up calls on the call stack and then unwinding them.

defer — one of my favorite Go features

defer was another thing that took a little time to understand.

It lets us schedule a function call to happen when the surrounding function is about to return.

A common example is closing a file:

func readFile() {
	file, err := os.Open("data.txt")
	if err != nil {
		return
	}

        defer file.Close()
         // work with the file
}

Instead of remembering to call:

file.Close()

somewhere later, we can defer it immediately after successfully opening the file.

That makes cleanup easier to reason about.

defer runs when the function returns

Here’s a simple example:

package main

import "fmt"

func example() {
   fmt.Println("Start")
  
   defer fmt.Println("Deferred")
  
   fmt.Println("End")
}

func main() {
   example()
}

Output

Start
End
Deferred

The deferred call doesn’t run immediately.

It waits until example() is about to return.

defer is function-scoped, not block-scoped

This is an important detail.

Consider:

func example() {
  {
    defer fmt.Println("deferred")
  }

  fmt.Println("outside block")
}

Output

outside block
deferred

The defer doesn't run when the inner block ends.

It runs when the function ends.

This becomes particularly important with loops.

Be careful with defer inside loops

Suppose we write:

func process() {
  for i := 0; i < 3; i++ {
    defer fmt.Println(i)
  }

  fmt.Println("Done")
}

Output

Done
2
1
0

All the deferred calls wait until process() returns.

So if we use defer for something like closing resources inside a large loop, those resources may stay open until the whole function finishes.

That’s something worth keeping in mind.

Multiple defer calls run in reverse order

This is another useful rule.

If we write:

package main

import "fmt"

func main() {
 defer fmt.Println("First")
 defer fmt.Println("Second")
 defer fmt.Println("Third")

 fmt.Println("Main")
}

Output

Main
Third
Second
First

The easiest way I remember this is:

Last deferred, first executed.

It’s basically LIFO — last in, first out.

Deferred arguments are evaluated immediately

This one is easy to miss.

Look at this:

package main

import "fmt"

func main() {
 x := 10
 defer fmt.Println(x)

 x = 20
 fmt.Println(x)
}

Output

20
10

Why?

Because the argument to the deferred function:

fmt.Println(x)

is evaluated when the defer statement is executed.

At that moment:

x == 10

So 10 is what gets passed to the deferred call.

Changing x afterward doesn't change that already-evaluated argument.

defer can also work with named return values

This is a slightly more advanced use, but it’s useful to know that a deferred function can modify a named return value.

For example:

package main

import "fmt"
func example() (result int) {
   defer func() {
    result++
   }()
  
   return 10
}

func main() {
   fmt.Println(example())
}

Output

11

The function prepares to return 10.

Before the function actually returns, the deferred function runs and changes result to 11.

So the caller receives:

11

This is one of those Go features that can be useful, but it can also make code harder to understand if it’s used unnecessarily.

Putting everything together

After going through all of this, the biggest thing I took away is that parameter passing in Go is simpler than it first appears.

The rule is:

Go passes arguments by value.

The confusion usually comes from what that value contains or refers to.

For an array:

array value
    ↓
entire array is copied

For a slice:

slice value
    ↓
slice is copied
    ↓
underlying array can still be shared

For a map:

map value
    ↓
map value is copied
    ↓
underlying map data can still be shared

For a pointer:

pointer value
    ↓
copy of pointer
    ↓
both pointers can refer to the same data

Once I started thinking about it this way, the behavior of arrays, slices, maps, and pointers became much less mysterious.

A few things I’m taking away

There are a few rules from this topic that I think are worth remembering:

1. Functions are values.

You can store functions in variables and pass them around.

2. Function signatures describe types.

Parameter names aren’t part of what makes two function types different.

3. Go passes arguments by value.

This is the underlying rule.

4. Arrays are copied.

Changing an array parameter doesn’t change the original array.

5. Slices can share their underlying array.

So changing an element through a slice can affect the original data.

6. Maps can share their underlying data.

Changing map contents can be visible to the caller, but assigning a new map to the local parameter doesn’t replace the caller’s map.

7. Multiple return values are normal in Go.

They make it easy for functions to return a result plus additional information.

8. Recursion needs a stopping condition.

Otherwise the function keeps calling itself.

9. defer runs when the surrounding function returns.

Not when a block or loop ends.

10. Multiple defer calls run in reverse order.

Last deferred, first executed.

Final thoughts

Functions looked simple when I first started with Go.

And in one sense, they are.

But once you understand how Go handles parameters, things like slices, maps, pointers, and defer start making a lot more sense.

The biggest thing I had to change in my thinking was this:

Instead of saying:

Slices are passed by reference.

or:

Maps are passed by reference.

it’s more accurate to think:

Go always passes values. Sometimes the copied value contains a reference to data that is shared underneath.

That small distinction cleared up a lot of confusion for me.

And defer is another feature that looks almost too simple at first, but has a few important details around function scope, execution order, and argument evaluation.

I think these are the kinds of Go concepts that become much easier once you stop trying to memorize the behavior and instead understand what is actually being copied and what is being shared.

That’s the mental model I’m taking away from this topic.

#Go #Golang #Programming #SoftwareEngineering #LearnInPublic #BackendDevelopment