Functions
Functions are the building blocks of any program. In this lesson, we'll explore how to create and use functions in Zig, including how to handle parameters, return values, and errors.
What You'll Learn
By the end of this lesson you will have built three functions with three different signatures — one that takes a value and returns a new one, one that changes a variable belonging to its caller through a pointer, and one that can refuse and say why — and a check battery that fails until each signature does what it promised.
This lesson carries no capstone milestone of its own, but choosing a signature is the decision every later one rests on. The record-analyzer is built from functions that parse a line, add to a running total, and reject a malformed row; which of those returns a value, which writes through a pointer, and which returns an error is exactly the choice this lesson teaches you to make on purpose.
You arrive here able to declare typed bindings and convert between numeric types (Variables and Data Types). What is new is that a name can now stand for a piece of behaviour, and that its parameter list and return type form a contract the compiler enforces.
Behaviour First: Three Shapes of Function
Functions in Zig are declared using the fn keyword. Click Run and read the three lines before
reading on — one function takes nothing, one takes a value, one hands a value back.
const std = @import("std");
// Basic function with no parameters or return value
fn greet() void {
std.debug.print("Hello, Zig!\n", .{});
}
// Function with parameters
fn greetPerson(name: []const u8) void {
std.debug.print("Hello, {s}!\n", .{name});
}
// Function with return value
fn add(a: i32, b: i32) i32 {
return a + b;
}
pub fn main() !void {
greet();
greetPerson("Alice");
const result = add(5, 7);
std.debug.print("5 + 7 = {}\n", .{result});
}
Return Values
Zig has no implicit return — every value a function returns must use the return keyword. Unlike Rust or Ruby, a bare last expression is not returned. This explicitness is deliberate: control flow is always visible.
This one is fully worked, with a note on every shape. The two examples after it get less help, and by the third you are writing the notes.
const std = @import("std");
// The return TYPE comes after the parameter list and before the brace. It is a
// promise: every path out of this body must hand back an i32.
fn multiply(a: i32, b: i32) i32 {
return a * b;
}
// One line of work still needs the keyword. There is no shortcut form, because
// a shortcut would make the exit point something you infer rather than read.
fn square(x: i32) i32 {
return x * x;
}
// A function may return from several places. Each one is visible as the word
// `return`, so you can find every exit by searching for it — which is the
// practical payoff of banning the implicit form.
fn absolute(x: i32) i32 {
if (x < 0) {
return -x;
}
return x;
}
pub fn main() !void {
std.debug.print("3 * 4 = {}\n", .{multiply(3, 4)});
std.debug.print("5^2 = {}\n", .{square(5)});
std.debug.print("|-10| = {}\n", .{absolute(-10)});
}
If a bare last expression is not returned, the obvious question is what Zig does with it. Work out the answer before reading on.
Predict
A developer coming from Rust writes square this way, dropping the return keyword. What does Zig do with it?
fn square(x: i32) i32 {
x * x;
}It does not compile. The value from x * x is being discarded, and Zig refuses to throw a non-void value away unless you say so explicitly with _ = x * x; — and separately, a function declared to return i32 that never reaches a return is an error in its own right. Write return x * x; and both complaints go away.
Public vs Private Functions
By default, functions are private to the file. Use pub to make them public. Fewer notes here:
one is left for you.
const std = @import("std");
fn privateHelper() void {
std.debug.print("This is private\n", .{});
}
// `pub` is the only difference between this declaration and the one above it,
// and it changes nothing about how the function behaves — only about who is
// allowed to name it from another file.
pub fn publicFunction() void {
std.debug.print("This is public\n", .{});
privateHelper();
}
pub fn main() !void {
publicFunction();
privateHelper(); // Works within the same file
}
Your note to write: private is the default and pub is the opt-in, rather than the other way
round. Say what that costs and what it buys, given that this file's main can call both. The
answer is not about safety — nothing stops you writing pub on everything — it is about what a
reader of your file can assume when a function is not marked.
Error Handling with Functions
Zig uses explicit error handling. Functions can return errors using the ! operator:
const std = @import("std");
// Define custom errors
const MathError = error{
DivisionByZero,
NegativeNumber,
};
// Function that may return an error
fn divide(a: i32, b: i32) MathError!i32 {
if (b == 0) {
return MathError.DivisionByZero;
}
return @divTrunc(a, b);
}
// Function with multiple possible errors
fn squareRoot(x: i32) MathError!i32 {
if (x < 0) {
return MathError.NegativeNumber;
}
// Simplified square root (just for demonstration)
var i: i32 = 0;
while (i * i <= x) : (i += 1) {}
return i - 1;
}
pub fn main() !void {
// Using try to propagate errors
const result1 = try divide(10, 2);
std.debug.print("10 / 2 = {}\n", .{result1});
// Using catch to handle errors
const result2 = divide(10, 0) catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
std.debug.print("Result: {}\n", .{result2});
// Using if to handle errors conditionally
if (squareRoot(16)) |root| {
std.debug.print("Square root of 16: {}\n", .{root});
} else |err| {
std.debug.print("Error calculating square root: {}\n", .{err});
}
}
Third stage of the fade: no notes at all. Read the fence above the way you will read unfamiliar
code — from the signatures inward. divide returns MathError!i32, so its two possible outcomes
are both written into the type; squareRoot returns the same union; and main handles the three
cases three different ways. Name which of try, catch, and if/else |err| each call site uses,
and why the third one needs its capture on the else side.
Reading a Failure
When a Zig program does fail, the failure is a value, and there is a routine for reading it. Full coverage is Error Handling's job; what follows is enough to make sense of an error message you meet before you get there.
1. Read the type first. MathError!i32 is one type with two halves: on success it carries an
i32, on failure one of MathError's members. That is why you cannot use the result directly —
there is nothing to add or print until you have said which half you are handling.
2. Let it reach the top and read the trace. If you write const value = try divide(10, 0); in
main and nothing catches it, the program exits with a non-zero status and prints an error return
trace:
error: DivisionByZero
/tmp/main.zig:6:17: 0x11d8ddd in divide (main.zig)
if (b == 0) return MathError.DivisionByZero;
^
/tmp/main.zig:11:19: 0x11d6acd in main (main.zig)
const value = try divide(10, 0);
^
Read it top-down: the first line names which error, and each frame below shows a place the error
passed through, starting at the return that created it. This is not a stack trace of where the
program was — it is a trace of where the error travelled, which is usually the more useful question.
3. Print it deliberately when you want to keep going. Inside an else |err| capture, err is
an ordinary value you can format: {t} prints the bare tag, so std.debug.print("failed: {t}\n", .{err}) gives failed: DivisionByZero, while {} prints the fully qualified error.DivisionByZero.
The habit worth forming: read the return type to learn what can go wrong, run it and read the trace to learn where it did, and reach for a capture only when you intend to carry on afterwards.
Function Parameters
Zig supports various parameter patterns:
const std = @import("std");
// Multiple parameters
fn displayInfo(name: []const u8, age: u8, height: f32) void {
std.debug.print("Name: {s}, Age: {}, Height: {d:.2}m\n", .{name, age, height});
}
// Mutable parameters (copy is made)
fn increment(x: i32) i32 {
var result = x; // Create mutable copy
result += 1;
return result;
}
// Pass by reference using pointers
fn incrementPointer(x: *i32) void {
x.* += 1; // Dereference and modify
}
pub fn main() !void {
// Multiple parameters
displayInfo("Alice", 30, 1.75);
// Value parameters (original not modified)
const num = 5;
const incremented = increment(num);
std.debug.print("Original: {}, Incremented: {}\n", .{num, incremented});
// Pointer parameter (modifies original)
var value: i32 = 10;
incrementPointer(&value);
std.debug.print("After incrementPointer: {}\n", .{value});
// Try modifying these examples!
}
That distinction between a value parameter and a pointer parameter is where most first Zig functions go wrong. Diagnose the next one before you fix it.
Debug
This should add a 10-point bonus to a score and print 'Score after bonus: 50'. The compiler rejects it and points at the function body. Commit a hypothesis about what addBonus is actually allowed to touch, then fix it so it prints 50.
const std = @import("std");
fn addBonus(score: i32, bonus: i32) void {
score += bonus;
}
pub fn main() !void {
var score: i32 = 40;
addBonus(score, 10);
std.debug.print("Score after bonus: {}\n", .{score});
}Expected output:
Score after bonus: 50
Parameters in Zig are immutable, so score += bonus inside addBonus is error: cannot assign to constant — and even if it compiled, the parameter is a copy, so it could never reach main's variable. Either take a pointer (score: *i32, called as addBonus(&score, 10), mutating in place) or return the new value and assign it at the call site. Prefer returning when you can; use a pointer when you genuinely need to modify in place.
Now assemble a call sequence yourself. These lines measure how much a pointer-taking function actually changed — and only one arrangement measures anything at all.
Arrange the code
These lines call a function that adds to its caller's variable through a pointer, and then report how much it gained. They are shuffled. Put them in the order that prints gained=10 — then answer what the order is really testing: which line has to run while the old value still exists, and what does it record if it runs too late?
const gained = score - before;bump(&score, 10);var score: i32 = 40;std.debug.print("before={} after={} gained={}\n", .{ before, score, gained });const before = score;
Generic Functions
Zig supports compile-time generics using anytype or explicit type parameters:
const std = @import("std");
// Generic function using anytype
fn max(a: anytype, b: anytype) @TypeOf(a, b) {
if (a > b) {
return a;
}
return b;
}
// Generic function with explicit type parameter
fn swap(comptime T: type, a: *T, b: *T) void {
const temp = a.*; // save a's value before overwriting it
a.* = b.*;
b.* = temp; // restore the saved value into b
}
pub fn main() !void {
// Works with different types
std.debug.print("max(5, 10) = {}\n", .{max(5, 10)});
std.debug.print("max(3.14, 2.71) = {d:.2}\n", .{max(3.14, 2.71)});
// Swap integers
var x: i32 = 1;
var y: i32 = 2;
std.debug.print("Before swap: x={}, y={}\n", .{x, y});
swap(i32, &x, &y);
std.debug.print("After swap: x={}, y={}\n", .{x, y});
}
Inline Functions
The inline keyword forces a function to be inlined at every call site:
const std = @import("std");
// Semantically inlined at every callsite — not a hint the optimizer may ignore
inline fn fastMultiply(a: i32, b: i32) i32 {
return a * b;
}
// An ordinary function, calling the inline one twice
fn calculate() i32 {
return fastMultiply(3, 4) + fastMultiply(5, 6);
}
pub fn main() !void {
std.debug.print("Result: {}\n", .{calculate()});
}
Two lessons, two places where Zig decides what a name is allowed to change. Close the code and see whether the rules line up the way you expect.
Recall
Without scrolling up: in Variables and Data Types you learned that a binding is immutable unless you declare it var. A function parameter has no such keyword in front of it. So which is a parameter — like a const, or like a var?
A parameter is a const: an immutable binding holding a copy of what the caller passed. That one fact produces both idioms in this lesson — declare your own var inside the body for a mutable local copy, and take a pointer when you need to change something belonging to the caller.
You have now seen two ways a function can affect the world outside itself. Here is the same distinction in a setting this lesson did not show.
Transfer
This lesson showed a value parameter (a copy the function cannot use to reach the caller) and a pointer parameter (a handle it can write through). Now read a function you have never seen, whose body you cannot see either: fn appendTotal(out: *i64, rows: []const i32) void. What can you conclude from the signature alone?
Practice Exercises
Try these exercises:
- Create a function that takes three numbers and returns the largest one
- Write a function that checks if a number is even (returns
bool) - Create an error set and a function that validates input (e.g., checks if age is valid)
- Write a generic function that works with both integers and floats
- Create a function that takes a pointer and modifies the value it points to
- Write a function that returns an optional value (
?i32) - returnnullfor invalid input
Challenge: Create a calculator function that takes two numbers and an operation (as a string like "add", "subtract") and returns the result. Handle division by zero as an error.
Try It Yourself
Reading about functions is not the same as choosing the right signature. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one returns a value, one modifies its caller's variable through a pointer, one returns an error union. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper that prints the failure and returns an error. Because an error returned from main ends the program with a non-zero exit status, the first failing check stops everything — no hidden control flow, exactly as Zig prefers it.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one does the right thing. Run it as-is to see which check fails first, decide what that function's signature is promising, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const std = @import("std");
const RangeError = error{ TooSmall, TooLarge };
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
// TODO 1: return three times the value. A plain value in, a plain value out —
// remember that Zig has no implicit return.
// triple(4) -> 12, triple(-2) -> -6
fn triple(value: i32) i32 {
_ = value;
return 0;
}
// TODO 2: add amount to the i32 the pointer points at, in place. The caller
// reads its own variable afterwards, so nothing may be returned.
// var n: i32 = 10; bump(&n, 5); // n is now 15
fn bump(target: *i32, amount: i32) void {
_ = target;
_ = amount;
}
// TODO 3: return grams unchanged when it is between 10 and 500 inclusive.
// Below 10 return RangeError.TooSmall; above 500 return RangeError.TooLarge.
// validatePortion(250) -> 250, validatePortion(2) -> error.TooSmall
fn validatePortion(grams: i32) RangeError!i32 {
_ = grams;
return 0;
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
try check(triple(4) == 12, "triple(4) should be 12");
try check(triple(-2) == -6, "triple(-2) should be -6");
var counter: i32 = 10;
bump(&counter, 5);
try check(counter == 15, "bump(&counter, 5) should leave the caller's counter at 15");
bump(&counter, -3);
try check(counter == 12, "a second bump should build on the first, leaving 12");
try check(try validatePortion(250) == 250, "validatePortion(250) should return 250");
try check(try validatePortion(10) == 10, "validatePortion(10) should be allowed — the low bound is inclusive");
try check(validatePortion(2) == RangeError.TooSmall, "validatePortion(2) should return error.TooSmall");
try check(validatePortion(900) == RangeError.TooLarge, "validatePortion(900) should return error.TooLarge");
std.debug.print("All checks passed.\n", .{});
std.debug.print("Triple of 4: {}\n", .{triple(4)});
std.debug.print("Counter after two bumps: {}\n", .{counter});
std.debug.print("Portion: {}\n", .{try validatePortion(250)});
}Expected output:
All checks passed. Triple of 4: 12 Counter after two bumps: 12 Portion: 250
Once it passes, try two variations and predict each before running:
- Pass by value instead of by pointer. Change
bump's signature tofn bump(target: i32, amount: i32) voidand its body to_ = target + amount;, then update both calls tobump(counter, 5)andbump(counter, -3). Predict what happens before running. It never reaches the checks: nothing writes tocounterany more, so the compiler stops aterror: local variable is never mutatedonvar counter. That error is the point — a value parameter is a copy, so nothing the function does can reachmain's variable, and here the compiler notices the variable is now write-free before the check ever gets to disagree. This is the whole reason the pointer is in the signature. - Move a boundary by one. In
validatePortion, change the low guard fromgrams < 10tograms <= 10. Predict which check fails before running. ThevalidatePortion(250)check still passes, butvalidatePortion(10)now returnserror.TooSmall, so the check demanding an inclusive low bound fails. One character in a comparison operator is the difference between 10 being valid and 10 being rejected.
Key Takeaways
- Functions are declared with
fnand can have parameters and return values - Use
pubto make functions public, they're private by default - Error handling uses
!in return types andtry/catchwhen calling - Functions can be generic using
anytypeorcomptimetype parameters - Parameters are passed by value unless you use pointers
- The
inlinekeyword forces inlining at the call site — usually better left to the compiler
Next Steps
Now that you understand functions, let's explore how to control the flow of your program using conditionals and loops.