Zig Defer and Errdefer

Resource cleanup — closing files, freeing memory, releasing locks — is one of the most error-prone tasks in systems programming. Forgetting to clean up causes memory leaks, file handle exhaustion, and deadlocks. Zig's defer and errdefer statements schedule cleanup code to run automatically when a scope exits, regardless of how it exits.

defer — Always Run at Scope Exit

{
    defer std.debug.print("Cleanup!\n", .{});
    std.debug.print("Working...\n", .{});
    std.debug.print("Done.\n", .{});
}

Output:

Working...
Done.
Cleanup!
  Scope timeline:
  ┌─────────────────────────────┐
  │ defer registered            │ ← defer statement
  │ print "Working..."          │
  │ print "Done."               │
  │ ← scope ends here           │
  │   → run deferred code       │ ← "Cleanup!" prints now
  └─────────────────────────────┘

No matter how the scope exits — normal completion, a return statement, or an error — the deferred code runs. This guarantee is the key value of defer.

defer with Resource Management

fn processFile(path: []const u8) !void {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();  // registered immediately after open

    // ... read and process the file ...
    const content = try file.readToEndAlloc(allocator, 1024 * 1024);
    defer allocator.free(content);  // free when done

    // Even if an error occurs mid-function,
    // file.close() and allocator.free() will run.
}
  openFile → success
  defer file.close() → scheduled
       |
  readToEndAlloc → success
  defer allocator.free(content) → scheduled
       |
  ... processing ...
       |
  function returns (success OR error)
       |
  deferred free(content) → runs
  deferred file.close()  → runs ← LIFO order

Placing the defer immediately after acquiring a resource means you cannot forget to release it. The cleanup code sits right next to the acquisition code, making it easy to verify at a glance.

Multiple Defers — LIFO Order

Multiple defers execute in Last-In First-Out order — the last one registered runs first. This mirrors the logical order of cleanup: things created last are usually destroyed first.

pub fn main() void {
    defer std.debug.print("Step 3: Final cleanup\n", .{});
    defer std.debug.print("Step 2: Middle cleanup\n", .{});
    defer std.debug.print("Step 1: First cleanup\n", .{});
    std.debug.print("Doing work...\n", .{});
}

Output:

Doing work...
Step 1: First cleanup
Step 2: Middle cleanup
Step 3: Final cleanup
  Defer stack:
  [Step 3 registered]
  [Step 2 registered]
  [Step 1 registered]

  At scope exit, pop stack:
  Step 1 runs first (last registered)
  Step 2 runs second
  Step 3 runs last (first registered)

errdefer — Run Only on Error Exit

errdefer schedules code that runs only if the scope exits with an error. If the function succeeds, the errdefer code does not run.

fn createUser(name: []const u8) !User {
    const user = try allocateUser();
    errdefer freeUser(user);   // only runs if something fails below

    try sendWelcomeEmail(user);
    try addToDatabase(user);

    return user;  // success → errdefer does NOT run
}
  allocateUser → success
  errdefer freeUser(user) → scheduled (only on error)
       |
  sendWelcomeEmail → fails with error!
       |
  Error path taken:
  → errdefer triggers → freeUser(user) runs
  → error returned to caller

  Success path:
  allocateUser → sendWelcomeEmail → addToDatabase → return user
  errdefer does NOT run

Without errdefer, you would need to write explicit cleanup code in every error branch — and missing even one branch causes a resource leak. errdefer handles this automatically.

defer with a Block

Defer can run a block of multiple statements:

defer {
    std.debug.print("Releasing lock...\n", .{});
    lock.release();
    std.debug.print("Lock released.\n", .{});
}

Practical Pattern: Allocate and Free

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();   // release GPA resources at end

    const allocator = gpa.allocator();

    const buffer = try allocator.alloc(u8, 100);
    defer allocator.free(buffer);   // free buffer at end

    // Work with buffer
    @memset(buffer, 'A');
    std.debug.print("First byte: {c}\n", .{buffer[0]});  // A
}
  Timeline:
  gpa created → defer gpa.deinit() registered
  buffer allocated → defer allocator.free(buffer) registered
  use buffer...
  ← scope ends
  free(buffer) runs ← LIFO
  gpa.deinit() runs

errdefer with Error Capture

fn riskyOperation() !void {
    const resource = try acquire();
    errdefer |err| {
        std.debug.print("Failed with: {}\n", .{err});
        release(resource);
    }
    try useResource(resource);
}

The |err| capture inside errdefer gives you the actual error value, useful for logging which specific error caused the cleanup to trigger.

defer vs Manual Cleanup

  Manual cleanup (error-prone):       With defer:
  ┌────────────────────────────┐      ┌────────────────────────────┐
  │ f = openFile()             │      │ f = try openFile()         │
  │ if err: return err         │      │ defer f.close()            │
  │                            │      │                            │
  │ data = readAll(f)          │      │ data = try readAll(f)      │
  │ if err:                    │      │ defer free(data)           │
  │   f.close()  ← easy        │      │                            │
  │   return err               │      │ result = try process(data) │
  │                            │      │ return result              │
  │ result = process(data)     │      │                            │
  │ if err:                    │      │ // All cleanup runs        │
  │   free(data) ← remember?   │      │ // automatically           │
  │   f.close()  ← remember?   │      └────────────────────────────┘
  │   return err               │
  │ free(data)                 │
  │ f.close()                  │
  │ return result              │
  └────────────────────────────┘

The manual approach requires you to write cleanup code in every error branch. Miss one and you have a resource leak. The defer approach writes cleanup once and guarantees it runs everywhere. This is one of Zig's most practical features for writing reliable systems code.

Leave a Comment

Your email address will not be published. Required fields are marked *