Skip to content

Formatting and Printing

Formatting means turning values into text.

Formatting means turning values into text.

Printing means sending that text somewhere, usually to the terminal.

You have already used this many times:

const std = @import("std");

pub fn main() void {
    std.debug.print("Hello, Zig!\n", .{});
}

This line is doing two things:

std.debug.print("Hello, Zig!\n", .{});

It prints a format string.

It receives a tuple of values to insert into that string.

The tuple is empty here:

.{}

because there are no values to insert.

Placeholders

A placeholder marks where a value should go.

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

Output:

x = 42

The {} means “format this value using its default format.”

The value comes from the tuple:

.{42}

If there are two placeholders, you need two values:

std.debug.print("{} + {} = {}\n", .{ 2, 3, 5 });

Output:

2 + 3 = 5

The order matters. The first {} gets 2, the second gets 3, the third gets 5.

Printing Strings

For strings, use {s}.

const std = @import("std");

pub fn main() void {
    const name = "Zig";
    std.debug.print("Hello, {s}!\n", .{name});
}

Output:

Hello, Zig!

A string literal such as "Zig" is a sequence of bytes. The {s} formatter says to print those bytes as a string.

If you use {} for a byte slice, Zig may print it in a less friendly form. For human-readable text, use {s}.

Printing Characters

A byte character can be printed with {c}.

const std = @import("std");

pub fn main() void {
    const letter: u8 = 'A';
    std.debug.print("letter = {c}\n", .{letter});
}

Output:

letter = A

This works for byte-sized characters. For full Unicode text, remember that strings are UTF-8 bytes, and one visible character may use more than one byte.

Printing Integers

The default integer format is decimal:

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

Output:

255

You can also print in hexadecimal:

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

Output:

ff

Uppercase hexadecimal:

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

Output:

FF

Binary:

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

Output:

1010

This is useful when working with flags, bytes, encodings, file formats, networking, and low-level code.

Printing Floating Point Values

Floating point values can be printed with the default formatter:

const pi: f64 = 3.1415926535;
std.debug.print("pi = {}\n", .{pi});

You can also choose precision:

std.debug.print("pi = {d:.2}\n", .{pi});

Output:

pi = 3.14

The .2 means two digits after the decimal point.

Printing Booleans

Booleans print naturally:

const ok = true;
std.debug.print("ok = {}\n", .{ok});

Output:

ok = true

The value false prints as:

false

Printing Types

Zig can print type names at compile time using @typeName.

const std = @import("std");

pub fn main() void {
    std.debug.print("type = {s}\n", .{@typeName(u32)});
}

Output:

type = u32

This is useful when learning generics and comptime.

Format Strings Are Checked

Zig checks format strings.

If you write a placeholder but do not pass enough values, the compiler reports an error.

Wrong:

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

There are two placeholders, but only one value.

Wrong:

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

{s} expects string-like data, but 42 is an integer.

This is one of Zig’s strengths. Formatting mistakes are usually caught early.

Formatting Into a Buffer

Printing sends text to an output destination.

Sometimes you want to create text and store it in memory.

Use std.fmt.bufPrint.

const std = @import("std");

pub fn main() !void {
    var buffer: [100]u8 = undefined;

    const text = try std.fmt.bufPrint(&buffer, "name={s}, age={}", .{
        "Alice",
        30,
    });

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

Output:

name=Alice, age=30

The buffer is fixed size:

var buffer: [100]u8 = undefined;

bufPrint writes into that buffer and returns the used slice:

const text = try std.fmt.bufPrint(&buffer, "name={s}, age={}", .{
    "Alice",
    30,
});

The returned text is not a new allocation. It is a slice pointing into buffer.

Buffer Size Matters

If the buffer is too small, bufPrint returns an error.

var buffer: [4]u8 = undefined;

const text = try std.fmt.bufPrint(&buffer, "hello", .{});

The word "hello" needs 5 bytes, but the buffer has only 4. The operation fails instead of writing past the end.

This is safer than C-style formatting functions that can easily overflow a buffer when used incorrectly.

Allocating Formatted Text

Sometimes you do not know the output size ahead of time.

In that case, use an allocator-based formatting function.

The exact helper names can vary across Zig versions, but the common idea is:

const text = try std.fmt.allocPrint(allocator, "id={}", .{123});
defer allocator.free(text);

This allocates enough memory for the formatted text.

Because it allocates, it can fail.

Because it allocates, you must free it.

The pattern is:

const text = try make_formatted_text;
defer allocator.free(text);

Use buffer formatting when a fixed maximum size is reasonable.

Use allocator formatting when the size is naturally dynamic.

Printing Custom Structs

Suppose you have this struct:

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

You can print its fields manually:

const std = @import("std");

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

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

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

Output:

Point(10, 20)

This is often the clearest beginner approach.

Later, you can define custom formatting behavior for your own types, but manual field printing is enough for now.

Debug Printing

The formatter {any} is useful for debugging many values.

const std = @import("std");

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

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

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

This can print a representation of the value useful during development.

For public output, write a deliberate format.

For quick inspection, {any} is convenient.

Printing Arrays and Slices

For text slices, use {s}:

const name: []const u8 = "Alice";
std.debug.print("{s}\n", .{name});

For non-text arrays or slices, use {any} while debugging:

const numbers = [_]u8{ 1, 2, 3 };
std.debug.print("{any}\n", .{numbers});

A byte slice can be either text or raw bytes. The formatter you choose tells Zig how you want to view it.

Text view:

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

Debug view:

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

Newlines

Printing does not automatically add a newline.

This:

std.debug.print("hello", .{});
std.debug.print("world", .{});

prints:

helloworld

Add \n when you want a new line:

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

Output:

hello
world

The sequence \n means newline.

Escaping Special Characters

Some characters need escape sequences inside string literals.

Newline:

"\n"

Tab:

"\t"

Double quote:

"She said \"hello\""

Backslash:

"C:\\Users\\Alice"

Example:

std.debug.print("path = C:\\Users\\Alice\n", .{});

Output:

path = C:\Users\Alice

Standard Error vs Standard Output

std.debug.print is mainly for debugging. It writes to standard error.

For serious command-line programs, you often distinguish between:

standard output for normal program output

standard error for diagnostics and errors

For example, a tool that prints JSON should write the JSON to stdout and error messages to stderr.

In early examples, std.debug.print is fine. It is simple and available. Later, when building real command-line tools, use explicit stdout and stderr writers.

Formatting Is Compile-Time Aware

Zig format strings are usually known at compile time.

That lets Zig check the format string against the arguments.

This fits the larger Zig style: catch mistakes before the program runs when possible.

Example:

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

The compiler can inspect the format string and the argument tuple.

That is why Zig formatting feels stricter than formatting in many dynamic languages.

A Small Report Program

Here is a complete program that prints a small report:

const std = @import("std");

pub fn main() void {
    const name = "Alice";
    const score: u32 = 42;
    const passed = score >= 30;

    std.debug.print("Student report\n", .{});
    std.debug.print("name: {s}\n", .{name});
    std.debug.print("score: {}\n", .{score});
    std.debug.print("passed: {}\n", .{passed});
}

Output:

Student report
name: Alice
score: 42
passed: true

This example uses the common formatters:

{s} for strings

{} for integers and booleans

\n for line breaks

A Small Buffer Formatting Program

This program creates a formatted message in memory before printing it:

const std = @import("std");

pub fn main() !void {
    var buffer: [128]u8 = undefined;

    const user_id: u32 = 1001;
    const active = true;

    const message = try std.fmt.bufPrint(
        &buffer,
        "user_id={}, active={}",
        .{ user_id, active },
    );

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

Output:

user_id=1001, active=true

This pattern is useful when you need formatted text for a file, a network message, a log line, or a larger string.

The Core Pattern

Most beginner formatting code follows this shape:

std.debug.print("text with {}\n", .{value});

For strings:

std.debug.print("hello {s}\n", .{name});

For buffer formatting:

var buffer: [128]u8 = undefined;
const text = try std.fmt.bufPrint(&buffer, "x = {}", .{x});

For allocated formatting:

const text = try std.fmt.allocPrint(allocator, "x = {}", .{x});
defer allocator.free(text);

What You Should Remember

Formatting turns values into text.

Printing sends text to an output destination.

std.debug.print is useful while learning.

The format string comes first.

The arguments come second as a tuple.

Use {} for default formatting.

Use {s} for strings.

Use {c} for characters.

Use {x}, {X}, and {b} for hexadecimal and binary integers.

Use std.fmt.bufPrint to format text into a fixed buffer.

Use allocator-based formatting when the size is dynamic.

Zig checks many formatting mistakes at compile time, which makes formatting safer than it first appears.