TL;DR
Learn how to install Zig and get started with systems programming — covers setup on macOS, Linux, and Windows plus your first program
Key concepts
- zig install
- zig getting started
- install zig
- zig setup
- zig programming language
- zig tutorial
- learn zig
- zig hello world
Introduction to Zig
Welcome to your first lesson in Zig programming! In this introduction, we'll cover what Zig is, why it's unique, and what makes it an excellent choice for systems programming.
What is Zig?
Zig is a general-purpose programming language designed for robustness, optimality, and clarity. Created by Andrew Kelley in 2015, Zig aims to be a better C, providing modern features while maintaining the simplicity and performance that systems programmers need.
What You'll Learn
By the end of this lesson you will have written and run a small Zig program that stores a set of readings in an array, adds them up, and reports how many there were and what they averaged — and you will have made it prove its own correctness, because the program checks its answers and refuses to finish until they are right.
That program is the seed of the project this whole track builds toward: record-analyzer, a command-line tool that reads a file of records, counts and summarises them, and prints a report. Counting a collection and reducing it to one number is the analyzer's core motion, in miniature. This lesson carries no capstone milestone of its own — the milestones start once you can model data — but everything the analyzer does is built out of the pieces below.
There is no prerequisite capability here: this is where you start, and the lesson assumes no prior Zig. Everything in this track is pinned to Zig 0.16.0, which is the version the Run button uses. When 0.17 lands, the language core you learn here holds; expect the build and I/O surfaces to move.
Getting Started
You can try Zig right now in our playground without installing anything. When you're ready to work locally, here's how to set up Zig on your machine.
Download the latest stable release from ziglang.org/download.
Or install via your platform's package manager:
- macOS:
brew install zig - Linux:
snap install zig --classic --beta(the snap has no stable channel), or use your distro's package manager, or download the tarball - Windows:
winget install zig.zigorscoop install zig
Verify it works:
zig version
Try your first program — click Run below to see it execute instantly in the browser:
const std = @import("std");
pub fn main() !void {
std.debug.print("Hello, Zig!\n", .{});
}
Key Features
Simple and Explicit
Zig has no hidden control flow, no hidden memory allocations, and no preprocessor. What you see is what you get:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
// The ability to do I/O arrives as a parameter too — same rule as memory.
const io = init.io;
// You supply the buffer, so you decide where the bytes are staged. Nothing
// is allocated behind your back to hold them.
var buffer: [1024]u8 = undefined;
var file_writer = std.Io.File.stdout().writer(io, &buffer);
const stdout = &file_writer.interface;
try stdout.print("Hello, Zig!\n", .{});
// Buffered output is not written until you say so. A missing flush is a
// program that "worked" and printed nothing — which is why it is explicit.
try stdout.flush();
}
Compare that with the one-line std.debug.print above it and you can see what the explicitness
buys and costs. std.debug.print writes to standard error, unbuffered, with no setup and no
failure to handle — which is why every example in this track uses it. The stdout version is longer
because each decision it makes is one you might want to make differently: which buffer, how large,
when to flush. Zig's position is that those are your decisions, so they appear in your code. Most
of what looks like ceremony in Zig is a decision another language made for you.
That slogan is only useful if it lets you predict something you have not been shown, so here is the rule underneath it. Zig has no global allocator. There is no built-in heap a function can quietly reach for, which means a function's ability to allocate memory has to arrive through its signature: it either takes an allocator parameter, or it takes a buffer the caller already owns. Nothing else can produce heap memory.
That single fact lets you read an unfamiliar signature and predict what it can and cannot do. A
function declared fn describe(buffer: []u8, n: i32) ![]u8 takes no allocator, so it cannot
allocate — whatever it hands back must live inside the buffer you passed in, and it stops being
valid the moment that buffer goes out of scope. A function declared
fn describe(allocator: std.mem.Allocator, n: i32) ![]u8 was given the means to allocate, so the
slice it returns is probably freshly allocated and probably yours to free. You have not been taught
allocators yet — that is lesson 11 — but you can already read that difference off the parameter
list, and in most languages you could not, because the heap is always available and never mentioned.
Memory Safety Without Garbage Collection
Zig provides memory safety through compile-time checks and clear ownership, without the runtime overhead of a garbage collector:
const std = @import("std");
pub fn main(init: std.process.Init) !void {
// The allocator arrives as a parameter, because there is no global heap to
// reach for — this is the "no hidden allocation" rule made concrete.
const allocator = init.gpa;
// alloc HANDS YOU the bytes: from here on, these 100 bytes are yours.
const message = try allocator.alloc(u8, 100);
// ...which means you are the one who must give them back. `defer` runs this
// at the end of the scope no matter how the function exits, so the free is
// written next to the alloc rather than at every exit path.
defer allocator.free(message);
std.debug.print("Memory allocated!\n", .{});
}
Read the ownership out of that fence, because it is the point. allocator.alloc returns a
slice the caller now owns: nothing else will ever free those 100 bytes, so if this function returns
without freeing them they are leaked for the life of the program. allocator.free(message) gives
them back, and it must be given back to the same allocator they came from. The defer is what
makes that reliable — it schedules the free for scope exit, so it still runs if a later line
returns an error, which is the failure a hand-written free at the bottom of the function always
misses. You will meet allocators properly in
Memory and Allocators; for now, read this fence as
whoever received the memory is responsible for returning it, and notice how much of that
responsibility is visible right here in the source rather than hidden in a runtime.
Compile-Time Code Execution
One of Zig's most powerful features is comptime - the ability to run code at compile time:
const std = @import("std");
// `comptime n` is the whole trick: it says this parameter must be known while
// the program is being built, which is what lets the recursion run in the
// compiler instead of in the finished binary.
fn fibonacci(comptime n: u32) u32 {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
pub fn main() !void {
// 10 is a literal, so it satisfies `comptime n`. The compiler evaluates the
// whole call and bakes the answer in; the shipped program does no recursion.
const fib_10 = fibonacci(10);
std.debug.print("10th Fibonacci number: {}\n", .{fib_10});
}
That program prints 10th Fibonacci number: 55 — the same answer a runtime version would print.
The difference is when the work happens: the comptime n: u32 parameter forces the compiler to
evaluate every call while it builds your program, so the finished binary holds the constant 55
and does no recursion at all.
This is a preview, not a lesson. comptime is Zig's deepest feature and it gets a lesson of
its own once you have written enough ordinary code to feel the repetition it removes — that is
Comptime, later in the track. It appears now only because you cannot describe
what makes Zig unusual without it. Do not try to master it today; recognise the keyword and move on.
Why Learn Zig?
- Performance: Zero-cost abstractions with C-level performance
- Safety: Catch bugs at compile-time without runtime overhead
- Simplicity: No hidden control flow or allocations
- Interoperability: Seamless C integration without bindings
- Modern Tooling: Built-in build system and package manager
First Behaviour: Reading the Program's Own Output
Here is a small program that uses every piece of Zig syntax this lesson needs. Click Run and read the four lines it prints before reading the notes underneath — the output is the thing being taught, not the source.
const std = @import("std");
pub fn main() !void {
// Variables in Zig
const language = "Zig";
const year: i32 = 2024;
// Print to debug output (easier than stdout for simple examples)
std.debug.print("Learning {s} in {}!\n", .{language, year});
// Basic arithmetic
const a: i32 = 10;
const b: i32 = 20;
std.debug.print("{} + {} = {}\n", .{a, b, a + b});
// Arrays
const numbers = [_]i32{1, 2, 3, 4, 5};
std.debug.print("First number: {}\n", .{numbers[0]});
std.debug.print("Array length: {}\n", .{numbers.len});
}
Four things are worth naming from that output. const declares a binding that will not change.
{s} prints bytes as text and {} prints a value in its default form, and each placeholder is
filled from the .{...} list in order — first placeholder, first value. [_]i32{...} builds an
array whose length Zig counts for you. And numbers[0] is the first element, because Zig indexes
from zero, which makes numbers.len one past the last valid index rather than the last index itself.
That last sentence has a consequence. Work it out before running anything.
Predict
The array has five elements, so numbers.len is 5. These three lines then index the array at numbers.len. What happens when you build and run them?
const numbers = [_]i32{ 1, 2, 3, 4, 5 };
std.debug.print("Array length: {}\n", .{numbers.len});
std.debug.print("Element: {}\n", .{numbers[numbers.len]});Now assemble that pattern yourself. The lines below compute an average of the readings — and only one arrangement of them produces the right answer.
Arrange the code
These six lines add up four readings and print their average. The total is seeded from the first reading and the loop adds the rest. They are shuffled. Put them in the order that computes 25 — then answer what the order is really testing: one of these lines captures a value at the instant it runs rather than tracking it, so which one is it, and what does it capture if it runs too early?
for (readings[1..]) |reading| {var total: i32 = readings[0];}const readings = [_]i32{ 12, 30, 8, 50 };total += reading;const average = @divTrunc(total, 4);
The pattern that sequence follows — set up an accumulator, fill it, then use it — is the one you are about to write from scratch.
Try It Yourself
Reading about a language is not the same as making it produce a right answer. This is a build
task: a small program that reports its own pass/fail. Three functions are stubbed out — one
reports how many readings there are, one adds them up, one returns the first. Run it as-is and it
fails immediately, naming the first check that did not pass. Implement each function until every
check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper that prints the failure
and returns an error. Because an error returned from main ends the program with a non-zero exit
status, the first failing check stops everything — no hidden control flow, exactly as Zig prefers it.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const std = @import("std");
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
// TODO 1: return how many readings are in the array. An array knows its own
// length — you do not count it yourself.
// countOf([_]i32{ 3, 9, 1, 4 }) -> 4
fn countOf(readings: [4]i32) i32 {
_ = readings;
return 0;
}
// TODO 2: add every reading together and return the total.
// totalOf([_]i32{ 10, 20, 30, 40 }) -> 100
fn totalOf(readings: [4]i32) i32 {
_ = readings;
return 0;
}
// TODO 3: return the FIRST reading in the array. Zig indexes from 0.
// firstOf([_]i32{ 7, 1, 1, 1 }) -> 7
fn firstOf(readings: [4]i32) i32 {
_ = readings;
return 0;
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
const readings = [_]i32{ 12, 30, 8, 50 };
try check(countOf(readings) == 4, "countOf should report 4 readings");
try check(countOf([_]i32{ 1, 1, 1, 1 }) == 4, "countOf should report the length, not the sum");
try check(totalOf(readings) == 100, "totalOf should add 12 + 30 + 8 + 50 to 100");
try check(totalOf([_]i32{ 0, 0, 0, 0 }) == 0, "totalOf on all zeros should be 0, not the count");
try check(firstOf(readings) == 12, "firstOf should return 12, the element at index 0");
try check(firstOf([_]i32{ 99, 12, 12, 12 }) == 99, "firstOf should read index 0, not the smallest value");
const average = @divTrunc(totalOf(readings), countOf(readings));
try check(average == 25, "total 100 over 4 readings should average to 25");
std.debug.print("All checks passed.\n", .{});
std.debug.print("Count: {}\n", .{countOf(readings)});
std.debug.print("Total: {}\n", .{totalOf(readings)});
std.debug.print("First: {}\n", .{firstOf(readings)});
std.debug.print("Average: {}\n", .{average});
}Expected output:
All checks passed. Count: 4 Total: 100 First: 12 Average: 25
Once it passes, try two variations and predict each before running:
- Count from the wrong end. In
countOf, changereturn readings.len;toreturn readings.len - 1;. Predict which check fails before running. The first check fails:countOfnow reports 3, and the message names it —countOf should report 4 readings. The average check would fail too, at 33 instead of 25, but you never reach it, because the first failing check ends the program. That is the off-by-one that turnsleninto "last index". - Declare the accumulator inside the loop. In
totalOf, movevar sum: i32 = 0;from above theforto the first line of the loop body, and return it from inside the loop. Predict the total before running. It reports 12, not 100: the accumulator is re-created and reset on every pass, so it only ever holds one reading, and returning from inside the loop means only the first iteration ever happens. Where an accumulator is declared decides whether a loop accumulates.
Not every wrong program announces itself. The next one compiles, runs, exits cleanly, and prints a line that is nonsense — with no warning of any kind.
Debug
This should print 'Learning Zig 0.16 in 2024!' — the language, then the version it is pinned to, then the year. It compiles, it runs, and it exits without a single complaint, but the line it prints is wrong. Commit a hypothesis about how a placeholder decides which value it gets, then fix it.
const std = @import("std");
pub fn main() !void {
const language = "Zig";
const year: i32 = 2024;
const version: f64 = 0.16;
std.debug.print("Learning {s} {d} in {}!\n", .{ language, year, version });
}Expected output:
Learning Zig 0.16 in 2024!
Reading and rearranging code is one skill; recognising the same idea somewhere it looks different is another. Here is the same explicitness rule, moved to a setting this lesson never showed you.
Transfer
You have seen Zig's explicitness rule in two places: memory arrives as a parameter, because there is no global heap a function can reach for, and output goes through a writer you supply a buffer to. Suppose you now meet a Zig function whose signature is fn loadConfig(allocator: std.mem.Allocator, path: []const u8) ![]u8, in a lesson you have not read yet. Which conclusion does the rule actually license?
Before you close the lesson, one more question — and this one reaches back to the very start of it, not to the paragraph above.
Recall
Without scrolling up: this lesson opened by claiming Zig has no hidden control flow and no hidden allocations, and then gave one concrete rule that makes the second half of that claim testable. What was the rule?
Practice Exercises
Try these, extending the program above:
- Change the
languagevariable to your favorite programming language and rerun — the string length changes but nothing else does, because{s}prints however many bytes the slice holds. - Add a sixth number to the
numbersarray and rerun. Predict whatArray lengthprints before you run it — you are not changing the line that prints it. - Change
numbers[0]tonumbers[2]and predict which value appears before running.
Key Takeaways
- Zig is a modern systems programming language focused on simplicity and safety
- It provides manual memory management without garbage collection overhead
- The
comptimefeature allows code execution at compile time - Zig uses explicit error handling with
!andtrykeywords - No hidden control flow makes code behavior predictable
Next Steps
In the next lesson, we'll dive deeper into variables and Zig's type system, exploring how to declare variables, work with different data types, and understand type inference.
Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.