A Go object with a finalizer is not reclaimed when the garbage collector first determines that it is unreachable. The runtime must retain the object for the finalizer call, and reclamation can occur only after a later collection finds the object unreachable again.
That behavior makes runtime.SetFinalizer materially different from ordinary garbage collection. It adds an asynchronous lifecycle phase between loss of application reachability and memory reclamation.
Finalization temporarily restores reachability
runtime.SetFinalizer(obj, f) associates f with obj. When the collector detects an unreachable object with that association, it clears the association and arranges a call to f(obj).
Passing obj into the finalizer makes the object reachable for that call. Its storage therefore cannot be reclaimed during the collection that first detects the object as unreachable. If the finalizer does not establish another lasting reference, a later garbage collection can reclaim the object.
This extra phase also retains data reachable through the object while the object itself remains live. A finalizer attached to a small wrapper can consequently delay reclamation of a substantially larger referenced graph.
Reference cycles can prevent finalization
Finalizers impose ordering constraints on references between finalized objects. If object A points to object B and both become otherwise unreachable, the runtime can finalize A before B so that A’s finalizer still sees a valid B.
A cycle removes a valid dependency order. The runtime documentation does not guarantee collection or finalizer execution for a cycle containing an object with a finalizer.
This makes a self-reference significant even when ordinary tracing garbage collection would otherwise collect the cycle:
type node struct {
self *node
}
n := &node{}
n.self = n
runtime.SetFinalizer(n, func(*node) {
releaseExternalState()
})The finalizer is not a reliable mechanism for breaking this cycle. The reference topology itself conflicts with the finalization ordering model.
Finalizer timing is not a resource deadline
A finalizer is scheduled after an object becomes unreachable, but its execution time is not a deterministic deadline. Program exit does not wait for all finalizers, and runtime.GC queues eligible finalizers without waiting for their callbacks to finish.
That boundary rules out finalizers as the sole mechanism for operations whose completion is semantically required, such as flushing buffered output or committing external state. Explicit lifecycle methods remain the deterministic path for those effects.
Finalizers can still serve as a fallback for non-memory resources in long-running processes, but fallback cleanup has different guarantees from an explicit Close operation.
Last source use can precede last resource use
Compiler and runtime liveness are based on object references, not on the lifetime of an external resource stored inside an object. A pointer can become unreachable after its last source-level use even while a system call using a copied field remains in progress.
Consider a wrapper whose finalizer closes a file descriptor:
type handle struct {
fd int
}
h := &handle{fd: fd}
runtime.SetFinalizer(h, func(h *handle) {
syscall.Close(h.fd)
})
n, err := syscall.Read(h.fd, buf)
runtime.KeepAlive(h)runtime.KeepAlive(h) marks a reachability boundary after syscall.Read returns. Without that boundary, the last use of h may occur while evaluating the call arguments, allowing its finalizer to close the descriptor too early.
KeepAlive controls reachability for finalization. It is not a general synchronization primitive and does not replace locking for mutable state shared with a finalizer.
Finalizers execute through a serialized path
Go runs finalizers sequentially through a single finalizer goroutine. A long-running finalizer can therefore delay unrelated finalizers elsewhere in the process.
That global serialization amplifies the cost of blocking operations inside finalizer callbacks. A callback that can block for substantial time can hand work to another goroutine, leaving the finalizer execution path available for other objects.
The ordering guarantee is limited. SetFinalizer(x, f) synchronizes before the call to f(x), but ordinary accesses to mutable fields still require appropriate synchronization when they can race with finalizer execution.
Cleanup APIs avoid object resurrection
Current Go releases provide runtime.AddCleanup as a lower-risk mechanism for cleanup associated with object reachability. Its cleanup function receives separate cleanup state rather than the original object.
That separation avoids resurrecting the object merely to execute cleanup. It also permits the object’s memory to be reclaimed without retaining the object for a callback argument and avoids the same cycle restriction imposed by finalizers.
runtime.SetFinalizer remains part of the runtime API, but its semantics include delayed reclamation, reference-topology constraints, nondeterministic execution, and explicit reachability boundaries. Those properties make finalization an observable part of object lifetime rather than a transparent extension of garbage collection.