Reading and Writing JSON in Zig
I needed to read a small JSON configuration file from a Zig program the other day and I was pleasantly surprised by how little code it took. Zig comes with std.json in the standard library and it covers both cases you run into in practice. If you already know what the data looks like, you parse it straight into a struct. If you do not, you parse it into a tree of std.json.Value and look around. The program below does both, and also goes the other way and turns a struct back into JSON text.
const std = @import("std");
const Book = struct {
title: []const u8,
year: u16,
pages: ?u32 = null,
tags: []const []const u8,
};
const input =
\\{
\\ "title": "Systems Programming with Zig",
\\ "year": 2026,
\\ "tags": ["zig", "systems", "unix"],
\\ "isbn": "ignored"
\\}
;
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
// 1. JSON text -> typed struct
var parsed = try std.json.parseFromSlice(Book, allocator, input, .{
.ignore_unknown_fields = true,
});
defer parsed.deinit();
const book = parsed.value;
std.debug.print("title: {s}\nyear: {d}\npages: {?d}\n", .{ book.title, book.year, book.pages });
for (book.tags, 0..) |tag, i| {
std.debug.print("tag[{d}]: {s}\n", .{ i, tag });
}
// 2. struct -> JSON text
const updated = Book{
.title = book.title,
.year = book.year,
.pages = 440,
.tags = book.tags,
};
const out = try std.json.Stringify.valueAlloc(allocator, updated, .{ .whitespace = .indent_2 });
defer allocator.free(out);
std.debug.print("{s}\n", .{out});
// 3. JSON of unknown shape -> dynamic tree
var tree = try std.json.parseFromSlice(std.json.Value, allocator, input, .{});
defer tree.deinit();
const root = tree.value.object;
if (root.get("isbn")) |v| {
std.debug.print("isbn: {s}\n", .{v.string});
}
std.debug.print("root has {d} keys\n", .{root.count()});
}
The Book struct describes the data. Field names have to match the JSON keys, and the field types tell the parser what to accept. So title is a string, year is an integer that has to fit in 16 bits and tags is an array of strings. Notice pages. It is an optional with a default value of null, and that is how you say that a key might not be there. Any field without a default is required. If it is missing, you get an error, not a zero.
The JSON input is a multiline string literal. Every line begins with \\ and the line breaks are kept, so you can paste JSON into a Zig file as is. No escaping needed.
The first thing main() does is call std.json.parseFromSlice() with the target type, an allocator, the input and some options. I set ignore_unknown_fields to true because the input contains an isbn key that Book knows nothing about. Without it, the parser stops at the first key it does not recognise. That is a good default if you want to catch typos in a config file. It is less good when you talk to an API that keeps adding fields.
What you get back is not a Book but a Parsed(Book). It owns an arena with all the strings and slices the parser allocated, and parsed.deinit() frees the lot. The data itself is in parsed.value. Keep in mind that book is a copy of the struct, but the slices inside it still point into that arena, so do not use them after the defer has run.
The {?d} in the format string prints an optional integer, either as a number or as null. The for loop with 0.. gives you the element and its index at the same time.
Going the other way is std.json.Stringify.valueAlloc(). Give it a value, some options and an allocator and it returns a string that you have to free. With .whitespace = .indent_2 the output is indented with two spaces. Leave the option out and you get everything on one line. Optional fields that are null are written as null unless you set .emit_null_optional_fields = false. There is no reflection at runtime here. The serializer looks at the struct at compile time and knows the field names and types already.
The last part is for JSON you do not control. Parsing into std.json.Value gives you a tagged union with one variant per JSON type, namely .null, .bool, .integer, .float, .number_string, .string, .array and .object. An .object is a std.json.ObjectMap, which is a StringArrayHashMap, so get() returns an optional and count() tells you how many keys there are. I read v.string directly because I happen to know that isbn is a string. In real code you would switch on the value, the same way you switch on any tagged union.
Two small experiments. Delete the "year" line from the input and the program stops with this:
$ zig run json.zig
error: MissingField
Now change "year": 2026 to "year": "2026" and it still runs. The parser is happy to read a number that was written as a string when the target field is numeric. That comes in handy with data exported from spreadsheets and shell scripts.
Save the code as json.zig and try it:
$ zig version
0.16.0
$ zig run json.zig
title: Systems Programming with Zig
year: 2026
pages: null
tag[0]: zig
tag[1]: systems
tag[2]: unix
{
"title": "Systems Programming with Zig",
"year": 2026,
"pages": 440,
"tags": [
"zig",
"systems",
"unix"
]
}
isbn: ignored
root has 4 keys
Happy coding in Zig!