Hello World & the Build
Hello, World
Zig's hello world is longer than C++'s, and every extra token is there on purpose — which is the fastest possible introduction to the language's argument.
#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
try stdout.writeStreamingAll(io, "Hello, World!\n");
}Writing to stdout is I/O, I/O can fail, so
main returns an error union (!void) and the write is prefixed with try. The I/O implementation arrives as a parameter rather than being reached for globally, because Zig objects to hidden global state as much as to hidden control flow. And no newline is added for you. Compare std::cout << …, which can throw if exceptions are enabled on the stream, allocates in ways you do not see, and is flushed by std::endl — three behaviors the Zig line makes visible or does without.Formatted output without allocating
The format grammar is a close cousin of C++20's. Where the two part company is who owns the memory the result lives in.
#include <format>
#include <iostream>
#include <string>
int main() {
std::string product = "widget";
int quantity = 7;
// std::format ALLOCATES a std::string to hold the result.
std::string line = std::format("{} x{}", product, quantity);
std::cout << line << std::endl;
return 0;
}const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
const product = "widget";
const quantity: i32 = 7;
// The buffer is YOURS. bufPrint allocates nothing and returns an
// error if the result would not fit.
var buffer: [64]u8 = undefined;
const line = try std.fmt.bufPrint(&buffer, "{s} x{d}", .{ product, quantity });
try stdout.writeStreamingAll(io, line);
try stdout.writeStreamingAll(io, "\n");
}std::format returns a std::string, which means a heap allocation on every call that you did not ask for and cannot decline. std::fmt.bufPrint writes into a buffer you supplied and returns a slice of it, so the allocation is a stack array you can see — and if the output does not fit you get error.NoSpaceLeft rather than a silent growth. Note the specifiers are typed: {s} for a string and {d} for an integer, because Zig will not guess. When you do want an allocating version it is std.fmt.allocPrint, and it takes an allocator as its first argument.The build system is Zig
Every C++ project learns a second language to describe its build. Zig declined to have one.
// The build is a separate language, and which one is a project decision:
//
// CMakeLists.txt — its own DSL, its own scoping rules
// Makefile — tabs, and shell quoting
// meson.build — Python-ish, but not Python
// BUILD.bazel — Starlark
//
// Cross-compiling means obtaining a cross toolchain and a sysroot for
// the target, which is usually the hardest part of the whole exercise.
#include <iostream>
int main() {
std::cout << "a build DSL, plus a cross toolchain to install" << std::endl;
return 0;
}// build.zig is written in ZIG. It is an ordinary program with the same
// syntax, types and editor support as the code it builds:
//
// pub fn build(b: *std.Build) void {
// const exe = b.addExecutable(.{
// .name = "report",
// .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig") }),
// });
// b.installArtifact(exe);
// }
//
// Cross-compiling needs no extra toolchain — libc for every supported
// target ships WITH the compiler:
//
// zig build -Dtarget=aarch64-linux-musl
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
try stdout.writeStreamingAll(io, "one language for the code and the build\n");
}Because
build.zig is Zig, there is no second syntax, no second type system and no second set of editor tooling — a build step is a function call and a conditional is an if. The more remarkable half is cross-compilation: Zig ships the C headers and libc source for every target it supports, so -Dtarget=aarch64-linux-musl works from any host with nothing else installed. That capability is why people who never write Zig still install it, which is the subject of the C-interop section below.No Hidden Control Flow
What a line of C++ can do behind your back
This is Zig's central claim, and it is worth stating as a testable question: reading one line of code, can you tell whether it allocates, and whether it can jump somewhere else?
In C++ the answer is no —
push_back may allocate, may move every element, runs constructors and destructors, and may throw, and none of that is visible at the call site. In Zig every one of those is spelled: memory comes from an allocator parameter, the failure path is marked try, cleanup is a defer you wrote, and there are no implicit constructor or destructor calls at all. The price is on the screen — the Zig column is longer and you can never stop thinking about allocation. Whether that is discipline or tedium is the question the rest of this page is really about.defer and errdefer instead of destructors
Zig has no destructors.
defer is the replacement, and errdefer is the piece C++ has no counterpart for.#include <iostream>
#include <string>
class Transaction {
public:
explicit Transaction(std::string name) : name_(std::move(name)) {
std::cout << "begin " << name_ << std::endl;
}
~Transaction() { std::cout << "end " << name_ << std::endl; }
private:
std::string name_;
};
void work() {
Transaction transaction("payment"); // cleanup belongs to the TYPE
std::cout << "working" << std::endl;
} // and runs automatically
int main() {
work();
return 0;
}const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
try stdout.writeStreamingAll(io, "begin payment\n");
// defer runs at scope exit; errdefer runs ONLY on an error return.
defer stdout.writeStreamingAll(io, "end payment\n") catch {};
errdefer stdout.writeStreamingAll(io, "rolled back\n") catch {};
try stdout.writeStreamingAll(io, "working\n");
}A C++ destructor belongs to the type, so its author guarantees cleanup for every user; a
defer belongs to the block, so every caller must write it and the compiler will not remind them. What Zig adds is errdefer, which runs only when the function returns an error — exactly the "undo what I have done so far" half of a constructor's exception safety, expressed directly instead of emerging from destructor ordering. Deferred statements run last-registered-first at scope exit, and unlike Go's they are scoped to the block, not the function.Variables & Types
const and var, and unused is an error
The
const/var split maps directly onto C++, with the same inversion Rust made: immutability is the default and the mutable case is the one you spell.#include <iostream>
int main() {
const int limit = 10;
int count = 0;
int unused = 99; // a warning with -Wall, never an error
count = 42;
(void)unused;
std::cout << count << " " << limit << std::endl;
return 0;
}const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const limit: i32 = 10;
var count: i32 = 0;
// var unused: i32 = 99; // COMPILE ERROR: unused local variable
count = 42;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d}\n", .{ count, limit }));
}The stricter rule is that an unused local is a compile error, as is an unused
const and a mutable var that is never mutated — the compiler will tell you to write const instead. This is genuinely irritating while debugging, when commenting out one line orphans a variable, and the escape hatch is _ = variable;. The reasoning matches Go's: a warning nobody must fix accumulates until nobody reads any of them.Every conversion is a builtin
C++ has one cast spelling covering several unrelated operations, plus a set of implicit conversions that fire without any spelling at all. Zig has neither.
#include <iostream>
int main() {
int large = 300;
// Narrowing is IMPLICIT and silently truncates. -Wconversion warns;
// it is off by default and not part of -Wall.
char narrowed = large;
double promoted = large;
std::cout << static_cast<int>(narrowed) << " " << promoted << std::endl;
return 0;
}const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const large: i32 = 300;
// 300 does NOT fit in i8. @intCast would TRAP here in a safe build;
// getting C++'s silent truncation requires naming it @truncate.
const narrowed: i8 = @truncate(large);
// Widening still needs a builtin, but @floatFromInt names which
// conversion is happening rather than leaving it to a lookup table.
const promoted: f64 = @floatFromInt(large);
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d} {d}\n", .{ narrowed, promoted }));
}There are no implicit numeric conversions, and each conversion has its own builtin naming exactly what it does:
@intCast between integer types, @floatFromInt and @intFromFloat across the divide, @truncate when you genuinely want the low bits, @bitCast to reinterpret. The safety difference is the important one: @intCast asserts the value fits and traps in a safe build if it does not, where C++'s implicit narrowing silently gives you 44. When truncation is what you want, @truncate says so.Overflow is defined, and there are several operators
Signed overflow being undefined is the most consequential piece of undefined behavior in C++, because it licenses the optimizer to delete the bounds check you wrote to guard against it.
#include <iostream>
#include <limits>
int main() {
int largest = std::numeric_limits<int>::max();
// Signed overflow is UNDEFINED BEHAVIOR — the optimizer may assume
// it never happens and delete the check you wrote against it.
if (largest > std::numeric_limits<int>::max() - 1) {
std::cout << "would overflow" << std::endl;
}
unsigned int unsigned_largest = std::numeric_limits<unsigned int>::max();
std::cout << "unsigned wraps to " << unsigned_largest + 1 << std::endl;
return 0;
}const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [128]u8 = undefined;
const largest: i32 = std.math.maxInt(i32);
// Plain + TRAPS on overflow in a safe build. To ask instead of
// trapping, there is a checked builtin:
if (@addWithOverflow(largest, 1)[1] == 1) {
try stdout.writeStreamingAll(io, "would overflow\n");
}
// And a separate operator when wrapping is what you actually mean.
const unsigned_largest: u32 = std.math.maxInt(u32);
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"unsigned wraps to {d}\n", .{unsigned_largest +% 1}));
}Zig has no undefined overflow. Plain
+ is checked and traps in Debug and ReleaseSafe, which turns a silent miscompilation into a crash at the exact line. The wrapping operators are separate — +%, -%, *% — so wrapping is something you opt into visibly rather than something unsigned types do behind you, and +| saturates. @addWithOverflow returns both the result and a flag when you want to ask. Note this is a runtime check with a real cost: in ReleaseFast it is removed, and overflow there is undefined.comptime vs Templates
A generic function is a function with a type parameter
This is the row that explains why people call Zig's metaprogramming simpler than C++'s: there is no second language involved.
#include <iostream>
#include <vector>
template <typename Element>
Element largest(const std::vector<Element>& values) {
Element best = values[0];
for (const Element& value : values) {
if (value > best) { best = value; }
}
return best;
}
int main() {
std::cout << largest(std::vector<int>{3, 9, 2}) << std::endl;
std::cout << largest(std::vector<double>{1.5, 0.5}) << std::endl;
return 0;
}const std = @import("std");
// No template syntax. Element is an ORDINARY PARAMETER whose type is
// `type` and whose value is known at compile time.
fn largest(comptime Element: type, values: []const Element) Element {
var best = values[0];
for (values) |value| {
if (value > best) best = value;
}
return best;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const integers = [_]i32{ 3, 9, 2 };
const floats = [_]f64{ 1.5, 0.5 };
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n{d}\n", .{
largest(i32, &integers),
largest(f64, &floats),
}));
}A C++ template is a separate, pattern-matching, compile-time language layered on top of the runtime one, with its own rules for everything. In Zig,
type is just a type, so a generic function is a normal function with a parameter marked comptime — the syntax in the body is the same syntax you write everywhere, and the same code can often run at compile time or runtime unchanged. The body is checked when instantiated, exactly as a template body is, so errors arrive at the call rather than the definition. That is the trade against Rust and Go, whose bounds are declared and checked once.A generic type is a function that returns a type
Once
type is an ordinary value, a class template stops needing to be a separate concept — it is a function you call.#include <iostream>
template <typename Element, int Capacity>
class Stack {
public:
void push(Element value) { items_[length_] = value; length_ += 1; }
Element top() const { return items_[length_ - 1]; }
int size() const { return length_; }
private:
Element items_[Capacity];
int length_ = 0;
};
int main() {
Stack<int, 8> stack;
stack.push(9);
std::cout << stack.top() << " " << stack.size() << std::endl;
return 0;
}const std = @import("std");
// Not a class template — a plain function, called at compile time,
// that RETURNS a struct type. std.ArrayList is written exactly this way.
fn Stack(comptime Element: type, comptime capacity: usize) type {
return struct {
items: [capacity]Element = undefined,
length: usize = 0,
const Self = @This();
fn push(self: *Self, value: Element) void {
self.items[self.length] = value;
self.length += 1;
}
fn top(self: Self) Element {
return self.items[self.length - 1];
}
};
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
var stack = Stack(i32, 8){};
stack.push(9);
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"{d} {d}\n", .{ stack.top(), stack.length }));
}Nothing here is template machinery:
Stack(i32, 8) is a function call evaluated at compile time whose result happens to be a type, and @This() refers to the enclosing struct so methods can name their own type. Non-type parameters like capacity need no special syntax because every comptime parameter is already a value. This is how the whole standard library is written — std.ArrayList(T) is that function — and it is also why Zig has no separate concept of specialization: an if inside the function returning a different struct is specialization.comptime replaces constexpr, macros and #ifdef
Count the compile-time mechanisms in the C++ column: the preprocessor,
constexpr, static_assert, and (not shown) templates, consteval and if constexpr. Zig has one.#include <iostream>
// C++ has accumulated several compile-time mechanisms, each with its
// own rules: the preprocessor, templates, constexpr, consteval,
// if constexpr, and static_assert.
constexpr int factorial(int value) {
return value <= 1 ? 1 : value * factorial(value - 1);
}
#define DOUBLE_IT(x) ((x) * 2) // textual, unhygienic, no types
int main() {
static_assert(factorial(5) == 120);
constexpr int precomputed = factorial(5);
std::cout << precomputed << " " << DOUBLE_IT(21) << std::endl;
return 0;
}const std = @import("std");
// One mechanism. This is an ordinary function; calling it in a comptime
// context runs it at compile time, calling it normally runs it at runtime.
fn factorial(value: u64) u64 {
return if (value <= 1) 1 else value * factorial(value - 1);
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
comptime {
// The compiler RUNS this. No separate constexpr dialect.
if (factorial(5) != 120) @compileError("arithmetic is broken");
}
const precomputed = comptime factorial(5);
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"{d} {d}\n", .{ precomputed, 21 * 2 }));
}comptime subsumes all of them. There is no preprocessor, so no #define, no #include and no #ifdef — conditional compilation is an ordinary if on a comptime value, and the dead branch is simply not analyzed. There is no constexpr keyword on functions because any function may run at compile time if its inputs are known. @compileError replaces static_assert and produces a message you wrote rather than a template instantiation backtrace. The constraint that makes this safe: comptime code cannot do I/O or call into the host, so a build stays deterministic.Reflection is a struct you can read
This is the capability C++ has wanted for twenty years and is only now standardizing, and in Zig it falls out of
comptime for free.#include <iostream>
#include <string>
#include <type_traits>
struct Point { int x; int y; };
int main() {
// C++ has type TRAITS, not reflection: you can ask yes/no questions
// but cannot enumerate a struct's fields. (P2996 reflection is
// approved for C++26 and not yet widely available.)
std::cout << std::boolalpha
<< std::is_trivially_copyable_v<Point> << " "
<< sizeof(Point) << std::endl;
return 0;
}const std = @import("std");
const Point = struct { x: i32, y: i32 };
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [128]u8 = undefined;
// @typeInfo returns ordinary comptime DATA, so a struct's fields
// can be enumerated with a normal loop.
inline for (@typeInfo(Point).@"struct".fields) |field| {
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"{s}: {s}\n", .{ field.name, @typeName(field.type) }));
}
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"size {d}\n", .{@sizeOf(Point)}));
}@typeInfo hands back an ordinary comptime value describing the type, so enumerating fields is a loop rather than a metaprogramming technique — and inline for unrolls it at compile time so field.name is a compile-time constant in each iteration. This is what makes Zig's JSON encoder, formatter and hashing work on any struct with no macros, no code generation and no boilerplate on the type. C++26's P2996 will bring genuine reflection; until then std::is_trivially_copyable_v and its siblings answer questions but cannot enumerate anything.Explicit Allocators
Allocation is a parameter, not a global
There is no global allocator in Zig — no
new, no malloc reachable without saying where the memory comes from.#include <iostream>
#include <vector>
// The allocator is a defaulted TEMPLATE parameter almost nobody
// changes, and new/malloc reach for one global heap.
std::vector<int> build() {
std::vector<int> readings;
readings.push_back(7); // allocates from the global heap
return readings;
}
int main() {
std::vector<int> readings = build();
std::cout << readings[0] << " " << readings.size() << std::endl;
return 0;
}const std = @import("std");
// If a function can allocate, it SAYS SO by taking an allocator. A
// signature without one is a promise that it does not.
fn build(allocator: std.mem.Allocator) !std.ArrayList(i32) {
var readings: std.ArrayList(i32) = .empty;
errdefer readings.deinit(allocator);
try readings.append(allocator, 7);
return readings;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit(); // reports leaks at exit
const allocator = debug_allocator.allocator();
var readings = try build(allocator);
defer readings.deinit(allocator);
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"{d} {d}\n", .{ readings.items[0], readings.items.len }));
}The consequence is a property C++ cannot state: a function's signature tells you whether it can allocate. That makes arena allocation, fixed-buffer allocation and embedded targets ordinary rather than heroic — swap
std.heap.FixedBufferAllocator in and the same code allocates from a stack array. DebugAllocator is worth knowing: it detects leaks, double frees and use-after-free, and reports them at deinit, so a leak is a test failure rather than something you go looking for with Valgrind. The cost is that the allocator becomes a parameter threaded through your entire API.No smart pointers, no ownership in the type
This is the place where a C++ programmer is most likely to conclude Zig went too far, and it is worth being clear about what is and is not given up.
#include <iostream>
#include <memory>
struct Node { int value; };
int main() {
// The TYPE says who owns it and the destructor enforces it. Leaking
// requires effort; double-freeing does not compile.
auto owned = std::make_unique<Node>(7);
std::cout << owned->value << std::endl;
return 0; // freed here, automatically
}const std = @import("std");
const Node = struct { value: i32 };
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
// A plain pointer. Ownership is a CONVENTION, documented in the
// function name and enforced only by the defer you remember to write.
const owned = try allocator.create(Node);
defer allocator.destroy(owned);
owned.* = .{ .value = 7 };
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{owned.value}));
}Zig has no
unique_ptr, no shared_ptr, no move semantics and no ownership in the type system. A pointer is a pointer; who frees it is a convention carried in documentation and in the defer you remember. Forgetting one is a leak, and freeing twice is undefined behavior the way it is in C. What Zig offers instead is detection rather than prevention: DebugAllocator catches leaks, double frees and use-after-free at runtime, and the safe build modes trap on them. Against Rust, which proves these statically, this is a clear step back; against C, where nothing is checked at all, a clear step forward.Error Unions
Error unions instead of exceptions
Zig has no exceptions, no stack unwinding and no
throw. Failure is in the return type, and error sets make that type checkable.#include <iostream>
#include <stdexcept>
#include <string>
int parse_port(const std::string& text) {
int value = std::stoi(text); // throws
if (value < 0) { throw std::out_of_range("negative"); }
return value;
}
int main() {
try {
std::cout << parse_port("8080") << std::endl;
std::cout << parse_port("-1") << std::endl;
} catch (const std::exception&) {
std::cout << "failed" << std::endl;
}
return 0;
}const std = @import("std");
const PortError = error{ NotANumber, Negative };
// The return type IS the contract: PortError!i32 lists exactly what
// can go wrong, and the compiler checks the list.
fn parsePort(text: []const u8) PortError!i32 {
const value = std.fmt.parseInt(i32, text, 10) catch return PortError.NotANumber;
if (value < 0) return PortError.Negative;
return value;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const good = parsePort("8080") catch -1;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{good}));
// catch handles it; try would propagate it to the caller.
_ = parsePort("-1") catch {
try stdout.writeStreamingAll(io, "failed\n");
return;
};
}An error union
PortError!i32 holds either an i32 or one of the named errors, and an error set is inferred automatically when you write !i32 — so the compiler knows the exhaustive list of what a function can return and will tell you if a switch over it misses a case. try propagates, catch handles, and both are visible at the call site, so there are no invisible early exits. Errors are just values with no payload and no allocation, which is why this costs nothing and works on a microcontroller. The tradeoff against exceptions is the same one Go makes, with better type checking.Partial construction, cleaned up
Exception safety in a constructor is one of the subtler corners of C++.
errdefer addresses the same problem by making the unwinding explicit.#include <iostream>
#include <memory>
#include <stdexcept>
class Resource {
public:
Resource() {
first_ = std::make_unique<int[]>(10);
// If THIS throws, ~Resource does not run — but first_'s own
// destructor does, because it is a fully constructed member.
second_ = std::make_unique<int[]>(10);
}
private:
std::unique_ptr<int[]> first_;
std::unique_ptr<int[]> second_;
};
int main() {
Resource resource;
(void)resource;
std::cout << "constructed" << std::endl;
return 0;
}const std = @import("std");
// errdefer runs ONLY on an error return, so each acquisition undoes
// itself if a LATER one fails. No destructor ordering to reason about.
fn build(allocator: std.mem.Allocator) ![]i32 {
const first = try allocator.alloc(i32, 10);
errdefer allocator.free(first);
const second = try allocator.alloc(i32, 10);
allocator.free(second); // done with it here
return first;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_allocator.deinit();
const allocator = debug_allocator.allocator();
const owned = try build(allocator);
defer allocator.free(owned);
try stdout.writeStreamingAll(io, "constructed\n");
}The C++ rule — if a constructor throws, the destructor does not run, but fully-constructed members are destroyed — is correct, non-obvious, and the reason RAII members rather than raw pointers are mandatory. Zig has no constructors and no such rule: you write
errdefer after each acquisition, and the failure path unwinds in reverse. It is more typing and there is nothing subtle left to know. The pattern to internalize is errdefer immediately after acquiring, switching to defer once the value is no longer being handed to the caller.Optionals
Optionals, and no null pointer
The type is
?i32 — a question mark, not a wrapper — and the important part is that a plain pointer in Zig can never be null.#include <iostream>
#include <optional>
std::optional<int> find_port(bool present) {
if (present) { return 8080; }
return std::nullopt;
}
int main() {
auto port = find_port(true);
// Nothing forces the check: *port on an empty optional is
// undefined behavior, not an exception.
if (port.has_value()) { std::cout << *port << std::endl; }
std::cout << find_port(false).value_or(80) << std::endl;
return 0;
}const std = @import("std");
fn findPort(present: bool) ?i32 {
return if (present) 8080 else null;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
// The unwrap is part of the if, so there is no way to reach the
// value without having handled null.
if (findPort(true)) |port| {
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{port}));
}
const fallback = findPort(false) orelse 80;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{fallback}));
}A
*Node is guaranteed non-null; absence is ?*Node, which the compiler optimizes to a nullable pointer so the abstraction is free. That is the same trick Rust plays with Option<Box<T>>, and it means the null-dereference bug is a type error rather than a crash. The capture syntax if (value) |unwrapped| binds the payload only inside the branch, so unlike std::optional there is no operator* to call on an empty one — .? exists for asserting, and it traps in safe builds rather than being undefined.Structs & Methods
Structs, methods and no classes
A Zig struct holds both fields and functions, so it looks like a class — with no constructors, no destructors, no access control and no inheritance.
#include <iostream>
class Counter {
public:
void increment(int by) { total_ += by; }
int total() const { return total_; }
private:
int total_ = 0;
};
int main() {
Counter counter;
counter.increment(5);
std::cout << counter.total() << std::endl;
return 0;
}const std = @import("std");
const Counter = struct {
total: i32 = 0, // a default field value, not a constructor
// self is an explicit parameter; *Self may mutate, Self is a copy.
fn increment(self: *Counter, by: i32) void {
self.total += by;
}
fn getTotal(self: Counter) i32 {
return self.total;
}
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
var counter = Counter{};
counter.increment(5);
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{counter.getTotal()}));
}The receiver is an explicit first parameter, and
*Counter versus Counter is the const-correctness: a pointer receiver may mutate, a value receiver gets a copy. Initialization is a struct literal with default field values rather than a constructor, so Counter{} allocates nothing and runs no code. There is no private: everything in a struct is reachable, and the module boundary (what a file marks pub) is the only encapsulation. Note that a struct with no runtime fields is zero-sized, and struct is also how Zig spells a namespace — a file is a struct.No inheritance, no virtual
Zig has no classes, no base types and no
virtual, so polymorphism is something you choose a representation for rather than something the language picks.#include <iostream>
#include <memory>
#include <vector>
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
};
class Square : public Shape {
public:
explicit Square(double side) : side_(side) {}
double area() const override { return side_ * side_; }
private:
double side_;
};
int main() {
std::unique_ptr<Shape> shape = std::make_unique<Square>(3.0);
std::cout << shape->area() << std::endl;
return 0;
}const std = @import("std");
const Square = struct { side: f64 };
const Circle = struct { radius: f64 };
// A tagged union is the usual answer for a CLOSED set of types: no
// vtable, no allocation, and the switch is exhaustiveness-checked.
const Shape = union(enum) {
square: Square,
circle: Circle,
fn area(self: Shape) f64 {
return switch (self) {
.square => |square| square.side * square.side,
.circle => |circle| 3.14159 * circle.radius * circle.radius,
};
}
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const shape = Shape{ .square = .{ .side = 3.0 } };
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{shape.area()}));
}For a closed set of types — one you control and can enumerate — a tagged union is the right answer and is strictly better than a class hierarchy: no vtable pointer in the object, no allocation, no indirect call, and the
switch is checked for exhaustiveness so adding a variant breaks every incomplete match. For an open set, where callers add types you have never seen, the answer is a hand-written vtable — a struct of function pointers plus an opaque pointer, which is what std.mem.Allocator itself is. Zig deliberately has no sugar for that, which is the most-requested feature it keeps declining.Tagged Unions
std::variant vs a tagged union
Both are tagged unions. One has language syntax and one is a library type built out of templates, and the difference in ergonomics is large.
#include <iostream>
#include <string>
#include <variant>
using Message = std::variant<int, std::string>;
int main() {
Message message = std::string("hello");
std::visit([](const auto& value) {
using Held = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Held, int>) {
std::cout << "number " << value << std::endl;
} else {
std::cout << "text " << value << std::endl;
}
}, message);
return 0;
}const std = @import("std");
// Named variants, first-class syntax, and no std::visit.
const Message = union(enum) {
number: i32,
text: []const u8,
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const message = Message{ .text = "hello" };
switch (message) {
.number => |value| try stdout.writeStreamingAll(io,
try std.fmt.bufPrint(&buffer, "number {d}\n", .{value})),
.text => |value| try stdout.writeStreamingAll(io,
try std.fmt.bufPrint(&buffer, "text {s}\n", .{value})),
}
}The variants are named, so
union(enum) { celsius: f64, fahrenheit: f64 } is expressible where std::variant<double, double> is useless. The switch captures the payload with |value| and is exhaustiveness-checked, so no std::visit, no if constexpr ladder, no std::get that throws, and no valueless-by-exception state. Zig also has bare union without the enum tag for C-style type punning, which is checked in safe build modes — reading the wrong field traps rather than being undefined as it is in C++.Arrays & Slices
Slices carry their length
C++20 added
std::span to retire the pointer-plus-length parameter pair. Zig had slices from the start and made them the default way to talk about a sequence.#include <iostream>
#include <span>
#include <vector>
int sum(std::span<const int> values) {
int total = 0;
for (int value : values) { total += value; }
return total;
}
int main() {
std::vector<int> readings{12, 7, 30, 4};
std::span<const int> window(readings.data() + 1, 2);
std::cout << sum(readings) << " " << sum(window) << std::endl;
return 0;
}const std = @import("std");
fn sum(values: []const i32) i32 {
var total: i32 = 0;
for (values) |value| total += value;
return total;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
// [_]i32 infers the length; the array KNOWS it, and so does a
// slice taken from it. Indexing is bounds-checked in safe builds.
const readings = [_]i32{ 12, 7, 30, 4 };
const window = readings[1..3];
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"{d} {d}\n", .{ sum(&readings), sum(window) }));
}A
[]const i32 is a pointer and a length, exactly like a std::span, and it accepts an array, part of an array, or heap memory. The difference is that indexing a slice is bounds-checked in Debug and ReleaseSafe, so an overrun traps at the line rather than corrupting memory — and the check disappears in ReleaseFast if you want it to. Note that arrays and slices are distinct types: [4]i32 has its length in the type and is passed by value, while []i32 carries it at runtime. Neither decays to a bare pointer, so sizeof-style bugs cannot happen.Sentinel-terminated types
Zig has a type-level answer to the single worst thing about C strings, and it has no C++ equivalent at all.
#include <cstring>
#include <iostream>
// A C string is a convention with no type-level support: this
// parameter might be null-terminated, or might not be, and nothing
// in the signature distinguishes the two.
void report(const char* text) {
std::cout << text << " (" << std::strlen(text) << ")" << std::endl;
}
int main() {
report("hello");
return 0;
}const std = @import("std");
// [:0]const u8 means "a slice whose element after the end is 0" —
// the null terminator is IN THE TYPE, and the length is still known.
fn report(io: std.Io, stdout: std.Io.File, text: [:0]const u8) !void {
var buffer: [64]u8 = undefined;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"{s} ({d})\n", .{ text, text.len }));
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
try report(io, stdout, "hello");
}A sentinel-terminated slice
[:0]const u8 knows its length and guarantees a zero element just past the end, so it can be handed to a C function expecting const char* with no conversion and no scan, while Zig code uses .len in O(1). The sentinel is generic — [:0] is merely the common case — and the same works for arrays and pointers. C++ has std::string (which guarantees termination but is a whole object), std::string_view (which does not), and const char* (which promises nothing); none of the three puts the guarantee in the type where a caller can rely on it.Safety & Build Modes
Four build modes, and safety is a dial
This is Zig's most practical safety idea, and the one most worth stealing: safety is a build-mode dial rather than an all-or-nothing property of the language.
// C++ has no standard build modes. -O0/-O2/-O3 and -DNDEBUG are
// conventions, and what "debug" means is a project decision.
//
// Undefined behavior is undefined in EVERY mode — there is no build
// setting that turns an out-of-bounds read into a defined trap.
// Sanitizers help and are separate, non-standard, and slow:
// -fsanitize=address,undefined
#include <iostream>
#include <vector>
int main() {
std::vector<int> readings{1, 2, 3};
// operator[] out of range is UB at every optimization level.
std::cout << readings[1] << std::endl;
return 0;
}const std = @import("std");
// Four modes, in the language, and safety is what varies:
//
// Debug — all checks, no optimization (the default)
// ReleaseSafe — all checks, optimized
// ReleaseFast — checks REMOVED, optimized
// ReleaseSmall — checks removed, optimized for size
//
// In Debug and ReleaseSafe, an out-of-bounds index, an integer
// overflow, a bad @intCast and reaching unreachable all TRAP with a
// message and a stack trace instead of being undefined.
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const readings = [_]i32{ 1, 2, 3 };
const index: usize = 1; // readings[5] would trap, not corrupt
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{readings[index]}));
}ReleaseSafe is the mode with no C++ counterpart — fully optimized, and still trapping on out-of-bounds access, integer overflow, invalid casts and unreachable code. Shipping in it means an exploitable memory error becomes a crash with a stack trace, at a cost of a few percent. C++ has no equivalent: sanitizers are separate tools with several-times overhead intended for testing, not production. And the checks are per-block, so
@setRuntimeSafety(false) can disable them in one hot function while the rest of the program keeps them. Zig is not memory-safe the way Rust is; it is considerably safer than C++ in its default configuration.undefined is a value you must write
Zig makes uninitialized memory something you have to ask for by name, which turns an accident into a decision.
#include <iostream>
int main() {
int total; // indeterminate, and reading it is UB —
// legal to write, warns at most
total = 0;
for (int value = 1; value <= 4; value += 1) { total += value; }
std::cout << total << std::endl;
return 0;
}const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined; // deliberately uninitialized, and SAID so
// var total: i32; // compile error: expected '=', found ';'
var total: i32 = 0; // every variable must be initialized
for (1..5) |value| total += @intCast(value);
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{total}));
}Every variable must be initialized, and when you genuinely want uninitialized memory — a scratch buffer about to be overwritten, as above — you write
undefined, which is greppable and reviewable. In Debug builds undefined is filled with 0xAA so a read shows up as an obviously wrong value rather than as whatever was on the stack. Compare C++, where omitting the initializer is the shorter thing to type and produces undefined behavior with at most a warning. Note for (1..5) |value| — Zig's only loop keywords are for (over sequences and ranges) and while.C and C++ Interop
@cImport reads the header directly
Reading C headers without ceremony is C++'s great structural advantage over every other language. Zig is the only serious contender that matches it.
// C++ reads C headers natively — this is the one thing it does
// better than every other language, and it is why C++ is where it is.
//
// The cost is that it reads them by TEXTUAL INCLUSION, which is the
// build-time story from the top of this page.
#include <cstdio>
#include <cstring>
int main() {
const char* text = "reading a C header";
std::printf("%s (%zu)\n", text, std::strlen(text));
return 0;
}// Zig has a C compiler and a C header PARSER built in, so it reads
// the real header and generates bindings at compile time:
//
// const c = @cImport({
// @cInclude("stdio.h");
// @cInclude("string.h");
// });
//
// c.printf("%s\n", text);
//
// No binding generator to run, no .h to translate by hand, no build
// step. (@cImport needs the C headers present, so this page shows the
// pure-Zig equivalent instead.)
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const text = "reading a C header";
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer,
"{s} ({d})\n", .{ text, text.len }));
}@cImport invokes Zig's built-in C compiler on the header and produces real Zig declarations at compile time — no bindgen step, no hand-translated declarations to drift, and no separate build phase. That is a genuinely different position from Rust and Go, both of which need a generator or a foreign-function shim. The limits are worth knowing: C macros that are not simple constants do not translate (Zig cannot know their types), and C++ headers are not supported at all — Zig speaks the C ABI, not the C++ one, which is the same boundary every non-C++ language meets.zig cc is a C and C++ compiler
This is worth knowing even if you never write a line of Zig, and it is how most C++ programmers first encounter the project.
// Cross-compiling a C++ project means acquiring a cross toolchain
// and a sysroot for the target, then convincing your build system to
// use them. This is routinely the hardest part of shipping.
//
// sudo apt install g++-aarch64-linux-gnu
// ...plus a sysroot, plus CMake toolchain file, plus...
#include <iostream>
int main() {
std::cout << "one host, one target, unless you do a lot of work"
<< std::endl;
return 0;
}// The Zig binary IS a C and C++ compiler — a Clang front end plus the
// libc source for every supported target, in one ~50MB download:
//
// zig cc -o app app.c # drop-in for gcc/clang
// zig c++ -o app app.cpp
//
// CC="zig cc -target aarch64-linux-musl" ./configure && make
//
// It cross-compiles to any target from any host with nothing else
// installed, which is why projects that contain no Zig at all adopt it
// as their build tool.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
try stdout.writeStreamingAll(io, "any host, any target, one download\n");
}zig cc is a drop-in replacement for gcc or clang that accepts the same flags, so it slots into an existing Makefile or ./configure unchanged. What makes it remarkable is that Zig ships libc source for musl, glibc, and the others, plus headers for every supported target — so cross-compilation needs no sysroot, no cross binutils and no Docker image. Uber famously adopted it to cross-compile Go's cgo dependencies. If you take one practical thing from this page, take this: try it as your cross-compiler before you take on the language.Tooling & Testing
Tests live in the language
test is a keyword. There is no framework to choose, add, or integrate before writing the first test.// C++ has no standard test framework, so a project picks one:
// Catch2, GoogleTest, doctest, Boost.Test — each with its own macros,
// its own build integration and its own runner.
//
// TEST_CASE("addition works") { REQUIRE(2 + 2 == 4); }
//
// Tests live in separate files, need separate build targets, and need
// a dependency added before the first one can be written.
#include <cassert>
#include <iostream>
int add(int first, int second) { return first + second; }
int main() {
assert(add(2, 2) == 4);
std::cout << add(2, 2) << std::endl;
return 0;
}const std = @import("std");
fn add(first: i32, second: i32) i32 {
return first + second;
}
// A test block sits NEXT TO the code it tests, in the same file.
// `zig test file.zig` runs them; a normal build skips them entirely,
// so they cost nothing in the shipped binary.
test "addition works" {
try std.testing.expectEqual(@as(i32, 4), add(2, 2));
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{add(2, 2)}));
}Test blocks live beside the code they exercise, so a private function can be tested without being made public and without a friend declaration — which is the thing C++ testing is most awkward about.
zig test discovers and runs them; an ordinary build ignores them completely, so there is no cost in the shipped binary and no separate target. std.testing.allocator is worth knowing: it fails any test that leaks, so leak checking is on by default rather than something you set up. The philosophy matches Go's and the opposite of C++'s: one answer in the toolchain beats several good libraries.What Zig Refuses
No operator overloading
This follows directly from the no-hidden-control-flow rule rather than being a separate decision.
#include <iostream>
class Money {
public:
explicit Money(int cents) : cents_(cents) {}
Money operator+(const Money& other) const { return Money(cents_ + other.cents_); }
int cents() const { return cents_; }
private:
int cents_;
};
int main() {
// Looks like arithmetic. May allocate, may throw, may do anything.
Money total = Money(150) + Money(275);
std::cout << total.cents() << std::endl;
return 0;
}const std = @import("std");
const Money = struct {
cents: i32,
// A method, because + cannot be given a new meaning.
fn add(self: Money, other: Money) Money {
return .{ .cents = self.cents + other.cents };
}
};
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const total = (Money{ .cents = 150 }).add(.{ .cents = 275 });
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{total.cents}));
}If
a + b could call arbitrary code, then reading a line would no longer tell you whether it allocates or jumps — which is the property the whole language is organized around. So operators work on numbers and nothing else. The cost lands on anything matrix- or bignum-shaped, where a.add(b).mul(c) is genuinely worse to read than a + b * c. Zig accepts that cost deliberately, and the same reasoning rules out function overloading, default parameters, and implicit conversions: in each case the call site would stop saying exactly what happens.No closures
Zig has function literals but they capture nothing, so there are no closures — and this is the omission C++ programmers find hardest to live with.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> readings{5, 3, 9, 1};
int threshold = 4;
// The lambda CAPTURES threshold, and the closure carries it.
auto above = std::count_if(readings.begin(), readings.end(),
[threshold](int value) { return value > threshold; });
std::cout << above << std::endl;
return 0;
}const std = @import("std");
// A function literal captures NOTHING. Context travels as an explicit
// parameter — which is the C pattern, made type-safe.
fn countAbove(values: []const i32, threshold: i32) usize {
var matches: usize = 0;
for (values) |value| {
if (value > threshold) matches += 1;
}
return matches;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
var buffer: [64]u8 = undefined;
const readings = [_]i32{ 5, 3, 9, 1 };
const above = countAbove(&readings, 4);
try stdout.writeStreamingAll(io, try std.fmt.bufPrint(&buffer, "{d}\n", .{above}));
}A closure has to store its captures somewhere, and doing that in general means allocating — which the language will not do implicitly. So state travels as an explicit parameter, or as a struct with a method, which is the same
void* context pattern C uses with the types restored. The knock-on effect is that the standard library has no filter/map/reduce chain: you write the loop. Whether that is a loss depends on how much of your C++ is written in the <algorithm> style — for some codebases it is barely noticeable, for others it is the deciding objection.The honest caveat: it is pre-1.0
Every other row on this page argues for Zig. This one is the reason a team can agree with all of them and still be right to say no.
// C++ is standardized by ISO, has multiple independent
// implementations, and takes backward compatibility so seriously that
// code from 1998 still compiles. That stability is a large part of
// what you are paying for, and what you would give up.
#include <iostream>
int main() {
std::cout << "C++98 code still builds in 2026" << std::endl;
return 0;
}const std = @import("std");
// Zig is at 0.16 and has NOT reached 1.0. Every release so far has
// made breaking changes — the I/O API this very example uses was
// reworked in 0.15/0.16, which is why older Zig tutorials do not
// compile. There is one implementation and no standard.
//
// Plan for it: pin the compiler version per project, expect to spend
// real time on upgrades, and do not start a decade-long project on it
// without accepting that.
pub fn main(init: std.process.Init) !void {
const io = init.io;
const stdout = std.Io.File.stdout();
try stdout.writeStreamingAll(io, "0.16, and the next release will break something\n");
}Zig has not reached 1.0, has one implementation, no standard, and a track record of breaking changes in every release — the
std.Io API used throughout this page replaced the previous one recently enough that most tutorials online no longer compile. Against that, C++ is ISO-standardized with several independent implementations and a compatibility record measured in decades, which is genuinely one of the most valuable things it offers. For a tool, a service you will rewrite, or a project where you control the upgrade schedule, the trade can be worth it. For a codebase that must build unchanged in fifteen years, it is not — and no amount of liking the language changes that arithmetic.