RAII vs defer vs manual cleanup
Three strategies for guaranteeing a resource is released on every exit path - destructors, deferred statements, and the hand-written goto ladder - and the memory trade-offs each one buys you.
Every program that touches the heap is really answering one question over and
over: who frees this, and when? A buffer from malloc, a FILE* from
fopen, a mutex you locked, a socket you opened - each is a resource that must
be released exactly once on every path out of the scope that owns it,
including the early return, the error bail-out, and (in languages that have
them) the thrown exception.
Get it wrong in two directions and you get the two canonical memory bugs:
- Leak - a path out of the function forgets to free, so the allocation outlives every pointer to it. The memory is gone until the process exits (or, in an arena-based design, until the arena is reset).
- Double free / use-after-free - a path frees something twice, or frees it and then reads it. On a protected OS this is a crash if you are lucky and a silently corrupted allocator if you are not.
Systems languages have converged on three structural answers to the "release on every path" problem. This article walks through all three, with real code, and then weighs what each one costs you in code, in runtime, and in control over memory.
- RAII - bind the resource's lifetime to an object; the destructor runs the cleanup automatically as the scope unwinds. (C++)
defer/errdefer- write the cleanup next to the acquisition, and let the compiler schedule it to run at scope exit. (Zig, Odin, Hare)- Manual cleanup - there is no mechanism; you hand-write every release on
every path, usually with a
gotoladder. (C, HolyC, Forth)
The job, stated precisely
To compare fairly, fix a concrete task that every strategy has to solve:
Allocate a 4 KiB heap buffer, then open a file. Read into the buffer. On any exit - success, failed open, failed read - free the buffer and close the file, releasing in the reverse of the order acquired, with no leak and no double free.
Two resources, acquired in sequence. The interesting part is the partial failure: if the open fails, the buffer is already live and must be freed, but there is no file to close. If the read fails, both are live. Reverse-order release matters because in real code later resources often depend on earlier ones (you can't free the arena a node lives in before you free the node).
Strategy 1: RAII (C++)
RAII - Resource Acquisition Is Initialization - is the idea that a resource's
lifetime should be the lifetime of an object. The constructor acquires; the
destructor releases. You never call the cleanup yourself. Instead, when control
leaves the scope where the object lives - by return, by falling off the end,
or by an exception unwinding the stack - the compiler runs the destructor for
you, and runs destructors in reverse construction order.
#include <cstdio>
#include <memory>
#include <stdexcept>
#include <vector>
// A custom deleter so unique_ptr can own a FILE* and close it.
struct FileCloser {
void operator()(std::FILE *f) const noexcept { if (f) std::fclose(f); }
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
void load_config(const char *path) {
// make_unique owns the heap buffer; ~unique_ptr frees it.
auto buf = std::make_unique<std::vector<char>>(4096);
// unique_ptr<FILE, FileCloser> owns the handle; ~unique_ptr closes it.
FilePtr f(std::fopen(path, "rb"));
if (!f)
throw std::runtime_error("open failed"); // buf is still freed on unwind
std::size_t n = std::fread(buf->data(), 1, buf->size(), f.get());
if (std::ferror(f.get()))
throw std::runtime_error("read failed"); // f closed, buf freed on unwind
std::printf("read %zu bytes\n", n);
} // here: f.~FilePtr() runs, then buf.~unique_ptr(), in reverse order
The defining property is exception safety. There is no catch block above
and no explicit cleanup, yet if fread is replaced by code that throws halfway
through, the stack unwinds and every fully-constructed local's destructor runs
on the way out. This is what makes RAII the only one of the three strategies
that survives exceptions automatically - defer and goto ladders both assume
errors arrive as return values, not as unwinding.
A few sharp edges worth knowing, because RAII's automation is exactly where its costs hide:
- Reverse order is guaranteed. Objects are destroyed in the opposite of the
order they were constructed, so a later resource that borrows from an earlier
one is always torn down first. The
goto-ladder code in Strategy 3 has to arrange this by hand. - Move semantics decide ownership.
std::unique_ptris move-only: moving it transfers ownership and leaves the source holdingnullptr, whose destructor is a no-op. This is how you return an owning handle from a function without a double free - the moved-from object's destructor does nothing. Theerrdeferpattern in Zig solves the same "transfer on success" problem differently (see below). - Destructors must not throw. A destructor that throws during unwinding
from another exception calls
std::terminate. That is whyFileCloseris markednoexcept. - The cost is real but bounded.
unique_ptrwith an empty deleter is the same size as a raw pointer and the cleanup compiles to the samefree/fclosecall you would have written - "zero-overhead." Butshared_ptradds an atomic-refcounted control block, and RAII's automation can quietly hide where allocations happen, which is the very thing arena-style designs (and Zig's "no hidden allocations" rule) push back against.
RAII is the most ergonomic of the three: ownership is a type, cleanup is
invisible, and it composes through containers (a vector<unique_ptr<T>>
destroys all its elements correctly). The price is that "invisible" cuts both
ways - the allocations and frees are real, just not written at the call site.
Strategy 2: defer and errdefer (Zig, Odin, Hare)
The defer family takes RAII's goal - cleanup runs automatically at scope exit
- and detaches it from the type system. Instead of putting cleanup in a
destructor tied to a type, you write a
deferstatement next to the acquisition, and the compiler runs the deferred statement when control leaves the scope, in reverse registration order.
The payoff is locality without machinery: the free sits one line below the
alloc, you can read both at once, and there are no constructors, deleters, move
semantics, or hidden code paths. The cost is that defer is per-statement, not
per-type, so it does not compose automatically the way a destructor does - if a
struct owns three buffers, something still has to free all three.
Zig: defer plus errdefer
Zig has no RAII and no garbage collector. Memory comes from an Allocator you
pass in explicitly (Zig's "no hidden allocations" rule), and cleanup is
scheduled with two keywords:
deferruns on every exit from the enclosing scope.errdeferruns only when the scope exits via an error.
const std = @import("std");
fn loadConfig(allocator: std.mem.Allocator, path: []const u8) !void {
const buf = try allocator.alloc(u8, 4096); // explicit allocation
defer allocator.free(buf); // freed on EVERY exit, ok or error
const file = try std.fs.cwd().openFile(path, .{});
defer file.close(); // closed on EVERY exit
const n = try file.readAll(buf); // 'try' may bail; defers still run
std.debug.print("read {d} bytes\n", .{n});
}
try is the key to why defer is enough here: errors travel as values in the
return type (!void is an error union), and try is sugar for "if this is an
error, return it." When try returns early, all defers registered so far run
on the way out - so any line can fail and nothing leaks.
errdefer exists for the case defer cannot express: the half-built
object that you want to keep on success but unwind on failure.
fn makeBuffer(allocator: std.mem.Allocator) ![]u8 {
const buf = try allocator.alloc(u8, 4096);
errdefer allocator.free(buf); // frees buf ONLY if something below errors
try validate(buf); // on error: errdefer frees buf, error propagates
return buf; // on success: ownership transfers to the caller
}
fn validate(buf: []u8) !void {
if (buf.len == 0) return error.Empty;
}
If validate fails, errdefer frees buf and the caller never sees a
half-built result. If it succeeds, the errdefer is skipped and ownership
transfers to the caller - who is now responsible for the free. This is exactly
the problem C++ solves with a move out of unique_ptr: keep on success,
destroy on failure. Zig spells it as a separate keyword instead of folding it
into the type.
Odin: defer plus an implicit allocator
Odin also has defer (reverse order at scope exit) and no RAII. Its twist is
the implicit context: every Odin-convention procedure receives a hidden
context carrying a context.allocator, so make/new/delete/free route
through whatever allocator is in scope without threading it through every call.
package config
import "core:fmt"
import "core:os"
load_config :: proc(path: string) -> bool {
buf := make([]u8, 4096) // uses context.allocator
defer delete(buf) // freed at scope exit, on every return path
fd, err := os.open(path, os.O_RDONLY)
if err != os.ERROR_NONE {
return false // buf still freed by the deferred delete
}
defer os.close(fd) // closed at scope exit (after delete, reverse order)
n, rerr := os.read(fd, buf)
if rerr != os.ERROR_NONE {
return false // both defers run: close fd, then delete buf
}
fmt.println("read", n, "bytes")
return true
}
Odin has no errdefer, but the implicit allocator opens a different escape
hatch that defer users in every language reach for eventually: the arena.
Swap context.allocator for an arena allocator and you can skip the per-resource
delete entirely - allocate freely inside a subsystem, then reclaim everything
in one free_all / arena reset. That turns the "free on every path" problem into
"free once, at the boundary," which is often the cheapest and least error-prone
design of all:
import "core:mem"
handle_request :: proc(req: Request) {
arena: mem.Arena
mem.arena_init(&arena, make([]byte, 1 << 20)) // 1 MiB scratch
defer mem.arena_free_all(&arena) // one reset frees it all
context.allocator = mem.arena_allocator(&arena)
// ... allocate freely; no individual delete calls needed ...
}
Hare: defer without errdefer
Hare rounds out the defer family. It has Go/Zig-style defer and the ?
operator for error propagation, manual alloc/free, no GC, and - like Odin -
no errdefer. So the common-path cleanup is clean, but "keep on success,
free on failure" must be coded by hand.
use fmt;
use fs;
use io;
use os;
fn load_config(path: str) (void | fs::error | io::error) = {
let buf: []u8 = alloc([0u8...], 4096)!; // explicit heap allocation; '!' aborts on nomem
defer free(buf); // freed on every scope exit
const file = os::open(path)?; // '?' returns the error if open fails
defer io::close(file)!; // closed on every scope exit
// io::read yields (size | io::EOF | io::error); '?' peels io::error, then we
// resolve EOF so the deferred frees still run on any path out.
const n = match (io::read(file, buf)?) {
case let n: size => yield n;
case io::EOF => yield 0z;
};
fmt::printfln("read {} bytes", n)!;
};
defer free(buf) and defer io::close(file)! run on every exit, so ? can bail
at any line without leaking. Note that alloc itself can fail: since Hare 0.25
(June 2025) it returns ([]u8 | nomem), so the ! is required to assert the
fixed 4 KiB allocation cannot fail (it aborts if it does). The lack of errdefer
is the price of Hare's minimalism: the language is small and freezable, and
"transfer ownership on success" is left to the programmer rather than the compiler.
Strategy 3: manual cleanup (C, HolyC, Forth)
The third strategy is no strategy from the language: there is no destructor and
no defer, so cleanup is entirely hand-written. The discipline that keeps it
correct is what the other two strategies automate - acquire in order, release in
reverse, and make sure every exit path releases exactly what is currently live.
C: the goto cleanup ladder
The canonical C idiom - used heavily in the Linux kernel - is a goto ladder.
Each acquired resource gets a label at the bottom; each failure jumps to the
label that unwinds exactly what has been acquired so far, and execution falls
through the rungs in reverse order.
#include <stdio.h>
#include <stdlib.h>
int load_config(const char *path) {
int rc = -1; /* assume failure */
char *buf = NULL;
FILE *f = NULL;
buf = malloc(4096); /* 1: heap buffer */
if (buf == NULL)
goto out; /* nothing acquired yet */
f = fopen(path, "rb"); /* 2: file handle */
if (f == NULL)
goto free_buf; /* undo step 1 only */
size_t n = fread(buf, 1, 4096, f);
if (ferror(f))
goto close_file; /* undo step 2, then 1 */
printf("read %zu bytes\n", n);
rc = 0; /* success */
close_file:
fclose(f);
free_buf:
free(buf); /* free(NULL) is a defined no-op */
out:
return rc;
}
Two C details make this robust and worth internalizing, because they are exactly
what defer/RAII give you for free:
free(NULL)is a guaranteed no-op, so initializing every pointer toNULLlets the cleanup rungs run unconditionally without re-checking which resources succeeded.- Reverse-order fall-through is manual. The labels are ordered so that
jumping to an earlier rung still falls through every later one -
close_filecloses then falls intofree_buf. You are hand-writing the reverse-construction order that a C++ destructor sequence or adeferstack produces automatically.
The goto ladder is correct, fast, and completely explicit - every byte of
cleanup is on the page. Its weakness is that it is manual: add a third
resource and you must add a label, a new jump target, and re-check every existing
jump. The bug it invites is jumping to the wrong rung (leaking) or to one too far
(double-freeing something not yet acquired).
HolyC: the same, with no safety net
HolyC - Terry A. Davis's C dialect, the native language of TempleOS - has no
defer and no RAII. Cleanup is hand-written exactly as in C: pair every
MAlloc with a Free, every FOpen with an FClose, on each return path.
// HolyC: cleanup is fully manual, like C. Acquire in order, Free in reverse.
I64 LoadConfig(U8 *path)
{
I64 rc = -1; // assume failure
U8 *buf = MAlloc(4096); // per-task heap; MAlloc throws 'OutMem', never returns NULL
CFile *f; // FOpen yields a CFile*, not an integer fd
f = FOpen(path, "r"); // returns NULL if the file is not found
if (!f) {
Free(buf); // undo the buffer before bailing out
return rc;
}
// FBlkRead reads cnt 512-byte blocks and returns a Bool (success), not a count.
Bool ok = FBlkRead(f, buf, 0, 8); // 8 blocks * 512 = 4096 bytes, from block 0
if (!ok) {
FClose(f); // reverse order: close first...
Free(buf); // ...then free
return rc;
}
Print("read %d bytes\n", 8 * 512);
rc = 0;
FClose(f); // single success-path cleanup, in reverse
Free(buf);
return rc;
}
What makes HolyC's manual cleanup higher-stakes than C's is the runtime it
lives in. TempleOS runs entirely in 64-bit ring 0 with a single flat address
space and no memory protection by design - Davis intended a modern Commodore
64, a machine one person could fully understand. There is no MMU to turn a wild
pointer into a clean segfault: a leaked or double-freed pointer can corrupt the
whole system. The flip side of that minimalism is genuinely elegant ergonomics
worth noting respectfully - memory comes from a per-task heap, so when a task
dies its heaps are reclaimed automatically, giving you a coarse, arena-like
"free everything this task allocated" for free. Free(NULL) is allowed, just as
in C, so the same NULL-initialization discipline applies.
Forth: no mechanism, single exit, or CATCH
Forth is the most minimal of the seven. Heap memory comes from the optional
ALLOCATE/FREE word set (C-style malloc/free returning an I/O result
code), and there is no automatic unwind at all. The closest idiom to scoped
cleanup is a disciplined single exit point: acquire, do the work, and FREE
unconditionally on the one path out.
\ Forth: no defer, no RAII. ALLOCATE/FREE must be paired by hand. The closest
\ thing to scoped cleanup is a single exit point that always FREEs.
: load-config ( c-addr u -- flag ) \ filename addr/len -> success?
2drop \ (demo) drop the name; pretend it is open
4096 allocate ( buf ior ) \ request 4096 bytes
if drop false exit then \ alloc failed: nothing to free, bail out
( buf )
dup 4096 read-into ( buf -- buf n ) \ app word: fill buffer, leave byte count
." read " . ." bytes" cr \ ( buf ) print the count
free drop \ ALWAYS free the buffer; drop the ior
true ;
Because the FREE sits on the single fall-through path, an early ABORT or
exception would skip it. Robust Forth therefore wraps the body in CATCH and
frees in the handler - manually emulating try/finally. This is the bare
mechanism that every other strategy on this page is, in effect, a convenience
layer on top of: capture the exit, run the cleanup.
Trade-offs side by side
| Strategy | Languages | Cleanup runs | On exceptions? | Composes over types? | "Keep on success" |
|---|---|---|---|---|---|
| RAII | C++ | destructor, reverse ctor order | yes, on unwind | yes, automatically | move out of unique_ptr |
defer / errdefer |
Zig | scope exit, reverse order | n/a (errors are values) | no - per statement | errdefer keyword |
defer |
Odin, Hare | scope exit, reverse order | n/a | no - per statement | by hand (no errdefer) |
Manual (goto) |
C, HolyC | hand-written, every path | no | no | by hand |
Manual (CATCH) |
Forth | hand-written / handler | only if you CATCH |
no | by hand |
The memory-management lens makes the real differences sharp:
- Visibility vs. ergonomics. RAII is the most ergonomic and the most
composable, but it makes allocation and free invisible - the work is real,
just not at the call site.
deferis a deliberate middle ground: cleanup is automatic at scope exit but written where you can see it, and Zig's explicit allocator means you also see every allocation. The manual strategies show you everything and automate nothing. - Exceptions are the dividing line. Only RAII unwinds cleanup automatically
through a thrown exception. The
deferlanguages sidestep this by modeling errors as values (!Tandtryin Zig,?in Hare, multreturn in Odin), so the same scope-exit machinery covers the error path. C and Forth have no unwinding to hook, which is why their cleanup is purely path-by-path. - The half-built object - keep on success, free on failure - is the case that
separates the
deferdialects. Zig'serrdeferhandles it in the language; Odin and Hare make you code it by hand; C++ folds it into move semantics. - The arena escape hatch outscales all of them. Per-resource cleanup -
whether by destructor,
defer, orgoto- is O(resources) in both code and runtime. An arena (Odin's swappablecontext.allocator, Zig'sArenaAllocator, HolyC's per-task heap) collapses that to a single bulk reset: allocate freely, free once at the boundary. When a phase of a program has a clear lifetime, that is frequently both the fastest and the least bug-prone design - and it is available, in some form, in every language here.
There is no universally "best" answer; there is a spectrum from invisible and
automatic (RAII) through visible and automatic (defer/errdefer) to
visible and manual (goto/CATCH). Picking a point on that spectrum is really
picking how much you want the compiler to know about ownership - and, in a
systems language, that is the central design choice.