⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2122+3e15e99e6What's newOn an older Zig?

JSON

const std = @import("std");
const expect = std.testing.expect;

const Config = struct {
    name: []const u8,
    port: u16,
    debug: bool = false, // defaults cover missing fields
};

test "parse into a struct" {
    const gpa = std.testing.allocator;
    const text =
        \\{ "name": "server", "port": 8080 }
    ;

    // The parse result owns its allocations; deinit frees them all at once.
    const parsed = try std.json.parseFromSlice(Config, gpa, text, .{});
    defer parsed.deinit();

    try expect(std.mem.eql(u8, parsed.value.name, "server"));
    try expect(parsed.value.port == 8080);
    try expect(parsed.value.debug == false);
}

test "unknown fields are rejected by default" {
    const gpa = std.testing.allocator;
    const text =
        \\{ "name": "x", "port": 1, "extra": true }
    ;
    try std.testing.expectError(
        error.UnknownField,
        std.json.parseFromSlice(Config, gpa, text, .{}),
    );

    // Opt out when the payload may carry fields you do not model.
    const lenient = try std.json.parseFromSlice(Config, gpa, text, .{
        .ignore_unknown_fields = true,
    });
    defer lenient.deinit();
    try expect(lenient.value.port == 1);
}

test "dynamic values when the shape is unknown" {
    const gpa = std.testing.allocator;
    const parsed = try std.json.parseFromSlice(
        std.json.Value,
        gpa,
        \\{ "a": [1, 2, 3] }
    ,
        .{},
    );
    defer parsed.deinit();

    const array = parsed.value.object.get("a").?.array;
    try expect(array.items.len == 3);
    try expect(array.items[0].integer == 1);
}

test "serialise" {
    const gpa = std.testing.allocator;
    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();

    try std.json.Stringify.value(
        Config{ .name = "out", .port = 99, .debug = true },
        .{},
        &out.writer,
    );
    try expect(std.mem.find(u8, out.written(), "\"port\":99") != null);
}

Parsing into a struct

parseFromSlice(T, gpa, text, .{}) maps JSON onto your type using its field names. The result owns every allocation it made:

const parsed = try std.json.parseFromSlice(Config, gpa, text, .{});
defer parsed.deinit();
parsed.value.port;

Note parsed.value: the payload lives behind the owner so a single deinit frees the whole tree. Strings in parsed.value point into that arena, so they die with it; duplicate anything you need to outlive the parse.

That arrangement is worth pausing on, because it is the answer to a problem every JSON library has. A parsed document is a tree of small allocations with no single owner, and freeing it correctly means walking it. Zig’s answer is to allocate the whole tree from an arena held by the Parsed(T) wrapper. deinit is then one call that releases everything at once and cannot miss a node.

The cost is that the lifetime is all or nothing. Holding onto one string from the document keeps nothing alive, because the arena is gone after deinit; it leaves you with a dangling slice. Copy what you need out, or keep the whole Parsed alive for as long as any part of it is in use.

How types map

The mapping is decided at compile time from your struct, which is where the validation comes from.

ZigJSON
bool, integers, floatsthe matching primitive
[]const u8a string
?Tthe value, or null, or absent
[]Tan array
a structan object, matched by field name
an enuma string equal to a tag name
a tagged unionan object with one key, the tag name

An integer field given a JSON value that does not fit is an error rather than a truncation. A string given where a number was expected is an error naming the field. None of that had to be written, and none of it can drift from the struct, because the struct is the schema.

Missing and unknown fields

A field with a default is optional in the input. An unknown field is an error by default:

error.UnknownField

That default is the right one: it catches typos in config files. Opt out deliberately when consuming a payload you do not fully model:

.{ .ignore_unknown_fields = true }

The distinction to keep straight is between a field that may be absent and a field that may be null. ?u16 with no default accepts null but still requires the key to be present. u16 = 8080 accepts the key being missing. ?u16 = null accepts both. Say which one you mean, because a config file silently taking a default it should have rejected is the failure this system exists to prevent.

When the shape is not known

Parse into std.json.Value for a dynamic tree, then walk .object, .array, .integer, .string, and friends. Prefer a concrete struct where you can: you get validation and field names checked at compile time.

std.json.Value is a tagged union, so walking it is a switch and the exhaustiveness check applies. The usual middle ground is to model the parts you care about as a struct and give one field the type std.json.Value for the part you are passing through untouched.

For input too large to hold in memory, std.json.Scanner is the streaming form: it emits tokens as it reads, so a multi-gigabyte document can be processed with a fixed buffer. It is more work to use and the right answer only when the document genuinely does not fit.

Writing

std.json.Stringify.value(x, .{}, &writer) serialises to any writer. Pair it with std.Io.Writer.Allocating to produce a string.

Options control the shape of the output: .whitespace = .indent_2 for something a person will read, the default for something a machine will. Since it writes to any std.Io.Writer, serialising straight to a file or a socket costs no intermediate string.