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, 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