⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2122+3e15e99e6What's newOn an older Zig?

Advanced Formatting

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

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

    // A `format` method taking `*std.Io.Writer` is used by the `{f}`
    // specifier. Older Zig passed a fmt string and options too; that
    // signature no longer applies.
    pub fn format(self: Point, writer: *std.Io.Writer) std.Io.Writer.Error!void {
        try writer.print("({d}, {d})", .{ self.x, self.y });
    }
};

const Colour = enum { red, green, blue };

test "{f} calls a custom format method" {
    var buf: [64]u8 = undefined;
    const text = try std.mem.print(&buf, "{f}", .{Point{ .x = 1, .y = 2 }});
    try expect(std.mem.eql(u8, text, "(1, 2)"));
}

test "{t} prints an enum tag name" {
    var buf: [64]u8 = undefined;
    const text = try std.mem.print(&buf, "{t}", .{Colour.green});
    try expect(std.mem.eql(u8, text, "green"));
}

test "{any} falls back to a structural dump" {
    var buf: [128]u8 = undefined;
    const text = try std.mem.print(&buf, "{any}", .{[_]u8{ 1, 2, 3 }});
    // Exact spelling is not contractual, but it contains the elements.
    try expect(std.mem.find(u8, text, "1") != null);
}

test "format strings are checked at compile time" {
    // Passing the wrong number of arguments, or a specifier the type does
    // not support, is a compile error, not a runtime surprise.
    var buf: [32]u8 = undefined;
    const ok = try std.mem.print(&buf, "{d} {s}", .{ 1, "two" });
    try expect(std.mem.eql(u8, ok, "1 two"));
}

test "writing to a list" {
    const gpa = std.testing.allocator;
    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();

    try out.writer.print("{f}", .{Point{ .x = 3, .y = 4 }});
    try expect(std.mem.eql(u8, out.written(), "(3, 4)"));
}

Custom format methods changed shape

Zig 0.15 rewrote this. The method now takes just a writer:

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

The old signature (which also received the format string and an options struct) no longer applies. If you find a tutorial showing format(self, comptime fmt: []const u8, options: FormatOptions, writer: anytype), it predates the change.

The reason for the change is worth knowing, because it explains the rest of this page. Under the old design, every type’s format had to parse the format string itself and decide what {d:>8} meant for it. So every implementation was slightly different, and most ignored the options entirely. Now a type says how it prints, once, and the specifiers that control width and alignment are the formatter’s job rather than yours.

{f} calls it

A custom format method is not used by {} or {any}. You must ask for it with {f}:

try writer.print("{f}", .{point});   // (1, 2)

This is the other half of the same change, and it is the one that surprises people mid-port. Under the old scheme {} found your method, so a type with a format printed nicely everywhere by accident. Now {} prints the struct the way the compiler sees it and {f} asks for yours. The upside is that you can see, at the call site, whether you are getting the type’s own rendering or the generic dump, which used to be invisible.

The specifiers worth knowing

SpecifierPrints
{d}a number in decimal
{x} / {X}hex, lower or upper; also works on byte slices
{b} / {o}binary, octal
{s}a string, meaning a []const u8 printed as text
{c}one byte as a character
{any}the generic dump, whatever the type
{f}the type’s own format method
{t}an enum tag name or an error name
{!}an error union, as error.Name
{*}a pointer’s address

Width, fill and alignment come after a colon: {d:0>4} pads with zeros to four characters, {s:<20} left-aligns in twenty. {d:.2} sets decimal places on a float.

Every one of these is checked at compile time against the tuple you passed. A {d} aimed at a string, or four specifiers with three arguments, is a build error rather than a runtime one. The format string has to be a comptime value for exactly that reason.

{t} for tag names

{t} prints an enum’s tag name or an error’s name, replacing the older practice of calling @tagName at every call site:

try writer.print("{t}", .{Colour.green});   // green

Composing writers

std.Io.Writer.Allocating gives you a growable in-memory writer, which is the usual way to build a string from formatted pieces:

var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
try out.writer.print("{f}", .{point});
out.written();   // []const u8

For a bounded string where you know the maximum size, std.mem.print writes into a stack buffer and returns the slice it filled. No allocator is involved, and it returns an error if the text does not fit. That is the right tool for formatting a number into a fixed field. Allocating is the right tool when the length depends on the data.

var buf: [32]u8 = undefined;
const text = try std.mem.print(&buf, "{d}%", .{percent});

If you have seen std.fmt.bufPrint and std.fmt.allocPrint, those are what this replaced: they are std.mem.print and Allocator.print now.

Both are the same underlying std.Io.Writer interface that a file or a socket implements. That is why the same print call works against all of them, and why a function taking a *std.Io.Writer can be tested by handing it a buffer. See readers and writers.