If you have ever shipped C or C++ into production, you know the feeling: the code is fast, the binaries are small, and the hardware does exactly what you tell it to do—right up until a single unchecked assumption turns into memory corruption, a weird crash on one customer machine, or a security bug you now get to explain.
In my day job, the “hard” part of low-level work is rarely writing the first version. It is keeping the program understandable and correct six months later, across platforms, compilers, build modes, and teams. That is where Zig earns attention. It keeps the directness of C-style systems work, but it asks you to be explicit in places where C quietly shrugs and invokes undefined behavior.
What I want to give you here is a first look at Zig from a working programmer’s point of view: what the language feels like, how it handles memory and errors, how it stays predictable without being ceremonious, and what patterns I recommend when you start writing real code.
Why Zig Exists (And Why You Might Care)
C remains the default “portable assembly” for a lot of the world: operating systems, embedded software, databases, game engines, cryptography, device drivers, audio/video codecs. The reason is simple: you can predict what C does at the machine level, and compilers have had decades to turn it into fast code.
The trouble is that C also lets you write programs that are accidentally wrong in ways the compiler cannot reliably catch. A pointer can dangle. An integer can overflow silently. You can read past the end of a buffer. You can forget to free memory, or free it twice. The worst part is not that these things can happen—you already know that. The worst part is that they can happen in code that “looks fine,” passes tests, and only fails under a specific allocator behavior, thread schedule, or input shape.
Zig’s pitch is not “write high-level code and get low-level speed.” Rust is closer to that story. Zig’s pitch is “keep control, but make correctness mistakes harder to hide.” It does this with a few consistent ideas:
- Explicitness over cleverness: if something can fail, it should look like it can fail.
- Simple building blocks: fewer features, but the ones that exist compose well.
- Clear build and cross-target support: the toolchain tries to make cross compilation feel like a normal workflow, not an exotic one.
- Safety as a build-mode choice: you can keep runtime checks when you want them, and you can remove them when you are done validating behavior.
If your work sits near the metal—systems, embedded, performance-critical libraries, language runtimes, infrastructure agents—Zig is worth knowing even if you do not adopt it immediately. It changes how you think about “being explicit” without piling on ceremony.
The First Thing You Notice: The Language Feels Small
Zig is deliberately compact as a language. That does not mean it is “toy-simple”; it means the core set of concepts stays manageable. In practice, this shows up as:
- No hidden control flow: there is no exception mechanism that can jump you out of a function without being visible in the signature.
- No implicit allocations: if memory is allocated, you usually see an allocator in the call chain.
- No inheritance tree to reason about: composition and interfaces by convention are the default.
Here is a small Zig program that prints a message. It is not exciting, but it demonstrates the “feel”: clear imports, explicit error handling, and a main that can return an error.
const std = @import("std");
pub fn main() !void {
const out = std.io.getStdOut().writer();
try out.print("Hello from Zig!\n", .{});
}
A few notes I point out to C/C++ folks:
pub fn main() !voidmeans “main may fail.”!Tis an error union type: either an error or aT.trypropagates errors upward in a very visible way.- Formatting is type-safe and uses a tuple-like argument list (
.{}) rather than varargs.
This kind of explicitness adds up. In systems code, the “error paths” are often the bug paths. Zig makes them hard to ignore.
Types, Values, and Slices: The Pieces You Use All Day
Zig’s type system is strong enough to be expressive without feeling academic. The parts you touch constantly are:
- Integers with explicit sizes:
u8,i32,usize, and so on. - Arrays with fixed length:
[16]u8. - Slices (pointer + length):
[]u8or[]const u8. - Pointers with explicit mutability:
Tvsconst T. - Optionals for “maybe a value”:
?T. - Error unions for “maybe an error”:
!T.
Slices are the real workhorse for string and buffer manipulation. In Zig, a “string” in user code is usually []const u8 (a slice of bytes). That means a string is not magically null-terminated, and functions that require sentinel termination say so.
Here is a complete runnable example that works with slices safely and shows why slices are a big deal.
const std = @import("std");
fn countAsciiDigits(input: []const u8) usize {
var count: usize = 0;
for (input)
ch {
if (ch >= ‘0‘ and ch <= '9') count += 1;
}
return count;
}
pub fn main() !void {
const msg: []const u8 = "Order 812 shipped";
const digits = countAsciiDigits(msg);
const out = std.io.getStdOut().writer();
try out.print("Digits: {d}\n", .{digits});
}
In C, that same function is usually forced into one of two awkward shapes:
- Accept a
const char*and assume it is null-terminated. - Accept
(const char*, size_t)and hope every caller passes the correct length.
Zig bakes the pointer+length into the type and makes that the default. That single decision prevents an entire category of bugs.
Memory: Manual, Explicit, and Much Harder to “Accidentally Forget”
One common misunderstanding is thinking Zig has automatic memory management in the GC sense. It does not. Zig expects you to manage memory deliberately, but it gives you tools that keep it orderly:
- Allocators are explicit values passed around.
deferanderrdeferhelp guarantee cleanup.- Many standard-library APIs come in allocator-taking forms.
Here is a small program that allocates a buffer, writes into it, and frees it reliably.
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
// Allocate 1024 bytes.
var buf = try allocator.alloc(u8, 1024);
defer allocator.free(buf);
// Fill with a pattern.
@memset(buf, 0);
buf[0] = ‘O‘;
buf[1] = ‘K‘;
buf[2] = ‘\n‘;
const out = std.io.getStdOut().writer();
try out.writeAll(buf[0..3]);
}
This is still manual allocation, but notice the ergonomics:
defer allocator.free(buf);sits next to the allocation site, which is exactly where I want cleanup to live.- The type of
bufis[]u8, so we track length along with the pointer.
If you are coming from Rust, you might ask: “Where is the ownership checker?” Zig does not do ownership as a compile-time discipline the way Rust does. Instead, Zig tries to make manual management less error-prone by keeping lifetimes close to the code that allocates.
A pattern I recommend early is: allocate in the narrowest scope possible, free with defer, and pass slices ([]T) instead of raw pointers.
defer vs errdefer
Zig has two closely related cleanup mechanisms:
defer: always runs when leaving scope.errdefer: runs only when leaving scope due to an error being returned.
That second one is especially useful for functions that build up multiple resources and want to clean up only on failure.
const std = @import("std");
fn buildMessage(allocator: std.mem.Allocator) ![]u8 {
var list = std.ArrayList(u8).init(allocator);
errdefer list.deinit(); // only on error
try list.appendSlice("Status: ");
try list.appendSlice("OK\n");
// On success, give ownership of the buffer to the caller.
return try list.toOwnedSlice();
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
const msg = try buildMessage(allocator);
defer allocator.free(msg);
const out = std.io.getStdOut().writer();
try out.writeAll(msg);
}
This is the kind of code I trust in production because the “happy path” stays clean and the cleanup logic is mechanically attached to the resource.
Errors and Optionals: Failure Is Part of the Type
In Zig, you do not throw exceptions. If something can fail, it should be visible:
- Use
!Twhen a function may return an error. - Use
?Twhen a value may be missing.
These are different ideas, and Zig keeps them separate. A missing value is not the same thing as a failure.
Error unions and try
Here is an example that reads a file and prints its size. It is complete and runnable.
const std = @import("std");
pub fn main() !void {
const allocator = std.heap.page_allocator;
var args = std.process.args();
_ = args.next(); // program name
const path = args.next() orelse {
const err = std.io.getStdErr().writer();
try err.writeAll("Usage: file_size \n");
return;
};
const data = try std.fs.cwd().readFileAlloc(allocator, path, 10 1024 1024);
defer allocator.free(data);
const out = std.io.getStdOut().writer();
try out.print("{s}: {d} bytes\n", .{ path, data.len });
}
Notice the split:
args.next() orelse { ... }is optional handling. No error involved.readFileAlloc(...)returns an error union, so we usetry.
Optionals and null safety
Zig does not let you “accidentally” treat null as a real pointer because nullability is in the type. If something is optional, you must handle it.
const std = @import("std");
fn findHeader(headers: []const []const u8, name: []const u8) ?[]const u8 {
for (headers)
h {
// Very small demo parser: "Name: Value"
if (std.mem.indexOf(u8, h, ": "))
sep {
const key = h[0..sep];
const value = h[(sep + 2)..];
if (std.mem.eql(u8, key, name)) return value;
}
}
return null;
}
pub fn main() !void {
const headers = [_][]const u8{
"Host: example.com",
"User-Agent: zig-client",
};
const ua = findHeader(&headers, "User-Agent") orelse "(missing)";
const out = std.io.getStdOut().writer();
try out.print("UA: {s}\n", .{ua});
}
In C, the equivalent often returns NULL and hopes the caller checks. In Zig, the check is not a hope; it is required by the type.
Safety Checks, Undefined Behavior, and Build Modes
If you are used to C, you are used to undefined behavior as a “performance tax you do not pay until you do.” Zig tries to keep the performance potential while giving you more guardrails.
A few practical points from experience:
- Many bounds checks exist in safety-focused builds. When you intentionally need unchecked access, you can ask for it, but you are making a deliberate choice.
- Integer overflow behavior is explicit and tools exist to control it.
- The language provides builtins that make “I meant this” clear to the compiler.
Bounds checks (and how to keep them honest)
Here is what array and slice indexing looks like. If you index out of range in a safety build, you get a trap rather than silent memory corruption.
const std = @import("std");
pub fn main() !void {
const out = std.io.getStdOut().writer();
const fixed = [_]u8{ 10, 20, 30, 40 };
const view: []const u8 = fixed[0..];
try out.print("First: {d}\n", .{view[0]});
// Uncommenting the next line is a good way to prove to yourself
// that bounds checks exist in safety-focused builds.
// _ = view[99];
}
When I teach Zig internally, I encourage people to treat “turning off checks” the way you treat unsafe blocks elsewhere: something you do in a small, reviewed area with a comment explaining why.
Integer overflow: choose your intent
Zig gives you explicit ways to handle overflow, which matters in parsing, hashing, crypto-adjacent code, and low-level protocols.
- If you want wrapping behavior, use wrapping operators like
+%. - If you want checked behavior, use builtins like
@addWithOverflow.
const std = @import("std");
pub fn main() !void {
const out = std.io.getStdOut().writer();
var x: u8 = 250;
const y: u8 = 20;
// Wrapping add.
const wrap = x +% y;
const add = @addWithOverflow(x, y);
const sum = add[0];
const overflowed = add[1] == 1;
try out.print("wrap={d} checked={d} overflow={any}\n", .{ wrap, sum, overflowed });
}
This is the kind of explicitness that makes code reviews faster. You are not arguing about what the compiler “happens to do.” You are reading the intent.
A quick table: C habits vs Zig habits
When I help teams move C-style thinking into Zig, this is the mapping I keep in my head:
Zig-first habit
—
NULL as a sentinel for “missing” ?T with orelse or if (x)
!T and try for propagation
defer at the allocation site
Prefer slices, then narrow pointer usage
Treat build modes as a deliberate safety/perf dialNone of this makes you “safe by default” in a magical way—this is still systems work. But it does make your intent visible, and that is the start of reliability.
Compile-Time Execution: A Tool, Not a Party Trick
One of Zig’s superpowers is that it can run code at compile time (often referred to as comptime). I do not treat this as a novelty; I treat it as a way to delete duplication.
Two practical uses you will run into early:
1) Generating data structures or lookup tables.
2) Writing generic code that stays readable.
Here is a small example that generates a lookup table at compile time.
const std = @import("std");
fn makeHexTable() [256]u8 {
var table: [256]u8 = undefined;
for (0..256)
i {
const b: u8 = @intCast(i);
const is_hex = (b >= ‘0‘ and b = ‘a‘ and b = ‘A‘ and b <= 'F');
table[i] = if (is_hex) 1 else 0;
}
return table;
}
pub fn main() !void {
const table = comptime makeHexTable();
const input: []const u8 = "deadBEEF!!";
var count: usize = 0;
for (input)
ch {
if (table[ch] == 1) count += 1;
}
const out = std.io.getStdOut().writer();
try out.print("hex chars: {d}\n", .{count});
}
The runtime loop is tiny, but the intent is clear: we precompute a classification table and use it in hot code. In other languages you might reach for macros or code generators; in Zig, you can often write normal Zig code and ask the compiler to run it.
My rule of thumb: use compile-time execution when it removes repetition or makes runtime code simpler. If it makes the program harder to read, it is probably not the right move.
Tooling and Build: One Command, Many Targets
For low-level work, “the language” is not just syntax; it is also how you compile, link, test, and ship across targets. Zig ships with a build system that is designed to be part of the experience.
In practice, this means:
- You can define builds in
build.zigusing Zig code. - Cross compilation is a normal operation, not a separate toolchain scavenger hunt.
- C and C++ compilation can be part of the same build graph.
A minimal build.zig can define an executable and a run step. (I am showing a sketch here rather than promising exact flags for every release; Zig evolves quickly.)
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const mode = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "hello",
.rootsourcefile = b.path("src/main.zig"),
.target = target,
.optimize = mode,
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
if (b.args)
args run_cmd.addArgs(args);
const run_step = b.step("run", "Run the app");
runstep.dependOn(&runcmd.step);
}
What I like about this approach is that your build logic is written in the same language you are already using. You can branch on the target, add platform-specific source files, include C libraries, generate code, and keep it all type-checked.
C interoperability: a realistic migration path
Zig works well with existing C code. That matters because most systems teams are not rewriting everything from scratch.
Common patterns include:
- Calling C functions from Zig while you replace one module at a time.
- Building a C library with Zig’s build system for a consistent, cross-target workflow.
- Exposing Zig code to C consumers by producing a C ABI boundary.
When you do this, I recommend you keep the ABI boundary small and “boring.” Put complicated types on the Zig side and present C with flat structs and function calls.
When Zig Is a Great Fit (And When I Would Not Pick It)
I like Zig most when I need control and clarity at the same time.
I would seriously consider Zig for:
- CLI tools that must be fast, static, and easy to ship.
- Libraries where you want C-like performance and a stable ABI story.
- Embedded or cross-platform agents where you care about binary size and predictable behavior.
- Performance-critical infrastructure components (parsers, compressors, protocol code, data-plane pieces).
I usually would not pick Zig for:
- A typical web backend where team velocity is dominated by framework and ecosystem rather than runtime speed.
- Projects that need a huge third-party ecosystem on day one.
- Apps where a managed runtime is the main advantage (rapid prototyping, heavy reflection, dynamic plugin models).
That is not a critique of Zig. It is choosing a tool that matches the constraints.
Common mistakes I see early (and how you avoid them)
These are patterns I have watched smart engineers trip over in their first week:
1) Treating Zig like C with nicer syntax
- Fix: prefer slices, optionals, and error unions. Do not default to raw pointers and sentinel values.
2) Passing allocators everywhere without a plan
- Fix: decide ownership boundaries. If a function allocates, document who frees. If possible, accept a caller-provided buffer or use an arena allocator at a higher level.
3) Using comptime for everything because it is fun
- Fix: use compile-time execution when it deletes duplication or builds tables for hot code. If the next reader has to mentally simulate the compiler, step back.
4) Mixing “missing value” and “error” into one channel
- Fix:
?Tis for absence,!Tis for failure. If both can occur, you can combine them deliberately (for example,!?T), but make sure your API remains readable.
5) Assuming “release build” means “safe enough”
- Fix: validate invariants with tests and debug runs. Keep runtime checks on while you are still shaking out correctness.
Where I Would Start If You Want Hands-On Practice
If you want to become productive quickly, I recommend you build a small tool that forces you to touch the core concepts:
- Parse a real input format (logs, CSV, HTTP headers, binary protocol fragments).
- Allocate buffers explicitly.
- Return structured errors.
- Add one compile-time-generated table or parser helper.
Then, keep the bar high:
- Treat every allocation site as a design decision.
- Make failure visible in the type signatures.
- Prefer slices and explicit lengths.
- Add assertions where an invariant matters.
The payoff is not just “fewer bugs.” The payoff is that you end up with code that tells the truth about what it is doing. In low-level software, that honesty is a feature.
If you take only a few ideas from Zig into your broader practice, make them these:
- Push correctness into types (
?T,!T) so you cannot ignore it. - Keep resource lifetimes close to allocation with
defer/errdefer. - Use compile-time execution as a way to remove repetition, not as a substitute for clear runtime code.
Once you have those in muscle memory, Zig stops feeling like “a new language to learn” and starts feeling like a calmer way to write the same kind of performance-focused software you already ship.


