← All blogs
  • Go
  • Closure
  • Function

Understanding Closures in Go: Scope, Lifetime, State, and the Loop Variable Gotcha

Understand Go closures through scope, lifetime, captured state, and the loop variable behavior that can cause subtle bugs.

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

Closures are one of those programming concepts that initially feel a little mysterious. You write a function inside another function, the inner function somehow remembers variables from the outer function, and those variables continue to exist even after the outer function has returned.

Once you understand scope, lifetime, and captured variables, however, closures become much easier to reason about.

Understanding Closures in Go: Scope, Lifetime, State, and the Loop Variable Gotcha

This post walks through the concept using Go examples and, importantly, covers one of the most common closure-related mistakes: capturing a variable that continues to change.

Scope and Lifetime Are Not the Same Thing

Before understanding closures, it helps to separate two concepts: scope and lifetime.

Scope

Scope answers:

Where in my code can I access this variable?

For example:

func doSomething() {
    x := 10
    fmt.Println(x)
}

x is accessible inside doSomething and its nested blocks.

Scope is primarily a property of the program’s structure. We can determine it by looking at the code.

Lifetime

Lifetime answers a different question:

How long does this variable actually continue to exist?

Consider:

func doIt() *int {
    var b int
    return &b
}

At first, this might seem strange. b is a local variable, so shouldn't it disappear when doIt() returns?

In Go, the compiler performs escape analysis. If it determines that a value needs to survive beyond the function in which it was created, it can arrange for that value to live longer.

So we can have:

Scope:     inside doIt()
Lifetime:  potentially beyond doIt()

This distinction is fundamental to understanding closures.

What Is a Closure?

A closure occurs when a function uses variables from an enclosing function’s scope.

Here’s a classic example:

func fib() func() int {
    a, b := 0, 1

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

The inner anonymous function uses a and b.

But a and b weren't declared inside the anonymous function. They belong to fib.

The inner function has closed over those variables.

That’s where the term closure comes from.

Conceptually, you can think of a closure as:

closure = function code + captured environment

The function contains the instructions to execute, while the captured environment provides access to the variables it needs.

A Closure Can Keep Variables Alive

Consider:

func fib() func() int {
    a, b := 0, 1

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

When fib() returns, its execution is finished.

But the returned function still needs a and b.

Therefore, those variables remain alive as part of the closure’s environment.

We can then write:

func main() {
    f := fib()

    fmt.Println(f())
    fmt.Println(f())
    fmt.Println(f())
    fmt.Println(f())
}

The result is:

1
2
3
5

The important part isn’t really the Fibonacci sequence.

The interesting part is that the same a and b are being reused and modified across calls to****f().

The closure effectively gives us a function with persistent state.

Closures Can Maintain Independent State

Now consider:

f := fib()
g := fib()

Are f and g sharing the same a and b?

No.

Each call to fib() creates a new set of variables.

Conceptually:

f
├── function
└── environment
    ├── a₁
    └── b₁
g
├── function
└── environment
    ├── a₂
    └── b₂

So:

fmt.Println(f())
fmt.Println(f())
fmt.Println(f())
fmt.Println(g())
fmt.Println(g())
fmt.Println(g())

produces:

1
2
3
1
2
3

Each closure has its own state.

This is a powerful property of closures: the same function logic can be associated with different environments.

Why Are Closures Useful?

One major benefit is that they allow a function to access additional context without adding that context to the function’s parameter list.

For example:

numbers := []int{5, 2, 8, 1}

sort.Slice(numbers, func(i, j int) bool {
    return numbers[i] < numbers[j]
})

The anonymous function receives:

i
j

as parameters.

But it also accesses:

numbers

from the surrounding scope.

numbers doesn't need to be passed as a parameter to the anonymous function. It is captured from the surrounding context.

This pattern is extremely common in Go APIs that accept functions as arguments.

The Loop-Variable Gotcha — What Changed in Go 1.22?

Closures and loop variables have historically been a common source of bugs in Go.

In older versions of Go, loop variables could be shared across iterations. This meant that code like this could produce surprising results when the closures were executed later:

var prints []func()

for i := 0; i < 4; i++ {
    prints = append(prints, func() {
        fmt.Println(i)
    })
}

for _, print := range prints {
    print()
}

With older Go semantics, the closures could all observe the final value of i:

4
4
4
4

Go 1.22 changed this behavior

Starting with Go 1.22, loop variables declared by the loop have per-iteration scope.

So with modern Go:

var prints []func()

for i := 0; i < 4; i++ {
    prints = append(prints, func() {
        fmt.Println(i)
    })
}

for _, print := range prints {
    print()
}

the closures retain their respective iteration values:

0
1
2
3

This means the traditional workaround:

for i := 0; i < 4; i++ {
    j := i
    prints = append(prints, func() {
        fmt.Println(j)
    })
}

is generally not required for this particular problem when using modern Go (1.22+).

However, if you’re working with an older codebase, it’s worth checking the go version declared in go.mod, because loop-variable semantics depend on the module's Go version.

The important lesson

The underlying closure concept hasn’t changed: closures still capture variables from their surrounding environment.

What changed in Go 1.22 is the lifetime/scope behavior of loop variables, so each iteration can have its own loop variable.

If you’re learning closures with modern Go, don’t memorize j := i as a mandatory closure rule. Understand why the old problem occurred and know that Go 1.22 changed the semantics.

A Useful Mental Model

When working with closures, ask yourself two questions.

Question 1: What variable is being captured?

For example:

func() {
    fmt.Println(i)
}

The closure captures i.

Question 2: Will that variable change before the closure executes?

If the answer is yes, be careful.

For example:

Create closure
      ↓
variable changes
      ↓
closure executes
      ↓
closure sees current variable

That’s different from:

Create closure
      ↓
execute immediately
      ↓
variable hasn't changed yet

Thinking about closures in terms of references and timing makes many seemingly confusing behaviors much easier to predict.

Closures as Stateful Functions

One of my favorite ways to think about closures is as lightweight state holders.

For example, a counter could conceptually work like this:

func counter() func() int {
    count := 0

    return func() int {
        count++
        return count
    }
}

Then:

a := counter()
b := counter()

fmt.Println(a())
fmt.Println(a())
fmt.Println(b())
fmt.Println(a())

would produce:

1
2
1
3

Why?

Because a and b each have their own captured count.

This demonstrates the same principle as the Fibonacci example, but perhaps makes the idea of private state attached to a function even more intuitive.

Key Takeaways

If you’re learning closures in Go, these are the concepts worth remembering:

1. Scope and lifetime are different

A variable can be outside its original lexical scope while still being alive because something still references it.

2. A closure captures surrounding variables

An inner function can access variables declared in an enclosing function.

3. Captured variables can outlive the outer function

The closure keeps the variables it needs alive.

4. Every invocation can create a separate environment

f := fib()
g := fib()

gives f and g independent captured state.

5. Closures don’t automatically capture snapshots

If a closure captures a variable that keeps changing, the closure can observe the variable’s later value.

6. Be especially careful with loops

If you need each closure to remember a different iteration’s value, create a new variable for that iteration:

j := i

and capture j.

Final Thought

Closures are powerful because they allow us to combine behavior with context.

Instead of passing every piece of information explicitly through parameters, a closure can carry the surrounding environment it needs:

function
   +
captured state
   =
closure

Once you understand that a closure captures variables and their environment, rather than simply taking a snapshot of values, the behavior of closures becomes much more predictable.

And when debugging a closure-related issue, especially one involving loops or asynchronous execution, ask:

“What variable did this closure capture, and what will that variable contain when the closure actually runs?”

That single question can save a lot of debugging time.