Go Closures — Junior Level¶
1. Introduction¶
What is it?¶
A closure is a function that "remembers" variables from the place where it was created. When you create an anonymous function inside another function, and the inner function uses variables from the outer function, those variables become part of the inner function's "memory" — even after the outer function returns.
How to use it?¶
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
c := makeCounter()
fmt.Println(c(), c(), c()) // 1 2 3
The returned function still has access to count, even though makeCounter has returned.
2. Prerequisites¶
- Functions basics (2.6.1)
- Anonymous functions (2.6.4)
- Slices and
range - Variable scope
3. Glossary¶
| Term | Definition |
|---|---|
| closure | A function that captures variables from its enclosing scope |
| capture | The act of "remembering" a variable from outside |
| free variable | A variable referenced inside a function but defined outside |
| enclosing function | The function in which the closure is defined |
| capture by reference | Sharing the actual variable, not a copy |
| factory | A function that returns a closure |
| state | The captured variables that persist across closure calls |
4. Core Concepts¶
4.1 What Gets Captured¶
A closure captures any variable it references that's defined OUTSIDE its body.
x is the captured (free) variable.
4.2 Capture Is By Reference¶
The closure and the outer scope share the SAME variable. Changes are visible both ways.
4.3 Captured Variables Survive¶
After the outer function returns, captured variables stay alive as long as the closure references them.
func makeGreeter(name string) func() {
return func() {
fmt.Println("Hello,", name)
}
}
greet := makeGreeter("Ada")
// makeGreeter has returned, but `name` is still alive inside the closure.
greet() // Hello, Ada
4.4 Each Closure Instance Has Its Own State¶
c1 and c2 each have their own count.
4.5 The Loop Variable Pitfall¶
In Go ≤ 1.21, all closures inside a for loop captured the SAME loop variable:
fns := []func() int{}
for i := 0; i < 3; i++ {
fns = append(fns, func() int { return i })
}
for _, f := range fns {
fmt.Println(f())
}
// Pre Go 1.22: 3 3 3 (all see final i)
// Go 1.22+: 0 1 2 (each iteration has its own i)
In Go ≥ 1.22 (with go 1.22 in go.mod), each iteration creates a fresh variable. Pre-1.22 code needed the shadow i := i workaround.
5. Real-World Analogies¶
A backpack: when you go on a trip, you carry a backpack with stuff you packed at home. Even when you're far away, you still have access to those items. The closure is the trip; the backpack is the captured variables.
A bank account passbook: the passbook (closure) records the balance (captured variable). You can check or update the balance anytime, and changes persist.
A photo with people in it: the photo "captures" the people in that moment — except in a closure, the captures are LIVE references, not photos. Changes to the people show up in the closure too.
6. Mental Models¶
makeCounter() returned closure caller
┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ count: 0 │ ←──────────│ refers to │ │ c := ... │
│ ─ moved to ─│ │ count (heap) │ ←── │ c() │
│ heap when ─│ └──────────────┘ │ c() │
│ factory │ └──────────┘
│ returned │ On each c(), count++ via closure
└──────────────┘
The captured variable lives on the heap (when the closure escapes); the closure value points to it.
7. Pros & Cons¶
Pros¶
- Encapsulate state without defining a struct
- Natural factories (counters, generators, builders)
- Natural callbacks that need context
- Powerful for functional patterns (decorators, memoization)
Cons¶
- Can capture more than intended (heavy memory)
- Subtle loop-variable bugs (pre Go 1.22)
- Concurrent access requires synchronization
- Stack traces show generic
funcNnames
8. Use Cases¶
- Counters and generators
- Memoization wrappers
- Event handlers with context
- Decorators (logging, retry, timing)
- Functional options
- Custom iterators
- Lazy initialization
- Stateful callbacks
9. Code Examples¶
Example 1 — Counter¶
package main
import "fmt"
func newCounter() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
c := newCounter()
fmt.Println(c(), c(), c()) // 1 2 3
}
Example 2 — Adder Factory¶
package main
import "fmt"
func adder(by int) func(int) int {
return func(x int) int {
return x + by
}
}
func main() {
add3 := adder(3)
add5 := adder(5)
fmt.Println(add3(10), add5(10)) // 13 15
}
Example 3 — Memoization¶
package main
import "fmt"
func memoize(fn func(int) int) func(int) int {
cache := map[int]int{}
return func(x int) int {
if v, ok := cache[x]; ok {
return v
}
v := fn(x)
cache[x] = v
return v
}
}
var calls int
func slow(x int) int { calls++; return x * x }
func main() {
fast := memoize(slow)
fmt.Println(fast(5), fast(5), fast(5)) // 25 25 25
fmt.Println(calls) // 1
}
Example 4 — Range Slider¶
package main
import "fmt"
func nextOdd() func() int {
n := -1
return func() int {
n += 2
return n
}
}
func main() {
odd := nextOdd()
for i := 0; i < 5; i++ {
fmt.Println(odd())
}
// 1 3 5 7 9
}
Example 5 — Capture Loop Variable (Pre 1.22 Workaround)¶
package main
import "fmt"
func main() {
fns := []func() int{}
for i := 0; i < 3; i++ {
i := i // shadow for pre-1.22 (no harm in 1.22+)
fns = append(fns, func() int { return i })
}
for _, f := range fns {
fmt.Println(f()) // 0 1 2 in all Go versions
}
}
Example 6 — Mutual Recursion¶
package main
import "fmt"
func main() {
var even, odd func(int) bool
even = func(n int) bool {
if n == 0 { return true }
return odd(n - 1)
}
odd = func(n int) bool {
if n == 0 { return false }
return even(n - 1)
}
fmt.Println(even(4), odd(7)) // true true
}
10. Coding Patterns¶
Pattern 1 — Stateful Generator¶
Pattern 2 — Once-Wrapper¶
Pattern 3 — Cache¶
func cached[T any](fn func(string) T) func(string) T {
cache := map[string]T{}
return func(k string) T {
if v, ok := cache[k]; ok { return v }
v := fn(k)
cache[k] = v
return v
}
}
Pattern 4 — Decorator¶
11. Clean Code Guidelines¶
- Capture only what you need. Don't pin large objects when you only need one field.
- Document captures when the closure escapes.
- Use named struct + methods when the state needs more than 2-3 fields.
- Synchronize concurrent captures.
- Avoid deep nesting of closures — readability suffers.
// Good — small captures
func makeF(threshold int) func(x int) bool {
return func(x int) bool { return x > threshold }
}
// Worse — captures large struct unnecessarily
func makeF(big *BigConfig) func(x int) bool {
return func(x int) bool { return x > big.Threshold }
}
// Better:
func makeF(big *BigConfig) func(x int) bool {
threshold := big.Threshold
return func(x int) bool { return x > threshold }
}
12. Product Use / Feature Example¶
A throttle helper:
package main
import (
"fmt"
"time"
)
func throttle(d time.Duration) func() bool {
var last time.Time
return func() bool {
now := time.Now()
if now.Sub(last) < d {
return false
}
last = now
return true
}
}
func main() {
canPing := throttle(100 * time.Millisecond)
for i := 0; i < 5; i++ {
if canPing() {
fmt.Println("ping")
} else {
fmt.Println("throttled")
}
time.Sleep(50 * time.Millisecond)
}
}
The closure captures last. Each instance of throttle has its own state.
13. Error Handling¶
Closures handle errors normally — but be careful with shared captured error state:
type Result struct{ Value int; Err error }
func collect() (func(), func() Result) {
var result Result
set := func() {
result = Result{Value: 42, Err: nil}
}
get := func() Result { return result }
return set, get
}
Both closures share result.
14. Security Considerations¶
- Closures may pin sensitive data — wipe captured secrets after use.
- Closures captured by long-lived goroutines keep their captures alive forever.
- Don't pass closures with captured credentials to untrusted code.
// Risky:
secret := loadKey()
go func() {
use(secret) // secret kept alive
}()
secret = nil // doesn't help; closure has its own reference
// Safer:
secret := loadKey()
go func(k []byte) {
defer wipe(k)
use(k)
}(append([]byte(nil), secret...))
wipe(secret)
15. Performance Tips¶
- Captures that don't escape are stack-allocated — free.
- Captures that escape go to the heap — one allocation per closure.
- Verify with
go build -gcflags="-m". - In hot loops, avoid creating closures per iteration — lift them out or pass state as args.
16. Metrics & Analytics¶
func counter() (incr func(), value func() int) {
n := 0
incr = func() { n++ }
value = func() int { return n }
return
}
inc, val := counter()
for i := 0; i < 5; i++ { inc() }
fmt.Println(val()) // 5
A pair of closures sharing state — useful for metrics.
17. Best Practices¶
- Capture only what you need.
- Use closures for simple stateful behavior.
- Use structs + methods when state grows.
- Synchronize concurrent captures.
- Document closure lifetimes.
- Watch for the loop-variable pitfall (still applies to pre-1.22 modules).
- Use the shadow
x := xfor explicit per-iteration capture.
18. Edge Cases & Pitfalls¶
Pitfall 1 — Loop Variable Capture (Pre 1.22)¶
Fix: pass as arg, shadow, or use Go 1.22+.Pitfall 2 — Heavy Capture¶
func makeRead(big *BigStruct) func() byte {
return func() byte { return big.firstByte() } // pins big
}
Pitfall 3 — Concurrent Mutation¶
Fix: synchronize internally:func newCounter() func() int {
var mu sync.Mutex
n := 0
return func() int {
mu.Lock(); defer mu.Unlock()
n++; return n
}
}
Pitfall 4 — Shadowing By Accident¶
Pitfall 5 — Recursion Without var¶
Fix: use var fact func(int) int; fact = .... 19. Common Mistakes¶
| Mistake | Fix |
|---|---|
| Loop var captured shared (pre-1.22) | Pass as arg, shadow, or upgrade |
| Concurrent capture access | Add mutex/atomic |
| Heavy capture pinning memory | Extract minimal data |
| Accidental shadowing | Use distinct names |
Recursion without var declaration | Use var f ...; f = ... |
20. Common Misconceptions¶
Misconception 1: "Closures copy the captured variable." Truth: Closures capture by REFERENCE. The variable is shared.
Misconception 2: "Captured variables die when the outer function returns." Truth: Their lifetime extends to match the closure's.
Misconception 3: "All closures heap-allocate." Truth: Only escaping closures. Non-escaping ones stay on the stack.
Misconception 4: "Closures are slow." Truth: Without escape, identical to direct calls. With escape, one heap alloc + indirect call cost.
Misconception 5: "Each closure call creates a new variable." Truth: Each closure INSTANCE has its own captures. Each CALL of an existing closure uses the same captures.
21. Tricky Points¶
- Closures capture by reference — even small values.
- To capture a snapshot, shadow with
x := x. - The Go 1.22 loop-variable change applies to all 3 for-forms when iteration var is
:=. - Recursive closures need
var f ...; f = .... - Concurrent capture mutation is a race; synchronize.
22. Test¶
package main
import "testing"
func newCounter() func() int {
n := 0
return func() int { n++; return n }
}
func TestCounter(t *testing.T) {
c := newCounter()
if got := c(); got != 1 { t.Errorf("got %d, want 1", got) }
if got := c(); got != 2 { t.Errorf("got %d, want 2", got) }
}
func TestSeparateCounters(t *testing.T) {
c1 := newCounter()
c2 := newCounter()
c1()
c1()
if got := c2(); got != 1 {
t.Errorf("c2 got %d, want 1 (independent state)", got)
}
}
23. Tricky Questions¶
Q1: What's the output?
A:99 99. Both closures capture the same x. Q2: What's the output?
A:1. The inner x := x shadows the outer; the snapshot was taken when the closure was created (when x == 1). Q3: What's the difference between these two?
A: - A: prints1 (args evaluated at defer time). - B: prints 99 (closure captures x by ref, reads at function exit). 24. Cheat Sheet¶
// Counter
n := 0
f := func() int { n++; return n }
// Factory
func make(by int) func(int) int {
return func(x int) int { return x + by }
}
// Snapshot capture
x := 5
f := func() int {
x := x // freeze value
return x
}
// Pre-1.22 loop fix
for _, item := range items {
item := item
go process(item)
}
// Recursive closure
var fact func(int) int
fact = func(n int) int {
if n <= 1 { return 1 }
return n * fact(n-1)
}
// Concurrent counter
var mu sync.Mutex
n := 0
incr := func() {
mu.Lock(); defer mu.Unlock()
n++
}
25. Self-Assessment Checklist¶
- I can write a closure factory
- I understand capture by reference
- I know captured variables outlive the outer function
- I know each closure instance has its own captures
- I can write the snapshot-capture idiom (
x := x) - I know the loop-variable change in Go 1.22
- I can write a recursive closure
- I synchronize concurrent capture access
- I avoid heavy captures
26. Summary¶
A closure is a function that captures variables from its enclosing scope. Captures are by reference: changes outside affect inside, and vice versa. Captured variables survive as long as the closure does. Each closure instance has its own captures. Watch the loop-variable pitfall (mostly fixed in Go 1.22). Use closures for simple stateful behavior; reach for structs + methods when state grows. Synchronize concurrent access. The cost is one heap allocation when the closure escapes.
27. What You Can Build¶
- Counters and ID generators
- Memoization wrappers
- Throttles and rate limiters
- Decorators (logging, retry)
- Functional options
- Custom iterators
- Lazy-init helpers
- Stateful callbacks
28. Further Reading¶
- Effective Go — Closures
- Go Tour — Closures
- Go Spec — Function literals
- Go 1.22 release notes — Loop variable change
29. Related Topics¶
- 2.6.4 Anonymous Functions
- 2.6.7 Call by Value
- 2.5 Loops (Go 1.22 semantics)
- Chapter 7 Concurrency