Learn Zig Series (#5) - Arrays, Slices, and Strings
What will I learn
- You will learn about arrays in Zig -- fixed size, stack-allocated, compile-time known;
- slices as pointer-plus-length views into memory;
- the difference between mutable and const slices;
- that strings in Zig are simply byte slices (
[]const u8); - common string operations using
std.mem; - sentinel-terminated slices for C interop;
- multi-line strings and compile-time string operations.
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
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings (this post)
Learn Zig Series (#5) - Arrays, Slices, and Strings
Welcome back! In episode #4 we went deep on error handling -- error unions, try, catch, defer, errdefer, optionals, orelse, and we put it all together in an Order Validator that processed a batch of trades and rejected invalid ones with specific error messages. If you haven't gone through that episode yet, go do it first -- we'll be using error handling concepts throughout this one, and I'm going to assume you're comfortable with try, catch, and optionals.
Today we tackle the most fundamental data containers in Zig: arrays, slices, and strings. If functions and control flow (ep003) were the verbs of your programs, these are the nouns -- the things your programs actually work with. And here's the thing that surprised me when I first started with Zig: strings aren't a type. They're just byte slices. No special String class, no .length() method on a magic object, no hidden allocations when you concatenate. Just bytes in memory, a pointer, and a length. Simple, explicit, and fast ;-)
Remember in ep003 when I introduced []const u8 and said "that's how you spell string in Zig, we'll go deep on slices in a later episode"? Well, this is that episode. Let's dive right in.
Solutions to Episode 4 Exercises
Before we start on new material, here are the solutions to last episode's exercises. If you actually typed these out and ran them yourself (and I really hope you did!), compare your solutions:
Exercise 1 -- safe divide:
fn safeDivide(a: f64, b: f64) error{DivisionByZero}!f64 {
if (b == 0) return error.DivisionByZero;
return a / b;
}
pub fn main() void {
const a = safeDivide(10.0, 3.0) catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
std.debug.print("10 / 3 = {d:.4}\n", .{a}); // 3.3333
const b = safeDivide(42.0, 0.0) catch |err| {
std.debug.print("42 / 0 = Error: {}\n", .{err});
return;
};
_ = b;
}
The function signature is the contract: "I might return DivisionByZero, or I might return an f64." The caller must handle both cases. If you wrote this without catch, the compiler refused. That's the whole point of ep004 -- you cannot ignore errors.
Exercise 2 -- chaining with try:
const std = @import("std");
fn fetchPrice(pair: []const u8) error{NotFound}!f64 {
if (std.mem.eql(u8, pair, "BTC/USD")) return 68000.0;
if (std.mem.eql(u8, pair, "ETH/USD")) return 3200.0;
return error.NotFound;
}
fn calculateValue(pair: []const u8, qty: f64) error{NotFound}!f64 {
const price = try fetchPrice(pair);
return price * qty;
}
Notice how calculateValue doesn't create any errors itself -- it just propagates whatever fetchPrice returns via try. Two characters. That's it. Compare that to Go's three-line if err != nil { return 0, err } pattern that you'd repeat at every call site.
Exercise 3 -- find asset:
fn findAsset(assets: []const []const u8, target: []const u8) ?usize {
for (assets, 0..) |a, i| {
if (std.mem.eql(u8, a, target)) return i;
}
return null;
}
Optional return (?usize) because not finding something isn't a failure -- it's a legitimate outcome. This is the error vs optional decision framework from ep004 in action. And notice: std.mem.eql(u8, a, target) for string comparison. We're about to learn exactly why that function exists and why you can't just write a == target.
Exercise 4 -- defer LIFO: prints "cleanup C", "cleanup B", "cleanup A" (reverse of declaration order). Last registered, first executed. Resources released in reverse acquisition order -- like unstacking plates.
Exercise 5 -- allocator with errdefer:
const std = @import("std");
fn createValue(allocator: std.mem.Allocator, val: u32) !*u32 {
const ptr = try allocator.create(u32);
errdefer allocator.destroy(ptr);
if (val == 0) return error.InvalidValue;
ptr.* = val;
return ptr;
}
The errdefer guarantees: if anything after the allocation fails, the memory gets freed. If the function succeeds, the caller owns the pointer. We'll get much deeper into allocators and memory management soon -- for now just remember the pattern.
Exercise 6 -- order validation system: if you built this, you essentially recreated the Order Validator from the ep004 walkthrough. Error sets with multiple variants, a validation function returning either an error or a computed balance, and a main loop that processes orders and prints accepted/rejected. If your version looks different from mine, that's fine -- there's no single right answer as long the errors flow correctly.
Now -- arrays, slices, and strings!
Arrays -- Fixed Size, Stack Allocated
An array in Zig is a sequence of values where the length is known at compile time. The data lives on the stack -- no heap allocation, no garbage collector involvement, no dynamic resizing. You declare the type, the compiler knows the size, and the memory is allocated and freed automatically when the scope ends.
const std = @import("std");
pub fn main() void {
// Explicit length
const prices: [5]f64 = .{ 64000, 65200, 63800, 67100, 68400 };
// Inferred length with [_]
const tickers = [_][]const u8{ "BTC", "ETH", "SOL", "AVAX", "DOT" };
// Initialize all elements to the same value
const empty_scores = [_]f64{0} ** 10;
std.debug.print("prices[2] = ${d:.0}\n", .{prices[2]}); // 63800
std.debug.print("tickers.len = {d}\n", .{tickers.len}); // 5
std.debug.print("scores.len = {d}\n", .{empty_scores.len}); // 10
}
Let me highlight the key concepts:
[5]f64 is the type. Five elements, each an f64. The length is part of the type itself -- [5]f64 and [3]f64 are different types. You cannot assign one to the other. This is fundamentally different from Python where a list can be any length and you find out at runtime. In Zig, the compiler knows the size before the program ever runs.
[_] infers the length from the initializer. [_]f64{1, 2, 3} becomes [3]f64. The underscore tells the compiler "count these for me" -- which is nice because manually counting elements and then updating the count when you add one more is exactly the kind of bookkeeping that produces bugs. Let the compiler do it.
.len is a property, not a function. Always arr.len, never arr.len(). Zig has no implicit function-call overhead hiding behind property syntax. .len is a compile-time known value baked directly into the type. For a [5]f64, .len is literally the number 5 -- the compiler substitutes it at compile time.
** repeats. [_]f64{0} ** 10 creates an array of ten zeros. This is a compile-time operation -- no loop at runtime. The ** operator works on arrays and strings (we'll see string repetition later in this episode).
Out-of-bounds access is caught. If the index is known at compile time, you get a compile error. If it's a runtime index, you get a panic in debug builds. Zig never silently reads garbage memory or corrupts adjacent data. Never. This is one of those "the language protects you" things that you don't appreciate until you've spent three days hunting a buffer overflow in C.
If you've been following the Learn Python Series, you know Python lists are dynamic -- you can .append(), .pop(), mix types, grow and shrink at will. Zig arrays are the opposite: fixed, homogeneous, stack-allocated, and their size is part of the type. What you lose in flexibility, you gain in predictibility and performance. No surprise allocations, no GC pauses, no "oops I appended a million items and now my program is swapping." The tradeoff is deliberate.
Iterating Over Arrays
We already saw for loops on arrays in ep003, but let me show a few more patterns now that we're focusing on arrays specifically:
const std = @import("std");
pub fn main() void {
const prices = [_]f64{ 64000, 65200, 63800, 67100, 68400, 66900, 67500 };
// Sum all elements
var total: f64 = 0;
for (prices) |p| {
total += p;
}
const avg = total / @as(f64, @floatFromInt(prices.len));
std.debug.print("Average: ${d:.2}\n", .{avg});
// Find the maximum
var max_price: f64 = prices[0];
var max_day: usize = 0;
for (prices, 0..) |p, i| {
if (p > max_price) {
max_price = p;
max_day = i;
}
}
std.debug.print("Highest: ${d:.0} on day {d}\n", .{ max_price, max_day });
}
Output:
Average: $66128.57
Highest: $68400 on day 4
The for (prices, 0..) |p, i| pattern gives you both the element and the index -- same as Python's enumerate() but without the function call overhead. We used this in the ep003 Trade Journal and in the ep004 Order Validator. You'll use it constantly.
Notice @as(f64, @floatFromInt(prices.len)) -- that's the integer-to-float conversion from ep002. .len is a usize (unsigned integer), and you can't divide an f64 by a usize directly. Zig forces you to be explicit about type conversions. Verbose? Yes. But it also means you never get a silent integer truncation bug where 7 / 2 == 3 surprised you at 2 AM.
Multidimensional Arrays
Arrays can contain arrays. A 2D array is an array of arrays:
const std = @import("std");
pub fn main() void {
// 3 weeks of daily prices (7 days each)
const weekly_data = [3][7]f64{
.{ 64000, 65200, 63800, 67100, 68400, 66900, 67500 },
.{ 67200, 68100, 69500, 68800, 70200, 69100, 71000 },
.{ 70500, 69800, 71200, 72000, 71500, 73100, 72800 },
};
for (weekly_data, 0..) |week, w| {
var sum: f64 = 0;
for (week) |day| {
sum += day;
}
const avg = sum / 7.0;
std.debug.print("Week {d}: avg ${d:.0}\n", .{ w + 1, avg });
}
}
Output:
Week 1: avg $66129
Week 2: avg $69129
Week 3: avg $71557
The type is [3][7]f64 -- three arrays of seven f64 values each. Each .{ ... } initializes one inner array. If you used labeled breaks from ep003, you could break out of the nested loop with break :outer if you were searching for a specific value. These patterns compose.
Slices -- Views Into Memory
Here's where things get interesting. An array owns its data and has a compile-time known length. A slice is a view into existing data -- it's a pointer plus a length, and it can refer to any contiguous sequence of elements in memory. The slice doesn't own the data. It just looks at it.
const std = @import("std");
pub fn main() void {
const daily_prices = [_]f64{ 64000, 65200, 63800, 67100, 68400, 66900, 67500 };
// Last 3 days
const recent: []const f64 = daily_prices[4..];
std.debug.print("Recent prices: ", .{});
for (recent) |p| std.debug.print("${d:.0} ", .{p});
std.debug.print("\n", .{});
// Middle slice (indices 2, 3, 4)
const mid_week = daily_prices[2..5];
std.debug.print("Mid-week: {d} prices\n", .{mid_week.len});
// First 3 days
const start = daily_prices[0..3];
std.debug.print("Start: {d} prices\n", .{start.len});
}
Output:
Recent prices: $68400 $66900 $67500
Mid-week: 3 prices
Start: 3 prices
The syntax is array[start..end] -- start is inclusive, end is exclusive (just like Python's slicing). array[4..] means "from index 4 to the end." array[0..3] means indices 0, 1, 2.
A slice is exactly 16 bytes on a 64-bit system -- 8 bytes for the pointer, 8 bytes for the length. Regardless of whether it points to 3 elements or 3 million. Passing a slice to a function is extremely cheap -- you're passing a pointer and a number, not copying data.
This is why Zig functions take slice parameters ([]const f64) rather than array parameters ([7]f64). A function that takes [7]f64 can only work with arrays of exactly 7 elements. A function that takes []const f64 works with any contiguous sequence of f64 values -- an array of 5, a slice of 100, a dynamically allocated buffer of 10,000. The slice abstracts away the length, making your functions general-purpose without any runtime cost.
If you've done any C programming, a slice is conceptually similar to passing a pointer and a length as two separate parameters -- void process(double* data, size_t len). Zig bundles them into one type, which means you can't accidentally mix up the length with some other integer, and the bounds checking catches out-of-range access. Same concept, safer implementation.
Mutable vs Const Slices
Slices come in two flavors: []const T (read-only) and []T (writable). This maps directly to the const vs var distinction we've been using since ep002:
const std = @import("std");
pub fn main() void {
var balances = [_]f64{ 10000, 5000, 2500, 15000 };
// Mutable slice -- can modify the underlying data
const writable: []f64 = &balances;
writable[0] = 12000;
// Const slice -- read only
const readonly: []const f64 = &balances;
// readonly[0] = 999; // COMPILE ERROR: cannot assign to constant
std.debug.print("Balance[0] = ${d:.0}\n", .{readonly[0]}); // 12000
}
Two things to notice:
&balances creates a slice from the whole array. The & operator takes the address -- you're creating a slice that points to the array's data. The slice doesn't copy anything; it references the original.
[]const f64 cannot be used to modify data. The const in the slice type means "I promise not to write through this view." You can still read every element, compute things, pass it to functions -- you just can't change anything. This is a contract, enforced by the compiler.
This two-level const system (const binding + const slice) is more precise than what most languages offer. In Python, there's no concept of a read-only view into a list -- if you have a reference, you can mutate. In Zig, you can give a function a []const f64 and be certain it won't modify your data, because the compiler won't let it. This matters when you're writing code where data integrity is critical.
Functions That Take Slices
Here's a function that computes the average of any number of values:
const std = @import("std");
fn average(values: []const f64) f64 {
if (values.len == 0) return 0;
var sum: f64 = 0;
for (values) |v| {
sum += v;
}
return sum / @as(f64, @floatFromInt(values.len));
}
fn findMax(values: []const f64) ?f64 {
if (values.len == 0) return null;
var max = values[0];
for (values[1..]) |v| {
if (v > max) max = v;
}
return max;
}
pub fn main() void {
const prices = [_]f64{ 64000, 65200, 63800, 67100, 68400 };
const few = [_]f64{ 100, 200 };
std.debug.print("Avg prices: ${d:.2}\n", .{average(&prices)});
std.debug.print("Avg few: ${d:.2}\n", .{average(&few)});
if (findMax(&prices)) |max| {
std.debug.print("Max price: ${d:.0}\n", .{max});
}
}
Output:
Avg prices: $65700.00
Avg few: $150.00
Max price: $68400
Both average and findMax take []const f64 -- they work on any contiguous block of f64 values regardless of how many there are. findMax returns ?f64 (an optional) because an empty input has no maximum -- same decision framework from ep004. We use values[1..] to skip the first element in findMax since we already set max = values[0].
This is where slices really shine. You write the function once, and it works with arrays of any size, sub-slices of larger arrays, even dynamically allocated buffers (when we get to allocators). One function, infinite applicability.
Strings Are Byte Slices
This is the conceptual leap that trips up programmers coming from languages with dedicated string types. In Zig, there is no String type. No str. No std::string. A string literal is just a []const u8 -- a read-only slice of bytes:
const std = @import("std");
pub fn main() void {
const greeting: []const u8 = "Hello, Zig!";
std.debug.print("Text: {s}\n", .{greeting});
std.debug.print("Length: {d} bytes\n", .{greeting.len});
std.debug.print("First byte: '{c}' (value: {d})\n", .{ greeting[0], greeting[0] });
std.debug.print("Last byte: '{c}'\n", .{greeting[greeting.len - 1]});
}
Output:
Text: Hello, Zig!
Length: 11 bytes
First byte: 'H' (value: 72)
Last byte: '!'
A string is bytes. greeting.len is the number of bytes, not "characters" in some abstract Unicode sense. greeting[0] is the first byte, which happens to be the ASCII value for 'H' (72). You can iterate over the bytes, slice them, pass them to functions, compare them -- using the exact same tools you use for any other slice.
This design is radically simple compared to most languages. Python has str (Unicode text) vs bytes (raw bytes), and converting between them requires encoding/decoding that can raise errors. Java has String (UTF-16 internally, immutable, GC-managed) plus StringBuilder, StringBuffer, char[], and a whole ecosystem of string-handling classes. C++ has std::string, std::string_view, const char*, std::wstring, and more.
Zig has []const u8. That's it. One type. Every function that works on byte slices works on strings. Every function that works on strings works on byte slices. There is no distinction because there is no difference.
Comparing Strings
You cannot compare strings with == in Zig:
const std = @import("std");
pub fn main() void {
const a: []const u8 = "BTC";
const b: []const u8 = "BTC";
const c: []const u8 = "ETH";
// This compares POINTERS, not content:
// if (a == b) -- might be true, might be false (depends on compiler)
// Use std.mem.eql to compare contents:
std.debug.print("a equals b: {}\n", .{std.mem.eql(u8, a, b)}); // true
std.debug.print("a equals c: {}\n", .{std.mem.eql(u8, a, c)}); // false
}
== on slices compares the pointer and length, not the content. Two slices could point to different copies of the same bytes and == would return false. std.mem.eql(u8, a, b) compares byte by byte -- it's the correct way to check if two strings have the same content.
This is one of those things where Zig's explicitness saves you from subtle bugs. In Python, == on strings compares content (which is usually what you want). In Java, == compares references (which is almost never what you want -- you need .equals()). In C, == on char* compares pointers (use strcmp). In Zig, the type system makes the distinction visible: == is defined for slices as pointer comparison. If you want content comparison, you call a function that does content comparison. Explicit. No surprises.
String Operations with std.mem
The std.mem namespace is your string manipulation toolkit. It works on any byte slice, which means it works on strings:
const std = @import("std");
pub fn main() void {
const log_entry = "2026-03-30 BUY BTC 0.5 @ 68000";
// Find a substring
if (std.mem.indexOf(u8, log_entry, "BUY")) |pos| {
std.debug.print("Found BUY at index {d}\n", .{pos});
}
// Check prefixes and suffixes
std.debug.print("Starts with 2026: {}\n", .{
std.mem.startsWith(u8, log_entry, "2026"),
});
std.debug.print("Ends with 68000: {}\n", .{
std.mem.endsWith(u8, log_entry, "68000"),
});
// Count occurrences
const data = "BTC,ETH,BTC,SOL,BTC,AVAX";
const btc_count = std.mem.count(u8, data, "BTC");
std.debug.print("BTC appears {d} times\n", .{btc_count});
// Split a string
std.debug.print("\nSplitting tickers:\n", .{});
var parts = std.mem.splitSequence(u8, "BTC:ETH:SOL:AVAX", ":");
while (parts.next()) |ticker| {
std.debug.print(" - {s}\n", .{ticker});
}
}
Output:
Found BUY at index 11
Starts with 2026: true
Ends with 68000: true
BTC appears 3 times
Splitting tickers:
- BTC
- ETH
- SOL
- AVAX
Notice that indexOf returns ?usize -- an optional! If the substring isn't found, you get null. The ep004 pattern of if (optional) |value| handles it cleanly. Every function in std.mem that does a search returns an optional. Consistent, predictable, no sentinel values like -1 to remember.
splitSequence returns an iterator -- you call .next() repeatedly to get each piece. The iterator itself doesn't allocate any memory. It just walks through the original slice and returns sub-slices pointing into the same data. Zero copies, zero allocations. If you've used Python's "a:b:c".split(":"), it's the same idea but without creating a list of new string objects.
Other useful std.mem functions you should know about: trim (remove characters from both ends), trimLeft/trimRight, replace (substitue bytes), containsAtLeast (check minimum occurrences). The standard library is well documented -- explore it.
Multi-Line Strings
When you need a string that spans multiple lines (SQL queries, formatted text, templates), Zig has a clean syntax for it:
const std = @import("std");
pub fn main() void {
const query =
\\SELECT ticker, price, volume
\\FROM daily_trades
\\WHERE date > '2026-03-01'
\\ AND volume > 1000
\\ORDER BY price DESC
;
std.debug.print("Query:\n{s}\n", .{query});
}
Output:
Query:
SELECT ticker, price, volume
FROM daily_trades
WHERE date > '2026-03-01'
AND volume > 1000
ORDER BY price DESC
Each line starts with \\ and the content follows immediately. No escape characters needed. No concatenation. No """triple quotes""" with indentation headaches. The \\ prefix is stripped, and each line gets a newline character automatically. The leading whitespace before \\ is for your code formatting -- it doesn't appear in the string.
This is excellent for embedding SQL, configuration templates, help text, or any multiline content. Python's triple-quoted strings are similar in spirit, but they include the indentation of your source code in the string itself (unless you use textwrap.dedent), which is awkward. Zig's approach is cleaner.
Compile-Time String Operations
Zig can manipulate strings at compile time using ++ (concatenation) and ** (repetition):
const std = @import("std");
const APP_NAME = "TradeWatch";
const VERSION = "1.0";
const HEADER = "=== " ++ APP_NAME ++ " v" ++ VERSION ++ " ===";
const SEPARATOR = "-" ** 30;
pub fn main() void {
std.debug.print("{s}\n", .{HEADER});
std.debug.print("{s}\n", .{SEPARATOR});
std.debug.print("System ready.\n", .{});
std.debug.print("{s}\n", .{SEPARATOR});
}
Output:
=== TradeWatch v1.0 ===
------------------------------
System ready.
------------------------------
Both ++ and ** only work at compile time. You cannot concatenate two runtime strings with ++ -- that would require memory allocation, and Zig never allocates behind your back. If you need to build strings at runtime, you'll use allocators and buffers (which we'll cover when we get to memory management). For now, compile-time string building is surprisingly useful for constants, headers, error messages, and formatted labels.
This is a fundamnetal design decision in Zig: things that need memory allocation are explicit about it. There is no hidden malloc when you write a + b on two strings like in Python or Java. If building a string at runtime requires allocating memory, the code must show it. We'll see exactly how that works when we get to allocators.
Sentinel-Terminated Slices
If you ever need to interact with C code from Zig (and one of Zig's selling points is zero-cost C interop), you'll encounter sentinel-terminated slices. C strings are null-terminated -- they end with a \0 byte. Zig represents this with the type [:0]const u8:
const std = @import("std");
pub fn main() void {
// String literals are actually sentinel-terminated
const greeting: [:0]const u8 = "Hello from Zig";
std.debug.print("Text: {s}\n", .{greeting});
std.debug.print("Length: {d}\n", .{greeting.len}); // 14 (not counting the \0)
std.debug.print("Sentinel byte: {d}\n", .{greeting[greeting.len]}); // 0
}
The :0 in [:0]const u8 means "this slice is terminated by a zero byte." The .len still gives you the length of the actual content (not counting the sentinel). But the sentinel is guaranteed to be there at slice[slice.len], which is normally out-of-bounds for a regular slice but valid for a sentinel-terminated one.
You probably won't need sentinel-terminated slices until you start calling C libraries from Zig. But when you do, the type system makes the C compatibility requirement explicit -- you can see in the function signature that it needs a null-terminated string, and the compiler won't let you pass a regular []const u8 where a [:0]const u8 is required.
Practical Example: Parsing Configuration Lines
Let me put arrays, slices, string operations, optionals, and error handling together into something that resembles real code. This function parses key-value configuration entries and builds a summary:
const std = @import("std");
const ParseError = error{MissingSeparator};
fn parseKeyValue(line: []const u8) ParseError!struct { key: []const u8, value: []const u8 } {
const sep_pos = std.mem.indexOf(u8, line, "=") orelse return ParseError.MissingSeparator;
return .{
.key = std.mem.trim(u8, line[0..sep_pos], " "),
.value = std.mem.trim(u8, line[sep_pos + 1 ..], " "),
};
}
fn findValue(entries: []const []const u8, key: []const u8) ?[]const u8 {
for (entries) |entry| {
const kv = parseKeyValue(entry) catch continue;
if (std.mem.eql(u8, kv.key, key)) return kv.value;
}
return null;
}
pub fn main() void {
const config = [_][]const u8{
"pair = BTC/USD",
"side = buy",
"quantity = 0.5",
"price = 68000",
"exchange = kraken",
"INVALID LINE WITHOUT SEPARATOR",
"timeout = 30",
};
std.debug.print("=== Order Config ===\n", .{});
for (config) |line| {
const kv = parseKeyValue(line) catch |err| {
std.debug.print(" SKIP: '{s}' ({any})\n", .{ line, err });
continue;
};
std.debug.print(" {s} -> '{s}'\n", .{ kv.key, kv.value });
}
std.debug.print("\n--- Lookups ---\n", .{});
const pair = findValue(&config, "pair") orelse "unknown";
const side = findValue(&config, "side") orelse "unknown";
const stoploss = findValue(&config, "stoploss") orelse "(not set)";
std.debug.print("Pair: {s}\n", .{pair});
std.debug.print("Side: {s}\n", .{side});
std.debug.print("Stop-loss: {s}\n", .{stoploss});
}
Output:
=== Order Config ===
pair -> 'BTC/USD'
side -> 'buy'
quantity -> '0.5'
price -> '68000'
exchange -> 'kraken'
SKIP: 'INVALID LINE WITHOUT SEPARATOR' (error.MissingSeparator)
timeout -> '30'
--- Lookups ---
Pair: BTC/USD
Side: buy
Stop-loss: (not set)
Look at how everything connects:
parseKeyValuereturns an error union -- missing separator is a real failure, not just "not found"findValuereturns an optional -- looking for a key that doesn't exist is normal, not an errorcatch continueinsidefindValueskips unparseable lines without stopping the searchorelse "unknown"provides defaults for missing keysstd.mem.trimcleans whitespace from keys and values -- it works on byte slicesstd.mem.indexOfreturns an optional (?usize) for the separator position- All the string data is
[]const u8-- no special types, no allocations
Zero memory allocations in this entire program. Every "string" we work with is a sub-slice of data that already exists. parseKeyValue returns slices that point into the original line parameter. findValue returns slices that point into the original entries data. Nothing is copied. The only memory used is the stack space for the arrays and slice descriptors.
This is something you'll notice more and more as you write Zig: programs that process data without allocating memory for intermediate results. Slices make this possible by letting you reference parts of existing data rather than copying them. When we get to allocators and heap allocation, you'll learn when and why you actually need to copy -- but the default in Zig is "don't copy unless you must."
A Note on UTF-8
Zig stores strings as raw bytes, which means they're UTF-8 by default (string literals are UTF-8 encoded). For ASCII text this is trivial -- one byte per character, len gives you the character count. For non-ASCII text (accented characters, emoji, CJK characters), a single "character" might be 2, 3, or 4 bytes, so len gives you the byte count, not the character count.
If you need to iterate over Unicode code points rather than bytes, std.unicode has tools for that. For most systems programming, server code, and data processing, raw byte operations are what you want. When you're processing log files, parsing config entries, comparing identifiers, or building protocol messages, bytes are the right abstraction. Unicode-aware text processing is a different domain with different requirements -- Zig supports it, but doesn't force it on you by default.
What We Didn't Cover (Yet)
We've been working entirely with stack-allocated arrays and slices that reference them. But what happens when you don't know the size at compile time? When data comes from a file, a network connection, or user input? You need dynamic memory allocation -- and in Zig, that means allocators.
Zig's allocator system is one of its most distinctive features. There's no global malloc. Every allocation goes through an explicit allocator that you pass around, which means you have complete control over where memory comes from, how it's tracked, and when it's freed. defer and errdefer from ep004 become critical here -- they're the tools that prevent memory leaks.
We've also been using anonymous structs (like the parseKeyValue return type) without formally learning about structs, enums, and tagged unions. Those are the building blocks for creating your own types -- and combined with slices and error handling, they'll let us build genuinely sophisticated data structures.
That's coming soon. For now, you have a solid foundation: arrays for fixed-size data, slices for flexible views into memory, and strings as byte slices that work with std.mem. These three concepts underpin everything else in Zig.
Exercises
Type these out. Compile them. Read the errors when you get them wrong. The muscle memory from fighting the compiler is how Zig's concepts go from "I understand this in theory" to "I can write this fluently."
Create an array of 10 daily prices, then slice it into the first 5 and last 5. Compute the average of each half using a function
fn average(values: []const f64) f64. Print both averages and which half was higher.Write a function
fn contains(haystack: []const u8, needle: u8) boolthat checks if a specific byte exists in a slice. Test it by checking whether several individual characters exist in a string.Split the string
"BTC=68000;ETH=3200;SOL=142"by";", then for each piece, split by"="and print the ticker and price separately. You'll need two nestedsplitSequencecalls.Create a
[_][]const u8array of 5 exchange names (e.g. "Binance", "Kraken", "Coinbase", etc.). Write a functionfn longest(names: []const []const u8) []const u8that returns the longest string. Handle the empty-input case by returning an optional.Build a compile-time table header using
++and**. Create aHEADERconstant that looks like"| Ticker | Price | Change |"and aDIVIDERconstant that's the right number of dashes. Print them with some sample data rows.Write a config parser that takes an array of
[]const u8lines in"key=value"format, finds a specific key, and returns the value ornull. Combinestd.mem.indexOf,std.mem.eql,std.mem.trim, and optionals. Handle lines that don't contain=by skipping them (usingcatch continueor an optional fromindexOf).
Exercises 1-2 test arrays and slices. Exercise 3 tests string splitting (you'll discover that iterator patterns compose nicely). Exercise 4 combines slices-of-slices with optionals. Exercise 5 is purely compile-time string work. Exercise 6 ties everything together -- arrays, slices, strings, std.mem, optionals, and error handling from ep004.
The types you've learned today -- [N]T, []T, []const T, []const u8 -- are not just data containers. They're the vocabulary that Zig uses to express ownership, mutability, and memory safety at the type level. An [N]T tells you "this data is right here, fixed size, owned by this scope." A []const T tells you "this is a read-only view into someone else's data." A []T tells you "this is a writable view -- whoever gave us this is letting us modify their data." Reading types in Zig is reading intent.
Once we add structs, enums, and allocators to this foundation, you'll be able to build real data structures -- hash maps, linked lists, trees, queues -- all with explicit ownership and zero hidden allocations. That's where Zig starts feeling less like "a systems language I'm learning" and more like "a language I can build anything in."