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, callwrite()next for the valuewrite(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
sources #
- coral — backend/src/entities.zig
- logfire-zig — src/exporter.zig
- tpuf — src/query.zig (tuple builders,
Attributes,Id), src/Namespace.zig (2026-09)