Finalizers and runtime.AddCleanup

A finalizer is a function the Go runtime promises to run “some time after” an object becomes unreachable but before its memory is reclaimed — a last-chance hook to release a non-memory resource (a file descriptor, a C allocation) that a programmer forgot to close explicitly. Go’s original mechanism, runtime.SetFinalizer, is powerful but notoriously error-prone: it resurrects the object, runs finalizers single-file on one goroutine, delays reclamation by a full GC cycle, and silently fails on reference cycles. Go 1.24 introduced runtime.AddCleanup as the modern, recommended replacement — “more flexible, more efficient, and less error-prone than runtime.SetFinalizer”, in the words of the Go 1.24 release notes. The runtime documentation is blunt: “New Go code should consider using AddCleanup instead” (pkg.go.dev/runtime).

Why Finalizers Are a Last Resort

Both mechanisms exist to solve one real problem: a Go object often wraps an external resource. An os.File wraps an operating-system file descriptor; a wrapper around a C library holds a C-allocated pointer. The garbage collector reclaims the Go object’s memory automatically, but it knows nothing about the file descriptor or the C memory — those leak unless something releases them. The disciplined answer is an explicit Close() plus defer (see defer Internals). A finalizer is the safety net for when the programmer forgets.

It is only a net, never the primary plan, because the runtime guarantees almost nothing about when — or even whether — it runs. The docs are explicit: “There is no guarantee that finalizers will run before a program exits, so typically they are useful only for releasing non-memory resources associated with an object during a long-running program.” The canonical mistake the documentation calls out: it is fine for an os.File finalizer to close a leaked descriptor, “but it would be a mistake to depend on a finalizer to flush an in-memory I/O buffer such as a bufio.Writer, because the buffer would not be flushed at program exit.” A finalizer reclaims; it must never be load-bearing for correctness.

Mental Model

Picture the GC walking the heap (see Tricolor Mark and Sweep). For a SetFinalizer object it finds unreachable, the GC does not free it. Instead it severs the finalizer association, hands the object to a single dedicated finalizer goroutine, and — by passing the object to that goroutine — makes the object reachable again. The object survives this GC cycle entirely. Only on the next cycle, when the GC again finds it unreachable (now with no finalizer attached), is its memory reclaimed. A finalized object therefore costs two GC cycles and resurrects itself once.

AddCleanup inverts the relationship. You do not attach a function to “the object”; you attach a (cleanup func(S), arg S) pair associated with a pointer. The cleanup function is given arg — deliberately not the object itself — so the cleanup closure holds no reference back to the object. The object can therefore be reclaimed immediately and normally, and the cleanup runs afterward against the captured arg. No resurrection, no second cycle, no cycle-leak.

flowchart TB
    subgraph SF["SetFinalizer — object resurrected"]
        A1["GC: object unreachable"] --> A2["clear finalizer assoc"]
        A2 --> A3["pass obj to finalizer goroutine\n→ obj REACHABLE again"]
        A3 --> A4["finalizer(obj) runs (1 goroutine, serial)"]
        A4 --> A5["next GC cycle: obj freed"]
    end
    subgraph AC["AddCleanup — object freed promptly"]
        B1["GC: ptr unreachable"] --> B2["obj memory reclaimed NOW"]
        B2 --> B3["queue cleanup(arg)\n(arg ≠ obj, no resurrection)"]
        B3 --> B4["cleanup(arg) runs\n(parallel, any order)"]
    end

Diagram: SetFinalizer (top) keeps the object alive through finalization and frees it only on the following cycle. AddCleanup (bottom) frees the object on the cycle it dies, then runs the cleanup against a separate arg. The insight: passing arg instead of the object is what breaks the resurrection chain and lets reclamation happen on schedule.

Mechanical Walk-through

SetFinalizer. Signature: func SetFinalizer(obj any, finalizer any). The obj must be “a pointer to an object allocated by calling new, by taking the address of a composite literal, or by taking the address of a local variable” — a heap object the runtime can track; otherwise “SetFinalizer may abort the program.” finalizer must take one argument assignable from obj’s type. When the GC finds an unreachable block with a finalizer, it “clears the association and runs finalizer(obj) in a separate goroutine. This makes obj reachable again, but now without an associated finalizer.” SetFinalizer(obj, nil) removes the finalizer. Ordering: finalizers run in dependency order — “if A points at B, both have finalizers, and they are otherwise unreachable, only the finalizer for A runs; once A is freed, the finalizer for B can run.” Concurrency: “A single goroutine runs all finalizers for a program, sequentially” — a slow finalizer therefore stalls every other finalizer, so a long-running one “should do so by starting a new goroutine.”

AddCleanup. Signature: func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup. It “attaches a cleanup function to a pointer. Some time after ptr is no longer reachable, the runtime will call cleanup(arg) in a separate goroutine.” It returns a Cleanup handle. The differences the Go 1.24 notes enumerate, mechanism by mechanism:

  • Multiple cleanups per object. Where each object has at most one finalizer, “multiple cleanups may be attached to the same pointer, or to different pointers within the same allocation.”
  • Interior pointers. “Cleanups may be attached to interior pointers” — a pointer into the middle of an object, e.g. a field — whereas SetFinalizer demands the head of an allocation.
  • No cycle leak. “Cleanups do not generally cause leaks when objects form a cycle.” This is the single biggest correctness win: a cycle with a SetFinalizer object is never collected (no dependency ordering exists), so the finalizer never runs and the memory leaks. Because a cleanup’s closure does not reference the object, a cycle of cleanup-bearing objects collects normally.
  • No reclamation delay. “Cleanups do not delay the freeing of an object or objects it points to” — no resurrection, no extra cycle.
  • Parallelism. Per the Go 1.25 notes, “Cleanup functions scheduled by AddCleanup are now executed concurrently and in parallel” — they may run concurrently with each other, unlike serial finalizers. “If a cleanup function must run for a long time, it should create a new goroutine to avoid blocking the execution of other cleanups.”

Ordering of cleanups. Unlike finalizers, cleanups have no dependency ordering: “There is no specified order in which cleanups will run … their cleanups all become eligible to run and can run in any order. This is true even if the objects form a cycle.” This is the deliberate price of breaking cycle leaks — cleanups cannot observe each other’s resources, so there is nothing to order.

Canceling a cleanup. AddCleanup returns a Cleanup handle with a Stop() method: “Stop cancels the cleanup call. Stop will have no effect if the cleanup call has already been queued for execution. To guarantee that Stop removes the cleanup function, the caller must ensure that the pointer that was passed to AddCleanup is reachable across the call to Stop.” Use this when the resource is closed explicitly — cancel the safety-net cleanup so it does not double-free.

Code: SetFinalizer vs AddCleanup

// --- Legacy: SetFinalizer ---
type FileOld struct{ fd int }
 
func OpenOld(path string) (*FileOld, error) {
    fd, err := syscall.Open(path, syscall.O_RDONLY, 0)
    if err != nil {
        return nil, err
    }
    f := &FileOld{fd: fd}
    runtime.SetFinalizer(f, func(f *FileOld) { syscall.Close(f.fd) }) // (1)
    return f, nil
}
 
func (f *FileOld) Read(b []byte) (int, error) {
    n, err := syscall.Read(f.fd, b)
    runtime.KeepAlive(f) // (2)
    return n, err
}

(1) The finalizer closure captures *FileOld — it references the object, which is why finalization resurrects it. (2) runtime.KeepAlive is mandatory, not optional. The compiler considers f dead after its last mention; the bare field access f.fd is read into a register before the syscall, so f itself can become unreachable mid-Read. Without KeepAlive, the docs warn, “the finalizer could run at the start of syscall.Read, closing the file descriptor before syscall.Read makes the actual system call” — a use-after-close.

// --- Modern: AddCleanup ---
type File struct{ fd int }
 
func Open(path string) (*File, error) {
    fd, err := syscall.Open(path, syscall.O_RDONLY, 0)
    if err != nil {
        return nil, err
    }
    f := &File{fd: fd}
    cl := runtime.AddCleanup(f, func(fd int) { syscall.Close(fd) }, fd) // (1)
    f.cleanup = cl                                                      // (2)
    return f, nil
}
 
func (f *File) Close() error {
    f.cleanup.Stop() // (3)
    return syscall.Close(f.fd)
}

(1) The cleanup’s arg is the bare int file descriptor — not *File. The closure captures only fd, so it has no path back to the File, and the File is reclaimed on the cycle it dies. (2) Stash the returned Cleanup handle so Close can cancel it. (3) On explicit Close, Stop() cancels the safety-net cleanup, preventing a double-close of a descriptor number the OS may have already recycled. The KeepAlive discipline from the Read path still applies for any method that touches f.fd directly.

Failure Modes and Common Misunderstandings

Premature finalization / cleanup. “A finalizer may run as soon as an object becomes unreachable” — and unreachable means at the last point the function mentions it, not at function return. Any method that pulls a wrapped resource out of an object and uses it must end with runtime.KeepAlive(obj). This trap is identical for cleanups and is the single most common finalizer bug.

Cycle leak (SetFinalizer only). “If a cyclic structure includes a block with a finalizer, that cycle is not guaranteed to be garbage collected and the finalizer is not guaranteed to run, because there is no ordering that respects the dependencies.” Two SetFinalizer objects pointing at each other leak forever. AddCleanup was created precisely to eliminate this.

AddCleanup panics on self-reference. “If ptr is reachable from cleanup or arg, ptr will never be collected and the cleanup will never run. As a protection against simple cases of this, AddCleanup panics if arg is equal to ptr.” Passing the object as its own arg re-creates the resurrection problem; the runtime catches the obvious form. It cannot catch indirect captures — a closure that closes over ptr will still leak silently.

Zero-size and tiny/batched objects. Neither cleanups nor finalizers are guaranteed to run for objects whose type is zero bytes — such objects may share an address. More subtly, both docs carry the batching caveat: the runtime “is allowed to perform a space-saving optimization that batches objects together in a single allocation slot. The cleanup for an unreferenced object in such an allocation may never run if it always exists in the same batch as a referenced object. Typically, this batching only happens for tiny (on the order of 16 bytes or less) and pointer-free objects.” This is the Tiny Allocator at work — a finalizer/cleanup on a tiny noscan object can be silently dropped.

Package-level variables. “It is not guaranteed that a finalizer will run for objects allocated in initializers for package-level variables. Such objects may be linker-allocated, not heap-allocated” — and only heap objects are GC-tracked.

Memory-model trap. “In the terminology of the Go memory model, a call SetFinalizer(x, f) ‘synchronizes before’ the finalization call f(x). However, there is no guarantee that KeepAlive(x) or any other use of x ‘synchronizes before’ f(x)” — so a finalizer that touches mutable state in x must use a mutex or other synchronization. The bare fact that the finalizer runs later in wall-clock time does not establish a happens-before edge with the program’s last write.

Diagnosing finalizer bugs. Go 1.25 added GODEBUG=checkfinalizers=1: per the Go 1.25 notes, in this mode “the runtime runs diagnostics on each garbage collection cycle, and will also regularly report the finalizer and cleanup queue lengths to stderr” — a growing queue length signals cleanups arriving faster than they drain. See GC Tuning and Observability.

Alternatives and When to Choose Them

The primary alternative to any finalizer is explicit lifecycle management: Close() plus defer, the io.Closer interface, and structured ownership. This is correct, deterministic, and timely; a finalizer or cleanup should exist only as the fallback when an API cannot force callers to close. Between the two finalization mechanisms, prefer AddCleanup for all new code (Go 1.24+) — it is strictly better on cycles, reclamation latency, parallelism, multiplicity, and interior pointers. Reach for SetFinalizer only on pre-1.24 toolchains or to read/maintain legacy code. Weak Pointers are a related but distinct tool: a weak pointer lets you observe that an object died without keeping it alive, where a cleanup lets you react to it dying; caches often use both — a weak pointer to detect eviction, a cleanup to release the backing resource.

Production Notes

The standard library itself moved with this change: the Go 1.24 notes describe AddCleanup as efficient enough for “heavy use like the unique package”, which interns values and relies on cleanups firing in parallel to release interned entries promptly — the Go 1.25 notes call out exactly this when explaining why cleanups were parallelized. The practical rules: (1) never make program correctness depend on a finalizer/cleanup running — it may not run before exit; (2) always pair the wrapper with an explicit Close, and Stop() the cleanup inside it; (3) put runtime.KeepAlive after the last real use of any object whose resource you handle directly; (4) keep cleanup/finalizer functions short and non-blocking — spawn a goroutine for slow work; (5) when migrating, give the cleanup a copy of the resource (arg), never the wrapping object, or you reintroduce the resurrection delay you were trying to escape.

See Also