Zig OBJ Parsing: A Small, Fast Parser You Can Verify
Parsing is not abstract. It is reading bytes, splitting tokens, converting types, and verifying output. If the output is predictable, the parser is correct.
This guide builds a real OBJ vertex parser in Zig. Every command is copy‑paste runnable. Every file path is explicit. Every verification step produces an expected result. There are no placeholders and no skipped steps.
Why Zig for parsing
Zig is a good fit for parsing tasks because it exposes memory, allocation, and I/O explicitly without adding runtime overhead. Unlike higher‑level languages, Zig does not hide allocations, implicit string copies, or garbage collection. That matters in parsing because the core work is moving bytes, splitting tokens, and converting them into typed values. Zig’s standard library provides these tools directly, but you remain in control of when memory is allocated, when it is freed, and how buffers are reused.
Another reason Zig works well here is that it treats errors as part of the type system. File access, parsing, and numeric conversion can all fail. In Zig, those failures are not exceptions; they are explicit error unions that must be handled. That forces the parser to describe exactly which operations can fail and where. The result is code that is predictable under malformed input rather than silently ignoring problems.
Finally, Zig’s compile-time safety checks catch common parser mistakes. Array bounds, null handling, and integer conversions are checked where possible. That means the example you are about to build is small, but it uses the same safety and performance model that scales to large real-world parsers.
What you are building
You will build a single Zig executable that reads a small .obj file, extracts v vertex lines, parses floats, stores them in a dynamic list, and prints them back out. This is the smallest complete parsing loop: input → tokenize → parse → store → output.
Here is the end state.
| Item | Path | Purpose |
|---|---|---|
| OBJ input file | data/sample.obj | Known test input for verification |
| Parser source | src/main.zig | Reads file, parses vertices, prints output |
When the program runs, it prints exactly the vertex data from the file. If the output matches, the parser works.
Prerequisites
You need Zig 0.12.x or later installed and available on your PATH.
Verify Zig is installed:
zig version
If this prints a version number, you are ready.
Step 1: Create the Zig project
Start with a clean executable project.
mkdir zig-objcd zig-objzig init-exe
Verify the empty project builds.
zig build
Expected result: build completes with no errors.
This confirms the Zig toolchain and project layout are correct before adding code.
Step 2: Create a known OBJ input file
The parser needs fixed input so verification is deterministic. Create a small OBJ file containing only vertex lines.
mkdir -p datacat > data/sample.obj <<'EOF'v 0.0 1.0 0.0v -1.0 0.0 0.0v 1.0 0.0 0.0EOF
Verify the file contents.
cat data/sample.obj
Expected output:
v 0.0 1.0 0.0v -1.0 0.0 0.0v 1.0 0.0 0.0
This file is now the single source of truth for parser verification.
Step 3: Implement the vertex parser
Replace the generated src/main.zig with a real parser. This program opens the OBJ file, reads it line‑by‑line, tokenizes vertex lines, parses floats, stores them in an ArrayList, and prints them back out.
cat > src/main.zig <<'EOF'const std = @import("std");const Vertex = struct {x: f32,y: f32,z: f32,};pub fn main() !void {var gpa = std.heap.GeneralPurposeAllocator(.{}){};defer _ = gpa.deinit();const allocator = gpa.allocator();var file = try std.fs.cwd().openFile("data/sample.obj", .{});defer file.close();const reader = file.reader();var buf_reader = std.io.bufferedReader(reader);var in_stream = buf_reader.reader();var vertices = std.ArrayList(Vertex).init(allocator);defer vertices.deinit();var line_buf: [512]u8 = undefined;while (try in_stream.readUntilDelimiterOrEof(&line_buf, '\n')) |line| {if (line.len == 0) continue;if (line[0] != 'v') continue;var it = std.mem.tokenize(u8, line, " ");_ = it.next(); // skip "v"const x = try std.fmt.parseFloat(f32, it.next().?);const y = try std.fmt.parseFloat(f32, it.next().?);const z = try std.fmt.parseFloat(f32, it.next().?);try vertices.append(.{ .x = x, .y = y, .z = z });}const out = std.io.getStdOut().writer();for (vertices.items) |v| {try out.print("v {d:.1} {d:.1} {d:.1}\n", .{ v.x, v.y, v.z });}}EOF
Walking through the code
The program begins by importing Zig’s standard library. In Zig, almost all I/O, memory allocation, and formatting lives inside std, so bringing it in once at the top is standard practice.
The Vertex struct defines the in‑memory representation of a parsed vertex. Each OBJ vertex line contains three floating‑point values, so the struct mirrors that shape exactly. Using a struct instead of three loose floats makes later processing clearer and safer.
Inside main, the first block creates a general‑purpose allocator. Zig does not allocate memory implicitly, so any dynamic container must be given an allocator. The defer line ensures the allocator is cleaned up when the program exits. This is Zig’s approach to deterministic resource cleanup without a garbage collector.
The file open call uses std.fs.cwd().openFile. This returns an error union, so try is required. If the file does not exist, the program exits with a clear error instead of continuing in an invalid state.
Zig’s buffered reader wraps the file reader to avoid reading one byte at a time from disk. The readUntilDelimiterOrEof call fills a fixed buffer with each line. This pattern avoids allocating a new string for every line, which is important in high‑throughput parsers.
The ArrayList(Vertex) is a dynamically growing list. Zig does not guess capacity; it expands when needed using the provided allocator. This is how we store an unknown number of vertices without pre‑counting the file.
Inside the loop, two early continue checks skip empty lines and non‑vertex lines. OBJ files contain many different record types. This keeps the example focused only on v records.
The tokenizer splits the line on spaces. The first token is the literal v, so it is skipped. The next three tokens are parsed as floats using std.fmt.parseFloat. Each parseFloat returns an error union, which is why try appears again. If the file contains invalid numbers, the program fails immediately with a clear error rather than producing corrupt data.
Each parsed vertex is appended to the ArrayList. Because the list owns its memory, the vertices remain valid after the loop ends.
Finally, the program writes the parsed vertices to stdout. Printing is done through std.io.getStdOut().writer(), and formatted printing uses Zig’s type‑safe formatting syntax. The printed output mirrors the original OBJ lines so verification is simple.
This parser ignores everything except v lines. It does not guess vertex counts. It dynamically grows storage. It prints parsed values so correctness is directly observable.
Step 4: Run the parser
Build and execute the program.
zig build run
Expected output:
v 0.0 1.0 0.0v -1.0 0.0 0.0v 1.0 0.0 0.0
At this stage, the parser has exercised all major systems Zig exposes for low‑level work: file I/O, buffered reading, tokenization, numeric conversion, dynamic allocation, and formatted output. None of these rely on hidden runtime services. Every allocation, read, and write is explicit in the code you just ran.
If your output matches exactly, the parser is correct for this input.
Step 5: Confirm determinism
Run the program again.
zig build run
The output should be identical. A deterministic parser producing stable output on fixed input is verified.
Common failure points
| Symptom | Likely cause | Fix |
|---|---|---|
zig: command not found | Zig not installed or PATH incorrect | Install Zig and verify zig version |
FileNotFound error | Wrong input path | Confirm data/sample.obj exists |
parseFloat error | Unexpected spacing in OBJ file | Ensure values are separated by single spaces |
| No output | Lines not starting with v | Confirm sample file contents |
Most of these errors occur because Zig does not silently ignore failures. Missing files, invalid numbers, and incorrect paths stop execution immediately. This is intentional. A parser that continues on invalid input often produces harder‑to‑debug downstream errors. Zig’s error‑first design keeps failures local and obvious.
These cover the typical errors when implementing small text parsers.
Extending the parser
Once the basic vertex loop works, extending the parser is mainly about adding more record types and mapping them into new structs. The core structure of the program does not change: open file, buffered read, tokenize line, parse tokens, append to typed storage. That repeatable pattern is what makes Zig attractive for building larger format parsers.
- Add support for
vnnormal lines - Add support for
fface indices - Skip comments and blank lines
- Store data in buffers used by a renderer
The core loop remains the same: read → tokenize → parse → store → verify.
Closing
A parser is correct when input, logic, and output are all visible. This lab keeps each step explicit so nothing is hidden behind abstractions.
When you can create input, run code, and predict output exactly, you have a verified parsing baseline you can build on.