Learn Zig Series (#23) - Iterators and Lazy Evaluation

Learn Zig Series (#23) - Iterators and Lazy Evaluation

zig.png

What will I learn

  • You will learn the Zig iterator convention: next() returning an optional ?T;
  • You will learn building custom iterators for your own data structures;
  • You will learn the while (iter.next()) |item| consumption pattern;
  • You will learn lazy evaluation: computing values only when they're actually needed;
  • You will learn chaining iterator adapters: map, filter, take, skip;
  • You will learn memory-efficient processing of large sequences with iterators;
  • You will learn comparing Zig's approach to Python generators and Rust iterators.

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

  • Intermediate

Curriculum (of the Learn Zig Series):

Learn Zig Series (#23) - Iterators and Lazy Evaluation

Welcome back! Last time we went on a tour of hash maps and data structures -- AutoHashMap, StringHashMap, PriorityQueue, SegmentedList, the whole collection. In the exercises I asked you to build a contact book, an LRU cache, and a frequency-sorted word tool. If you did those, you've already seen the .iterator() pattern on hash maps -- calling .next() in a while loop until it returns null. Today we zoom in on exactly that pattern: iterators, and the lazy evaluation philosophy behind them.

In Python, you'd reach for a generator. In Rust, the Iterator trait and .map().filter().collect() chains. Zig doesn't have a formal trait system and it doesn't have generators -- but it has something arguably more transparent: a simple convention. Any struct with a pub fn next(*Self) ?T method is an iterator. No interface to implement, no trait to derive, no runtime overhead. Just a method that returns an optional. When it returns null, you're done. That's it.

This simplicity is deceptive though. Once you internalize the pattern, you can build pipelines of lazy transformations that process data without ever materializing intermediate collections. And because Zig gives you full control over memory, your iterators can operate with zero allocations. We'll build all of this from scratch today. Here we go!

Solutions to Episode 22 Exercises

Exercise 1 -- Contact book with StringHashMap:

const std = @import("std");

const Contact = struct {
    name: []const u8,
    email: []const u8,
    phone: []const u8,
};

const ContactBook = struct {
    map: std.StringHashMap(Contact),
    allocator: std.mem.Allocator,

    fn init(allocator: std.mem.Allocator) ContactBook {
        return .{
            .map = std.StringHashMap(Contact).init(allocator),
            .allocator = allocator,
        };
    }

    fn deinit(self: *ContactBook) void {
        self.map.deinit();
    }

    fn add(self: *ContactBook, name: []const u8, email: []const u8, phone: []const u8) !void {
        try self.map.put(name, .{
            .name = name,
            .email = email,
            .phone = phone,
        });
    }

    fn find(self: *ContactBook, name: []const u8) ?Contact {
        return self.map.get(name);
    }

    fn remove(self: *ContactBook, name: []const u8) bool {
        return self.map.remove(name);
    }

    fn listAll(self: *ContactBook) !void {
        // Collect keys into ArrayList, sort, then print
        var keys = std.ArrayList([]const u8).init(self.allocator);
        defer keys.deinit();

        var it = self.map.iterator();
        while (it.next()) |entry| {
            try keys.append(entry.key_ptr.*);
        }

        std.mem.sort([]const u8, keys.items, {}, struct {
            fn cmp(_: void, a: []const u8, b: []const u8) bool {
                return std.mem.order(u8, a, b) == .lt;
            }
        }.cmp);

        for (keys.items) |name| {
            const c = self.map.get(name).?;
            std.debug.print("{s}: {s} | {s}\n", .{ c.name, c.email, c.phone });
        }
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var book = ContactBook.init(allocator);
    defer book.deinit();

    try book.add("Charlie", "[email protected]", "555-0003");
    try book.add("Alice", "[email protected]", "555-0001");
    try book.add("Bob", "[email protected]", "555-0002");

    if (book.find("Alice")) |c| {
        std.debug.print("Found: {s} at {s}\n", .{ c.name, c.email });
    }

    _ = book.remove("Bob");
    std.debug.print("\nAll contacts (sorted):\n", .{});
    try book.listAll();
}

The key insight is that hash maps don't maintain insertion order, so to list contacts alphabetically you need to extract the keys, sort them, then look up each one. We used std.mem.sort with a custom comparator that delegates to std.mem.order for lexicographic byte comparison.

Exercise 2 -- Simple LRU cache:

const std = @import("std");

fn LruCache(comptime V: type, comptime capacity: usize) type {
    return struct {
        const Self = @This();

        map: std.StringHashMap(V),
        order: [capacity]?[]const u8,
        oldest: usize,
        count: usize,
        allocator: std.mem.Allocator,

        fn init(allocator: std.mem.Allocator) Self {
            return .{
                .map = std.StringHashMap(V).init(allocator),
                .order = [_]?[]const u8{null} ** capacity,
                .oldest = 0,
                .count = 0,
                .allocator = allocator,
            };
        }

        fn deinit(self: *Self) void {
            self.map.deinit();
        }

        fn get(self: *Self, key: []const u8) ?V {
            return self.map.get(key);
        }

        fn put(self: *Self, key: []const u8, value: V) !void {
            if (self.map.contains(key)) {
                try self.map.put(key, value);
                return;
            }

            if (self.count >= capacity) {
                // Evict oldest
                if (self.order[self.oldest]) |old_key| {
                    _ = self.map.remove(old_key);
                }
                self.order[self.oldest] = key;
                self.oldest = (self.oldest + 1) % capacity;
            } else {
                self.order[self.count] = key;
                self.count += 1;
            }
            try self.map.put(key, value);
        }
    };
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var cache = LruCache(i32, 3).init(allocator);
    defer cache.deinit();

    try cache.put("a", 1);
    try cache.put("b", 2);
    try cache.put("c", 3);
    std.debug.print("a={?}, b={?}, c={?}\n", .{
        cache.get("a"), cache.get("b"), cache.get("c"),
    });

    // Inserting "d" evicts "a" (oldest)
    try cache.put("d", 4);
    std.debug.print("After adding d: a={?}, d={?}\n", .{
        cache.get("a"), cache.get("d"),
    });
}

This is a simplified LRU -- it tracks insertion order in a circular buffer rather than true access recency. A full LRU would need a doubly-linked list to move accessed entries to the front, but the basic eviction concept is the same: when the cache is full, drop the entry that's been sitting around longest.

Exercise 3 -- Frequency-sorted unique words with HashMap + PriorityQueue:

const std = @import("std");

const WordCount = struct {
    word: []const u8,
    count: u32,
};

fn countCompare(_: void, a: WordCount, b: WordCount) std.math.Order {
    // Max-heap: higher count comes out first
    return std.math.order(b.count, a.count);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const text = "the quick brown fox jumps over the lazy dog the fox the the dog";

    // Count frequencies
    var counts = std.StringHashMap(u32).init(allocator);
    defer counts.deinit();

    var words = std.mem.splitScalar(u8, text, ' ');
    while (words.next()) |word| {
        if (word.len == 0) continue;
        const result = try counts.getOrPut(word);
        if (result.found_existing) {
            result.value_ptr.* += 1;
        } else {
            result.value_ptr.* = 1;
        }
    }

    // Load into priority queue (max-heap by count)
    var pq = std.PriorityQueue(WordCount, void, countCompare).init(allocator, {});
    defer pq.deinit();

    var it = counts.iterator();
    while (it.next()) |entry| {
        try pq.add(.{ .word = entry.key_ptr.*, .count = entry.value_ptr.* });
    }

    // Drain in frequency order
    std.debug.print("Words by frequency:\n", .{});
    while (pq.removeOrNull()) |wc| {
        std.debug.print("  {s}: {d}\n", .{ wc.word, wc.count });
    }
}

HashMap for O(1) counting, PriorityQueue for ordered extraction without needing a full sort. The max-heap trick is to reverse the comparison -- we compare b.count against a.count so higher counts come out first. Three data structures, each doing exactly what it's best at.

Right, on to iterators! ;-)

The iterator convention: next() ?T

Zig's iterator pattern is dead simple. An iterator is any struct that has a next method returning an optional. When the sequence is exhausted, it returns null. No interfaces, no vtables, no trait bounds. Just a convention that everyone follows because it works.

You've already seen this pattern in the standard library. std.mem.splitScalar returns an iterator. std.mem.tokenizeScalar returns an iterator. Hash map .iterator() returns one. std.mem.window returns one. They all follow the same shape:

const std = @import("std");

pub fn main() !void {
    const text = "one,two,three,four";

    // splitScalar returns a SplitIterator -- it has a next() method
    var it = std.mem.splitScalar(u8, text, ',');

    // Consume with while + optional capture
    while (it.next()) |segment| {
        std.debug.print("'{s}'\n", .{segment});
    }
    // Output: 'one', 'two', 'three', 'four'
}

The while (it.next()) |segment| pattern is the idiomatic way to drain an iterator. The while loop calls next() on each iteration. If the return is non-null, it unwraps the optional into segment and runs the body. If null, the loop ends. This is one of those Zig patterns that feels completely natural once you've seen it a few times -- optionals and while loops compose beautifully.

Notice something important: the iterator is stateful. Each call to next() advances its internal position. After the loop finishes, calling next() again just returns null forever. If you want to iterate again, you need a fresh iterator (or a reset() method if you built one into your struct). This is different from, say, Python where you can call iter() on a list multiple times to get fresh iterators. In Zig, the iterator IS the state.

Building your own iterator

The power of the convention is that building your own iterator is trivial. Any struct with a next method that returns an optional works. Let's start with the simplest useful example -- a range iterator:

const std = @import("std");

const Range = struct {
    current: i32,
    end: i32,
    step: i32,

    fn init(start: i32, end: i32) Range {
        return .{ .current = start, .end = end, .step = 1 };
    }

    fn initWithStep(start: i32, end: i32, step: i32) Range {
        return .{ .current = start, .end = end, .step = step };
    }

    fn next(self: *Range) ?i32 {
        if ((self.step > 0 and self.current >= self.end) or
            (self.step < 0 and self.current <= self.end))
        {
            return null;
        }
        const val = self.current;
        self.current += self.step;
        return val;
    }
};

pub fn main() !void {
    // Count from 0 to 9
    var r = Range.init(0, 10);
    while (r.next()) |val| {
        std.debug.print("{d} ", .{val});
    }
    std.debug.print("\n", .{});

    // Count by 3s
    var r2 = Range.initWithStep(0, 20, 3);
    while (r2.next()) |val| {
        std.debug.print("{d} ", .{val});
    }
    std.debug.print("\n", .{});

    // Count down
    var r3 = Range.initWithStep(10, 0, -1);
    while (r3.next()) |val| {
        std.debug.print("{d} ", .{val});
    }
    std.debug.print("\n", .{});
}

Python has range(0, 10) built in. Zig doesn't -- but building your own takes about 20 lines. And because it's just a struct, you can see exactly what it does, how much memory it uses (12 bytes: three i32 fields), and when it allocates (never).

Let's do something a bit more interesting -- an iterator over a linked list:

const std = @import("std");

fn LinkedList(comptime T: type) type {
    return struct {
        const Self = @This();

        const Node = struct {
            data: T,
            next_node: ?*Node,
        };

        head: ?*Node,
        allocator: std.mem.Allocator,

        fn init(allocator: std.mem.Allocator) Self {
            return .{ .head = null, .allocator = allocator };
        }

        fn deinit(self: *Self) void {
            var current = self.head;
            while (current) |node| {
                const next_node = node.next_node;
                self.allocator.destroy(node);
                current = next_node;
            }
        }

        fn prepend(self: *Self, value: T) !void {
            const node = try self.allocator.create(Node);
            node.* = .{ .data = value, .next_node = self.head };
            self.head = node;
        }

        // The iterator type -- just holds a pointer into the list
        const Iterator = struct {
            current: ?*Node,

            fn next(self: *Iterator) ?T {
                const node = self.current orelse return null;
                self.current = node.next_node;
                return node.data;
            }
        };

        fn iterator(self: *const Self) Iterator {
            return .{ .current = self.head };
        }
    };
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var list = LinkedList(i32).init(allocator);
    defer list.deinit();

    try list.prepend(30);
    try list.prepend(20);
    try list.prepend(10);

    var it = list.iterator();
    while (it.next()) |value| {
        std.debug.print("{d} -> ", .{value});
    }
    std.debug.print("null\n", .{});
    // Output: 10 -> 20 -> 30 -> null
}

The Iterator is a nested struct inside LinkedList. It has exactly one field: a pointer to the current node (or null). Each next() call grabs the current node's data, advances the pointer to the next node, and returns the data. When the pointer is null, we're done. Zero allocations during iteration -- the iterator borrows pointers into the existing list.

This is a pattern you'll see everywhere in Zig: the data structure owns the data and the iterator borrows a view into it. The iterator is lightweight (just a pointer or index), and creating one is free. Having said that, you MUST NOT mutate the underlying data structure while iterating -- same rule as with hash maps from episode 22.

Lazy evaluation: compute only what you need

Now here's where iterators become really powerful. An iterator doesn't need to have a backing data structure at all. It can compute values on demand -- producing them one at a time, only when next() is called. This is lazy evaluation: no work happens until you ask for the result.

const std = @import("std");

// Fibonacci iterator -- infinite sequence, zero memory
const Fibonacci = struct {
    a: u64,
    b: u64,

    fn init() Fibonacci {
        return .{ .a = 0, .b = 1 };
    }

    fn next(self: *Fibonacci) ?u64 {
        const val = self.a;
        const new_b = self.a +% self.b; // wrapping add to avoid overflow
        self.a = self.b;
        self.b = new_b;
        return val;
    }
};

// Powers of 2 -- also infinite
const PowersOfTwo = struct {
    current: u64,

    fn init() PowersOfTwo {
        return .{ .current = 1 };
    }

    fn next(self: *PowersOfTwo) ?u64 {
        if (self.current == 0) return null; // overflow guard
        const val = self.current;
        self.current *%= 2;
        if (self.current == 0) self.current = 0; // signal exhaustion
        return val;
    }
};

pub fn main() !void {
    // First 15 Fibonacci numbers
    var fib = Fibonacci.init();
    std.debug.print("Fibonacci: ", .{});
    for (0..15) |_| {
        if (fib.next()) |val| {
            std.debug.print("{d} ", .{val});
        }
    }
    std.debug.print("\n", .{});

    // Powers of 2
    var pow = PowersOfTwo.init();
    std.debug.print("Powers of 2: ", .{});
    for (0..10) |_| {
        if (pow.next()) |val| {
            std.debug.print("{d} ", .{val});
        }
    }
    std.debug.print("\n", .{});
}

The Fibonacci iterator uses exactly 16 bytes of state (two u64 fields) and produces an effectively infinite sequence. Compare this to the eager approach of pre-computing a large array of Fibonacci numbers -- that would allocate memory proportional to how many numbers you want, even if you end up using only a few. The lazy iterator computes each number the moment you ask for it and stores nothing except the two values it needs for the next computation.

This "compute on demand" approach is exactly what Python generators do with yield. The difference is that Python generators are built on coroutine machinery (stack frames, suspension points, the yield keyword) while Zig iterators are just plain structs with a method. There's no magic, no special compiler support, no hidden allocations. You can inspect the struct in a debugger and see every byte of state ;-)

Iterator adapters: map, filter, take, skip

The real fun starts when you build iterator adapters -- structs that wrap another iterator and transform its output. This is how you build processing pipelines without allocating intermediate collections. Each adapter is itself an iterator, so they chain together naturally.

Let's build the most common ones:

const std = @import("std");

// Map adapter: transforms each element with a function
fn MapIterator(comptime Inner: type, comptime T: type, comptime R: type) type {
    return struct {
        const Self = @This();
        inner: Inner,
        map_fn: *const fn (T) R,

        fn next(self: *Self) ?R {
            const val = self.inner.next() orelse return null;
            return self.map_fn(val);
        }
    };
}

// Filter adapter: skips elements that don't pass the predicate
fn FilterIterator(comptime Inner: type, comptime T: type) type {
    return struct {
        const Self = @This();
        inner: Inner,
        pred: *const fn (T) bool,

        fn next(self: *Self) ?T {
            while (self.inner.next()) |val| {
                if (self.pred(val)) return val;
            }
            return null;
        }
    };
}

// Take adapter: yields at most N elements, then stops
fn TakeIterator(comptime Inner: type, comptime T: type) type {
    return struct {
        const Self = @This();
        inner: Inner,
        remaining: usize,

        fn next(self: *Self) ?T {
            if (self.remaining == 0) return null;
            self.remaining -= 1;
            return self.inner.next();
        }
    };
}

// Skip adapter: skips the first N elements
fn SkipIterator(comptime Inner: type, comptime T: type) type {
    return struct {
        const Self = @This();
        inner: Inner,
        to_skip: usize,

        fn next(self: *Self) ?T {
            while (self.to_skip > 0) {
                _ = self.inner.next() orelse return null;
                self.to_skip -= 1;
            }
            return self.inner.next();
        }
    };
}

// Helper functions to create adapters without typing the generic params
fn map(comptime T: type, comptime R: type, iter: anytype, f: *const fn (T) R) MapIterator(@TypeOf(iter), T, R) {
    return .{ .inner = iter, .map_fn = f };
}

fn filter(comptime T: type, iter: anytype, pred: *const fn (T) bool) FilterIterator(@TypeOf(iter), T) {
    return .{ .inner = iter, .pred = pred };
}

fn take(comptime T: type, iter: anytype, n: usize) TakeIterator(@TypeOf(iter), T) {
    return .{ .inner = iter, .remaining = n };
}

fn skip(comptime T: type, iter: anytype, n: usize) SkipIterator(@TypeOf(iter), T) {
    return .{ .inner = iter, .to_skip = n };
}

// Our Range from before
const Range = struct {
    current: i32,
    end: i32,

    fn init(start: i32, e: i32) Range {
        return .{ .current = start, .end = e };
    }

    fn next(self: *Range) ?i32 {
        if (self.current >= self.end) return null;
        const val = self.current;
        self.current += 1;
        return val;
    }
};

fn isEven(x: i32) bool {
    return @mod(x, 2) == 0;
}

fn square(x: i32) i32 {
    return x * x;
}

pub fn main() !void {
    // Pipeline: range(0..20) -> filter(even) -> map(square) -> take(5)
    var pipeline = take(
        i32,
        map(
            i32,
            i32,
            filter(
                i32,
                Range.init(0, 20),
                &isEven,
            ),
            &square,
        ),
        5,
    );

    std.debug.print("Even squares: ", .{});
    while (pipeline.next()) |val| {
        std.debug.print("{d} ", .{val});
    }
    std.debug.print("\n", .{});
    // Output: Even squares: 0 4 16 36 64
}

Look at what happened there. We built a processing pipeline: generate numbers 0-19, keep only the even ones, square each one, take the first 5 results. At NO point did we allocate an intermediate array. No ArrayList for the filtered results, no ArrayList for the mapped results. Each adapter processes one element at a time, passing it to the next adapter in the chain. The entire pipeline uses the stack space of the nested structs -- which is small and fixed.

The pipeline reads "inside out" which I'll admit is not the prettiest thing. In Rust you'd write (0..20).filter(|x| x % 2 == 0).map(|x| x * x).take(5) with method chaining. In Zig we're using free functions and nesting. It's more verbose, but the mechanism is completey transparent. You can step through it in a debugger and see exactly which next() calls which other next(). No hidden trait dispatch, no closure captures, no iterator fusion compiler passes.

Memory-efficient processing with iterators

One of the biggest practical advantages of lazy iterators is processing data that's too large to fit in memory. Instead of loading everything into an array, transforming it, and then consuming the result, you process one element at a time and keep only what you need.

Here's an example -- imagine processing log lines from a large file, counting only the lines that match a certain pattern:

const std = @import("std");

// Iterator that yields lines from a buffer (simulating file reading)
const LineIterator = struct {
    data: []const u8,
    pos: usize,

    fn init(data: []const u8) LineIterator {
        return .{ .data = data, .pos = 0 };
    }

    fn next(self: *LineIterator) ?[]const u8 {
        if (self.pos >= self.data.len) return null;

        const start = self.pos;
        while (self.pos < self.data.len and self.data[self.pos] != '\n') {
            self.pos += 1;
        }
        const line = self.data[start..self.pos];

        // Skip the newline
        if (self.pos < self.data.len) self.pos += 1;

        return line;
    }
};

fn contains(haystack: []const u8, needle: []const u8) bool {
    if (needle.len > haystack.len) return false;
    var i: usize = 0;
    while (i + needle.len <= haystack.len) : (i += 1) {
        if (std.mem.eql(u8, haystack[i..][0..needle.len], needle)) return true;
    }
    return false;
}

pub fn main() !void {
    // Simulate a log file
    const log_data =
        \\2026-04-19 10:01:22 INFO  Server started on port 8080
        \\2026-04-19 10:01:23 DEBUG Loading configuration
        \\2026-04-19 10:01:25 ERROR Failed to connect to database
        \\2026-04-19 10:02:01 INFO  Retrying database connection
        \\2026-04-19 10:02:02 ERROR Connection timeout after 30s
        \\2026-04-19 10:02:05 INFO  Database connected successfully
        \\2026-04-19 10:03:00 WARN  High memory usage: 87%
        \\2026-04-19 10:03:30 ERROR Out of memory in worker thread 4
        \\2026-04-19 10:03:31 INFO  Worker thread 4 restarted
    ;

    // Count and display error lines -- no intermediate allocations
    var lines = LineIterator.init(log_data);
    var error_count: u32 = 0;

    std.debug.print("Error lines:\n", .{});
    while (lines.next()) |line| {
        if (contains(line, "ERROR")) {
            std.debug.print("  {s}\n", .{line});
            error_count += 1;
        }
    }
    std.debug.print("Total errors: {d}\n", .{error_count});
}

The LineIterator doesn't copy any data. It returns slices that point into the original buffer. Each next() call just moves the position forward to the next newline, returning a view of the bytes in between. If your log file is 10 gigabytes, this approach uses constant memory (the iterator struct is two fields: a slice header and an index). The eager approach would require loading the entire file or at least large chunks into memory.

This is fundamentally the same idea as Python's for line in open("huge.log") -- file objects in Python are iterators that yield one line at a time. Zig's version is more explicit (you manage the buffer), but the principle is identical: process data as a stream, not as a blob.

Composing iterators with your data structures

Let me show you a more practical example that ties iterators back to the data structures we covered last episode. Here's a simple in-memory database of records where we use iterators to query and transform results without building intermediate collections:

const std = @import("std");

const Record = struct {
    id: u32,
    name: []const u8,
    score: i32,
    active: bool,
};

const Database = struct {
    records: []const Record,

    // Iterator over all records
    const RecordIterator = struct {
        records: []const Record,
        index: usize,

        fn next(self: *RecordIterator) ?Record {
            if (self.index >= self.records.len) return null;
            const rec = self.records[self.index];
            self.index += 1;
            return rec;
        }
    };

    fn allRecords(self: *const Database) RecordIterator {
        return .{ .records = self.records, .index = 0 };
    }

    // Convenience: collect active records with score above threshold
    fn activeAboveScore(self: *const Database, threshold: i32) ActiveScoreIterator {
        return .{
            .records = self.records,
            .index = 0,
            .threshold = threshold,
        };
    }

    const ActiveScoreIterator = struct {
        records: []const Record,
        index: usize,
        threshold: i32,

        fn next(self: *ActiveScoreIterator) ?Record {
            while (self.index < self.records.len) {
                const rec = self.records[self.index];
                self.index += 1;
                if (rec.active and rec.score > self.threshold) {
                    return rec;
                }
            }
            return null;
        }
    };
};

pub fn main() !void {
    const records = [_]Record{
        .{ .id = 1, .name = "Alice", .score = 92, .active = true },
        .{ .id = 2, .name = "Bob", .score = 45, .active = false },
        .{ .id = 3, .name = "Carol", .score = 88, .active = true },
        .{ .id = 4, .name = "Dave", .score = 71, .active = true },
        .{ .id = 5, .name = "Eve", .score = 95, .active = true },
        .{ .id = 6, .name = "Frank", .score = 60, .active = false },
        .{ .id = 7, .name = "Grace", .score = 83, .active = true },
    };

    const db = Database{ .records = &records };

    // Query: active users with score > 80
    std.debug.print("Active users with score > 80:\n", .{});
    var query = db.activeAboveScore(80);
    var total_score: i32 = 0;
    var count: u32 = 0;
    while (query.next()) |rec| {
        std.debug.print("  {s}: {d}\n", .{ rec.name, rec.score });
        total_score += rec.score;
        count += 1;
    }
    if (count > 0) {
        std.debug.print("Average: {d}\n", .{@divTrunc(total_score, @as(i32, @intCast(count)))});
    }
}

This pattern -- a data structure that exposes one or more iterator methods -- is extremely common in real Zig code. The standard library itself is full of it: std.mem.splitScalar, std.mem.tokenizeScalar, std.mem.window, hash map .iterator(), std.fs.Dir.iterate() for directory entries, and many more. They all follow the exact same convention. Once you know the pattern, you can read any Zig API that returns an iterator without looking at documentation.

Zig vs Python generators vs Rust iterators

Let me give you a quick comparison because I know a lot of you come from Python (we've spent 22 Python episodes together after all!) and some of you may be looking at Rust too.

Python generators use yield to suspend a function mid-execution:

# Python: lazy range with generator
def my_range(start, end):
    i = start
    while i < end:
        yield i
        i += 1

for x in my_range(0, 5):
    print(x)

Python's approach is ergonomic -- yield turns any function into a lazy iterator automatically. But there's hidden machinery: the runtime creates a generator object with its own stack frame, suspension state, and reference counting. You can't see how much memory it uses without profiling.

Rust iterators use the Iterator trait with method chaining:

// Rust: lazy range with Iterator trait
(0..20)
    .filter(|x| x % 2 == 0)
    .map(|x| x * x)
    .take(5)
    .for_each(|x| println!("{}", x));

Rust's approach is compact and composable. The compiler fuses the iterator chain into a single loop (zero-cost abstraction). But the trait system and closure syntax have a learning curve, and understanding what the optimizer actually does requires reading compiler output.

Zig iterators are just structs with a method:

// Zig: what you see is what you get
var r = Range.init(0, 20);
while (r.next()) |val| {
    std.debug.print("{d} ", .{val});
}

Zig's approach is the most transparent. The iterator is a struct you can inspect. The next() method is a function you can step through. There's no hidden state, no runtime overhead, no trait dispatch, no closure environment, no compiler magic. The cost is verbosity -- you write more code to build the same pipeline. But when you're debugging a performance issue at 3 AM, that transparency is worth its weight in gold (trust me on this one).

All three approaches achieve the same goal: lazy, memory-efficient data processing. The tradeoffs are ergonomics (Python > Rust > Zig), performance transparency (Zig > Rust > Python), and runtime overhead (Zig = Rust < Python by a large margin).

Exercises

  1. Build a Countdown iterator that yields numbers from a starting value down to 0 (inclusive). Then build a Zip iterator adapter that takes two iterators and yields pairs (as a two-element struct). Test by zipping a Range(0, 5) with a Countdown(4) and printing each pair like (0, 4) (1, 3) (2, 2) (3, 1) (4, 0).

  2. Build a ChunkIterator that takes a slice and a chunk size, and yields sub-slices of that size. The last chunk may be shorter if the slice length isn't evenly divisible. For example, chunking "abcdefgh" by 3 should yield "abc", "def", "gh". Then use your chunker to process a byte array in fixed-size blocks, printing each block's length and contents.

  3. Build a FlattenIterator that takes an iterator of slices and yields individual elements from each slice in sequence. For example, if the inner iterator yields "hello", "world", "zig", the flatten iterator should yield 'h', 'e', 'l', 'l', 'o', 'w', 'o', 'r', ... and so on. Test it with an array of string slices.

Dus, wat we geleerd hebben

  • Zig's iterator convention is a struct with pub fn next(*Self) ?T -- nothing more. When it returns null, the sequece is done.
  • The standard library uses this pattern everywhere: splitScalar, tokenizeScalar, hash map .iterator(), directory walking, and more.
  • Building your own iterator is just defining a struct with the right method. Custom iterators for linked lists, databases, generators -- all follow the same shape.
  • Lazy evaluation means computing values on demand. An infinite Fibonacci iterator uses 16 bytes of state and produces values forever, no allocation needed.
  • Iterator adapters (map, filter, take, skip) wrap one iterator to transform another. They compose by nesting, and the entire pipeline runs with zero intermediate allocations.
  • For memory-efficient processing, iterators let you handle data as a stream rather than loading it all into memory. Same principle as Python's for line in file.
  • Compared to Python generators and Rust iterators, Zig's approach is the most explicit and transparent -- more verbose, but no hidden runtime cost.

This iterator foundation is going to matter a LOT going forward. When we start looking at formatting output and working with debug/log streams, you'll see iterators pop up everywhere in those APIs too. The convention is simple but it's the backbone of how Zig handles sequences of anything.

Bedankt en tot de volgende keer!

@scipio



0
0
0.000
3 comments
avatar

Congratulations @scipio! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You have been a buzzy bee and published a post every day of the week.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP

0
0
0.000
avatar

Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!

Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).

Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive. 
 

0
0
0.000
avatar

Thank you for the upvote support StemSocial team!!! <3

0
0
0.000