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.
