Skip to content

Custom Formatting

Zig has a formatting system built into the standard library. You have already used it many times through std.debug.print.

Zig has a formatting system built into the standard library. You have already used it many times through std.debug.print.

const std = @import("std");

pub fn main() void {
    std.debug.print("name = {s}, age = {}\n", .{ "Ada", 36 });
}

This prints:

name = Ada, age = 36

The first argument is the format string:

"name = {s}, age = {}\n"

The second argument is a tuple of values:

.{ "Ada", 36 }

The braces inside the format string tell Zig where to put each value.

{s}

means “format this value as a string.”

{}

means “format this value using its default format.”

Custom formatting means this: you define how your own type should appear when it is printed.

Why Custom Formatting Exists

Suppose we have a Point type:

const Point = struct {
    x: i32,
    y: i32,
};

A point has two numbers. We might want to print it like this:

Point(10, 20)

Or like this:

{x = 10, y = 20}

Or like this:

10,20

Different programs need different output styles. Debug output, logs, terminal output, config files, and generated code may all want different text.

Without custom formatting, every call site must manually decide how to print the value:

std.debug.print("Point({}, {})\n", .{ p.x, p.y });

That works, but it spreads formatting logic across the program. If you later change how points should be printed, you must find every print call and update it.

Custom formatting lets the type own its display logic.

The Basic Idea

In Zig, a type can provide a format method.

Here is a simple example:

const std = @import("std");

const Point = struct {
    x: i32,
    y: i32,

    pub fn format(
        self: Point,
        writer: *std.Io.Writer,
    ) !void {
        try writer.print("Point({}, {})", .{ self.x, self.y });
    }
};

pub fn main() void {
    const p = Point{ .x = 10, .y = 20 };

    std.debug.print("{}\n", .{p});
}

This prints:

Point(10, 20)

The important part is this method:

pub fn format(
    self: Point,
    writer: *std.Io.Writer,
) !void {
    try writer.print("Point({}, {})", .{ self.x, self.y });
}

When Zig sees this:

std.debug.print("{}\n", .{p});

it notices that Point has a format method, then calls that method to write the value.

Formatting Writes to a Writer

A formatter does not return a string.

It writes bytes into a writer.

That is an important Zig idea. Building strings often requires allocation. Writing directly to a writer avoids unnecessary memory allocation.

This is efficient:

try writer.print("Point({}, {})", .{ self.x, self.y });

This would be less direct:

// Build a string first, then print it later.

Zig prefers direct output when possible.

A writer can represent many destinations:

terminal
file
memory buffer
network stream
test buffer

The same custom formatter can work with all of them.

A More Complete Example

Let us define a User type.

const std = @import("std");

const User = struct {
    id: u64,
    name: []const u8,
    active: bool,

    pub fn format(
        self: User,
        writer: *std.Io.Writer,
    ) !void {
        try writer.print("User(id={}, name=\"{s}\", active={})", .{
            self.id,
            self.name,
            self.active,
        });
    }
};

pub fn main() void {
    const user = User{
        .id = 42,
        .name = "Ada",
        .active = true,
    };

    std.debug.print("{}\n", .{user});
}

Output:

User(id=42, name="Ada", active=true)

Now every place that prints a User with {} gets the same representation.

std.debug.print("created: {}\n", .{user});
std.debug.print("loaded: {}\n", .{user});
std.debug.print("deleted: {}\n", .{user});

The formatting rule lives in one place: the User type.

Formatting Should Not Hide Expensive Work

A formatter should usually be simple.

Good formatter:

try writer.print("User(id={}, name=\"{s}\")", .{
    self.id,
    self.name,
});

Risky formatter:

// Avoid doing database access, network calls, or large allocations here.

Formatting is often used in logs, errors, tests, and debugging. If printing a value secretly performs expensive work, the program becomes harder to reason about.

A good rule: formatting should describe a value, not compute the value from scratch.

Debug Formatting vs Display Formatting

Sometimes you want different output for different purposes.

For humans:

Ada

For debugging:

User(id=42, name="Ada", active=true)

For a file format:

42,Ada,true

Zig’s formatting system can support different format specifiers. Conceptually, a formatter can inspect the requested formatting style and choose different output.

A simplified design looks like this:

const User = struct {
    id: u64,
    name: []const u8,
    active: bool,

    pub fn format(
        self: User,
        writer: *std.Io.Writer,
    ) !void {
        try writer.print("User(id={}, name=\"{s}\", active={})", .{
            self.id,
            self.name,
            self.active,
        });
    }
};

For many beginner and intermediate programs, one default custom format is enough. Start there. Add multiple styles only when you have a real need.

Formatting Nested Values

Custom formatting becomes more useful when types contain other custom types.

const std = @import("std");

const Point = struct {
    x: i32,
    y: i32,

    pub fn format(
        self: Point,
        writer: *std.Io.Writer,
    ) !void {
        try writer.print("Point({}, {})", .{ self.x, self.y });
    }
};

const Rectangle = struct {
    top_left: Point,
    bottom_right: Point,

    pub fn format(
        self: Rectangle,
        writer: *std.Io.Writer,
    ) !void {
        try writer.print("Rectangle({}, {})", .{
            self.top_left,
            self.bottom_right,
        });
    }
};

pub fn main() void {
    const rect = Rectangle{
        .top_left = Point{ .x = 0, .y = 0 },
        .bottom_right = Point{ .x = 100, .y = 50 },
    };

    std.debug.print("{}\n", .{rect});
}

Output:

Rectangle(Point(0, 0), Point(100, 50))

Notice this part:

try writer.print("Rectangle({}, {})", .{
    self.top_left,
    self.bottom_right,
});

Rectangle does not manually print Point.x and Point.y. It lets Point format itself.

This is the main advantage of custom formatting. Each type knows how to describe itself.

Formatting Collections

Now consider a small list-like type.

const std = @import("std");

const Scores = struct {
    values: []const u32,

    pub fn format(
        self: Scores,
        writer: *std.Io.Writer,
    ) !void {
        try writer.writeAll("[");

        for (self.values, 0..) |value, i| {
            if (i != 0) {
                try writer.writeAll(", ");
            }

            try writer.print("{}", .{value});
        }

        try writer.writeAll("]");
    }
};

pub fn main() void {
    const values = [_]u32{ 90, 75, 88 };
    const scores = Scores{ .values = values[0..] };

    std.debug.print("scores = {}\n", .{scores});
}

Output:

scores = [90, 75, 88]

This formatter writes the opening bracket, then each value, then the closing bracket.

The loop uses an index:

for (self.values, 0..) |value, i| {

The index lets us avoid printing a comma before the first item.

if (i != 0) {
    try writer.writeAll(", ");
}

This is a common pattern for formatting lists.

Formatting Must Handle Errors

A formatter returns an error union:

!void

Writing can fail. A file can fail. A stream can fail. A buffer can run out of space.

That is why formatting code uses try:

try writer.writeAll("[");
try writer.print("{}", .{value});
try writer.writeAll("]");

Do not ignore these errors. The caller decides what to do with them.

This is normal Zig style: if an operation can fail, the type says so.

Avoid Allocating Inside Formatters

Try not to allocate memory inside a formatter.

Prefer this:

try writer.print("Point({}, {})", .{ self.x, self.y });

Avoid this kind of design unless you really need it:

// Allocate a temporary string.
// Print the temporary string.
// Free the temporary string.

A formatter may be called in many places. It may be used during error reporting. It may be used while debugging allocation problems. If the formatter itself allocates, debugging becomes harder.

Direct writing is simpler and usually faster.

Keep Formatting Stable

Once a type has a formatter, other code may start depending on the output.

This is especially true for:

logs
tests
snapshots
generated files
command-line output

Before changing custom formatting, ask whether the text is part of a stable interface.

For debugging, changing the format is usually fine.

For command-line tools, changing output can break scripts.

For generated files, changing output can create noisy diffs.

For logs, changing output can break parsers.

A formatter is just code, but its output may become part of your program’s contract.

Custom Formatting for Tests

Custom formatting is useful in tests because it makes failed output easier to read.

Suppose a test prints this:

expected Point(10, 20), got Point(10, 21)

That is much better than a vague message.

Example:

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

const Point = struct {
    x: i32,
    y: i32,

    pub fn format(
        self: Point,
        writer: *std.Io.Writer,
    ) !void {
        try writer.print("Point({}, {})", .{ self.x, self.y });
    }
};

test "points are equal" {
    const expected = Point{ .x = 10, .y = 20 };
    const actual = Point{ .x = 10, .y = 21 };

    if (expected.x != actual.x or expected.y != actual.y) {
        std.debug.print("expected {}, got {}\n", .{ expected, actual });
        return error.TestExpectedEqual;
    }
}

The custom formatter makes the test failure clearer.

Custom Formatting and API Design

A public type should have a public formatting story.

For a small internal type, you can choose any output that helps you debug.

For a library type, be more careful. Users may print your values in their logs or CLI tools. The output should be clear, predictable, and boring.

Good public formatting:

Duration(seconds=3, nanos=500000000)

Less useful formatting:

duration thing!!!

Cute output is fun once. Clear output remains useful for years.

Prefer names and structure that help the reader understand the value.

Common Mistakes

A common mistake is trying to return a formatted string from the formatter. In Zig, the formatter writes to a writer.

Another common mistake is forgetting try. Writer operations can fail, so the formatter must propagate errors.

A third mistake is putting too much logic in the formatter. The formatter should present data. It should not modify the object, fetch remote data, or perform unrelated work.

A fourth mistake is making the output too clever. Use clear text. Future readers, logs, and tests all benefit from plain structure.

A Practical Pattern

For most custom types, start with this pattern:

const MyType = struct {
    field_a: u32,
    field_b: []const u8,

    pub fn format(
        self: MyType,
        writer: *std.Io.Writer,
    ) !void {
        try writer.print("MyType(field_a={}, field_b=\"{s}\")", .{
            self.field_a,
            self.field_b,
        });
    }
};

This gives you:

MyType(field_a=123, field_b="hello")

It is readable. It is stable. It includes field names. It works well in logs and tests.

The Main Idea

Custom formatting lets your types control how they are printed.

Instead of scattering print logic across the program, you attach that logic to the type itself.

A good formatter is simple, direct, and predictable. It writes to a writer, propagates errors, avoids unnecessary allocation, and produces output that helps humans understand the value.