# 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: ```zig 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: ```zig 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: ```zig 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: ```zig 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: ```zig 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: ```zig 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](https://tangled.sh/@zzstoatzz.io/coral/tree/main/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: ```zig 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: ```zig // 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](https://tangled.sh/zzstoatzz.io/logfire-zig/tree/main/src/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)