Go Defer — Tasks¶
Instructions¶
Each task includes a description, starter code, expected behavior, and an evaluation checklist. Use defer idiomatically; capture only what you need; do not place defers in loops.
Difficulty levels: Easy, Medium, Hard, Extra-hard.
Task 1 — File Reader (Easy)¶
Topic: Basic acquire-and-defer pattern
Description: Implement readAll(path string) ([]byte, error) that opens the file at path, reads its full contents, and ensures the file is closed regardless of success or failure.
Constraints: - Use defer for the close. - Handle the case where os.Open fails (don't defer on a nil file). - Return the read error if io.ReadAll fails.
Starter Code:
package main
import (
"fmt"
"io"
"os"
)
func readAll(path string) ([]byte, error) {
// TODO
return nil, nil
}
func main() {
data, err := readAll("/etc/hostname")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("read %d bytes\n", len(data))
}
Hint
The defer must come AFTER the error check on `os.Open`. Otherwise, `f` is nil and `f.Close()` panics.Reference Solution
Self-check: What happens if os.Open returns err != nil and you defer f.Close() before the check?
Task 2 — LIFO Order (Easy)¶
Topic: Defer execution order
Description: Write a function printRange(n int) that prints numbers from 1 to n in descending order, using only defer statements (no explicit reversal logic).
Starter Code:
package main
import "fmt"
func printRange(n int) {
// TODO
}
func main() {
printRange(5) // expected: 5 4 3 2 1 (each on its own line)
}
Hint
LIFO means the last `defer` runs first. Defer in ascending order; the calls execute in descending order.Reference Solution
Output: The defer's argument `i` is captured at each iteration. LIFO order produces descending output.Self-check: What if you replaced defer fmt.Println(i) with defer func() { fmt.Println(i) }()? (Hint: it depends on Go version.)
Task 3 — Mutex Counter (Easy)¶
Topic: Defer with mutex unlock
Description: Implement a thread-safe counter using sync.Mutex and defer mu.Unlock().
Starter Code:
package main
import (
"fmt"
"sync"
)
type Counter struct {
// TODO: fields
}
func (c *Counter) Incr() {
// TODO
}
func (c *Counter) Value() int {
// TODO
return 0
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Incr()
}()
}
wg.Wait()
fmt.Println(c.Value()) // 1000
}
Hint
Lock the mutex at the start of each method; defer the unlock immediately.Reference Solution
Notice the `defer wg.Done()` inside the goroutine in `main` — also a textbook defer pattern.Self-check: If you removed defer and used explicit c.mu.Unlock(), what happens if the critical section panics?
Task 4 — Named Return Error Wrap (Medium)¶
Topic: Defer + named return for error annotation
Description: Implement loadProfile(path string) (*Profile, error) that reads a JSON profile from a file. Use a deferred closure to wrap any error with a uniform prefix "loadProfile %q: %v".
Starter Code:
package main
import (
"encoding/json"
"fmt"
"os"
)
type Profile struct {
Name string `json:"name"`
Age int `json:"age"`
}
func loadProfile(path string) (*Profile, error) {
// TODO: use named return + deferred wrap
return nil, nil
}
func main() {
p, err := loadProfile("missing.json")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(p)
}
Hint
Declare named returns: `(p *Profile, err error)`. Defer a closure that checks if `err != nil` and wraps it.Reference Solution
func loadProfile(path string) (p *Profile, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("loadProfile %q: %w", path, err)
}
}()
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
p = &Profile{}
if err = json.NewDecoder(f).Decode(p); err != nil {
return nil, err
}
return p, nil
}
Self-check: Why does this require the return value to be named?
Task 5 — Tracing Helper (Medium)¶
Topic: The defer trace(name)() idiom
Description: Implement trace(name string) func() that prints "enter <name>" immediately and returns a closure that prints "exit <name> (took <duration>)" when called.
Usage:
Expected console output for work():
Starter Code:
package main
import (
"fmt"
"time"
)
func trace(name string) func() {
// TODO
return nil
}
func work() {
defer trace("work")()
time.Sleep(50 * time.Millisecond)
}
func main() { work() }
Hint
Capture `time.Now()` and the name in `trace`. Print the enter message. Return a closure that prints the exit message using captured state.Reference Solution
The trick is the doubled `()`: `trace("work")` runs immediately and returns a closure. `defer X()` defers calling that closure on exit.Self-check: What if you wrote defer trace("work") (no second ())? What gets deferred?
Task 6 — Recovery Wrapper (Medium)¶
Topic: Defer + recover
Description: Implement safeCall(fn func()) (recovered interface{}) that calls fn() and returns the value passed to panic() if fn panics. If fn returns normally, safeCall returns nil.
Starter Code:
package main
import "fmt"
func safeCall(fn func()) (recovered interface{}) {
// TODO
return nil
}
func main() {
r := safeCall(func() { panic("boom") })
fmt.Println("recovered:", r) // recovered: boom
r = safeCall(func() { /* nothing */ })
fmt.Println("recovered:", r) // recovered: <nil>
}
Hint
`recover()` only works inside a deferred function. Wrap it in a deferred closure that assigns to a named return.Reference Solution
The deferred closure runs even if `fn()` panics. `recover()` returns the panic value. The named return `recovered` is set inside the closure.Self-check: What happens if the deferred closure itself calls a function that calls recover()?
Task 7 — Multi-Resource Cleanup (Medium)¶
Topic: Stacking defers for ordered cleanup
Description: Implement copyGzipped(src, dst string) error that: 1. Opens src for reading. 2. Creates dst for writing. 3. Wraps the writer in gzip.NewWriter. 4. Copies bytes from src to the gzipped writer. 5. Ensures gzip is flushed before the file is closed (LIFO order). 6. Captures any close error if no other error occurred.
Starter Code:
package main
import (
"compress/gzip"
"io"
"os"
)
func copyGzipped(src, dst string) error {
// TODO
return nil
}
Hint
Defer in this order: 1. `defer in.Close()` 2. `defer out.Close()` (with err capture) 3. `defer gz.Close()` (with err capture) LIFO will run them: gz.Close (flushes), out.Close, in.Close.Reference Solution
func copyGzipped(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer func() {
if cerr := out.Close(); cerr != nil && err == nil {
err = cerr
}
}()
gz := gzip.NewWriter(out)
defer func() {
if cerr := gz.Close(); cerr != nil && err == nil {
err = cerr
}
}()
_, err = io.Copy(gz, in)
return err
}
Self-check: If gz.Close() is the only error and io.Copy succeeded, does the function return that error?
Task 8 — Defer In Loop Bug Fix (Medium)¶
Topic: Avoiding defer in loop
Description: The function below has a bug. With 5000 paths, it crashes with "too many open files". Identify the bug and fix it without removing the defer.
Buggy Code:
func processPaths(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close()
if err := process(f); err != nil {
return err
}
}
return nil
}
Constraints: - Keep defer for the close (no manual f.Close() in the loop). - Handle 100,000 paths without exhausting file descriptors.
Hint
The defer doesn't fire until `processPaths` returns. Each iteration accumulates one open file. Move the per-iteration body into its own function.Reference Solution
Each call to `processOne` has its own defer scope. The file closes when each call returns, not when `processPaths` returns.Self-check: How many files are open simultaneously after the fix? After the bug?
Task 9 — Argument Snapshot vs Closure (Hard)¶
Topic: Argument evaluation timing
Description: Implement two functions, snapshot(x int) and live(x *int), where: - snapshot(x) defers a print that always shows the value passed in (not later changes). - live(x) defers a print that shows the latest value of *x at function exit.
Starter Code:
package main
import "fmt"
func snapshot(x int) {
// TODO: defer prints x at defer-time (will show passed-in value)
}
func live(x *int) {
// TODO: defer prints *x at exit-time
}
func main() {
n := 1
snapshot(n)
n = 99
fmt.Println("--- snapshot done ---")
live(&n)
n = 1000
fmt.Println("--- live done ---")
}
Expected output:
Hint
For snapshot: `defer fmt.Println(x)` captures x at defer-time. For live: defer a closure that dereferences the pointer at call-time.Reference Solution
`snapshot` shows 1 because `x` (the int parameter) was 1 when the defer ran. The argument is captured immediately. `live` shows 1000 because the closure reads `*x` when the defer fires. `*x` is whatever `n` is then.Self-check: If you wrote defer fmt.Println(*x) in live, what would print?
Task 10 — Transaction Rollback Or Commit (Hard)¶
Topic: Defer + named return for two-phase commit
Description: Implement transfer(db *sql.DB, from, to int, amount int64) error that performs a transaction. If anything fails, rollback. If everything succeeds, commit. The deferred logic should NOT swallow the original error.
Starter Code:
package main
import "database/sql"
func transfer(db *sql.DB, from, to int, amount int64) error {
// TODO: begin tx, debit from, credit to, commit; rollback on any err
return nil
}
Hint
Use a named `err` return. Defer a closure that calls Rollback if `err != nil`. Call Commit at the end; if Commit fails, the err return reflects that.Reference Solution
func transfer(db *sql.DB, from, to int, amount int64) (err error) {
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.Exec("UPDATE acct SET bal = bal - ? WHERE id = ?", amount, from); err != nil {
return err
}
if _, err = tx.Exec("UPDATE acct SET bal = bal + ? WHERE id = ?", amount, to); err != nil {
return err
}
return tx.Commit()
}
Self-check: What happens if the function panics between the two Exec calls?
Task 11 — Bounded Defer Count (Hard)¶
Topic: Open-coded defer threshold
Description: Without using runtime reflection, write two versions of a function setup10: - Version A: registers 10 defers, all in one function. - Version B: registers 10 defers but groups them into a helper to keep each function ≤ 8 defers.
Verify (mentally or with go build -gcflags="-m") that Version B uses open-coded defer.
Starter Code:
package main
import "fmt"
func cleanup(name string) func() {
return func() { fmt.Println("cleanup", name) }
}
func setup10A() {
// TODO: 10 defers in one function
}
func setup10B() {
// TODO: split so each function has ≤ 8 defers
}
Hint
For B, create two helpers (each with 5 defers) and call them in sequence.Reference Solution
func setup10A() {
defer cleanup("a1")()
defer cleanup("a2")()
defer cleanup("a3")()
defer cleanup("a4")()
defer cleanup("a5")()
defer cleanup("a6")()
defer cleanup("a7")()
defer cleanup("a8")()
defer cleanup("a9")()
defer cleanup("a10")()
}
func setupFirst5() {
defer cleanup("b1")()
defer cleanup("b2")()
defer cleanup("b3")()
defer cleanup("b4")()
defer cleanup("b5")()
}
func setupLast5() {
defer cleanup("b6")()
defer cleanup("b7")()
defer cleanup("b8")()
defer cleanup("b9")()
defer cleanup("b10")()
}
func setup10B() {
setupFirst5()
setupLast5()
}
Self-check: Why is the cleanup order different between A and B?
Task 12 — Tracing Multiple Returns (Hard)¶
Topic: Trace pattern with named returns
Description: Implement a generic trace helper that logs the return values and any error of any function. The trace should fire on every exit path.
Starter Code:
package main
import "fmt"
func traceResult[T any](name string, result *T, err *error) func() {
return func() {
if *err != nil {
fmt.Printf("[%s] ERR: %v\n", name, *err)
} else {
fmt.Printf("[%s] OK: %v\n", name, *result)
}
}
}
func compute() (result int, err error) {
defer traceResult("compute", &result, &err)()
// TODO: set result and err appropriately
return 42, nil
}
Hint
The trace closure captures pointers to the named return values. It reads them at exit-time, so it sees the final values.Reference Solution
The trace helper builds a closure that reads `*result` and `*err` at exit-time. Because the function uses named returns, `&result` and `&err` are stable pointers throughout the function's lifetime. The deferred closure observes the final values.Self-check: What if result and err are not named? Can the trace still see the return value?
Task 13 — Custom Mutex With Tracking (Extra-hard)¶
Topic: Defer + closure + atomics
Description: Implement a TrackedMutex that records the number of currently-held locks (across all goroutines). Provide Lock() and Unlock() methods. The unlock should be implemented with defer-able semantics (you do not need to literally enforce defer; just make Unlock work correctly when invoked via defer).
Add a Held() int method that returns the count of currently held locks.
Constraints: - Use sync.Mutex internally. - Use sync/atomic for the held counter. - Provide an example demonstrating usage with defer.
Starter Code:
package main
import (
"sync"
"sync/atomic"
)
type TrackedMutex struct {
mu sync.Mutex
held int64 // number of times Lock has been called minus Unlock
}
func (t *TrackedMutex) Lock() { /* TODO */ }
func (t *TrackedMutex) Unlock() { /* TODO */ }
func (t *TrackedMutex) Held() int64 { /* TODO */; return 0 }
func main() {
var m TrackedMutex
func() {
m.Lock()
defer m.Unlock()
if m.Held() != 1 {
panic("expected 1")
}
}()
if m.Held() != 0 {
panic("expected 0")
}
}
Hint
Use atomic.AddInt64 to bump `held` in Lock and decrement in Unlock. The mutex enforces ordering so the counter is consistent.Reference Solution
Used with defer: The `held` counter is updated within the critical section. Other goroutines reading `Held()` see the count consistently.Self-check: Why is it important to update held while holding the underlying mutex?
Task 14 — Defer Bug Hunt (Extra-hard)¶
Topic: Multi-bug defer code
Description: Below is a piece of production-style code with multiple defer-related bugs. Identify all of them, fix the code, and explain each bug.
Buggy Code:
package main
import (
"fmt"
"os"
"sync"
)
var mu sync.Mutex
func processPaths(paths []string) error {
mu.Lock()
for i, p := range paths {
f, err := os.Open(p)
defer f.Close()
if err != nil {
return err
}
defer func() { fmt.Println("processed:", i, p) }()
// ... do work with f ...
}
mu.Unlock()
return nil
}
func handlePanic() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}
func safe() {
defer handlePanic()
panic("boom")
}
func main() {
defer fmt.Println("done")
safe()
paths := []string{"/etc/hostname", "/etc/missing"}
if err := processPaths(paths); err != nil {
fmt.Println("err:", err)
os.Exit(1)
}
}
Hint
There are at least 5 bugs. Look for: 1. defer before error check 2. defer in a loop 3. mutex unlock not via defer 4. recover called from non-deferred function 5. os.Exit skipping defers 6. (pre-1.22) loop variable capture in deferred closureReference Solution
**Bugs**: 1. `defer f.Close()` runs before the error check — if `os.Open` failed, `f` is nil, `f.Close()` panics. 2. `defer f.Close()` and `defer func() { ... }()` are inside the loop — they accumulate, leaking handles. 3. `mu.Unlock()` is not via `defer` — if any error path returns, the mutex stays locked. Permanent deadlock. 4. `defer handlePanic()` — `handlePanic` is called via defer, so `recover()` inside it actually does work. Wait, this is correct. (Tricky: if the user expected `handlePanic` to be a *helper* called from another defer, it would NOT work. Here, it IS the deferred function, so it works.) 5. `os.Exit(1)` skips the deferred `fmt.Println("done")`. 6. Pre-Go 1.22: `defer func() { fmt.Println("processed:", i, p) }()` captures loop variables `i` and `p` by reference; all closures see the final values. **Fixed Code**:func processPaths(paths []string) (err error) {
mu.Lock()
defer mu.Unlock()
for _, p := range paths {
if err = processOne(p); err != nil {
return err
}
}
return nil
}
func processOne(p string) (err error) {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close()
p2 := p // shadow for pre-1.22; harmless on 1.22+
defer func() { fmt.Println("processed:", p2) }()
// ... do work with f ...
return nil
}
func main() {
defer fmt.Println("done")
safe()
paths := []string{"/etc/hostname", "/etc/missing"}
if err := processPaths(paths); err != nil {
fmt.Println("err:", err)
return // don't os.Exit
}
}
Self-check: Which bug, if any, would not show up in tests but would in production?
Task 15 — Build Your Own Defer (Extra-hard)¶
Topic: Conceptual understanding of defer
Description: Without using the defer keyword, build a small "defer queue" and demonstrate LIFO cleanup. The goal is to understand what defer does under the hood.
Constraints: - No defer keyword. - LIFO order. - Cleanup must run on normal return AND on panic.
Starter Code:
package main
import "fmt"
type DeferQueue struct {
fns []func()
}
func (q *DeferQueue) Push(fn func()) {
// TODO
}
func (q *DeferQueue) RunAll() {
// TODO: LIFO
}
func process() {
var q DeferQueue
q.Push(func() { fmt.Println("cleanup A") })
q.Push(func() { fmt.Println("cleanup B") })
q.Push(func() { fmt.Println("cleanup C") })
// ... work that might panic ...
q.RunAll()
}
Hint
Push appends. RunAll iterates in reverse. To handle panics, you'd need to wrap RunAll in a real `defer` — but since the task says no defer, you can rely on Go's normal panic propagation and require the caller to call RunAll manually before any panic. To handle panics, an actual `defer` would still be needed. So this exercise demonstrates why `defer` is part of the language — you can't fully simulate its panic-safety without it.Reference Solution
type DeferQueue struct {
fns []func()
}
func (q *DeferQueue) Push(fn func()) {
q.fns = append(q.fns, fn)
}
func (q *DeferQueue) RunAll() {
for i := len(q.fns) - 1; i >= 0; i-- {
q.fns[i]()
}
}
func process() {
var q DeferQueue
q.Push(func() { fmt.Println("cleanup A") })
q.Push(func() { fmt.Println("cleanup B") })
q.Push(func() { fmt.Println("cleanup C") })
fmt.Println("body")
q.RunAll() // manually invoked at exit
}
Self-check: Why is defer a language keyword and not a library function?
Summary¶
These 15 tasks span basic file cleanup through hard runtime-level questions. Working through them systematically should build deep fluency with defer:
| Difficulty | Tasks | Focus |
|---|---|---|
| Easy | 1-3 | Basic patterns: file close, LIFO, mutex unlock |
| Medium | 4-7 | Named returns, tracing, recovery, multi-resource cleanup |
| Hard | 8-12 | Loop pitfall, argument timing, transactions, defer threshold, tracing returns |
| Extra-hard | 13-15 | Tracking mutex, multi-bug hunt, build-your-own-defer |
After completing all 15, you should be able to explain to a colleague: - The defer execution model (LIFO, defer-time evaluation, named-return interaction). - The panic-safety contract that makes defer essential. - The cost model and how to avoid the slow path. - The common bugs and how to spot them in code review.
References¶
- Go Spec — Defer statements
- Effective Go — Defer
- Go Blog — Defer, panic, recover
- See also:
junior.md,middle.md,senior.md,find-bug.md,optimize.md