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.

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