
Learn Zig Series (#20) - Working with JSON
Learn Zig Series (#20) - Working with JSON

What will I learn
- You will learn parsing JSON into Zig structs with
std.json.parseFromSlice; - the
std.json.Valuetagged union for dynamic (untyped) JSON; - handling optional and nullable JSON fields;
- serializing Zig structs back to JSON with
std.json.stringify; - streaming JSON tokenization with
std.json.Scanner; - error handling for malformed JSON input;
- practical example: reading a config file and processing API responses.
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):
- @scipio/zig-programming-tutoroial-ep001-intro" target="_blank" rel="noopener noreferrer">Zig Programming Tutorial - ep001 - Intro
- @scipio/learn-zig-series-2-hello-zig-variables-and-types" target="_blank" rel="noopener noreferrer">Learn Zig Series (#2) - Hello Zig, Variables and Types
- @scipio/learn-zig-series-3-functions-and-control-flow" target="_blank" rel="noopener noreferrer">Learn Zig Series (#3) - Functions and Control Flow
- @scipio/learn-zig-series-4-error-handling-zigs-best-feature" target="_blank" rel="noopener noreferrer">Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- @scipio/learn-zig-series-5-arrays-slices-and-strings" target="_blank" rel="noopener noreferrer">Learn Zig Series (#5) - Arrays, Slices, and Strings
- @scipio/learn-zig-series-6-structs-enums-and-tagged-unions" target="_blank" rel="noopener noreferrer">Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- @scipio/learn-zig-series-7-memory-management-and-allocators" target="_blank" rel="noopener noreferrer">Learn Zig Series (#7) - Memory Management and Allocators
- @scipio/learn-zig-series-8-pointers-and-memory-layout" target="_blank" rel="noopener noreferrer">Learn Zig Series (#8) - Pointers and Memory Layout
- @scipio/learn-zig-series-9-comptime-zigs-superpower" target="_blank" rel="noopener noreferrer">Learn Zig Series (#9) - Comptime (Zig's Superpower)
- @scipio/learn-zig-series-10-project-structure-modules-and-file-io" target="_blank" rel="noopener noreferrer">Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- @scipio/learn-zig-series-11-mini-project-building-a-step-sequencer" target="_blank" rel="noopener noreferrer">Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- @scipio/learn-zig-series-12-testing-and-test-driven-development" target="_blank" rel="noopener noreferrer">Learn Zig Series (#12) - Testing and Test-Driven Development
- @scipio/learn-zig-series-13-interfaces-via-type-erasure" target="_blank" rel="noopener noreferrer">Learn Zig Series (#13) - Interfaces via Type Erasure
- @scipio/learn-zig-series-14-generics-with-comptime-parameters" target="_blank" rel="noopener noreferrer">Learn Zig Series (#14) - Generics with Comptime Parameters
- @scipio/learn-zig-series-15-the-build-system-buildzig" target="_blank" rel="noopener noreferrer">Learn Zig Series (#15) - The Build System (build.zig)
- @scipio/learn-zig-series-16-sentinel-terminated-types-and-c-strings" target="_blank" rel="noopener noreferrer">Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- @scipio/learn-zig-series-17-packed-structs-and-bit-manipulation" target="_blank" rel="noopener noreferrer">Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- @scipio/learn-zig-series-18-async-concepts-and-event-loops" target="_blank" rel="noopener noreferrer">Learn Zig Series (#18) - Async Concepts and Event Loops
- @scipio/learn-zig-series-18b-addendum-async-returns-in-zig-016" target="_blank" rel="noopener noreferrer">Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- @scipio/learn-zig-series-19-simd-with-vector" target="_blank" rel="noopener noreferrer">Learn Zig Series (#19) - SIMD with @Vector
- @scipio/learn-zig-series-20-working-with-json" target="_blank" rel="noopener noreferrer">Learn Zig Series (#20) - Working with JSON (this post)
Learn Zig Series (#20) - Working with JSON
Welcome back! Last time in @scipio/learn-zig-series-19-simd-with-vector" target="_blank" rel="noopener noreferrer">episode #19 we went deep into SIMD -- processing multiple data elements in a single instruction with @Vector. That was low-level hardware exploitation at its finest. Today we go in the opposite direction: parsing structured data from the most ubiquitous interchange format on earth. JSON is everywhere. Config files, REST APIs, WebSocket messages, database exports, logging systems -- if two programs need to talk to each other in 2026, there's about an 80% chance they're speaking JSON.
Most languages give you JSON support through an external library (Python's json, Rust's serde_json, Go's encoding/json). Zig ships it in the standard library as std.json, and its approach is very Zig: you get both typed parsing (deserialize directly into structs, letting the compiler verify your data model at compile time) and dynamic parsing (walk the JSON tree manually when you don't know the shape ahead of time). No reflection, no macros, no code generation -- just comptime and the type system doing what they're designed to do.
If you've been following this series since @scipio/learn-zig-series-6-structs-enums-and-tagged-unions" target="_blank" rel="noopener noreferrer">episode #6 (structs and tagged unions) and @scipio/learn-zig-series-7-memory-management-and-allocators" target="_blank" rel="noopener noreferrer">episode #7 (allocators), you already have everything you need to understand how Zig's JSON works under the hood. Tagged unions map naturally to JSON's type system, and the allocator pattern shows up everywhere in parsing because JSON strings and arrays need dynamic memory.
Here we go!
Solutions to Episode 19 Exercises
Exercise 1 -- simdMin finding the minimum in a float slice:
const std = @import("std");
fn scalarMin(data: []const f32) f32 {
var min_val: f32 = std.math.inf(f32);
for (data) |val| {
if (val < min_val) min_val = val;
}
return min_val;
}
fn simdMin(data: []const f32) f32 {
const vec_len = 8;
var min_vec: @Vector(vec_len, f32) = @splat(std.math.inf(f32));
var i: usize = 0;
while (i + vec_len <= data.len) : (i += vec_len) {
const chunk: @Vector(vec_len, f32) = data[i..][0..vec_len].*;
min_vec = @min(min_vec, chunk);
}
var result = @reduce(.Min, min_vec);
// Scalar tail
while (i < data.len) : (i += 1) {
if (data[i] < result) result = data[i];
}
return result;
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
const n: usize = 1_000_000;
const data = try allocator.alloc(f32, n);
defer allocator.free(data);
for (data, 0..) |*val, idx| {
val.* = @floatFromInt(idx % 997);
}
data[567_890] = -42.0; // hide the minimum
var timer = try std.time.Timer.start();
const scalar_result = scalarMin(data);
const scalar_ns = timer.read();
timer.reset();
const simd_result = simdMin(data);
const simd_ns = timer.read();
std.debug.print("Scalar min: {d:.1} ({d}ns)\n", .{ scalar_result, scalar_ns });
std.debug.print("SIMD min: {d:.1} ({d}ns)\n", .{ simd_result, simd_ns });
std.debug.print("Speedup: {d:.1}x\n", .{
@as(f64, @floatFromInt(scalar_ns)) / @as(f64, @floatFromInt(simd_ns)),
});
}
The key insight: initialize the min vector with positive infinity (std.math.inf(f32)) so every real value is smaller. Use @min (which works on vectors element-wise) to keep a running minimum vector of 8 lanes. At the end, @reduce(.Min, ...) collapses the 8 lane minimums to the global minimum.
Exercise 2 -- simdCountByte counting occurrences of a byte:
const std = @import("std");
fn scalarCountByte(haystack: []const u8, needle: u8) usize {
var count: usize = 0;
for (haystack) |byte| {
if (byte == needle) count += 1;
}
return count;
}
fn simdCountByte(haystack: []const u8, needle: u8) usize {
const vec_len = 16;
const needle_vec: @Vector(vec_len, u8) = @splat(needle);
var total: usize = 0;
var offset: usize = 0;
while (offset + vec_len <= haystack.len) : (offset += vec_len) {
const chunk: @Vector(vec_len, u8) = haystack[offset..][0..vec_len].*;
const matches = chunk == needle_vec;
const mask: u16 = @bitCast(matches);
total += @popCount(mask);
}
// Scalar tail
while (offset < haystack.len) : (offset += 1) {
if (haystack[offset] == needle) total += 1;
}
return total;
}
pub fn main() void {
const data = "the quick brown fox jumps over the lazy dog and the cat";
const scalar = scalarCountByte(data, 't');
const simd = simdCountByte(data, 't');
std.debug.print("Scalar count of 't': {d}\n", .{scalar});
std.debug.print("SIMD count of 't': {d}\n", .{simd});
}
Instead of returning on the first match (like the byte search in ep19), we use @popCount on the bitmask to count how many bits are set -- each set bit is one matching byte. This accumulates accross all chunks. @popCount maps to hardware POPCNT on modern CPUs, so the entire 16-byte comparison plus count is a handful of instructions.
Exercise 3 -- SIMD distance from origin (SoA layout):
const std = @import("std");
fn scalarDistances(xs: []const f32, ys: []const f32, out: []f32) void {
for (xs, ys, out) |x, y, *d| {
d.* = @sqrt(x * x + y * y);
}
}
fn simdDistances(xs: []const f32, ys: []const f32, out: []f32) void {
std.debug.assert(xs.len == ys.len);
std.debug.assert(xs.len == out.len);
const vec_len = 8;
var i: usize = 0;
while (i + vec_len <= xs.len) : (i += vec_len) {
const vx: @Vector(vec_len, f32) = xs[i..][0..vec_len].*;
const vy: @Vector(vec_len, f32) = ys[i..][0..vec_len].*;
const dist = @sqrt(vx * vx + vy * vy);
out[i..][0..vec_len].* = dist;
}
// Scalar tail
while (i < xs.len) : (i += 1) {
out[i] = @sqrt(xs[i] * xs[i] + ys[i] * ys[i]);
}
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
const n: usize = 100_000;
const xs = try allocator.alloc(f32, n);
defer allocator.free(xs);
const ys = try allocator.alloc(f32, n);
defer allocator.free(ys);
const out_scalar = try allocator.alloc(f32, n);
defer allocator.free(out_scalar);
const out_simd = try allocator.alloc(f32, n);
defer allocator.free(out_simd);
for (xs, ys, 0..) |*x, *y, idx| {
x.* = @floatFromInt(idx % 100);
y.* = @floatFromInt((idx * 3 + 7) % 100);
}
var timer = try std.time.Timer.start();
scalarDistances(xs, ys, out_scalar);
const scalar_ns = timer.read();
timer.reset();
simdDistances(xs, ys, out_simd);
const simd_ns = timer.read();
std.debug.print("Scalar: {d}ns\n", .{scalar_ns});
std.debug.print("SIMD: {d}ns\n", .{simd_ns});
std.debug.print("Speedup: {d:.1}x\n", .{
@as(f64, @floatFromInt(scalar_ns)) / @as(f64, @floatFromInt(simd_ns)),
});
std.debug.print("First 5: ", .{});
for (out_simd[0..5]) |d| std.debug.print("{d:.2} ", .{d});
std.debug.print("\n", .{});
}
The SoA (Structure of Arrays) layout is the key. Having all x values contiguous and all y values contiguous means we load 8 x values and 8 y values as clean vectors, with no shuffling or gathering. @sqrt works element-wise on vectors, so we compute 8 distances simultaneously. This is why game engines and physics simulations store position data as separate x, y, z arrays rather than as arrays of point structs -- the AoS (Array of Structs) layout forces scatter-gather loads that kill SIMD performance.
Right, enough SIMD! Time for JSON ;-)
Why JSON parsing matters in Zig
You might wonder: in a language designed for systems programming, why would JSON be in the standard library? Fair question. The answer is practical -- even systems software needs configuration files, needs to talk to HTTP APIs, needs to parse log output, needs to read package manifests. Zig's build system (build.zig.zon) uses a JSON-like format. Debuggers emit JSON. Profilers emit JSON.
In C you'd reach for cJSON or jansson. In C++ you'd use nlohmann/json or RapidJSON. Every project pulled in a different library with a different API. Zig puts a solid, zero-allocation-where-possible JSON parser in std.json so you never need to make that choice. And because it uses Zig's type system -- specifically comptime reflection on structs -- the typed parsing is remarkably clean.
Typed parsing with std.json.parseFromSlice
The most common case: you know what shape the JSON has, you define a Zig struct, and you tell std.json to parse directly into it. The parser uses comptime to generate the parsing code for your exact type -- no runtime reflection, no interface{} boxing like Go, no serde proc macros like Rust.
const std = @import("std");
const Config = struct {
host: []const u8,
port: u16,
debug: bool,
max_connections: u32,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const json_input =
\\{
\\ "host": "127.0.0.1",
\\ "port": 8080,
\\ "debug": true,
\\ "max_connections": 1024
\\}
;
const parsed = try std.json.parseFromSlice(
Config,
allocator,
json_input,
.{},
);
defer parsed.deinit();
const config = parsed.value;
std.debug.print("Host: {s}\n", .{config.host});
std.debug.print("Port: {d}\n", .{config.port});
std.debug.print("Debug: {}\n", .{config.debug});
std.debug.print("Max connections: {d}\n", .{config.max_connections});
}
That's the whole flow. Define a struct, call parseFromSlice with the type as the first argument, and you get back a Parsed(Config) wrapper whose .value field is your populated struct. The wrapper owns the allocations (string slices in particular -- those []const u8 fields point into allocator-owned memory), and calling .deinit() frees everything.
The \\ syntax is Zig's multi-line string literal -- each line starts with \\ and there's no need for escape sequences. Very handy for embedding test JSON.
A few things to notice. First, the field names in your struct must exactly match the JSON keys. If the JSON has "max_connections" and your struct has maxConnections, parsing fails. Second, types are checked at parse time: if the JSON has "port": "not a number", you get an error, not a panic or silent wrong value. Third, the allocator is needed because JSON strings are dynamically allocated -- the parser copies string values into allocator-owned buffers that the Parsed wrapper tracks.
What about parseFromSliceLeaky?
There's a leaky variant that skips the ownership tracking:
const std = @import("std");
const Point = struct {
x: f64,
y: f64,
label: []const u8,
};
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
const json_input =
\\{"x": 3.14, "y": 2.71, "label": "origin-ish"}
;
// parseFromSliceLeaky: no Parsed wrapper, no individual deinit
const point = try std.json.parseFromSliceLeaky(
Point,
allocator,
json_input,
.{},
);
std.debug.print("Point({d:.2}, {d:.2}) label={s}\n", .{
point.x, point.y, point.label,
});
// arena.deinit() frees everything at once
}
parseFromSliceLeaky returns the value directly (not wrapped in Parsed) and does NOT track individual allocations. The idea is you pair it with an arena allocator: the arena frees everything in one shot when you're done. This is faster for batch processing -- parse 1000 JSON objects, process them, destroy the arena. No per-object cleanup overhead.
The naming is intentionally alarming. "Leaky" means "this WILL leak if you use a general-purpose allocator and forget to free." With an arena that's fine because the arena handles bulk deallocation. With a GPA you'd leak every string and array the parser allocated. The scary name is Zig's way of making you think twice about whether you really want this ;-)
If you've been following along since @scipio/learn-zig-series-7-memory-management-and-allocators" target="_blank" rel="noopener noreferrer">ep7 where we covered arena allocators, this pattern should feel familiar. Arenas are the natural pairing for batch parsing workloads.
Dynamic JSON with std.json.Value
Sometimes you don't know the JSON shape at compile time. Maybe you're writing a tool that processes arbitrary JSON files, or the API response format varies. For these cases, std.json provides std.json.Value -- a tagged union (remember @scipio/learn-zig-series-6-structs-enums-and-tagged-unions" target="_blank" rel="noopener noreferrer">ep6?) that represents any JSON value:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const json_input =
\\{
\\ "name": "Zig",
\\ "version": 0.14,
\\ "features": ["comptime", "simd", "json"],
\\ "stable": false,
\\ "creator": {
\\ "name": "Andrew Kelley",
\\ "year": 2015
\\ }
\\}
;
const parsed = try std.json.parseFromSlice(
std.json.Value,
allocator,
json_input,
.{},
);
defer parsed.deinit();
const root = parsed.value;
// root is an ObjectMap -- access fields by string key
if (root.object.get("name")) |name_val| {
std.debug.print("Name: {s}\n", .{name_val.string});
}
if (root.object.get("version")) |ver| {
std.debug.print("Version: {d}\n", .{ver.float});
}
// Navigate nested objects
if (root.object.get("creator")) |creator| {
if (creator.object.get("name")) |cname| {
std.debug.print("Creator: {s}\n", .{cname.string});
}
}
// Iterate over arrays
if (root.object.get("features")) |features| {
std.debug.print("Features: ", .{});
for (features.array.items) |item| {
std.debug.print("{s} ", .{item.string});
}
std.debug.print("\n", .{});
}
}
std.json.Value is a tagged union with these variants: .null, .bool, .integer, .float, .string, .array, and .object. The .array variant holds an ArrayList(Value) and .object holds an ObjectMap (which is a StringArrayHashMap(Value)). You navigate the tree by checking variants and accessing fields.
This is the "jq for Zig" approach -- walk the JSON tree and pick out what you need. It's more verbose than typed parsing, but it handles arbitrary JSON. You pay for the flexibility with runtime checks (accessing .string on a .float value is a panic, not a compile error).
Handling optional and nullable fields
Real-world JSON is messy. Fields might be missing, or present but null. Zig handles both cases through its type system:
const std = @import("std");
const UserProfile = struct {
username: []const u8,
email: []const u8,
bio: ?[]const u8 = null, // nullable: can be JSON null
age: ?u32 = null, // optional with default
verified: bool = false, // missing field uses default
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// JSON with some fields missing, some null
const json_input =
\\{
\\ "username": "zigmaster",
\\ "email": "zig@example.com",
\\ "bio": null,
\\ "age": 28
\\}
;
const parsed = try std.json.parseFromSlice(
UserProfile,
allocator,
json_input,
.{},
);
defer parsed.deinit();
const user = parsed.value;
std.debug.print("Username: {s}\n", .{user.username});
std.debug.print("Email: {s}\n", .{user.email});
if (user.bio) |bio| {
std.debug.print("Bio: {s}\n", .{bio});
} else {
std.debug.print("Bio: (none)\n", .{});
}
if (user.age) |age| {
std.debug.print("Age: {d}\n", .{age});
}
std.debug.print("Verified: {}\n", .{user.verified});
}
The rules are straightforward:
?T(optional) maps to JSONnull. If the JSON value isnull, the field becomesnullin Zig. If the field is missing from the JSON entirely AND the struct field has a default value, the default is used.- Default values (
= false,= null) are used when the JSON key is completely absent. Without a default, a missing key is a parse error. - Required fields (no
?, no default) MUST be present in the JSON with a non-null value. Missing or null = error.
This gives you fine-grained control. A field like username: []const u8 says "this MUST be present and MUST be a string." A field like bio: ?[]const u8 = null says "this can be absent, or present as null, or present as a string -- and I handle all three cases." No guessing, no surprise nulls at runtime.
Parsing options
The fourth argument to parseFromSlice is an options struct. Mostly you pass .{} (all defaults), but there are useful knobs:
const std = @import("std");
const Data = struct {
name: []const u8,
count: u32,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const json_input =
\\{
\\ "name": "test",
\\ "count": 42,
\\ "extra_field": "ignored",
\\ "another_unknown": true
\\}
;
// By default, unknown fields cause an error.
// .ignore_unknown_fields = true lets you parse a subset.
const parsed = try std.json.parseFromSlice(
Data,
allocator,
json_input,
.{ .ignore_unknown_fields = true },
);
defer parsed.deinit();
std.debug.print("Name: {s}, Count: {d}\n", .{
parsed.value.name, parsed.value.count,
});
}
The .ignore_unknown_fields option is critical for real-world use. API responses typically contain dozens of fields and you only care about a few. Without this option, every unknown key in the JSON triggers a parse error. With it, the parser silently skips keys that don't match any struct field. It's the difference between "parse exactly this shape" and "extract these fields from whatever the server sends."
Other useful options include .allocate which controls whether strings are duplicated (.alloc_always is the safest default -- the parser copies every string into its own allocation, so the parsed result doesn't depend on the input slice's lifetime).
Serializing to JSON
Going the other direction -- Zig structs to JSON strings -- uses std.json.stringify (write to a writer) or std.json.stringifyAlloc (allocate and return a string):
const std = @import("std");
const ServerConfig = struct {
host: []const u8,
port: u16,
workers: u32,
tls_enabled: bool,
max_body_size: ?u64 = null,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const config = ServerConfig{
.host = "0.0.0.0",
.port = 443,
.workers = 8,
.tls_enabled = true,
.max_body_size = 10_485_760,
};
// Compact output (no whitespace)
const compact = try std.json.stringifyAlloc(allocator, config, .{});
defer allocator.free(compact);
std.debug.print("Compact:\n{s}\n\n", .{compact});
// Pretty-printed output
const pretty = try std.json.stringifyAlloc(allocator, config, .{
.whitespace = .indent_2,
});
defer allocator.free(pretty);
std.debug.print("Pretty:\n{s}\n", .{pretty});
}
The compact output gives you a single-line JSON string (good for wire protocols, log lines, storage where size matters). The pretty output indents with 2 spaces per level (good for config files, debugging, human reading). Other whitespace options include .indent_4, .indent_tab, and .minified (same as default -- no whitespace).
stringifyAlloc allocates and returns a []u8 that you own and must free. If you'd rather write directly to a file or socket, use stringify with a writer:
const std = @import("std");
const LogEntry = struct {
timestamp: i64,
level: []const u8,
message: []const u8,
};
pub fn main() !void {
const entry = LogEntry{
.timestamp = 1713200000,
.level = "info",
.message = "server started on port 8080",
};
// Write directly to stderr (no allocation needed)
const writer = std.io.getStdErr().writer();
try std.json.stringify(entry, .{}, writer);
try writer.writeByte('\n');
}
This is nice for structured logging -- you serialize each log entry as a JSON line directly to the output stream. No intermediate string allocation, no copying.
Serializing dynamic values
You can also serialize std.json.Value trees that you've built or modified:
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Build a JSON value tree programmatically
var obj = std.json.ObjectMap.init(allocator);
defer obj.deinit();
try obj.put("status", .{ .string = "ok" });
try obj.put("code", .{ .integer = 200 });
// Build an array
var items = std.json.Array.init(allocator);
defer items.deinit();
try items.append(.{ .string = "alpha" });
try items.append(.{ .string = "beta" });
try items.append(.{ .integer = 42 });
try obj.put("items", .{ .array = items });
const root = std.json.Value{ .object = obj };
const output = try std.json.stringifyAlloc(allocator, root, .{
.whitespace = .indent_2,
});
defer allocator.free(output);
std.debug.print("{s}\n", .{output});
}
Building JSON trees programmatically is useful for constructing API responses, test fixtures, or any situation where the output shape depends on runtime conditions. You create ObjectMap and Array values, nest them however you want, and serialize the root.
Having said that, for most serialization tasks the typed approach (define a struct, populate it, serialize it) is cleaner and safer. Dynamic construction is the escape hatch for when your output shape isn't known at compile time.
Streaming parsing with std.json.Scanner
Both parseFromSlice and parseFromSliceLeaky require the entire JSON input in memory. For large files (multi-megabyte log dumps, huge API responses, streaming data), you might want to parse without loading everything at once. That's what std.json.Scanner is for -- it tokenizes JSON incrementally:
const std = @import("std");
pub fn main() !void {
const json_input =
\\{"users": [{"name": "Alice", "score": 95}, {"name": "Bob", "score": 87}]}
;
var scanner = std.json.Scanner.initCompleteInput(
std.heap.page_allocator,
json_input,
);
defer scanner.deinit();
while (true) {
const token = scanner.next() catch |err| {
std.debug.print("Parse error: {}\n", .{err});
return;
};
switch (token) {
.object_begin => std.debug.print("{{ ", .{}),
.object_end => std.debug.print("}} ", .{}),
.array_begin => std.debug.print("[ ", .{}),
.array_end => std.debug.print("] ", .{}),
.string => |s| std.debug.print("\"{s}\" ", .{s}),
.number => |n| std.debug.print("{s} ", .{n}),
.true => std.debug.print("true ", .{}),
.false => std.debug.print("false ", .{}),
.null => std.debug.print("null ", .{}),
.end_of_document => break,
else => {},
}
}
std.debug.print("\n", .{});
}
The scanner yields tokens one at a time: object_begin, string, number, object_end, etc. You consume them in whatever order makes sense for your use case. This is a SAX-style parser (if you've done XML -- same idea, different format).
When would you use this instead of parseFromSlice? Mainly when memory is constrained or the input is enormous. A 500MB JSON file parsed with parseFromSlice would allocate the entire parsed tree in memory. With a scanner, you can extract what you need as tokens flow through, using constant memory regardless of file size.
The tradeoff is complexity. With parseFromSlice you get a struct or value tree and access fields by name. With the scanner you're dealing with a flat stream of tokens and YOU have to track where you are in the structure. For most applications, parseFromSlice is the right choice. Save the scanner for the rare case where you actually need streaming.
Error handling for malformed JSON
Zig's JSON parser produces clear errors when input is malformed. This is one area where Zig's explicit error handling (from @scipio/learn-zig-series-4-error-handling-zigs-best-feature" target="_blank" rel="noopener noreferrer">ep4) shines:
const std = @import("std");
const Settings = struct {
name: []const u8,
value: i32,
};
fn tryParse(allocator: std.mem.Allocator, input: []const u8) void {
const result = std.json.parseFromSlice(
Settings,
allocator,
input,
.{},
);
if (result) |parsed| {
defer parsed.deinit();
std.debug.print("OK: name={s} value={d}\n", .{
parsed.value.name, parsed.value.value,
});
} else |err| {
std.debug.print("FAIL: {}\n", .{err});
}
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Valid JSON
tryParse(allocator, "{\"name\": \"test\", \"value\": 42}");
// Missing closing brace
tryParse(allocator, "{\"name\": \"test\", \"value\": 42");
// Wrong type: string where int expected
tryParse(allocator, "{\"name\": \"test\", \"value\": \"not a number\"}");
// Unknown field (without ignore_unknown_fields)
tryParse(allocator, "{\"name\": \"test\", \"value\": 42, \"extra\": true}");
// Missing required field
tryParse(allocator, "{\"name\": \"test\"}");
// Not JSON at all
tryParse(allocator, "this is not json");
}
Common errors you'll encounter:
error.UnexpectedToken-- the JSON syntax itself is wrong (missing braces, invalid characters)error.UnknownField-- a JSON key doesn't match any struct field (fix with.ignore_unknown_fields = true)error.MissingField-- a required struct field isn't present in the JSON
One thing I appreciate about Zig's approach: the error is immediate and the error type is the standard anyerror that works with try/catch from ep4. No exceptions, no panics (unless you explicitly access the wrong variant on a Value), no silent defaults. You know exactly what went wrong and where.
Nested structs and arrays
Real JSON often has nested objects and arrays. Zig handles these naturally:
const std = @import("std");
const Address = struct {
street: []const u8,
city: []const u8,
zip: []const u8,
};
const Contact = struct {
name: []const u8,
email: []const u8,
address: Address,
tags: []const []const u8,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const json_input =
\\{
\\ "name": "Jan de Vries",
\\ "email": "jan@example.com",
\\ "address": {
\\ "street": "Keizersgracht 123",
\\ "city": "Amsterdam",
\\ "zip": "1015 CJ"
\\ },
\\ "tags": ["customer", "premium", "eu"]
\\}
;
const parsed = try std.json.parseFromSlice(
Contact,
allocator,
json_input,
.{},
);
defer parsed.deinit();
const c = parsed.value;
std.debug.print("{s} <{s}>\n", .{ c.name, c.email });
std.debug.print(" {s}, {s} {s}\n", .{
c.address.street, c.address.zip, c.address.city,
});
std.debug.print(" Tags: ", .{});
for (c.tags) |tag| {
std.debug.print("[{s}] ", .{tag});
}
std.debug.print("\n", .{});
}
Nested JSON objects map to nested Zig structs. JSON arrays map to slices ([]const T). The parser recursively parses each level, and the Parsed wrapper owns all the memory for every level of nesting. One .deinit() call frees everything.
This composability is where typed parsing really shines. You define your data model as nested structs, and the parser either succeeds (giving you a fully validated, type-safe data structure) or fails with a descriptive error. No partial parsing, no half-initialized objects.
Practical example: config file loader
Let's build something real -- a config file loader that reads a JSON config, validates it, applies defaults, and uses the result:
const std = @import("std");
const DatabaseConfig = struct {
host: []const u8 = "localhost",
port: u16 = 5432,
name: []const u8,
max_pool: u32 = 10,
};
const LoggingConfig = struct {
level: []const u8 = "info",
file: ?[]const u8 = null,
json_output: bool = false,
};
const AppConfig = struct {
app_name: []const u8,
listen_port: u16 = 3000,
database: DatabaseConfig,
logging: LoggingConfig = .{},
allowed_origins: []const []const u8 = &.{},
};
fn loadConfig(allocator: std.mem.Allocator, path: []const u8) !std.json.Parsed(AppConfig) {
const file = std.fs.cwd().openFile(path, .{}) catch |err| {
std.debug.print("Cannot open config file '{s}': {}\n", .{ path, err });
return err;
};
defer file.close();
const content = try file.readToEndAlloc(allocator, 1024 * 1024); // 1MB max
defer allocator.free(content);
return std.json.parseFromSlice(
AppConfig,
allocator,
content,
.{ .ignore_unknown_fields = true },
);
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const config_result = loadConfig(allocator, "config.json");
if (config_result) |parsed| {
defer parsed.deinit();
const cfg = parsed.value;
std.debug.print("=== {s} ===\n", .{cfg.app_name});
std.debug.print("Listen: :{d}\n", .{cfg.listen_port});
std.debug.print("DB: {s}:{d}/{s} (pool: {d})\n", .{
cfg.database.host,
cfg.database.port,
cfg.database.name,
cfg.database.max_pool,
});
std.debug.print("Log level: {s}", .{cfg.logging.level});
if (cfg.logging.file) |f| {
std.debug.print(" -> {s}", .{f});
}
std.debug.print("\n", .{});
if (cfg.allowed_origins.len > 0) {
std.debug.print("CORS origins: ", .{});
for (cfg.allowed_origins) |origin| {
std.debug.print("{s} ", .{origin});
}
std.debug.print("\n", .{});
}
} else |err| {
std.debug.print("Config error: {}\n", .{err});
// Fall back to a minimal default config for development
std.debug.print("Using development defaults\n", .{});
}
}
A matching config.json file would look like:
{
"app_name": "my-api",
"listen_port": 8080,
"database": {
"host": "db.internal",
"port": 5432,
"name": "production",
"max_pool": 25
},
"logging": {
"level": "warn",
"file": "/var/log/api.log",
"json_output": true
},
"allowed_origins": ["https://example.com", "https://app.example.com"]
}
The defaults in the struct definitions mean you can have a minimal config that only specifies what's different from defaults. A development config might be just:
{
"app_name": "my-api-dev",
"database": {"name": "devdb"}
}
Everything else gets defaults: port 3000, database on localhost:5432, info logging to stderr, no CORS origins. This is the same pattern you see in Go's json.Unmarshal or Rust's serde with #[serde(default)] -- but in Zig it's just normal struct field defaults. No annotations, no attributes, no derive macros. The language feature you already know (default field values from @scipio/learn-zig-series-6-structs-enums-and-tagged-unions" target="_blank" rel="noopener noreferrer">ep6) doubles as JSON default handling.
Practical example: processing API responses
Here's a pattern you'll use frequently -- fetching JSON from an API and extracting what you need. We'll simulate the API response (actual HTTP is a topic for a futrue episode), but the JSON parsing part is exactly what you'd use in production:
const std = @import("std");
const GithubRepo = struct {
name: []const u8,
full_name: []const u8,
description: ?[]const u8 = null,
stargazers_count: u64,
language: ?[]const u8 = null,
fork: bool,
};
const ApiResponse = struct {
items: []const GithubRepo,
total_count: u64,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Simulated API response (GitHub search API format)
const api_json =
\\{
\\ "total_count": 3,
\\ "items": [
\\ {
\\ "name": "zig",
\\ "full_name": "ziglang/zig",
\\ "description": "A programming language",
\\ "stargazers_count": 35000,
\\ "language": "Zig",
\\ "fork": false,
\\ "html_url": "https://github.com/ziglang/zig"
\\ },
\\ {
\\ "name": "zls",
\\ "full_name": "zigtools/zls",
\\ "description": "Zig Language Server",
\\ "stargazers_count": 3200,
\\ "language": "Zig",
\\ "fork": false,
\\ "html_url": "https://github.com/zigtools/zls"
\\ },
\\ {
\\ "name": "zig-cookbook",
\\ "full_name": "community/zig-cookbook",
\\ "description": null,
\\ "stargazers_count": 800,
\\ "language": null,
\\ "fork": true,
\\ "html_url": "https://github.com/community/zig-cookbook"
\\ }
\\ ]
\\}
;
const parsed = try std.json.parseFromSlice(
ApiResponse,
allocator,
api_json,
.{ .ignore_unknown_fields = true },
);
defer parsed.deinit();
const resp = parsed.value;
std.debug.print("Found {d} repos:\n\n", .{resp.total_count});
for (resp.items) |repo| {
std.debug.print("{s}", .{repo.full_name});
if (repo.fork) std.debug.print(" (fork)", .{});
std.debug.print("\n", .{});
if (repo.description) |desc| {
std.debug.print(" {s}\n", .{desc});
}
if (repo.language) |lang| {
std.debug.print(" Language: {s}\n", .{lang});
}
std.debug.print(" Stars: {d}\n\n", .{repo.stargazers_count});
}
}
Notice .ignore_unknown_fields = true -- the simulated API response includes html_url which our struct doesn't define. Without that option, the parser would reject the response. In production, you ALWAYS want this for external APIs because the server can add new fields at any time. Your struct defines the contract: "I need at minimum these fields" and the parser extracts them regardless of what else the JSON contains.
The ?[]const u8 for description and language handles the fact that some repos have null descriptions or no language detected. Optional types are your safety net against nullable API fields. If you defined description as []const u8 (non-optional) and the API returned null, parsing would fail. Better to model the reality of the data source in your types.
Enums in JSON
One more useful pattern -- Zig enums serialize to and from JSON strings automatically:
const std = @import("std");
const Priority = enum {
low,
medium,
high,
critical,
};
const Task = struct {
title: []const u8,
priority: Priority,
done: bool,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// JSON string values map to enum variants
const json_input =
\\{"title": "Fix memory leak", "priority": "high", "done": false}
;
const parsed = try std.json.parseFromSlice(
Task,
allocator,
json_input,
.{},
);
defer parsed.deinit();
const task = parsed.value;
std.debug.print("Task: {s} [{s}] done={}\n", .{
task.title, @tagName(task.priority), task.done,
});
// Serialize back -- enum becomes string
const output = try std.json.stringifyAlloc(allocator, task, .{});
defer allocator.free(output);
std.debug.print("JSON: {s}\n", .{output});
}
The JSON string "high" maps to the enum value Priority.high. Serialization goes the other way: the enum value becomes its tag name as a JSON string. This is exactly how you'd expect it to work, and it means your API contracts can use enums directly instead of parsing strings manually and switching on them. Much cleaner.
Exercises
Write a program that reads a JSON file called
students.jsoncontaining an array of student objects (each withname: string,grade: int,passed: bool, and an optionalnotes: string). Parse the file into a slice of structs, then print a summary: total students, number who passed, average grade, and any students who have notes. Usestd.fs.cwd().openFileto read the file andparseFromSlicewith appropriate options.Write a JSON "pretty printer" using
std.json.Scanner. The program takes a compact JSON string (everything on one line), tokenizes it, and prints it with proper indentation -- 2 spaces per nesting level. Track the nesting depth yourself by incrementing onobject_begin/array_beginand decrementing onobject_end/array_end. This is a real-world tool (equivalent topython -m json.toolorjq .).Build a simple "JSON diff" tool. Write a function that takes two
std.json.Valuetrees and compares them recursively. For objects, report keys that exist in one but not the other, and keys where the values differ. For arrays, report length differences and element-by-element comparisons. Print a human-readable diff likefield "name": "Alice" vs "Bob". Parse two JSON strings intoValuetrees and run your diff function on them.
Before you close this tab...
std.json.parseFromSlice(T, allocator, input, options)parses JSON into any Zig type -- structs, slices, optionals, enums. The compiler generates specialized parsing code via comptime, no runtime reflection needed.parseFromSliceLeakyis the arena-friendly variant -- returns the value directly without tracking individual allocations. Pair it with an arena allocator for batch processing.std.json.Valueis the dynamic alternative -- a tagged union representing any JSON value. Use it when you don't know the shape at compile time.- Optional fields (
?T) handle JSONnull. Default values handle missing keys. Required fields (no?, no default) enforce that the key must be present. This maps perfectly to Zig's existing type system. .ignore_unknown_fields = trueis essential for parsing external API responses. Without it, any unexpected JSON key is a hard error.std.json.stringifyAllocandstd.json.stringifygo the other direction -- Zig values to JSON strings. Enums become their tag names. Optionals becomenull. Slices become arrays.std.json.Scannertokenizes JSON incrementally for streaming or memory-constrained scenarios. Most applications should useparseFromSliceinstead.- The allocator pattern from @scipio/learn-zig-series-7-memory-management-and-allocators" target="_blank" rel="noopener noreferrer">ep7 shows up everywhere in JSON parsing -- strings and arrays need dynamic memory, and the caller controls how that memory is managed.
Next time we'll look at networking -- making TCP connections and building real network protocols. If you think parsing JSON was useful on its own, wait until you're parsing JSON that arrived over a socket you opened yourself ;-)