⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1454+5faa79730On an older Zig?

Transactions and errdefer

The problem

A transaction wrapper has one job: no path may leave the transaction open. Languages solve it with finally, context managers, or RAII, and the bugs live in the paths someone forgot: the early return, the error between two statements, the failed COMMIT itself. Zig’s errdefer was built for exactly this shape, and a library API is where it earns its keep.

The pattern

The entire contract:

try r.driver.exec("BEGIN");
errdefer r.driver.exec("ROLLBACK") catch {};
try body.run(r);
try r.driver.exec("COMMIT");

Every try after the errdefer is covered by it: an error inside the caller’s body, or in COMMIT itself, fires the ROLLBACK and propagates the original error. Success skips the errdefer entirely. There is no path that reaches the function’s end with the transaction open, and you can verify that by reading four lines rather than auditing every return.

const std = @import("std");
const expect = std.testing.expect;

const RecordingDriver = struct {
    gpa: std.mem.Allocator,
    log: std.ArrayList([]const u8) = .empty,

    pub fn exec(d: *RecordingDriver, sql: []const u8) !void {
        try d.log.append(d.gpa, try d.gpa.dupe(u8, sql));
    }

    pub fn deinit(d: *RecordingDriver) void {
        for (d.log.items) |sql| d.gpa.free(sql);
        d.log.deinit(d.gpa);
    }
};

fn Repo(comptime Driver: type) type {
    return struct {
        driver: *Driver,

        // The whole transaction contract in four lines. errdefer fires
        // on every error return between BEGIN and COMMIT, so there is
        // no path that leaves the transaction open: not an early return,
        // not a failure inside the callback, not a failed COMMIT.
        pub fn transaction(r: @This(), body: anytype) !void {
            try r.driver.exec("BEGIN");
            errdefer r.driver.exec("ROLLBACK") catch {};
            try body.run(r);
            try r.driver.exec("COMMIT");
        }
    };
}

const R = Repo(RecordingDriver);

test "success path commits" {
    var driver = RecordingDriver{ .gpa = std.testing.allocator };
    defer driver.deinit();

    const body = struct {
        fn run(_: @This(), repo: R) !void {
            try repo.driver.exec("INSERT INTO users");
        }
    }{};
    try (R{ .driver = &driver }).transaction(body);

    try expect(driver.log.items.len == 3);
    try expect(std.mem.eql(u8, driver.log.items[0], "BEGIN"));
    try expect(std.mem.eql(u8, driver.log.items[1], "INSERT INTO users"));
    try expect(std.mem.eql(u8, driver.log.items[2], "COMMIT"));
}

test "a failing body rolls back and the error escapes" {
    var driver = RecordingDriver{ .gpa = std.testing.allocator };
    defer driver.deinit();

    const body = struct {
        fn run(_: @This(), repo: R) !void {
            try repo.driver.exec("INSERT INTO users");
            return error.ValidationFailed;
        }
    }{};
    try std.testing.expectError(
        error.ValidationFailed,
        (R{ .driver = &driver }).transaction(body),
    );

    // The work happened, then was rolled back; nothing was committed.
    try expect(driver.log.items.len == 3);
    try expect(std.mem.eql(u8, driver.log.items[0], "BEGIN"));
    try expect(std.mem.eql(u8, driver.log.items[2], "ROLLBACK"));
}

Why the ROLLBACK swallows its own error

The catch {} on the ROLLBACK is deliberate and worth documenting in a real library: the wrapper is already unwinding with the caller’s error, which is the one that explains what went wrong. Replacing it with a secondary “and also the rollback failed” error would hide the cause. A production library logs the rollback failure; it still propagates the original.

The callback shape

body is anytype with a run method, the snippet’s stand-in for a closure. The caller’s work runs inside the wrapper’s frame, which is what lets errdefer see its errors. The same shape appears anywhere a library brackets user code: arena-per-request handlers, file locks, the draft’s pool checkout (acquire, run, always release).

In a full ORM

Real transactions nest (SAVEPOINT instead of BEGIN when already inside one), which the draft tracks with a depth counter on the connection; the errdefer shape is identical at every depth. The recording-driver tests from the previous page extend naturally: assert BEGIN/SAVEPOINT/ROLLBACK sequences for the nested cases.