Learn Zig Series (#8) - Pointers and Memory Layout

What will I learn
- You will learn how single-item pointers (
*T) work in Zig; - the difference between
*T(mutable pointer) and*const T(read-only pointer); - passing data by reference to modify it inside functions;
- that slices are pointer-plus-length under the hood -- and what that means for your code;
- many-item pointers (
[*]T) for C interop scenarios; - struct memory layout: default,
extern, andpacked; @ptrCastand@alignCastfor reinterpreting memory;- optional pointers for null-safe linked structures;
- sentinel-terminated pointers and how Zig talks to C strings.
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
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout (this post)
Learn Zig Series (#8) - Pointers and Memory Layout
Welcome back! In episode #7 we jumped into the deep end of Zig's memory system -- stack vs heap, the GeneralPurposeAllocator with its built-in leak detection, ArrayList and StringHashMap for dynamic collections, arena allocators for batch cleanup, FixedBufferAllocator for zero-heap work, and the allocator parameter pattern that makes all your functions testable and flexible. We used defer and errdefer from ep004 to guarantee that every allocation gets freed, and we built a dynamic portfolio tracker that combined everything from episodes 2-7 into one cohesive program.
At the end of ep007 I mentioned that we'd soon learn what actually happens underneath those allocators -- how a slice is really a pointer plus a length, how create and destroy work at the pointer level, and what the *T syntax actually means. Well, this is that episode ;-)
Pointers. The word alone strikes fear into Python programmers who've never had to think about memory addresses. And fair enough -- in C, pointers are the number one source of bugs: null pointer dereferences, dangling pointers, wild pointer arithmetic, buffer overflows, use-after-free... the list goes on. But Zig's pointers are not C's pointers. There's no pointer arithmetic by default. No void pointers. No implicit conversions between pointer types. No silent null dereferences. The compiler catches most pointer mistakes at compile time, and the debug runtime catches the rest. If C pointers are a loaded gun with the safety off, Zig pointers are the same gun with a trigger lock, a chamber indicator, and a bright orange tip.
Let's dive right in.
Solutions to Episode 7 Exercises
Before we start on new material, here are the solutions to last episode's exercises. As always, if you actually typed these out and compiled them (and I really hope you did!), compare your solutions:
Exercise 1 -- ArrayList of tickers:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var tickers = std.ArrayList([]const u8).init(allocator);
defer tickers.deinit();
try tickers.append("BTC");
try tickers.append("ETH");
try tickers.append("SOL");
try tickers.append("AVAX");
try tickers.append("DOT");
for (tickers.items) |t| {
std.debug.print("{s} ", .{t});
}
std.debug.print("\n", .{});
}
GPA setup, ArrayList of []const u8, five appends with try, iterate over .items, and defer deinit() right after creation. Zero leaks -- the GPA would tell you otherwise.
Exercise 2 -- HashMap holdings:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var portfolio = std.StringHashMap(f64).init(allocator);
defer portfolio.deinit();
try portfolio.put("BTC", 0.5);
try portfolio.put("ETH", 5.0);
try portfolio.put("SOL", 50.0);
try portfolio.put("AVAX", 200.0);
if (portfolio.get("BTC")) |qty| {
std.debug.print("BTC: {d:.4}\n", .{qty});
}
if (portfolio.get("DOGE")) |qty| {
std.debug.print("DOGE: {d:.4}\n", .{qty});
} else {
std.debug.print("DOGE: not found\n", .{});
}
std.debug.print("Has SOL: {}\n", .{portfolio.contains("SOL")});
std.debug.print("Count before remove: {d}\n", .{portfolio.count()});
_ = portfolio.remove("ETH");
std.debug.print("Count after remove: {d}\n", .{portfolio.count()});
}
portfolio.get("BTC") returns ?f64 -- an optional. Missing key returns null. The portfolio.remove("ETH") call returns a boolean indicating whether the key was found. Same optional unwrapping pattern from ep004 and ep005.
Exercise 3 -- Allocator parameter pattern: write one processData function, call it with a GPA, an arena, and a FixedBufferAllocator. The function works identically with all three because it takes std.mem.Allocator -- the generic interface.
Exercise 4 -- Leak detection: remove one defer free, run in debug mode, read the GPA's leak report. It tells you the file, line, and size of the leaked allocation. Fix it, run again, clean exit. Understanding what a leak report looks like saves you hours later.
Exercise 5 -- Dynamic portfolio with removeTicker: search the ArrayList for a matching symbol, use orderedRemove(i) to pull it out while preserving order. Return true if found, false otherwise.
Now -- pointers!
What Is a Pointer?
A pointer is a variable that holds a memory address -- the location of some other value in memory. That's it. It doesn't hold the value itself; it holds the address where the value lives.
If you've only ever written Python, you've never needed to think about this. When you write x = 42 in Python, you don't know (and don't care) where 42 lives in memory. Python manages all of that for you. When you write my_list[3], Python follows internal pointers to find the fourth element, but you never see those pointers. They're hidden behind the curtain.
In Zig, pointers are explicit. You create them, you pass them around, you dereference them to get the value. And the type system tracks what kind of thing a pointer points to, so you can't accidentally treat a pointer-to-integer as a pointer-to-string. Let me show you:
const std = @import("std");
pub fn main() void {
var price: f64 = 68000.0;
const ptr: *f64 = &price;
std.debug.print("Value: ${d:.2}\n", .{ptr.*});
std.debug.print("Address: {*}\n", .{ptr});
ptr.* = 69500.0;
std.debug.print("New value: ${d:.2}\n", .{price});
}
Output:
Value: $68000.00
Address: f64@7ffe4a3c8e80
New value: $69500.00
Three operations, three symbols:
&pricetakes the address ofprice. This gives you a pointer to wherepricelives in memory. The&operator is the "address-of" operator.ptr.*dereferences the pointer -- it follows the address and reads (or writes) the value that lives there. The.*suffix is the "dereference" operator.*f64is the pointer type -- "pointer to f64". It tells the compiler that this variable holds an address where anf64value lives.
Notice what happened on the third line: we wrote ptr.* = 69500.0, which modifies the value at the address ptr points to. Then when we print price directly, it shows 69500.00. The pointer and the variable refer to the same memory. Modifying through the pointer modifies the original. This is fundamentaly different from Python, where x = 42; y = x; y = 99 does NOT change x -- Python rebinds the name y to a new object. In Zig, a pointer gives you direct access to the original memory.
Const vs Mutable Pointers
Remember the const vs var distinction from ep002? The same principle applies to pointers. A *f64 lets you read AND write. A *const f64 lets you read ONLY:
const std = @import("std");
pub fn main() void {
var price: f64 = 68000.0;
const frozen: f64 = 42000.0;
const mut_ptr: *f64 = &price;
const const_ptr: *const f64 = &frozen;
mut_ptr.* = 70000.0; // OK -- mutable pointer to var
std.debug.print("price: ${d:.2}\n", .{price}); // 70000.00
std.debug.print("frozen: ${d:.2}\n", .{const_ptr.*}); // 42000.00
// const_ptr.* = 50000.0; // COMPILE ERROR: cannot assign to const
}
The rules are strict:
- You can get a
*f64(mutable pointer) only from avarbinding - You can get a
*const f64(read-only pointer) from eithervarorconst - A
*f64implicitly converts to*const f64(you can always restrict access) - A
*const f64does NOT convert to*f64(you can't gain write access)
This is a compile-time guarantee. Not a convention, not a "please don't modify this" comment. The type system enforces it. If a function takes *const f64, it physically cannot modify the pointed-to value. You know this from the function signature alone, without reading the function body.
If you've used C, you'll recognise this as const f64* vs f64*. Same idea, different syntax. If you've used Rust, this is &T vs &mut T. Zig's version is visually clear: the const keyword appears in the pointer type, exactly where it matters.
Pointers and Functions -- Pass by Reference
This is where pointers become genuinly useful in practice. When you pass a value to a function normally, the function gets a copy. Modifications inside the function don't affect the original. But when you pass a pointer, the function can modify the original value:
const std = @import("std");
fn applyFee(balance: *f64, fee_pct: f64) void {
balance.* *= (1.0 - fee_pct / 100.0);
}
fn displayBalance(balance: *const f64) void {
std.debug.print("Balance: ${d:.2}\n", .{balance.*});
}
pub fn main() void {
var bal: f64 = 10000.0;
displayBalance(&bal); // $10000.00
applyFee(&bal, 0.1); // deducts 0.1%
displayBalance(&bal); // $9990.00
applyFee(&bal, 0.5); // deducts 0.5%
displayBalance(&bal); // $9940.05
}
Output:
Balance: $10000.00
Balance: $9990.00
Balance: $9940.05
Look at the function signatures. applyFee takes balance: *f64 -- it WILL modify the balance. displayBalance takes balance: *const f64 -- it will only READ the balance. From the signatures alone, you know exactly which functions can change your data and which can't. No need to read the function body. No surprises.
This is the same principle we used for struct methods in ep006: self: *Account for mutation, self: Account for read-only. Struct methods that take self: *Account are just syntactic sugar for functions that take a pointer to the struct. Same mechanism, different syntax.
For Python developers: this is related to the concept of "mutable vs immutable" objects. In Python, integers and strings are immutable -- you can't change them in place. Lists and dicts are mutable -- methods like .append() modify them in place. But this distinction is implicit in Python (you have to know which types are mutable). In Zig, the pointer type tells you explicitly. *f64 = mutable access. *const f64 = immutable access. Crystal clear.
Pointers to Structs
Pointers to structs work exactly the same way, and Zig has a convenience that makes them pleasant to use -- you don't need to explicitly dereference to access fields:
const std = @import("std");
const Account = struct {
name: []const u8,
balance: f64,
trade_count: u32 = 0,
fn deposit(self: *Account, amount: f64) void {
self.balance += amount;
self.trade_count += 1;
}
fn display(self: *const Account) void {
std.debug.print("{s}: ${d:.2} ({d} trades)\n", .{
self.name, self.balance, self.trade_count,
});
}
};
fn transferFunds(from: *Account, to: *Account, amount: f64) !void {
if (amount > from.balance) return error.InsufficientFunds;
from.balance -= amount;
to.balance += amount;
from.trade_count += 1;
to.trade_count += 1;
}
pub fn main() !void {
var alice = Account{ .name = "Alice", .balance = 50000.0 };
var bob = Account{ .name = "Bob", .balance = 10000.0 };
alice.display();
bob.display();
std.debug.print("\n--- Transfer $5000 ---\n\n", .{});
try transferFunds(&alice, &bob, 5000.0);
alice.display();
bob.display();
std.debug.print("\n--- Transfer $999999 ---\n\n", .{});
transferFunds(&alice, &bob, 999999.0) catch |err| {
std.debug.print("Transfer failed: {}\n", .{err});
};
}
Output:
Alice: $50000.00 (0 trades)
Bob: $10000.00 (0 trades)
--- Transfer $5000 ---
Alice: $45000.00 (1 trades)
Bob: $15000.00 (1 trades)
--- Transfer $999999 ---
Transfer failed: error.InsufficientFunds
Notice that self.balance works on *Account without writing self.*.balance. Zig automatically dereferences struct pointers for field access. This is one of those small quality-of-life features that make pointer-heavy code readable. In C, you'd write self->balance for pointer access and self.balance for value access. Zig uses . for both -- the compiler knows whether the left-hand side is a pointer or a value and does the right thing.
Also notice how transferFunds takes two *Account pointers and modifies both. The error handling with try and catch from ep004 integrates perfectly -- if the transfer fails, neither account is corrupted because the error check happens before any modification.
Slices ARE Pointer + Length
Here's something I teased at the end of ep007: a slice ([]T) is not some magical opaque type. Under the hood, it's literally just a struct with two fields:
struct { ptr: [*]T, len: usize }
A pointer to the first element, and a count of how many elements follow. That's it. 16 bytes on a 64-bit system (8 bytes for the pointer, 8 bytes for the length). This is why slices are cheap to pass around -- you're passing 16 bytes regardless of whether the slice references 5 elements or 5 million.
const std = @import("std");
pub fn main() void {
var prices = [_]f64{ 64000, 65200, 63800, 67100, 68400 };
const slice: []f64 = &prices;
std.debug.print("Slice length: {d}\n", .{slice.len});
std.debug.print("Slice ptr: {*}\n", .{slice.ptr});
// Modifying through the slice modifies the original array
slice[2] = 99999;
std.debug.print("prices[2] = {d:.0}\n", .{prices[2]}); // 99999
// Sub-slicing creates a new view, same underlying memory
const last_three = slice[2..];
std.debug.print("last_three[0] = {d:.0}\n", .{last_three[0]}); // 99999
std.debug.print("last_three.len = {d}\n", .{last_three.len}); // 3
}
Output:
Slice length: 5
Slice ptr: f64@7ffe4a3c8e60
prices[2] = 99999
last_three[0] = 99999
last_three.len = 3
A slice is a view, not a copy. Multiple slices can reference the same underlying memory. When you sub-slice with slice[2..], you get a new pointer (pointing 2 elements further into the array) and a new length (3 instead of 5), but the actual data is shared. This is exactly what we used in ep005 when we passed array slices to functions -- now you know the mechanism underneath.
The .ptr field gives you the raw multi-item pointer ([*]f64), and .len gives you the count. You generally don't need to access these directly -- slice indexing with slice[i] does bounds checking in debug mode and compiles to a direct memory access in release mode. But knowing what's underneath helps you reason about performance and aliasing.
Many-Item Pointers -- [*]T
A single-item pointer (*T) points to exactly one value. A slice ([]T) points to a range of values with a known length. But what about C-style pointers that point to "some number of elements, but I don't have a length"? That's [*]T:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// allocator.alloc returns []f64 (slice with known length)
const prices = try allocator.alloc(f64, 5);
defer allocator.free(prices);
prices[0] = 64000;
prices[1] = 65200;
prices[2] = 63800;
prices[3] = 67100;
prices[4] = 68400;
// Get the raw pointer -- no length information
const raw: [*]f64 = prices.ptr;
// You can index it, but there's NO bounds checking
std.debug.print("raw[0] = {d:.0}\n", .{raw[0]}); // 64000
std.debug.print("raw[4] = {d:.0}\n", .{raw[4]}); // 68400
// Convert back to a slice by providing the length
const restored: []f64 = raw[0..5];
var sum: f64 = 0;
for (restored) |p| sum += p;
std.debug.print("Average: {d:.0}\n", .{sum / 5.0});
}
Output:
raw[0] = 64000
raw[4] = 68400
Average: 65700
[*]T is Zig's equivalent of C's T* -- a pointer to an unknown number of contiguous elements. You can index it, but there's no bounds checking because the compiler doesn't know how many elements are there. This is inherently unsafe territory, which is why you'll mostly encounter [*]T at the boundary between Zig and C code.
The key conversion: raw[0..5] turns a [*]T back into a []T by providing the missing length. Once you have a slice, you get bounds checking back. In practice, you grab the [*]T from a C function, immediately convert it to a slice with the known length, and work with the slice from there.
If you're coming from Python, you'll never need [*]T unless you're calling C libraries. Stick with []T slices for everything else -- they're safe, they're ergonomic, and they carry their length with them.
Struct Memory Layout
When you define a struct, the compiler decides how to arrange the fields in memory. In Zig, you have three options, and the choice matters for C interop and binary data processing:
const std = @import("std");
const DefaultStruct = struct { a: u8, b: u32, c: u8 };
const ExternStruct = extern struct { a: u8, b: u32, c: u8 };
const PackedStruct = packed struct { a: u8, b: u32, c: u8 };
pub fn main() void {
std.debug.print("Default: {d} bytes\n", .{@sizeOf(DefaultStruct)});
std.debug.print("Extern: {d} bytes\n", .{@sizeOf(ExternStruct)});
std.debug.print("Packed: {d} bytes\n", .{@sizeOf(PackedStruct)});
}
Output:
Default: 8 bytes
Extern: 12 bytes
Packed: 6 bytes
Wait -- the fields are u8 + u32 + u8 = 6 bytes of actual data, but the three layouts produce three different sizes? Here's what's happening:
Default (struct): Zig is free to reorder fields and add padding for optimal alignment and performance. It might rearrange {a: u8, b: u32, c: u8} to {b: u32, a: u8, c: u8} internally, putting the 4-byte field first for natural alignment. The result is 8 bytes -- 4 for b, 1 for a, 1 for c, 2 bytes padding. This is the most performant layout, and it's what you should use for all Zig-only code.
Extern (extern struct): Fields are laid out exactly as a C compiler would, in the order you declare them. a (1 byte) + 3 bytes padding (to align b to a 4-byte boundary) + b (4 bytes) + c (1 byte) + 3 bytes padding (to pad the struct to a multiple of 4). Result: 12 bytes. Use extern struct when you need to match a C struct layout for interop -- reading C headers, calling C functions, mapping binary data that was written by C code.
Packed (packed struct): No padding at all. Fields are packed tightly, bit by bit. a (1 byte) + b (4 bytes) + c (1 byte) = 6 bytes exactly. Use packed struct for binary protocols, network packet headers, or hardware register maps where every byte counts and the layout must match an external specification.
When should you care about this? For pure Zig programs, use the default struct. The compiler optimizes the layout for you. Only reach for extern when interfacing with C code, and packed when parsing binary formats. Having said that, if you plan to build things like file parsers, network protocols, or embedded device drivers, understanding layout is essential -- and Zig makes it explicit rather than leaving you guessing.
@ptrCast -- Reinterpreting Memory
Sometimes you need to look at the same block of memory as a different type. Zig makes this explicit with @ptrCast:
const std = @import("std");
pub fn main() void {
var value: u32 = 0xDEADBEEF;
const bytes: *[4]u8 = @ptrCast(&value);
std.debug.print("Hex value: 0x{X:0>8}\n", .{value});
std.debug.print("Bytes: ", .{});
for (bytes) |b| {
std.debug.print("{X:0>2} ", .{b});
}
std.debug.print("\n", .{});
}
Output (on a little-endian system like x86):
Hex value: 0xDEADBEEF
Bytes: EF BE AD DE
We took a pointer to a u32 and cast it to a pointer to [4]u8 -- four bytes. Now we can inspect the individual bytes of the integer. On a little-endian system (which is most modern hardware), the least significant byte (0xEF) comes first in memory. This is why the output reads EF BE AD DE instead of DE AD BE EF.
@ptrCast is the Zig equivalent of C's (uint8_t*)&value. The difference: in C, casts like this are silent and easy to get wrong. In Zig, the @ptrCast built-in is visually distinct -- it screams "I am reinterpreting memory." It's a signal to anyone reading the code that something unusual is happening. Use it for C interop, binary data parsing, and low-level memory inspection. For normal Zig code, you shouldn't need it much.
Optional Pointers -- No Null Crashes
Here's where Zig's type system really shines. In C, any pointer can be null. If you forget to check, you get a segfault at runtime. In Zig, a regular pointer *T is never null. If you need a pointer that might not point to anything, you use ?*T -- an optional pointer:
const std = @import("std");
const PriceNode = struct {
price: f64,
timestamp: u64,
next: ?*PriceNode,
};
fn walkChain(head: *const PriceNode) void {
var current: ?*const PriceNode = head;
var count: usize = 0;
var total: f64 = 0;
while (current) |node| {
std.debug.print(" t={d}: ${d:.0}\n", .{ node.timestamp, node.price });
total += node.price;
count += 1;
current = node.next;
}
if (count > 0) {
std.debug.print(" Average: ${d:.2} ({d} data points)\n", .{
total / @as(f64, @floatFromInt(count)), count,
});
}
}
pub fn main() void {
var p5 = PriceNode{ .price = 68400, .timestamp = 5, .next = null };
var p4 = PriceNode{ .price = 67100, .timestamp = 4, .next = &p5 };
var p3 = PriceNode{ .price = 63800, .timestamp = 3, .next = &p4 };
var p2 = PriceNode{ .price = 65200, .timestamp = 2, .next = &p3 };
var p1 = PriceNode{ .price = 64000, .timestamp = 1, .next = &p2 };
std.debug.print("=== Price History ===\n", .{});
walkChain(&p1);
}
Output:
=== Price History ===
t=1: $64000
t=2: $65200
t=3: $63800
t=4: $67100
t=5: $68400
Average: $65700.00 (5 data points)
This is a linked list -- each PriceNode contains a price, a timestamp, and an optional pointer to the next node. The last node has next = null. The walkChain function traverses the chain using while (current) |node| -- the same optional unwrapping pattern from ep004, applied to pointers.
The beauty of ?*PriceNode is that the type system forces you to handle the null case. You can't just write current.next.price and hope for the best. You MUST unwrap the optional first. The while (current) |node| pattern does this safely: when current is null, the loop exits. No null pointer dereference. No segfault. No crash.
Compare this with C:
// C -- null dereference is YOUR problem
while (current != NULL) {
printf("price: %f\n", current->price);
current = current->next; // what if next is garbage, not NULL?
}
In C, nothing stops you from dereferencing a null or garbage pointer. The program just crashes (if you're lucky) or silently corrupts memory (if you're not). In Zig, the type system won't let you forget the check. ?*T means "this might be null" and *T means "this is NEVER null." Two different types. Two different contracts.
Sentinel-Terminated Pointers
Zig has one more pointer variant that's important for C interop: sentinel-terminated pointers. These are pointers where the data is followed by a known sentinel value (typically zero):
const std = @import("std");
pub fn main() void {
// String literals in Zig are null-terminated
const greeting: [*:0]const u8 = "Hello, Zig!";
// You can pass these directly to C functions expecting char*
var i: usize = 0;
while (greeting[i] != 0) : (i += 1) {
std.debug.print("{c}", .{greeting[i]});
}
std.debug.print("\n", .{});
// Convert to a slice for safe Zig-side use
const slice: [:0]const u8 = std.mem.span(greeting);
std.debug.print("Length: {d}\n", .{slice.len});
std.debug.print("Content: {s}\n", .{slice});
}
Output:
Hello, Zig!
Length: 11
Content: Hello, Zig!
[*:0]const u8 is a null-terminated pointer to bytes -- C's const char*. [:0]const u8 is a null-terminated slice -- it has both a length AND a sentinel. The std.mem.span function converts the former to the latter by scanning for the sentinel.
String literals in Zig are always null-terminated, which means you can pass them directly to C functions without conversion. This is why C interop in Zig is so seamless -- the most common data type (strings) just works across the boundary.
Putting It Together: A Price History With Pointer Operations
Let me show you how pointers, structs, slices, and the allocator pattern from ep007 combine in a practical example. This builds a price history tracker using heap-allocated linked nodes:
const std = @import("std");
const PricePoint = struct {
price: f64,
volume: f64,
next: ?*PricePoint,
fn display(self: *const PricePoint) void {
std.debug.print("${d:.2} (vol: {d:.0})", .{ self.price, self.volume });
}
};
fn addPrice(
allocator: std.mem.Allocator,
head: ?*PricePoint,
price: f64,
volume: f64,
) !*PricePoint {
const node = try allocator.create(PricePoint);
node.* = .{
.price = price,
.volume = volume,
.next = head,
};
return node;
}
fn freeChain(allocator: std.mem.Allocator, head: ?*PricePoint) void {
var current = head;
while (current) |node| {
const next = node.next;
allocator.destroy(node);
current = next;
}
}
fn computeStats(head: *const PricePoint) struct { count: usize, avg: f64, high: f64, low: f64 } {
var count: usize = 1;
var total: f64 = head.price;
var high: f64 = head.price;
var low: f64 = head.price;
var current: ?*const PricePoint = head.next;
while (current) |node| {
total += node.price;
if (node.price > high) high = node.price;
if (node.price < low) low = node.price;
count += 1;
current = node.next;
}
return .{
.count = count,
.avg = total / @as(f64, @floatFromInt(count)),
.high = high,
.low = low,
};
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Build the chain (newest first)
var head: ?*PricePoint = null;
head = try addPrice(allocator, head, 64000, 1200);
head = try addPrice(allocator, head, 65200, 980);
head = try addPrice(allocator, head, 63800, 1450);
head = try addPrice(allocator, head, 67100, 890);
head = try addPrice(allocator, head, 68400, 1100);
defer freeChain(allocator, head);
// Walk and display
std.debug.print("=== Price History ===\n", .{});
var current: ?*const PricePoint = head;
var idx: usize = 0;
while (current) |node| {
std.debug.print(" [{d}] ", .{idx});
node.display();
std.debug.print("\n", .{});
current = node.next;
idx += 1;
}
// Statistics
if (head) |h| {
const stats = computeStats(h);
std.debug.print("\n--- Stats ---\n", .{});
std.debug.print(" Count: {d}\n", .{stats.count});
std.debug.print(" Average: ${d:.2}\n", .{stats.avg});
std.debug.print(" High: ${d:.2}\n", .{stats.high});
std.debug.print(" Low: ${d:.2}\n", .{stats.low});
std.debug.print(" Range: ${d:.2}\n", .{stats.high - stats.low});
}
}
Output:
=== Price History ===
[0] $68400.00 (vol: 1100)
[1] $67100.00 (vol: 890)
[2] $63800.00 (vol: 1450)
[3] $65200.00 (vol: 980)
[4] $64000.00 (vol: 1200)
--- Stats ---
Count: 5
Average: $65700.00
High: $68400.00
Low: $63800.00
Range: $4600.00
Let me walk through the important bits.
allocator.create(PricePoint) allocates exactly one PricePoint on the heap and returns *PricePoint. This is the single-item allocation we learned about in ep007 -- create for single values, alloc for slices.
node.* = .{ ... } writes all fields at once through the pointer. The .* dereferences the pointer to access the PricePoint it points to, and the .{ ... } is anonymous struct initialization (same pattern from ep006).
freeChain walks the linked list and destroys each node. Notice we save node.next BEFORE calling allocator.destroy(node) -- after destruction, the node's memory is invalid, so accessing .next would be use-after-free. This is a classic pattern in manual memory management: grab what you need from the node, then free the node.
computeStats returns an anonymous struct with four fields -- the same pattern from ep006. It takes *const PricePoint (read-only pointer) because it only computes statistics without modifying anything.
The whole program has zero memory leaks. The GPA at the top catches any leaks in debug mode. The defer freeChain(allocator, head) guarantees cleanup even if something goes wrong later in the function.
Compare this to the ArrayList-based portfolio tracker from ep007. That version used ArrayList(Asset) -- a dynamic array managed by the standard library. This version uses a hand-built linked list with raw pointer operations. Both are valid approaches. ArrayList is simpler and faster for most use cases (cache-friendly, no pointer overhead per element). Linked lists shine when you need constant-time insertion/deletion at arbitrary positions. Knowing how pointers work lets you build ANY data structure you need -- and also helps you understand what data structures like ArrayList are doing internally.
When Pointers, When Values, When Slices?
A quick cheat sheet for choosing the right parameter type:
| You want to... | Use | Example |
|---|---|---|
| Read a single value | T (by value) | fn pnl(self: Position) f64 |
| Read a single value (large struct) | *const T | fn display(self: *const BigStruct) void |
| Modify a single value | *T | fn deposit(self: *Account, amount: f64) void |
| Read a sequence | []const T | fn average(values: []const f64) f64 |
| Modify a sequence | []T | fn reverse(values: []f64) void |
| C interop (string) | [*:0]const u8 | fn cFunction(str: [*:0]const u8) void |
| C interop (array) | [*]T | fn cArray(ptr: [*]f64, len: usize) void |
For small types (f64, u32, bool, small structs up to ~64 bytes), pass by value. Copying is cheap and avoids pointer indirection. For large structs, pass a pointer to avoid copying. The compiler is also smart enough to optimize pass-by-value into pass-by-pointer internally when it makes sense, but being explicit doesn't hurt.
Slices are almost always the right choice for sequences. They carry their length with them, they support bounds checking in debug mode, and they compose cleanly with Zig's for loops and standard library functions. Only drop down to [*]T when you're interfacing with C.
Exercises
You know the drill by now. Type these out. Compile them. Read the compiler errors when you get something wrong. The GPA's leak detector is your friend -- let it catch your mistakes.
Write a function
fn swap(a: *f64, b: *f64) voidthat swaps two values using a temporary variable. Call it from main to swap two prices and verify the swap worked by printing before and after.Build a linked list of 5
PriceNodevalues (on the stack, like the first example). Walk the chain and compute the average price. Then modify thewalkChainfunction to also find the highest and lowest prices.Compare
@sizeOffor a struct with fields{ a: u8, b: u16, c: u32, d: u8 }as a defaultstruct, anextern struct, and apacked struct. Predict the sizes before running. (Hint: think about alignment rules -- each field wants to be aligned to its own size.)Write a function
fn reverseSlice(values: []f64) voidthat reverses a slice in place. Use pointers or direct indexing -- your choice. Test it with a price array and print before/after.Build the price history tracker from the walkthrough yourself (with heap-allocated nodes via the GPA), but add a
removeFirstfunction that removes the head node and returns the new head. Add 5 prices, remove 2, then walk and display the remaining chain. Make sure you have zero leaks.Write a function that takes
*const [4]u8and prints each byte in hexadecimal. Call it with@ptrCaston a*u32to inspect the byte layout of0xCAFEBABE. Verify you understand little-endian byte ordering by predicting the output before running.
Exercises 1-2 test basic pointer operations and linked structures. Exercise 3 is about understanding memory layout -- predict first, then verify. Exercise 4 combines slices with in-place mutation. Exercise 5 integrates allocators from ep007 with the pointer patterns from this episode. Exercise 6 is about @ptrCast and byte-level memory inspection.
Pointers are the bridge between "high-level Zig" (structs, slices, allocators, error handling) and "the actual machine" (memory addresses, byte layouts, cache lines). Everything we've built in this series so far -- from the simple functions in ep003 to the allocator-powered data structures in ep007 -- ultimately runs on pointers underneath. Understanding them gives you the ability to reason about what your program is actually doing at the hardware level, and that's what separates systems programmers from application programmers.
And the best part: Zig gives you this power without the chaos. Const pointers, optional pointers, explicit casts, bounds-checked slices, and a leak-detecting allocator -- all working together to keep you safe while you operate at the lowest level. It's systems programming with guardrails, and those guardrails are the compiler itself.