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 = 42The {} 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 = 5The 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 = AThis 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:
255You can also print in hexadecimal:
std.debug.print("{x}\n", .{255});Output:
ffUppercase hexadecimal:
std.debug.print("{X}\n", .{255});Output:
FFBinary:
std.debug.print("{b}\n", .{10});Output:
1010This 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.14The .2 means two digits after the decimal point.
Printing Booleans
Booleans print naturally:
const ok = true;
std.debug.print("ok = {}\n", .{ok});Output:
ok = trueThe value false prints as:
falsePrinting 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 = u32This 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=30The 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:
helloworldAdd \n when you want a new line:
std.debug.print("hello\n", .{});
std.debug.print("world\n", .{});Output:
hello
worldThe 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\AliceStandard 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: trueThis 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=trueThis 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.