Learn Zig Series (#2) - Hello Zig, Variables and Types
What will I learn
- You will learn how to write and run your first real Zig programs;
- Zig's format strings and why they are type-safe at compile time;
- the fundamental difference between
constandvarin Zig; - Zig's integer type system including arbitrary-width integers;
- how booleans work in Zig (and why there's no "truthy/falsy");
- how to run and build Zig programs from the command line;
- why unused variables are compile errors in Zig (and how to discard values explicitly).
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Beginner
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types (this post)
Learn Zig Series (#2) - Hello Zig, Variables and Types
Alright! In the first episode of this series I introduced the Zig programming language, talked about what it is, how it compares to other systems programming languages like Rust, C++, and Nim, and we got a "Hello world" running. If you haven't read that episode yet, I'd strongly recommend going back and doing that first -- it sets the stage for everything that follows. We also installed Zig on our machines and verified the installation works. Good stuff.
Now it's time to properly get our hands dirty. In this episode we'll write real code, understand how Zig's format strings work (and why they are fundamentally different from what you may be used to from Python or C), explore variables and constants, and dive into Zig's type system. By the end of this post, you'll have written multiple Zig programs and understand the building blocks that make Zig code tick ;-)
If you've been following my Learn Python Series (56+ episodes and counting!) or the Learn AI Series, you know I prefer the "concept-first" approach: understand the WHY before the HOW. Same philosophy here. Let's go.
Your First Real Zig Program
You already saw a basic "Hello world" in ep001. Let's go a step further and actually use the language. Create a file called basics.zig:
const std = @import("std");
pub fn main() void {
std.debug.print("Hello, Zig!\n", .{});
std.debug.print("This is episode 2 of the Learn Zig Series.\n", .{});
}
Run it:
zig run basics.zig
Output:
Hello, Zig!
This is episode 2 of the Learn Zig Series.
Simple enough. But let's take the time to really understand what every piece does here, because nothing in Zig is accidental or implicit. And I mean nothing. This is a language that was designed from the ground up to make every operation visible, every intent explicit, and every side effect obvious. Once you internalize that philosophy, Zig code reads like a blueprint.
The Import
const std = @import("std");
@import is a builtin function -- all Zig builtins start with @. This imports the Zig standard library and binds it to the name std. The const keyword means this binding can never be reassigned (more on const vs var in a moment).
If you've been following my Learn Python Series, think of this as Python's import statement. But whereas Python has os, sys, json, math as separate modules that you import individually, Zig's standard library is one unified namespace under std. Need file I/O? std.fs. Need memory allocation? std.mem and std.heap. Need networking? std.net. Everything lives under that single std umbrella, organized in a tree structure that you navigate with dots. Clean, predictable, no guessing which module something lives in.
The Entry Point
pub fn main() void {
pub means public (exported from the module). fn defines a function. main is the entry point -- the Zig runtime calls this when your program starts. void is the return type -- this function returns nothing.
In Python you'd just write code at the top level (or use if __name__ == "__main__": as a convention). In Zig, all code lives inside functions, and execution starts at main. The return type void is explicit -- Zig never hides what's happening. You'll notice this principle everywhere in the language. Having said that, main can also return !void which means "void or an error" -- but we'll get to error handling in a later episode, because it honestly deserves its own deep dive. It's one of Zig's best features.
The Print
std.debug.print("Hello, Zig!\n", .{});
std.debug.print writes to stderr (not stdout!). It's meant for debugging output. The .{} at the end is an anonymous struct literal -- it's how Zig passes format arguments. When there are no arguments to format, you pass an empty struct .{}.
Why stderr and not stdout? Because in systems programming, stdout is for data (piped between programs), and stderr is for human-readable messages. If you pipe the output of one program into another, you don't want debug messages corrupting the data stream. Zig respects this Unix convention from day one. When you need to write to stdout properly (for actual program output), you'd use std.io.getStdOut() and its associated writer -- but for learning and debugging, std.debug.print is your friend.
Format Strings -- Compile-Time Type Safety
Here's where Zig immediately reveals its character. Let's write something more interesting:
const std = @import("std");
pub fn main() void {
const language = "Zig";
const version: f64 = 0.14;
const year: u16 = 2016;
std.debug.print("Language: {s}\n", .{language});
std.debug.print("Version: {d:.2}\n", .{version});
std.debug.print("First appeared: {d}\n", .{year});
std.debug.print("{s} v{d:.2}, since {d}\n", .{ language, version, year });
}
Output:
Language: Zig
Version: 0.14
First appeared: 2016
Zig v0.14, since 2016
The .{ language, version, year } is an anonymous struct holding multiple format arguments. Let's break down the format specifiers:
{s}-- string (a slice of bytes){d}-- decimal number{d:.2}-- decimal with 2 decimal places{any}-- print anything (useful for debugging unknown types){}-- use the default formatter for whatever type was passed
Now here's the critical difference from Python, C, or practically any other language you've used: if you pass the wrong number of arguments, or the wrong types, the Zig compiler rejects your program at build time. Not at runtime. Not with a cryptic segfault. Not by silently printing garbage. At compile time, with a clear error message explaining exactly what went wrong.
In Python, "Price: %s" % 42 silently converts the integer to a string. In C, printf("%s", 42) compiles fine but causes undefined behavior at runtime -- it might crash, it might print garbage, it might appear to work and then fail in production at 3 AM on a Saturday. In Zig, the equivalent won't even compile. The compiler is your safety net, and it catches format string bugs before your program ever runs.
Let me show you what happens if you try:
// This will NOT compile:
std.debug.print("{s}\n", .{42});
// Compile error: format '{s}' expects '[]const u8', found 'comptime_int'
That error message is not something you have to decode with a magnifying glass -- it tells you exactly what's wrong and what it expected. This is the Zig experience in a nutshell: the compiler does the work upfront so you don't have to debug it at runtime. If you've ever spent hours tracking down a printf format string bug in C, you know exactly how valuable this is ;-)
Variables and Constants
This is fundamental. In Zig, every binding you create must be declared as either const (immutable) or var (mutable). There is no default. There is no shortcut:
const std = @import("std");
pub fn main() void {
// const -- immutable, can never be reassigned
const pi: f64 = 3.14159265;
const greeting = "Hello";
const is_active = true;
// var -- mutable, can be changed after declaration
var counter: u32 = 0;
counter += 1;
counter += 1;
counter += 1;
std.debug.print("Pi: {d:.5}\n", .{pi});
std.debug.print("Greeting: {s}\n", .{greeting});
std.debug.print("Active: {}\n", .{is_active});
std.debug.print("Counter: {d}\n", .{counter});
// This would NOT compile:
// pi = 2.71828; // error: cannot assign to constant
}
Let me highlight the key principles here, because they are very different from what you're used to in Python or JavaScript:
1. You must choose const or var every single time. There is no "default" mutability. There's no equivalent of Python's x = 5 where everything is silently mutable. Zig forces you to think about whether this value should ever change. In practice, you'll find that the vast majority of your bindings are const -- and that's a good thing. Immutable by default means fewer bugs. The Zig community even has a soft guideline: if you can use const, you should use const.
2. Type inference works, but you can be explicit. const greeting = "Hello" -- Zig infers the type automatically (it's a *const [5:0]u8, a pointer to a null-terminated byte array, but don't worry about that yet). var counter: u32 = 0 explicitly declares a 32-bit unsigned integer. When in doubt, be explicit. The compiler will tell you if the types don't match.
3. Underscores in number literals. 21_000_000 is the same as 21000000 -- the underscores are purely for readability. Just like Python's 21_000_000. Handy for large numbers, and you'll be grateful for this when working with memory sizes and bit patterns in systems programming ;-)
4. Unused variables are a compile error. This is a big one. If you write var x: u32 = 5; and never use x, your code will not compile. Zig enforces this strictly. No dead code, no forgotten variables, no "I'll use this later" that never actually happens. Coming from Python where you can declare anything and forget about it? This feels strict at first. After a few weeks you'll wonder why every language doesn't do this.
The _ underscore works as an explicit discard:
_ = someFunction(); // "I know this returns something, I'm deliberately ignoring it"
This is Zig saying: be intentional about everything. If you're ignoring a return value, say so explicitly. No silent drops, no hidden side effects. The code says what it does and does what it says.
Zig's Integer Types
Most languages give you a handful of integer sizes -- int, long, short, maybe int8, int16, int32, int64 if you're lucky. Zig starts there but goes much further:
u8 u16 u32 u64 u128 -- unsigned integers (no negative values)
i8 i16 i32 i64 i128 -- signed integers (can be negative)
usize -- pointer-sized unsigned (64-bit on modern systems)
isize -- pointer-sized signed
f16 f32 f64 f128 -- floating point numbers
For day-to-day programming, you'll use u8, u32, u64, i32, i64, usize, f32, and f64 most of the time. That usize type is particularly important -- it's the type used for array indices and memory sizes. On a 64-bit system it's equivalent to u64, on a 32-bit system it'd be u32. It adapts to the platform, which matters when you're writing portable systems code.
But here's where it gets interesting -- Zig lets you have arbitrary-width integers:
const x: u3 = 5; // 3-bit unsigned integer (range: 0-7)
const y: u12 = 4095; // 12-bit unsigned integer (range: 0-4095)
const z: i5 = -16; // 5-bit signed integer (range: -16 to 15)
Why would you ever want a 3-bit integer? In systems programming, data isn't always neatly aligned to byte boundaries. A network protocol header might have a 4-bit version field followed by a 4-bit header length. A hardware register might pack flags into individual bits. A binary data format might use 12-bit audio samples. Zig lets you express these constraints precisely in the type system, in stead of doing manual bit-shifting and masking like you would in C. The type system does the work for you.
Let me show you the practical implication of another design choice that really sets Zig apart: no implicit integer conversions. Ever.
const std = @import("std");
pub fn main() void {
const small: u8 = 42;
// This will NOT compile:
// const big: u32 = small;
// Error: expected 'u32', found 'u8'
// You must be explicit about the conversion:
const big: u32 = @intCast(small);
std.debug.print("small (u8): {d}\n", .{small});
std.debug.print("big (u32): {d}\n", .{big});
// Going the other way is even more telling:
const large: u32 = 300;
// This ALSO won't compile without an explicit cast:
// const tiny: u8 = large;
// Why? Because 300 doesn't fit in a u8 (max 255)!
// Zig won't silently truncate your data.
// If you KNOW it fits, you explicitly cast:
const fits: u32 = 200;
const tiny: u8 = @intCast(fits);
std.debug.print("fits (u32): {d}\n", .{fits});
std.debug.print("tiny (u8): {d}\n", .{tiny});
}
The @intCast builtin makes the conversion visible in the code. No silent promotions, no hidden truncations. If the value doesn't actually fit in the target type at runtime, Zig will trigger a safety check (a panic in debug builds, which we'll cover later). Compare this to C where an int silently overflows to some unpredictable value, or where assigning a long to a short just chops off the high bits without telling you. Zig's approach is stricter, yes, but it prevents an entire category of numerical bugs that plague C codebases -- the kind of bugs that have caused actual security vulnerabilities in real production systems.
Boolean and Truthiness (or Rather, the Lack Thereof)
Zig has a bool type with two values: true and false. And that's it. There is no concept of "truthy" or "falsy" values:
const std = @import("std");
pub fn main() void {
const active = true;
const count: u32 = 42;
if (active) {
std.debug.print("Active is true\n", .{});
}
// Check a number explicitly
if (count != 0) {
std.debug.print("Count is non-zero: {d}\n", .{count});
}
// This will NOT compile:
// if (count) { }
// Error: expected type 'bool', found 'u32'
}
In Python, if 42: evaluates to True. In JavaScript, if ("hello") is truthy. In C, any non-zero integer is "true." In Zig, only bool works in conditions. Period. If you want to check if a number is non-zero, write if (count != 0). If you want to check if a pointer is non-null, write if (ptr != null). You write exactly what you mean.
This might feel restrictive if you're coming from a dynamic language. But it eliminates an entire class of bugs -- the "I accidentally used an integer where I meant a boolean" class. I've seen Python code where if len(my_list): silently evaluates to False when the list is empty, and True when it's not -- which works, sure, but it's an implicit conversion that you have to know about to read correctly. In Zig, if (my_slice.len != 0) says EXACTLY what it's checking. No ambiguity. No "well, in this language, zero means false." The code is the documentation.
(If you've been following the Learn AI Series where we've been writing quit some NumPy code -- you know how easy it is to accidentally mix up array shapes and scalar booleans in Python. Zig just... doesn't let you. That's the philosophy.)
Running vs Building
Two fundamental ways to execute Zig code:
# Compile + run in one step (binary is temporary)
zig run basics.zig
# Build a permanent binary
zig build-exe basics.zig
./basics
zig run is perfect for experimentation -- write code, run it, see the output, iterate. The binary is created in a temp directory and cleaned up. zig build-exe produces a real executable in your current directory that you can distribute, profile, benchmark, whatever you need.
And for real projects with a build.zig file (which we'll cover when we get to project structure in a later episode):
zig build # compile
zig build run # compile + run
The build.zig file is the build system -- and here's the beautiful part: it's Zig code that describes how to compile your project. Not YAML, not TOML, not Make, not CMake, not a new DSL you have to learn. You write your build configuration in the same language as your program. One language for everything. I love this design choice because it means you can use the full power of a programming language (conditionals, loops, functions, imports) in your build scripts, rather than fighting with the limitations of a declarative configuration format ;-)
If you've ever wrestled with CMakeLists.txt for a C++ project or tried to figure out why cargo refuses to link a C library correctly in Rust, you'll appreciate this. But that's a story for later.
A Bigger Example: Putting It Together
Let's combine everything we've covered into a program that actually does something. We'll create a small server metrics calculator -- the kind of thing you might write as a systems programmer monitoring infrastructure:
const std = @import("std");
pub fn main() void {
// Server metrics (all const -- these are snapshot values)
const hostname = "node-alpha-01";
const cpu_cores: u8 = 16;
const ram_total_mb: u32 = 65_536;
const ram_used_mb: u32 = 42_108;
const disk_total_gb: u32 = 2_000;
const disk_used_gb: u32 = 1_247;
const uptime_hours: u32 = 2_160;
// Computed values
const ram_free_mb: u32 = ram_total_mb - ram_used_mb;
const disk_free_gb: u32 = disk_total_gb - disk_used_gb;
// Percentages need floating point
const ram_pct: f64 = @as(f64, @floatFromInt(ram_used_mb)) /
@as(f64, @floatFromInt(ram_total_mb)) * 100.0;
const disk_pct: f64 = @as(f64, @floatFromInt(disk_used_gb)) /
@as(f64, @floatFromInt(disk_total_gb)) * 100.0;
const uptime_days: u32 = uptime_hours / 24;
std.debug.print("=== Server Report: {s} ===\n", .{hostname});
std.debug.print("CPU cores: {d}\n", .{cpu_cores});
std.debug.print("RAM: {d} / {d} MB ({d:.1}% used), {d} MB free\n", .{
ram_used_mb, ram_total_mb, ram_pct, ram_free_mb,
});
std.debug.print("Disk: {d} / {d} GB ({d:.1}% used), {d} GB free\n", .{
disk_used_gb, disk_total_gb, disk_pct, disk_free_gb,
});
std.debug.print("Uptime: {d} hours ({d} days)\n", .{ uptime_hours, uptime_days });
// Status check using boolean logic
const ram_critical = ram_pct > 90.0;
const disk_critical = disk_pct > 90.0;
if (ram_critical) {
std.debug.print("WARNING: RAM usage above 90%!\n", .{});
}
if (disk_critical) {
std.debug.print("WARNING: Disk usage above 90%!\n", .{});
}
if (!ram_critical and !disk_critical) {
std.debug.print("Status: All systems nominal.\n", .{});
}
}
Output:
=== Server Report: node-alpha-01 ===
CPU cores: 16
RAM: 42108 / 65536 MB (64.2% used), 23428 MB free
Disk: 1247 / 2000 GB (62.4% used), 753 GB free
Uptime: 2160 hours (90 days)
Status: All systems nominal.
Let me point out a few things happening in this program that connect back to what we've covered:
The @floatFromInt builtin. We needed to compute percentages, which requires division with decimal results. But ram_used_mb and ram_total_mb are both u32 integers -- and Zig does NOT do implicit int-to-float conversion. So we use @floatFromInt to explicitly convert each integer to f64 before dividing. The @as(f64, ...) part tells Zig the target type. Every conversion is visible. You read this code and you know exactly where the type boundaries are.
Integer division truncates. uptime_hours / 24 gives us 90, not 90.0. Integer division in Zig always rounds toward zero (same as in C, Python 3's // operator, and most other languages). If you need the remainder, there's the % operator: 2160 % 24 would give 0 (no leftover hours in this case).
Boolean expressions are explicit. We don't write if (!ram_critical && !disk_critical) with C-style &&. Zig uses the words and and or for logical operators. More readable, less likely to confuse with bitwise & and |. The parentheses around the if condition are required in Zig (unlike in some languages where they're optional).
All those const declarations. Count them -- almost every binding is const. The only case where we'd need var is if we were updating values in a loop or accumulating something. For a snapshot report like this, everything is immutable. This is idiomatic Zig: prefer const, reach for var only when you genuinely need mutation.
What Makes Zig Special -- A Quick Reference
Before we wrap up, here's a summary table of what you've learned and why it matters. I like to include these in tutorials because they're useful to come back to later:
| Feature | What it means for you |
|---|---|
const vs var | You decide mutability for every value. Immutable by default = fewer bugs. |
| Type-safe format strings | Wrong format = compile error, not runtime crash or undefined behavior |
| No implicit conversions | @intCast, @floatFromInt etc. make every conversion visible |
| No truthy/falsy | Only bool in conditions. Say what you mean. |
| Unused variables = error | No dead code accumulates silently |
| Arbitrary-width integers | Express hardware and protocol constraints precisely in the type system |
@ builtins | Explicit, discoverable, consistent interface for compiler operations |
and/or keywords | Logical operators are words, not symbols -- less confusion with bitwise ops |
Every design choice in Zig serves the same philosophy: make the implicit explicit. You read Zig code and you know what it does. No surprises, no hidden promotions, no "well, it depends on the platform" ambiguity. If you're coming from a language like Python where the runtime handles everything for you behind the scenes -- this is a very different mindset. But it's a mindset that produces reliable, predictable, maintainable code. And once it clicks? You start wishing other languages were this honest about what they're doing ;-)
Exercises
I strongly encourage you to actually do these. Reading code teaches you the syntax; writing code teaches you the language. Every episode in this series builds on the previous ones, and muscle memory matters more than you'd think.
- Write a program that declares
constvalues for a server name, its IP address (as a string), and its number of CPU cores, then prints a formatted summary usingstd.debug.print - Declare a
var counter: u32 = 0and increment it 5 times in sequence, printing the value after each increment - Try assigning to a
const-- read the compiler error message carefully (Zig's errors are genuinly excellent, get in the habit of reading them thoroughly) - Try using an integer in an
ifcondition (if (42) { ... }) and read the error - Declare a variable and never use it -- read the error message, then fix it using
_ = your_variable; - Write a program that converts between
u8andu32using@intCast-- try converting a value that's too large for the target type and observe what happens
These exercises might seem basic, and they are -- intentionally. We're building a foundation. Every concept introduced today will come back in force as we move into functions, control flow, and eventually the features that make Zig truly powerful (error handling, memory management, and that mind-bending compile-time execution system called comptime). If the foundation is solid, the advanced stuff flows naturally. If it's shaky, everything built on top wobbles.