Finalizers and runtime.AddCleanup

Updated

September 8, 2026

Finalizers and runtime.AddCleanup

Overview

Go is GC-managed, but some resources are outside the heap (OS FDs, cgo memory). Historically runtime.SetFinalizer ran a function when an object became unreachable. Modern Go prefers runtime.AddCleanup (and explicit Close) because finalizers are subtle and easy to get wrong.

Diagram: Lifecycle preference

flow:
  [Backstop] --timing not guaranteed--> [Explicit]

Prefer Explicit Lifecycle

f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close() // always first choice

Finalizers are a backstop, not a primary API.

SetFinalizer (Legacy Pattern)

runtime.SetFinalizer(obj, func(o *T) {
    // runs asynchronously when obj is unreachable
    o.release()
})

Problems:

  1. No guarantee on timing — may run late or, in edge cases, not before process exit.
  2. Resurrection — finalizer can store the object globally and keep it alive.
  3. Order — interdependent objects need careful design.
  4. Performance — finalizers delay reclaim and add GC bookkeeping.

AddCleanup (Preferred Direction)

// Go 1.24+ style cleanup (check your version docs)
runtime.AddCleanup(ptr, func(res Resource) {
    res.Release()
}, resourceToken)

Cleanups are designed to avoid several classic finalizer pitfalls (exact API surface evolves — verify on pkg.go.dev/runtime).

Rules of Thumb

Need Approach
Files, sockets, body.Close defer Close
Cache with eviction Explicit pool / LRU
C malloc companion runtime.SetFinalizer only if library requires; prefer free in Close
Tests for leaks testing + explicit asserts, not finalizer timing

Experiment

go mod init example
go run .
package main

import (
    "fmt"
    "runtime"
    "time"
)

type blob struct {
    id int
}

func main() {
    for i := 0; i < 3; i++ {
        b := &blob{id: i}
        id := i
        runtime.SetFinalizer(b, func(_ *blob) {
            fmt.Println("finalizer", id)
        })
        b = nil
    }
    runtime.GC()
    time.Sleep(50 * time.Millisecond)
    runtime.GC()
    time.Sleep(50 * time.Millisecond)
    fmt.Println("done")
}

What to notice: Finalizers may print after GC, asynchronously; never rely on them for correctness of security-sensitive cleanup without also having an explicit path.

Try next: Wrap an os.File only with Close; use GODEBUG=gctrace=1 while allocating to see GC activity separately from finalizers.