What is Zig, and where does it fit?
Zig is a systems programming language designed for programmers who want explicit control over memory and direct interoperability with C. One of the language's goals is that memory allocation is not hidden. Mostly that comes from API design: functions that allocate generally take the allocator as a parameter supplied by the caller, and because allocation can fail, the error travels in the return type. This is a design convention, not a rule the compiler enforces; the newlineOffsets(allocator, data) example later on follows it. In this guide we will write a single small tool: satir-say. This command-line program prints the line and byte count of a file, and it will show the language's core concepts, the current I/O approach, the build system and the C boundary on the same code.
As of 7 October 2026, when I wrote this guide, the current stable release, according to the official download index, is Zig 0.17.0, dated 1 October 2026; the release announcement came on 2 October. The preceding stable release is 0.16.0, dated 13 April 2026. Zig 1.0 has not shipped: the 0.17.0 release notes say language stability is still required before 1.0. So every API detail in this article applies to 0.17.0 and may change between 0.x releases.
Before you copy older tutorials, know what the last two releases changed. 0.16.0 replaced the old std.io and std.fs patterns with an I/O model that runs through a std.Io value passed to the program. In 0.17.0 the build system was largely rewritten and a new Build Server Protocol was added; the release notes say ZLS support is disrupted while it adapts to the change, so don't trust an editor integration until you have tried it with the Zig version you installed. @cImport was removed and SafeAllocator is now recommended in place of DebugAllocator. The language reference also says async functions regressed in 0.11, so I will not teach them as a stable feature.
Instead of pitting Zig against other languages, look at your constraints. If an existing C ABI and toolchain and a manually managed resource policy are central to the work, C is a natural candidate, and Zig can work alongside it at the same boundary. If compile-time ownership checking is a core requirement, Rust's ownership model builds it into the language itself (see the ownership chapter of the Rust Book); Zig performs no such check. If Go's standard library and runtime model suit a service, the Go documentation explains that approach well. None of these is universally better; the right choice depends on your project's constraints.
Two examples from real projects: TigerBeetle's architecture document explains why it chose Zig, and Ghostty's packaging document describes its use of Zig and pinned toolchain versions. These are those projects' own choices; neither means they build on Zig 0.17.0. The Zig project announced on 26 November 2025 that it was moving from GitHub to Codeberg: Codeberg is now canonical and the GitHub repository is read-only. The nonprofit that funds the project is the Zig Software Foundation (ZSF).
Installing Zig and starting a project
Get Zig from the official download page: choose version 0.17.0 and the archive for your operating system. A Zig installation is less an installer than an unpacked folder, so you can unpack the official archives of different versions into separate folders and keep them side by side. After unpacking, add the folder that contains the compiler to your PATH.
Verify the archive before you run it. For this article I downloaded the Linux x86_64 archive into a temporary folder: its SHA-256 digest matched the value 1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026 in the official index.json, and the adjacent Minisign signature and its trusted comment also verified against the public key published on the download page. The unpacked compiler reported 0.17.0. Compare the digest of your own archive with the value from the same source.
The commands below are for Linux x86_64 and are run in the directory that contains both the archive (zig-x86_64-linux-0.17.0.tar.xz) and the unpacked zig-x86_64-linux-0.17.0 folder. If you use another operating system or architecture, change the archive and folder names to match the file you downloaded. The export line changes PATH only for the shell session that is open; it is not a persistent setting, and you need to run it again in a new terminal.
01sha256sum zig-x86_64-linux-0.17.0.tar.xz02export PATH="$PWD/zig-x86_64-linux-0.17.0:$PATH"03zig versionIf you want a tool that switches versions for you, there is the third-party zigup. Its README, however, says its maintainer has moved to another tool, so I mention it only as an option, not as an official recommendation.
Once the version is confirmed, start the project in an empty folder. zig init generates a template project, zig build run builds and runs it, and zig build test runs the tests.
01mkdir satir-say02cd satir-say03zig init04zig build run05zig build testReading the generated project
zig init generates four files. build.zig is Zig code that describes how the project is built; in other words, the build script is written in Zig itself rather than in a separate configuration language. build.zig.zon is the manifest that holds package metadata and dependencies. src/main.zig is the entry point of the executable. src/root.zig is the root of the reusable module and the home of its tests.
01satir-say/02├── build.zig03├── build.zig.zon04└── src/05 ├── main.zig06 └── root.zigWe will not use the template's sample code in this guide. Replace build.zig, src/main.zig and src/root.zig with the satir-say code given in the following sections, and leave build.zig.zon as zig init generated it.
main.zig deals with the command line and I/O; root.zig holds the counting logic and its tests, which know nothing about files or terminals, so zig build test runs the test blocks in that file.
Learning the language with the counter: values, errors and cleanup
Let's start with the program's entry point. The whole of src/main.zig is below. The rest of the article walks through this file and root.zig line by line; the details of the I/O lines come in section 7.
01const std = @import("std");02const Io = std.Io;03const counter = @import("root.zig");04 05pub fn main(init: std.process.Init) !void {06 const args = try init.minimal.args.toSlice(init.arena.allocator());07 const maybe_path: ?[]const u8 = if (args.len == 2) args[1] else null;08 const path = maybe_path orelse return error.InvalidArguments;09 10 const contents = Io.Dir.cwd().readFileAlloc(11 init.io,12 path,13 init.gpa,14 .limited(16 * 1024 * 1024),15 ) catch |err| {16 std.debug.print("could not read {s}: {s}\n", .{ path, @errorName(err) });17 return err;18 };19 defer init.gpa.free(contents);20 21 const summary = counter.summarize(contents);22 23 var output_buffer: [256]u8 = undefined;24 var output_file_writer: Io.File.Writer = .init(.stdout(), init.io, &output_buffer);25 const output = &output_file_writer.interface;26 try output.print("{s}: {d} lines, {d} bytes\n", .{ path, summary.lines, summary.bytes });27 try output.flush();28}Binding in Zig comes in two forms: const prevents the bound name from being reassigned, while var allows reassignment. const does not recursively make the memory that is pointed to immutable; whether the elements of a slice can be written is decided by the const in its type. For example, const contents means that no other slice can be assigned to the name contents, but because readFileAlloc returns a []u8, the slice's elements remain mutable. It is []const u8 that prevents writing elements through a slice; path and the data parameter of summarize have that type. Make const your default; in this file var appears only for the output buffer and the writer that wraps it. Integer types are written with their bit width: u8 means unsigned 8-bit and i32 signed 32-bit; the buffer is [256]u8, an array of 256 bytes. Lengths and indices use usize, the unsigned integer of pointer size (32 or 64 bits depending on the target). You don't always have to write the type: in const summary = counter.summarize(contents) it is inferred from the right-hand side, while a constant expression like 16 * 1024 * 1024 is computed at compile time and converts to the expected integer type if it fits.
A slice ([]T) consists of a pointer and a length and does not own the memory. path, which comes from args[1], is a read-only []const u8 slice pointing at the argument text; in Zig a string is not a separate type but a slice of bytes. contents is different: readFileAlloc returns the file contents in memory allocated with init.gpa, and responsibility for releasing that memory is now yours. The argument slices, on the other hand, we allocated through init.arena; an arena releases what it holds all at once rather than one by one, which is why we write no free for them.
?T says a value is either present or absent. maybe_path has type ?[]const u8: args[1] if exactly two arguments were given (the program name and the file path), otherwise null. orelse runs its right-hand side if the value is null; here return error.InvalidArguments leaves the function. You cannot skip the null check: the compiler does not let you use a ?T directly as a T.
!T is an error union: the value is either a T or an error. The !void return type of main declares that the function can fail. try expression passes the error on to the caller if there is one and otherwise yields the value. catch handles the error on the spot: the readFileAlloc(...) catch |err| { ... } block prints the error's name to standard error with @errorName and returns the same error again.
The defer init.gpa.free(contents) line runs however the scope ends: a normal exit, a return, or an error passed on by try. Writing the release right below the allocation makes it hard to forget. errdefer runs only if the function returns with an error; we will see its use in root.zig in the next section.
Making allocation visible
In Zig, what makes allocation visible is an API design convention, not a rule of the language: functions that allocate memory generally take the allocator as a parameter supplied by the caller. The compiler does not require this, and a function that picks its own allocator can be written too. Nor does the compiler prove ownership or that the memory is released; the function's contract, defer/errdefer and tests are what secure that. The newlineOffsets(allocator, data) function in this guide deliberately follows the convention. The whole of src/root.zig is below: newlineOffsets accepts a std.mem.Allocator and returns the byte position of every line end as ![]usize. Because the caller chooses the allocator, the same function can run with init.gpa in the application and with std.testing.allocator in a test.
01const std = @import("std");02 03pub const Summary = struct {04 lines: usize,05 bytes: usize,06};07 08pub fn summarize(data: []const u8) Summary {09 var lines: usize = 0;10 for (data) |byte| {11 if (byte == '\n') lines += 1;12 }13 if (data.len > 0 and data[data.len - 1] != '\n') lines += 1;14 return .{ .lines = lines, .bytes = data.len };15}16 17pub fn countMatching(comptime T: type, values: []const T, needle: T) usize {18 var count: usize = 0;19 for (values) |value| {20 if (value == needle) count += 1;21 }22 return count;23}24 25pub fn newlineOffsets(allocator: std.mem.Allocator, data: []const u8) ![]usize {26 var offsets: std.ArrayList(usize) = .empty;27 errdefer offsets.deinit(allocator);28 for (data, 0..) |byte, index| {29 if (byte == '\n') try offsets.append(allocator, index);30 }31 return offsets.toOwnedSlice(allocator);32}33 34test "summarize newline terminated and unterminated input" {35 try std.testing.expectEqual(@as(usize, 2), summarize("a\nb\n").lines);36 try std.testing.expectEqual(@as(usize, 2), summarize("a\nb").lines);37 try std.testing.expectEqual(@as(usize, 0), summarize("").lines);38}39 40test "generic count and compile-time type argument" {41 try std.testing.expectEqual(@as(usize, 2), countMatching(u8, &.{ 1, 2, 1 }, 1));42}43 44test "allocated newline offsets are released by the testing allocator" {45 const offsets = try newlineOffsets(std.testing.allocator, "a\nb\n");46 defer std.testing.allocator.free(offsets);47 try std.testing.expectEqualSlices(usize, &.{ 1, 3 }, offsets);48}49 50test "arena allocator releases a group of allocations" {51 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);52 defer arena.deinit();53 const allocated = try arena.allocator().dupe(u8, "temporary");54 try std.testing.expectEqualStrings("temporary", allocated);55}56 57test "SafeAllocator reports no leaks after explicit free" {58 var safe = std.heap.SafeAllocator.init(std.testing.allocator, .{});59 const allocated = try safe.allocator().dupe(u8, "temporary");60 safe.allocator().free(allocated);61 try std.testing.expectEqual(@as(usize, 0), safe.deinit());62}var offsets: std.ArrayList(usize) = .empty; starts an empty list. In this version ArrayList does not store the allocator; as you can see in the code, append, deinit and toOwnedSlice ask for it on every call. During the loop append can fail with an allocation error, which is why we use try. For the memory accumulated up to that point we also wrote errdefer offsets.deinit(allocator): if the function returns with an error the list is released, and if it returns successfully this line does not run. In the last line toOwnedSlice turns the list's memory into a slice and hands ownership to the caller.
Ownership is written in the contract, not in the return type. When newlineOffsets returns, the slice belongs to the caller, and the caller must write defer allocator.free(...); the defer std.testing.allocator.free(offsets) line in the test is exactly that.
In the application, std.process.Init gives main its allocator: init.gpa is the general-purpose allocator, and satir-say keeps the whole file content in a single buffer allocated with it. When you want to release a group of temporary allocations together, use ArenaAllocator. In the test, arena.allocator().dupe(u8, "temporary") takes its memory from the arena; defer arena.deinit() releases the whole group in one go, and you don't write individual free calls.
std.testing.allocator catches leaks in tests: a test that allocates memory and ends without releasing it fails. You can try this yourself in the second step of the practice card.
In 0.17 there is std.heap.SafeAllocator for explicit leak checking. Our test sets it up with std.testing.allocator as the underlying allocator, allocates a slice, frees it, and expects the number returned by deinit() to be 0, meaning no leaks. This is a runtime check: the compiler does not prove ownership, and leaks are found only for allocations that are not released while the program runs. DebugAllocator and std.heap.Check from older tutorials are deprecated in 0.17, and SafeAllocator takes their place.
comptime and generic functions
The countMatching(comptime T: type, values: []const T, needle: T) function shows what a comptime parameter is for. T: type is a type, and its value must be known at compile time. When the test calls countMatching(u8, &.{ 1, 2, 1 }, 1), the compiler produces a separate, specialised copy of the function for T = u8; the comparisons in that copy are for u8 values.
There are two layers here: the type is chosen at compile time, while the slice's elements are walked and counted at runtime. values and needle are runtime values. So comptime does not mean “do the whole computation at build time”; it only marks a value, like a type, that must be known at compile time.
In the test, &.{ 1, 2, 1 } is the address of a constant array and converts to a []const u8 slice; the 1 constants become bytes because T = u8. T is the parameter's name; the type argument the test gives it is u8. Had you passed u32 instead of u8, that would set T = u32 and the same constants would be u32: the same source works for different types. Generic code in Zig is not a separate template or macro language; it is an ordinary function written in the same language that can run at compile time. For the details see the comptime section of the language reference.
Current I/O: bounded reads, the writer interface and flush
Since 0.16, I/O runs through a std.Io value passed to the program. That is why main takes a std.process.Init; you pass init.io to the calls that do I/O. The line that reads the file is: Io.Dir.cwd().readFileAlloc(init.io, path, init.gpa, .limited(16 * 1024 * 1024)). The file is opened relative to the current working directory, and its contents are read into a buffer allocated from init.gpa.
.limited(...) puts an upper bound on the read. satir-say reads at most 16 MiB and buffers the whole file in memory, so it is not a suitable tool for very large files. The purpose of the limit is to keep an unexpectedly large input from filling memory.
For output we set up an Io.File.Writer: .init(.stdout(), init.io, &output_buffer) connects standard output to the 256-byte buffer we keep on the stack. The writer's general interface is &output_file_writer.interface; we write formatted text to that interface with print. In the format string, {s} prints a byte sequence as text and {d} prints a number in decimal.
The writer is buffered: print writes data to the buffer first, and at that point you must not assume the output has reached its destination. That is why try output.flush() is necessary; if you leave a short output unflushed you may see nothing at all. Tutorials written with std.io and the old filesystem calls target versions before 0.16; don't mix them with this model.
Let's spell out the tool's contract with the outside world, because counting is byte-based. Empty input has zero lines. A final line without a trailing line break is counted too: a\nb is two lines. In files whose lines end with CRLF, only \n bytes are counted, so each line is counted once. The second number in the output is bytes, not characters: a letter like ş takes two bytes in UTF-8 and is counted as two; counting Unicode characters is a separate job, and this tool does not do it.
01printf 'bir\niki\nuc' > sample.txt02zig build run -- sample.txt03# sample.txt: 3 lines, 10 bytesIn this example sample.txt is three lines and 10 bytes: bir, iki and uc, which has no line break. If no argument is given, or too many, the program ends with the InvalidArguments error.
Build modes, tests and cross-compilation
build.zig is a Zig program that the zig build command runs. The whole file is below.
01const std = @import("std");02 03pub fn build(b: *std.Build) void {04 const target = b.standardTargetOptions(.{});05 const optimize = b.standardOptimizeOption(.{});06 07 const exe = b.addExecutable(.{08 .name = "satir-say",09 .root_module = b.createModule(.{10 .root_source_file = b.path("src/main.zig"),11 .target = target,12 .optimize = optimize,13 }),14 });15 b.installArtifact(exe);16 17 const run_step = b.step("run", "Run the app");18 const run_cmd = b.addRunArtifact(exe);19 run_step.dependOn(&run_cmd.step);20 run_cmd.step.dependOn(b.getInstallStep());21 run_cmd.addPassthruArgs();22 23 const test_module = b.createModule(.{24 .root_source_file = b.path("src/root.zig"),25 .target = target,26 });27 const tests = b.addTest(.{ .root_module = test_module });28 const run_tests = b.addRunArtifact(tests);29 b.step("test", "Run unit tests").dependOn(&run_tests.step);30}The first two lines define command-line options: standardTargetOptions is read as -Dtarget=... and standardOptimizeOption as -Doptimize=.... addExecutable defines the executable named satir-say from the src/main.zig root; installArtifact installs it under zig-out/bin/. The run step runs the program once installation has finished, and thanks to addPassthruArgs, arguments after -- are passed on to satir-say. The test step builds and runs a test runner for src/root.zig.
If you give no option, the build is in Debug mode; that is the default during development, and runtime safety checks are on. The release modes are chosen with -Doptimize.
| Mode | Purpose | Runtime safety checks |
|---|---|---|
Debug | The default for development | On |
ReleaseSafe | Optimisations on, checks kept | On |
ReleaseFast | Prioritises speed | Off |
ReleaseSmall | Prioritises output size | Off |
The table describes each mode's goal, not a measurement; measure any speed or size difference in your own program.
The commands below run the tests, ask for a release build and select another target.
01zig build test02zig build -Doptimize=ReleaseFast03zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseSafezig build test runs the five test blocks in root.zig; in my run with a fresh cache, 5/5 tests passed. Because the tests' allocators also catch leaks, this command checks not only the logic but also memory discipline. The ReleaseFast build also completed successfully.
The target triple (such as x86_64-windows-gnu) is given with -Dtarget; the -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseSafe build completed successfully. One important distinction here: you run a separate zig build for each target you want; a single invocation does not produce every target. Cross-compilation is artifact production: it shows that the compiler produced output for the requested target, not that the output works on the target system. The example whose output I ran and checked was the native build on the host machine.
The toolchain is still evolving. Since 0.16.0 the compiler's own x86_64 backend has been the default for Debug builds, while the 0.17.0 release notes still list Windows support for that backend among the work to be completed. Incremental compilation is documented with -fincremental and is qualified by target and project. Treat both as developing features to try in your own project, not as a speed guarantee.
Crossing the C boundary and a checklist
Zig can also be used as a C compiler driver. To compile an existing C file with the Zig toolchain, zig cc is enough. I compiled and ran the C11 program below with zig cc; its output was C compiled with zig cc.
zig cc.c01#include <stdio.h>02int main(void) {03 puts("C compiled with zig cc");04 return 0;05}01zig cc -std=c11 -Wall -Wextra hello.c -o hello && ./helloUsing a C header from Zig code is a separate matter, and old knowledge is dangerous here. @cImport was deprecated in 0.16 and removed in 0.17; the standard TranslateC build step was likewise deprecated and replaced by the separate translate-c package. The migration path in the official release notes is to record this package as a dependency with zig fetch --save. Because it brings in a dependency, I show it as an optional second mini example.
Set the example up in a folder of its own named zig-c-demo/, separate from satir-say. Its build.zig defines a different executable name and a dependency, so it cannot replace satir-say/build.zig, and the two folders must not be mixed. If the folder has no build.zig.zon, first run zig init as you did for satir-say, then record the dependency.
01zig fetch --save git+https://codeberg.org/ziglang/translate-cThe zig fetch I tried resolved the dependency to commit 875969d3493e245e01bf5d7860f792d8f3eb9ef5, and the tool wrote a content hash into build.zig.zon; those two values pin the dependency.
01static inline int zig_demo_value(void) {02 return 42;03}c.zig01const std = @import("std");02const Translator = @import("translate_c").Translator;03 04pub fn build(b: *std.Build) void {05 const target = b.standardTargetOptions(.{});06 const optimize = b.standardOptimizeOption(.{});07 const dependency = b.dependency("translate_c", .{});08 const translator: Translator = .init(dependency, .{09 .c_source_file = b.path("src/c.h"),10 .target = target,11 .optimize = optimize,12 });13 const exe = b.addExecutable(.{14 .name = "zig-c-demo",15 .root_module = b.createModule(.{16 .root_source_file = b.path("src/main.zig"),17 .target = target,18 .optimize = optimize,19 .imports = &.{.{ .name = "c", .module = translator.mod }},20 }),21 });22 b.installArtifact(exe);23}01const std = @import("std");02const c = @import("c");03 04pub fn main() void {05 std.debug.print("C says {d}\n", .{c.zig_demo_value()});06}In build.zig, b.dependency("translate_c", .{}) obtains the dependency, Translator.init translates the header to Zig, and translator.mod provides a module that the main module can import under the name c. main.zig then calls c.zig_demo_value() through @import("c"). Building and running it printed C says 42.
01zig build && ./zig-out/bin/zig-c-demoLet's finish with a short checklist of the most common mistakes we met in this guide:
- Don't copy
std.ioor pre-0.16 filesystem examples without checking their version. - Don't assume
async/awaitis a stable, current language feature. - Don't assume
@cImportstill exists in 0.17 or that you can use the pre-0.17TranslateCbuild step; use thetranslate-cpackage. - Don't return an allocated slice without documenting who frees it; a slice alone does not transfer allocator ownership.
- Don't forget
defer allocator.free(...), orerrdeferfor partial work before an error return. - Don't treat
GeneralPurposeAllocatorandstd.heap.DebugAllocatorfrom older tutorials as the 0.17 default; useinit.gpain the application andSafeAllocatorwhen you want explicit leak checks. - Don't mistake a successful cross-compile for a program that has been run; it only shows that the compiler produced output for the requested target.
- Don't treat
build.zig.zonas the lockfile of a registry with a stable ecosystem policy; it records package metadata and content hashes, while package management is still on the roadmap. - Don't assume 0.x APIs are stable or that your editor integration already supports 0.17's build-system protocol.
Zig's appeal is that everything is in the open: the allocator is a parameter, the error is written in the return type, the target is chosen on the command line. In return, following version details is up to you: 0.x APIs can change, so compare every example you copy with the documentation of the Zig version you use.
Official sources and further reading
The version details and API behaviour in this article were checked against these sources on 7 October 2026; the code examples were tried with Zig 0.17.0:
- Zig: Downloads and public signing key
- Zig: download index (index.json)
- Zig 0.17.0 release announcement
- Zig 0.17.0 release notes
- Zig 0.16.0 release announcement
- Zig 0.16.0 release notes
- Zig 0.17.0 language reference
- Zig 0.17.0 standard library reference
- Zig: Learn
- Zig: Getting started
- Zig: Build system guide
- Zig: Language overview
- Zig: Migrating from GitHub to Codeberg
- Zig Software Foundation
- Zig repository on Codeberg
- translate-c package on Codeberg
- Official Zig logo (SVG)
- TigerBeetle: architecture
- Ghostty: packaging
- The Rust Book: Understanding ownership
- Go documentation
- zigup README (third-party, not an official Zig source)
Version-specific details follow Zig 0.17.0; 0.x APIs can change, so rely on the documentation for the Zig version your project uses. The Zig logo belongs to the Zig Software Foundation; it is used only for promotional purposes.
In Zig everything is in the open: the allocator, the error and the target are chosen deliberately.
Explore more articles ↗