about things notes.zzstoatzz.io
notes

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.

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