about things notes.zzstoatzz.io
notes
6.8 kB
Markdown
at main

json #

written against zig 0.15; value serialization section checked against 0.16.

building and parsing json. std.json.Stringify for output and std.json.parse for input.

building json: describe the value, don't drive the writer #

Stringify.write(v) serializes any value: a struct becomes an object (fields in declaration order), a tuple becomes an array (so heterogeneous json arrays like ["vector", "ANN", [0.1, …]] are just .{ "vector", "ANN", vec }), an optional becomes null, []const u8 becomes a string, and nested anonymous structs nest. Stringify.valueAlloc(gpa, v, .{}) does the whole body in one call:

const body = try std.json.Stringify.valueAlloc(alloc, .{
    .rank_by = .{ "Max", .{ .{ "name", "BM25", text }, .{ "caption", "BM25", text } } },
    .top_k = top_k,
    .include_attributes = .{ "name", "url" },
}, .{});
defer alloc.free(body);

that replaced 83 jw.beginObject/objectField/write/endArray lines across four modules of a search backend with four value literals — same bytes out, and the shape of the request is now readable as a shape. reach for the incremental api below only when the value can't be expressed as a literal: streaming a large array, or embedding raw pre-serialized json.

Options defaults: .minified, emit_null_optional_fields = true. set emit_null_optional_fields = false for request bodies where an absent field means "default" and null would be rejected.

functions that return tuples compose into these literals, which is how a client exposes an api's grammar without a builder type:

fn Triple(comptime C: type) type { return struct { []const u8, []const u8, C }; }
pub fn eq(attr: []const u8, value: anytype) Triple(@TypeOf(value)) { return .{ attr, "Eq", value }; }
pub fn @"and"(clauses: anytype) struct { []const u8, @TypeOf(clauses) } { return .{ "And", clauses }; }

.filters = @"and"(.{ eq("kind", "post"), eq("status", "live") }),
// ["And",[["kind","Eq","post"],["status","Eq","live"]]]

gotcha: a union's void field in an anonymous literal is the tag, not the union #

given const Attrs = union(enum) { all, only: []const []const u8 } with a custom jsonStringify, writing .{ .include_attributes = Attrs.all } in an anonymous struct literal serializes "all", not what jsonStringify says: in that position Attrs.all resolves to the tag enum (@typeInfo(Attrs).@"union".tag_type), and the enum has no custom serializer. a typed field (x: Attrs = .all) gets the union. the fix that survives both positions is a typed declaration on the union:

pub const all: Attrs = .every;          // field renamed so the decl can own the name
.include_attributes = Attrs.all,        // now a union value everywhere

parsing a bare value into a union #

parseFromValue(T, ...) expects a tagged union as {"tag": value}. for a field that is a bare string-or-integer on the wire (an id), give the union jsonParse and jsonParseFromValue that switch on the token instead:

pub fn jsonParseFromValue(alloc: Allocator, source: json.Value, options: json.ParseOptions) !Id {
    _ = alloc; _ = options;
    return switch (source) {
        .string => |s| .{ .string = s },
        .integer => |i| .{ .integer = @intCast(i) },
        else => error.UnexpectedToken,
    };
}

parseFromValue(T, alloc, value, .{ .ignore_unknown_fields = true }) then turns one dynamic object into a caller struct, with @"$dist": f64 reaching a key that isn't an identifier.

gotcha: static parses may borrow the input; Value never does #

parseFromSlice(T, ...) defaults to allocate = .alloc_if_needed, so a []const u8 field can point into the slice that was parsed and the Parsed must not outlive that buffer (or pass .allocate = .alloc_always). parseFromSlice(json.Value, ...) copies every string and key regardless of the option, so a Parsed(Value) is self-contained and the input can be freed at once.

building json incrementally #

use json.Stringify with a writer. call methods to build the structure incrementally:

const std = @import("std");
const json = std.json;

fn buildJson(allocator: std.mem.Allocator, data: MyData) ![]u8 {
    var output: std.Io.Writer.Allocating = .init(allocator);
    errdefer output.deinit();
    var jw: json.Stringify = .{ .writer = &output.writer };

    try jw.beginObject();
    try jw.objectField("name");
    try jw.write(data.name);           // strings, ints, floats, bools
    try jw.objectField("count");
    try jw.write(data.count);
    try jw.objectField("items");
    try jw.beginArray();
    for (data.items) |item| {
        try jw.write(item);
    }
    try jw.endArray();
    try jw.endObject();

    return output.toOwnedSlice();
}

key methods:

  • beginObject() / endObject() - { and }
  • beginArray() / endArray() - [ and ]
  • objectField("key") - write a key, call write() next for the value
  • write(value) - writes any json-serializable value (handles UTF-8 and escaping correctly)

fixed buffer (no allocation) #

when you have a stack buffer and don't want to allocate:

fn toJson(data: MyData, buf: []u8) []const u8 {
    var w: std.Io.Writer = .fixed(buf);
    var jw: std.json.Stringify = .{ .writer = &w };

    jw.beginObject() catch return "{}";
    jw.objectField("name") catch return "{}";
    jw.write(data.name) catch return "{}";
    jw.endObject() catch return "{}";

    return w.buffered();  // slice of what was written
}

key difference: use std.Io.Writer = .fixed(buf) and call w.buffered() to get the written slice.

see: coral/backend/src/entities.zig

raw json passthrough #

when you have a json string that's already valid and want to embed it without re-parsing:

try jw.objectField("nested");
try jw.beginWriteRaw();
try jw.writer.writeAll(raw_json_string);
jw.endWriteRaw();

useful when proxying json from external apis.

gotcha: numbers vs strings #

otlp and other protocols care about the difference. timestamps are often strings (to avoid precision loss with large nanosecond values), but counts are numbers:

// timestamp as string
try jw.objectField("timeUnixNano");
var buf: [32]u8 = undefined;
const s = std.fmt.bufPrint(&buf, "{d}", .{timestamp_ns}) catch unreachable;
try jw.write(s);  // "1234567890000000000"

// count as number
try jw.objectField("count");
try jw.write(count);  // 42

see: logfire-zig/exporter.zig

sources #

  • coral — backend/src/entities.zig
  • logfire-zig — src/exporter.zig
  • tpuf — src/query.zig (tuple builders, Attributes, Id), src/Namespace.zig (2026-09)