← History

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.

C++ZigOdinC

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:

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.

  1. RAII - bind the resource's lifetime to an object; the destructor runs the cleanup automatically as the scope unwinds. (C++)
  2. defer / errdefer - write the cleanup next to the acquisition, and let the compiler schedule it to run at scope exit. (Zig, Odin, Hare)
  3. Manual cleanup - there is no mechanism; you hand-write every release on every path, usually with a goto ladder. (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:

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

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:

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:

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:

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.