Cleanup Ordering — Find the Bug¶
Each example contains a cleanup-related bug. Find the bug, then read the explanation. Some bugs are subtle; others are blatant. All compile.
Bug 1¶
func write(path string, data []byte) error {
f, err := os.Create(path)
if err != nil { return err }
defer f.Close()
_, err = f.Write(data)
return err
}
The bug. The error from f.Close() is discarded. If the disk is full or the filesystem fails on close, the function returns nil but the data was not durably written.
Fix. Use named return and check Close's error:
func write(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil { return err }
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
_, err = f.Write(data)
return err
}
Bug 2¶
func process(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close()
// process f
}
return nil
}
The bug. Defer in a loop. All files are held open until process returns. For thousands of paths, this exhausts FDs.
Fix. Extract the per-file work into a helper:
func process(paths []string) error {
for _, p := range paths {
if err := processOne(p); err != nil { return err }
}
return nil
}
func processOne(p string) error {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close()
return nil
}
Bug 3¶
The bug. The cancel function is discarded. The context's internal goroutine and timer leak.
Fix. Always store and defer cancel:
(go vet catches this via lostcancel.)
Bug 4¶
func handler(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get("http://example.com")
if err != nil {
http.Error(w, err.Error(), 500); return
}
defer resp.Body.Close()
// ... do something else, return without reading resp.Body ...
w.Write([]byte("ok"))
}
The bug. resp.Body is closed but never read. For HTTP/1.1 keep-alive, the connection is discarded instead of returned to the pool. Under load, connection pool exhaustion.
Fix. Drain the body before closing:
Bug 5¶
ctx, cancel := context.WithCancel(parent)
stop := context.AfterFunc(ctx, cleanup)
defer cancel()
defer stop()
The bug. Defer order: stop() is registered second, so it pops first at function exit. It deregisters the callback before cancel() runs. Result: cleanup never runs on normal exit.
Fix. Swap the order:
Now cancel() pops first, triggering the AfterFunc; then stop() runs (no-op since callback already fired).
Bug 6¶
func openFile(path string) (*os.File, error) {
f, err := os.Open(path)
if err != nil { return nil, err }
defer f.Close()
return f, nil
}
The bug. The deferred f.Close() runs before the caller can use the returned file. The caller gets a closed file.
Fix. Drop the defer; the caller now owns the file:
Bug 7¶
The bug. os.Exit skips defers. "cleanup" never prints.
Fix. Return normally, or do cleanup explicitly before exit:
Bug 8¶
The bug (pre-Go-1.22). The closure captures the loop variable i by reference. After the loop, i == 3. All three defers print 3.
In Go 1.22+, the loop variable is per-iteration, so each defer captures its own i. Output: 2, 1, 0.
Fix (universal). Pass i as an argument:
Or rely on Go 1.22+ scoping.
Bug 9¶
type Service struct {
db *sql.DB
}
func (s *Service) Stop() error {
return s.db.Close()
}
func main() {
s := &Service{db: openDB()}
defer s.Stop()
defer s.Stop() // safety net
}
The bug. defer s.Stop() is registered twice. At function exit, both run. The second call returns sql.ErrConnDone or similar — but worse, if Close is not idempotent on the user's DB driver, panic.
Fix. Make Stop idempotent via sync.Once, OR register only once.
Bug 10¶
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
stop := context.AfterFunc(ctx, func() { fmt.Println("late") })
// no defer stop()
The bug. stop is not deferred. If ctx is not cancelled before this function returns, the AfterFunc registration persists until ctx eventually cancels. For long-lived parent contexts, this leaks the callback.
Fix. defer stop().
Bug 11¶
func work() (err error) {
f, err := os.Create("/tmp/x")
if err != nil { return err }
defer func() {
f.Close()
}()
_, err = f.Write([]byte("data"))
return err
}
The bug. The deferred function calls Close but doesn't capture its error. If Close fails, the error is lost.
Compare to Bug 1, which uses the named-return pattern correctly.
Fix. Same as Bug 1.
Bug 12¶
done := make(chan struct{})
go func() {
defer close(done)
work()
}()
// no select; main returns immediately
The bug. Main does not wait for the goroutine. If main returns, the goroutine is killed. Cleanup may not run.
Fix. Wait on done:
Or use a sync.WaitGroup.
Bug 13¶
The bug. If compute() panics, the lock is never released. Other goroutines wait forever.
Fix. defer mu.Unlock():
Bug 14¶
func (s *Server) Shutdown(ctx context.Context) error {
s.db.Close() // step 1
s.workers.Stop(ctx) // step 2
return nil
}
The bug. DB is closed before workers stop. Workers may still be running queries against the now-closed DB. Panics or errors.
Fix. Reverse the order:
func (s *Server) Shutdown(ctx context.Context) error {
s.workers.Stop(ctx)
s.db.Close()
return nil
}
Bug 15¶
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
wg.Done() // BUG: missing defer
work()
}()
}
wg.Wait()
}
The bug. wg.Done() is called immediately, not after work(). The Wait may return before workers complete.
Fix. defer wg.Done() at the top of the goroutine:
Bug 16¶
The bug. ctx is already cancelled by the time server.Shutdown is called. Shutdown receives a cancelled context, immediately aborts in-flight requests.
Fix. Use a fresh context:
ctx, _ := signal.NotifyContext(parent, syscall.SIGTERM)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
Bug 17¶
var once sync.Once
func cleanup() error {
var err error
once.Do(func() {
err = real Close()
})
return err
}
The bug. Variable err is scoped to the function call. On the second call, once.Do skips the body; err stays nil. Caller sees nil error even if the first close failed.
Fix. Store the error in the type:
type Once struct {
once sync.Once
err error
}
func (o *Once) Close() error {
o.once.Do(func() { o.err = realClose() })
return o.err
}
Bug 18¶
func handler(w http.ResponseWriter, r *http.Request) {
f, _ := os.CreateTemp("", "req-*")
defer f.Close()
defer os.Remove(f.Name())
// ... use f ...
}
The bug. Defer order: os.Remove runs first (last registered), then f.Close. On some systems, removing an open file fails or leaves the FD orphaned.
Fix. Swap the order so Close runs first:
LIFO: Close first, then Remove. Correct.
Bug 19¶
func work(ctx context.Context) error {
var n int32
stop := context.AfterFunc(ctx, func() {
n = 1
})
defer stop()
cancel() // imagine this is in scope
return nil // n is racy!
}
The bug. The AfterFunc callback writes n in a goroutine. The main path may read n (via return or other). Race condition.
Fix. Use atomic or a channel.
Bug 20¶
func processBatch(items []Item) error {
var wg sync.WaitGroup
for _, item := range items {
item := item
wg.Add(1)
go func() {
defer wg.Done()
if err := process(item); err != nil {
wg.Wait() // BUG
return
}
}()
}
wg.Wait()
return nil
}
The bug. The goroutine calls wg.Wait() inside itself. Deadlock: it's waiting for all goroutines (including itself) to call Done. The defer wg.Done() would fire eventually, but the Wait blocks first.
Fix. Don't call wg.Wait inside a goroutine that hasn't called Done. Restructure.
Bug 21¶
func produce() <-chan int {
out := make(chan int)
go func() {
for i := 0; i < 10; i++ {
out <- i
}
close(out)
}()
return out
}
func main() {
out := produce()
for v := range out {
if v == 5 {
break
}
fmt.Println(v)
}
// BUG
}
The bug. After breaking out of the range loop, the producer goroutine is still trying to send into out. It blocks forever (channel not drained). Leak.
Fix. Use context to cancel the producer, or drain the channel:
out := produce()
defer func() { for range out {} }() // drain on exit
for v := range out {
if v == 5 { break }
fmt.Println(v)
}
Bug 22¶
type Pool struct {
mu sync.Mutex
items []*resource
}
func (p *Pool) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
var errs []error
for _, item := range p.items {
if err := item.Close(); err != nil {
errs = append(errs, err)
}
}
p.items = nil
return errors.Join(errs...)
}
The bug. Holding the lock during item.Close. If Close does I/O or is slow, the lock is held a long time. Other goroutines block.
Fix. Copy the slice under lock, then close outside:
func (p *Pool) Close() error {
p.mu.Lock()
items := p.items
p.items = nil
p.mu.Unlock()
var errs []error
for _, item := range items {
if err := item.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
Bug 23¶
func work() {
defer func() {
if r := recover(); r != nil {
log.Println("recovered:", r)
}
}()
defer func() {
// some cleanup that panics
if rand.Intn(2) == 0 {
panic("cleanup panic")
}
}()
panic("work panic")
}
The bug. When the cleanup defer panics, it replaces the work panic. The outer recover catches "cleanup panic", losing "work panic" information.
Fix. Recover inside the cleanup defer too:
defer func() {
defer func() {
if r := recover(); r != nil {
log.Println("cleanup panic:", r)
}
}()
// cleanup
}()
Now the cleanup's panic is logged but doesn't propagate. The original "work panic" still propagates to the outer recover.
Bug 24¶
ctx, cancel := context.WithCancel(parent)
defer cancel()
go func() {
defer fmt.Println("done")
<-ctx.Done()
}()
// main returns immediately
The bug. Main returns immediately. cancel fires (via defer). The goroutine wakes up, prints "done". But main has already returned. If main was the main goroutine, the program exits before the print.
Fix. Wait for the goroutine:
done := make(chan struct{})
go func() {
defer close(done)
defer fmt.Println("done")
<-ctx.Done()
}()
// later
<-done
Bug 25¶
var globalDB *sql.DB
func init() {
var err error
globalDB, err = sql.Open("sqlite3", "file.db")
if err != nil { panic(err) }
}
func main() {
defer globalDB.Close()
// ... use globalDB ...
}
The bug. globalDB may be used by other init functions or goroutines beyond main. The defer in main closes it before they finish. Use-after-close.
Fix. Make the DB's lifecycle explicit via a Service type with Start/Stop.
Bug 26¶
type Server struct {
listener net.Listener
wg sync.WaitGroup
}
func (s *Server) Shutdown() {
s.listener.Close()
s.wg.Wait()
}
The bug. No context. If a stuck request prevents Wait from returning, Shutdown hangs forever.
Fix. Take a context:
func (s *Server) Shutdown(ctx context.Context) error {
s.listener.Close()
done := make(chan struct{})
go func() { s.wg.Wait(); close(done) }()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
Bug 27¶
The bug. "cleanup" is registered first, runs last (LIFO). The intent might be "always cleanup last", but the labelling is misleading. Adjust labels or order to match.
Not a real bug per se, but a readability issue.
Bug 28¶
type Cache struct {
mu sync.RWMutex
items map[string]string
}
func (c *Cache) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.items[key]
if !ok {
c.mu.RUnlock() // BUG
c.mu.Lock()
defer c.mu.Unlock()
// double unlock from the original deferred RUnlock
}
return v
}
The bug. The deferred RUnlock fires at the end. We also manually RUnlock and acquire a write lock. The deferred RUnlock then runs on the write lock — undefined behaviour, likely panic ("sync: Unlock of unlocked RWMutex").
Fix. Restructure to use only one lock or use a different pattern (lock once with the correct mode).
Bug 29¶
func process(ctx context.Context, items []item) error {
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
item := item
g.Go(func() error {
return processOne(ctx, item)
})
}
return g.Wait()
}
func processOne(ctx context.Context, item item) error {
f, err := os.Open(item.path)
if err != nil { return err }
// BUG: no defer f.Close
return doWork(ctx, f, item)
}
The bug. processOne opens a file but never closes it. FD leak per item.
Fix. defer f.Close() after the successful open.
Bug 30¶
The bug. Actually this is not a bug — the deferred wg.Done runs on the early return. But it's a common point of confusion. The defer covers all return paths.
If you wrote:
Then yes — the early return skips wg.Done, leak. But the defer version is correct.
Conclusion¶
Cleanup bugs share a common shape: a path through code that fails to release a resource, in the wrong order, with the wrong context, or with errors silently dropped. Train your eye for them. Use go vet, staticcheck, errcheck, and tests with goroutine-leak detection. Code review with cleanup in mind.
You will catch 90% of these by: 1. Always pairing acquire/release in adjacent lines. 2. Always deferring cancel/close/stop. 3. Always handling the error from Close on writers. 4. Always using fresh contexts for shutdown. 5. Always testing the shutdown path.
The remaining 10% are subtle and require domain-specific knowledge. The exercises here cover many of them.
End of find-bug.