Intermediate

Functions in Zig

Learn how to define and call functions in Zig - parameters, return values, pass-by-value vs pointers, recursion, error unions, and higher-order functions with Docker-ready examples

Functions are the primary unit of code organization in Zig. As a systems language built around the idea of “no hidden control flow,” Zig’s functions are refreshingly predictable: parameters are immutable by default, there is no operator-overloading magic hiding extra calls, and every function’s return type — including whether it can fail — is spelled out in its signature.

Zig is multi-paradigm (imperative, procedural, and functional), and its function model reflects that. You get plain procedural functions like C, error-aware functions through error unions, and functional patterns like passing functions as values and writing generic functions with compile-time type parameters. What you won’t find is implicit behavior: there are no default arguments, no method overloading, and no surprise allocations.

In this tutorial you’ll learn how to define and call functions, the difference between passing values and passing pointers, how recursion works, how functions report errors with error unions, and how to write higher-order and generic functions using Zig’s comptime type parameters.

Defining and Calling Functions

A function is declared with the fn keyword, followed by a parameter list with explicit types, the return type, and a body. Use void when a function returns nothing. The pub keyword (seen on main) makes a function visible outside its file.

Create a file named functions.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const std = @import("std");

// Takes two parameters, returns their sum
fn add(a: i32, b: i32) i32 {
    return a + b;
}

// A single-parameter function
fn square(n: i32) i32 {
    return n * n;
}

// Returns nothing (void) - performs a side effect
fn greet(name: []const u8) void {
    std.debug.print("Hello, {s}!\n", .{name});
}

pub fn main() void {
    const sum = add(3, 4);
    std.debug.print("3 + 4 = {d}\n", .{sum});
    std.debug.print("square(5) = {d}\n", .{square(5)});
    greet("Zig");
}

Every parameter must have an explicit type, and the return type is never inferred — Zig favors clarity over brevity. Note that Zig has no default parameters and no function overloading: each function name maps to exactly one function. To emulate optional configuration, Zig programmers commonly pass an anonymous struct of options instead.

Parameters Are Immutable: Value vs Pointer

Function parameters in Zig are immutable — inside a function, you cannot reassign a parameter, and the function receives a copy of the argument’s value. To let a function modify the caller’s variable, you pass a pointer (*T) and dereference it with .*.

Create a file named parameters.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const std = @import("std");

// Receives a copy - the caller's value is untouched
fn tryToDouble(n: i32) void {
    const doubled = n * 2; // work on a local copy
    std.debug.print("inside (copy): {d}\n", .{doubled});
}

// Receives a pointer - modifies the caller's value
fn doubleInPlace(n: *i32) void {
    n.* = n.* * 2;
}

pub fn main() void {
    var value: i32 = 21;

    tryToDouble(value);
    std.debug.print("after tryToDouble: {d}\n", .{value});

    doubleInPlace(&value);
    std.debug.print("after doubleInPlace: {d}\n", .{value});
}

Here &value takes the address of value, producing a *i32. Inside doubleInPlace, n.* reads and writes through that pointer. This explicit distinction between pass-by-value and pass-by-pointer is central to Zig’s “what you see is what you get” philosophy — there is no hidden pass-by-reference.

Recursion

Functions can call themselves. Recursion is fully supported and is the natural way to express problems like factorials and Fibonacci numbers.

Create a file named recursion.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const std = @import("std");

fn factorial(n: u64) u64 {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

fn fibonacci(n: u32) u32 {
    if (n < 2) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

pub fn main() void {
    std.debug.print("factorial(5) = {d}\n", .{factorial(5)});
    std.debug.print("factorial(10) = {d}\n", .{factorial(10)});
    std.debug.print("fibonacci(10) = {d}\n", .{fibonacci(10)});
}

Each recursive call gets its own stack frame. Because Zig gives you precise control over integer widths, the return types (u64, u32) are part of the contract — choosing u64 for factorial avoids overflow that a narrower type would trigger.

Functions That Can Fail: Error Unions

Many functions need to signal failure. Zig encodes this directly in the return type using an error union, written ErrorSet!ReturnType. The caller must handle the error explicitly — either with catch or by capturing both branches with if/else.

Create a file named errors.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const std = @import("std");

const DivError = error{
    DivisionByZero,
};

// Returns either a DivError or an i32
fn divide(a: i32, b: i32) DivError!i32 {
    if (b == 0) return DivError.DivisionByZero;
    return @divTrunc(a, b);
}

pub fn main() void {
    // `catch` handles the error and provides a fallback path
    const result = divide(10, 2) catch |err| {
        std.debug.print("Error: {s}\n", .{@errorName(err)});
        return;
    };
    std.debug.print("10 / 2 = {d}\n", .{result});

    // `if`/`else` captures the success value or the error
    if (divide(10, 0)) |value| {
        std.debug.print("10 / 0 = {d}\n", .{value});
    } else |err| {
        std.debug.print("Cannot divide: {s}\n", .{@errorName(err)});
    }
}

The ! in DivError!i32 means “this returns an i32 or an error from DivError.” There is no way to accidentally ignore the failure — the compiler forces you to deal with it. @errorName converts an error value to its name as a string slice, which is handy for printing.

Higher-Order and Generic Functions

Zig’s functional side shines through function pointers and compile-time generics. You can pass a function as an argument using the *const fn(...) ... pointer type, and you can write a single function that works for many types by accepting a comptime T: type parameter.

Create a file named higher_order.zig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const std = @import("std");

// Accepts a function pointer and applies it twice
fn applyTwice(f: *const fn (i32) i32, x: i32) i32 {
    return f(f(x));
}

fn increment(n: i32) i32 {
    return n + 1;
}

fn triple(n: i32) i32 {
    return n * 3;
}

// Generic: the type is a compile-time parameter
fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

pub fn main() void {
    std.debug.print("applyTwice(increment, 5) = {d}\n", .{applyTwice(increment, 5)});
    std.debug.print("applyTwice(triple, 2) = {d}\n", .{applyTwice(triple, 2)});

    std.debug.print("max i32: {d}\n", .{max(i32, 10, 20)});
    std.debug.print("max f64: {d}\n", .{max(f64, 3.5, 2.1)});
}

applyTwice receives increment or triple as a value and calls it. The max function uses comptime T: type so the same source generates specialized versions for i32 and f64 at compile time — this is how Zig does generics without templates or macros. Each call site resolves T during compilation, so there is no runtime cost.

Running with Docker

You can run every example without installing Zig locally by using the official Alpine Zig image.

1
2
3
4
5
6
7
8
9
# Pull the Zig image
docker pull kassany/alpine-ziglang:0.14.0

# Run each example
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run functions.zig
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run parameters.zig
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run recursion.zig
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run errors.zig
docker run --rm -v $(pwd):/app -w /app kassany/alpine-ziglang:0.14.0 zig run higher_order.zig

Expected Output

Running functions.zig:

3 + 4 = 7
square(5) = 25
Hello, Zig!

Running parameters.zig:

inside (copy): 42
after tryToDouble: 21
after doubleInPlace: 42

Running recursion.zig:

factorial(5) = 120
factorial(10) = 3628800
fibonacci(10) = 55

Running errors.zig:

10 / 2 = 5
Cannot divide: DivisionByZero

Running higher_order.zig:

applyTwice(increment, 5) = 7
applyTwice(triple, 2) = 18
max i32: 20
max f64: 3.5

Key Concepts

  • Explicit signatures — Every parameter and the return type must be typed; Zig never infers a function’s return type, keeping call sites unambiguous.
  • Immutable parameters — Functions receive copies and cannot reassign parameters. To mutate a caller’s variable, pass a pointer (*T) and dereference with .*.
  • No defaults or overloading — Zig has no default arguments and no function overloading; pass an anonymous struct of options to emulate optional configuration.
  • Error unions — A return type of ErrorSet!T forces callers to handle failure with catch or if/else; errors can never be silently ignored.
  • Recursion — Functions may call themselves; choose integer widths (u32, u64) deliberately to avoid overflow in recursive accumulations.
  • Function pointers — Functions are values; accept them with the *const fn(...) ... type to build higher-order functions.
  • Compile-time generics — A comptime T: type parameter lets one function serve many types, resolved during compilation with zero runtime cost — Zig’s alternative to templates and macros.

Next Steps

Continue to I/O Operations to learn how Zig reads from standard input, writes formatted output, and works with files — building on the error-handling patterns introduced here.

Running Today

All examples can be run using Docker:

docker pull kassany/alpine-ziglang:0.14.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining