Functions & Cleanup
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
}
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.