Functions & Cleanup

In Zig a function is an ordinary value: declared with fn, passed by value into parameters, and free to return an error union. The true star of this lesson is defer — deterministic cleanup that runs when the scope exits, making early returns safe by construction.

Function Declarations

A function name, a parameter list, a return type, and a block. Parameters are typed and — unlike C — parameters and locals are immutable unless declared var.

Basic Signature

fn add(a: u32, b: u32) u32 {
    return a + b;
}
// parameters behave like const values — reassigning a throws an error

pub and Scope

Declarations are file-scoped and private by default; pub exports a function to importers. Keep private the helpers that are only internal details, and publish the stable API.

pub fn publicApi() void {} // visible to other files via @import
fn privateHelper() void {} // visible only inside this file

Pass-by-Value

Zig parameters are passed by value: the callee receives a copy, so mutating a parameter cannot mutate the caller value. There are no default parameters and no hidden references — everything is visible.

Copy Semantics

fn double(x: u32) u32 {
    return x * 2; // x is a copy; the caller value is untouched
}
var score: u32 = 21;
_ = double(score);
// score is still 21

Pointers When Needed

When you genuinely need to mutate the caller value, pass a pointer. Zig makes pointers explicit — there is no reference type pretending to be a value (see the pointers lesson).

fn increment(p: *u32) void {
    p.* += 1; // star dereferences; mutation reaches the caller
}
var n: u32 = 1;
increment(&n);
// n is now 2

Return Values

Return types are written after the parameter list. Three families matter most: void for doing things, values for computing things, and error unions for fallible operations.

Error Unions

A function that can fail returns !T — either a value of type T or an error. The caller must handle the failure visibly: with try to propagate or catch to recover. Nothing is ever thrown invisibly.

fn mightFail(ok: bool) !u32 {
    if (!ok) return error.NotReady;
    return 42;
}
// try propagates the error to the caller of main (which is !void)
const x = try mightFail(true);

void and noreturn

void means the function succeeds and returns nothing. noreturn is stronger: functions like std.process.exit and @panic never return, and the compiler uses that to prove control flow.

defer — Guaranteed Cleanup

defer is Zig replacement for RAII. Instead of running at an object lifetime boundary, it runs when the enclosing block exits — no matter how: normal end, early return, or even a break. Cleanup is always next to the acquisition, in the same function, visible to the reader.

Basics

const allocator = std.heap.page_allocator;
const buf = try allocator.alloc(u8, 16);
defer allocator.free(buf); // runs at scope exit, whatever happens
std.debug.print("allocated\n", .{});
// buf is freed here, automatically, even on early returns before this line

Multiple Defers Run Last-In-First-Out

Defers execute in reverse order of declaration — the mirror image of acquisition. Acquire file, then buffer, then lock: the lock frees first, then the buffer, then the file. This symmetry is what makes cleanup deterministic.

defer std.debug.print("1\n", .{});
defer std.debug.print("2\n", .{});
// prints 2 then 1 — LIFO, like unwinding a stack

errdefer — Cleanup on Error

errdefer runs only when the scope exits with an error. It is the correct partner for allocations that must become the caller responsibility on success but be freed on failure.

Basics

fn build() ![]u8 {
    const out = try std.heap.page_allocator.alloc(u8, 64);
    errdefer std.heap.page_allocator.free(out); // freed only on error
    if (shouldFail()) return error.BadState;    // errdefer fires
    return out;                                 // success: caller owns it
}
Thinking in Zig: defer/errdefer are read in one glance, unlike destructors hidden in class definitions. The pairing of an allocator with a defer free on the next line is the single most common rhythm in real Zig code — learn to read it as one unit.

inline Functions

Prefixing fn with inline forces the compiler to duplicate the function body at every call site. It is not a hint — it is a guarantee, and it enables compile-time tricks with types. Use it sparingly; the optimizer handles most cases on its own.

inline fn

inline fn twice(comptime T: type, value: T) T {
    return value * 2; // T is known at compile time — no runtime polymorphism
}
const a = twice(u32, 21); // instantiates the u32 version

Common Pitfalls

Mutating Parameters

Treating parameters as mutable causes confusion: without var, reassigning a parameter is a compile error, which is the compiler's way of saying the copy was not meant to change.

Forgetting defer

Manual free on every exit path is exactly how leaks and double-frees are born. If a resource is acquired, answer the question: which defer or errdefer releases it?

Next: Compile-Time Metaprogramming — the feature that makes Zig feel like a macro platform without macros.