Skip to content
Make room for learningYour learning studio
Course outline

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.zig or scoop install zig

Verify it works:

bash
zig version

Try your first program — click Run below to see it execute instantly in the browser:

zig
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:

zig
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:

zig
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:

zig
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?

  1. Performance: Zero-cost abstractions with C-level performance
  2. Safety: Catch bugs at compile-time without runtime overhead
  3. Simplicity: No hidden control flow or allocations
  4. Interoperability: Seamless C integration without bindings
  5. 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.

zig
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?

zig
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?

  1. for (readings[1..]) |reading| {
  2. var total: i32 = readings[0];
  3. }
  4. const readings = [_]i32{ 12, 30, 8, 50 };
  5. total += reading;
  6. 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.

zig
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:

  1. Count from the wrong end. In countOf, change return readings.len; to return readings.len - 1;. Predict which check fails before running. The first check fails: countOf now 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 turns len into "last index".
  2. Declare the accumulator inside the loop. In totalOf, move var sum: i32 = 0; from above the for to 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.

zig
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:

  1. Change the language variable to your favorite programming language and rerun — the string length changes but nothing else does, because {s} prints however many bytes the slice holds.
  2. Add a sixth number to the numbers array and rerun. Predict what Array length prints before you run it — you are not changing the line that prints it.
  3. Change numbers[0] to numbers[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 comptime feature allows code execution at compile time
  • Zig uses explicit error handling with ! and try keywords
  • 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.

Next lesson

Variables and Data Types

Master Zig variables and the Zig type system — declare const and var bindings, use type inference, and convert between numeric types safely.

20 min