⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1456+2b1c6633aOn an older Zig?

Recipe: A Custom JSON Serializer

The problem

The default JSON serializer emits your struct exactly as declared: same field names, same values, nothing more. Often the wire format has to differ. The field is is_admin in Zig but admin in the API, or the response needs a value you compute rather than store. You want that control without hand-rolling string concatenation and without giving up the checks that keep the output valid.

Zig’s answer is a hook: declare pub fn jsonStringify on the type and std.json.Stringify calls it instead of walking the fields.

The plan

  1. Define the struct as usual.
  2. Add pub fn jsonStringify(self: T, jws: anytype) !void. jws is a pointer to the write stream.
  3. Drive the stream: beginObject, then objectField(name) and write(value) per field, then endObject.
  4. Serialize with std.json.Stringify.value(x, .{}, writer). The hook is picked up automatically.
const std = @import("std");

// Identical fields, no hook: this is what the default serializer emits.
const Plain = struct {
    id: u64,
    name: []const u8,
    is_admin: bool,
    created_at: i64,
};

const User = struct {
    id: u64,
    name: []const u8,
    is_admin: bool,
    created_at: i64,

    // When present, std.json.Stringify calls this instead of walking the
    // fields. `jws` is a *std.json.Stringify write stream. It balances
    // begin/end and quotes strings for you, so the output is always valid.
    pub fn jsonStringify(self: User, jws: anytype) !void {
        try jws.beginObject();

        try jws.objectField("user_id"); // rename id
        try jws.write(self.id);

        try jws.objectField("full_name"); // rename name
        try jws.write(self.name);

        try jws.objectField("admin"); // rename is_admin
        try jws.write(self.is_admin);

        // A computed field the struct does not store: whole days since epoch.
        try jws.objectField("epoch_day");
        try jws.write(@divTrunc(self.created_at, std.time.s_per_day));

        try jws.endObject();
    }
};

pub fn main(init: std.process.Init) !void {
    var buf: [1024]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &file_writer.interface;

    const id = 101;
    const name = "Ziggy Stardust";
    const admin = true;
    const ts = 1672531200; // 2023-01-01T00:00:00Z

    try out.writeAll("default: ");
    try std.json.Stringify.value(
        Plain{ .id = id, .name = name, .is_admin = admin, .created_at = ts },
        .{},
        out,
    );
    try out.writeAll("\n");

    try out.writeAll("custom:  ");
    try std.json.Stringify.value(
        User{ .id = id, .name = name, .is_admin = admin, .created_at = ts },
        .{},
        out,
    );
    try out.writeAll("\n");

    try out.flush();
}

The hook, field by field

objectField("user_id") writes the key, and write(self.id) writes the value with normal serialization, including quoting strings and escaping them. Because you name each key yourself, renaming is just typing a different string: is_admin becomes admin, name becomes full_name. The stream tracks structure, so if you forget an endObject or write a value where a key belongs, it is a compile-time or runtime error rather than silently malformed JSON.

Computed fields

epoch_day is not a struct field. The hook computes it from created_at and writes it like any other value. This is the main reason to write a custom serializer: the output can carry derived data (a formatted timestamp, a signed URL, a total) that you would not want to store on the struct itself.

write versus print

write(value) serializes a Zig value as JSON: numbers stay numbers, strings get quoted. When you need to emit a literal fragment, jws.print("{d}", .{n}) writes raw text into the stream, so you are responsible for the quotes and escaping. Prefer write unless you specifically need the raw form; it is the one that cannot produce invalid output.

Variations

  • Whitespace: std.json.Stringify.value(x, .{ .whitespace = .indent_2 }, w) pretty-prints with two-space indentation.
  • Omitting fields: a hook can skip a field entirely (write nothing for it), which the default serializer cannot do.
  • Unions and enums: the same jsonStringify hook works on tagged unions and enums, not just structs.
  • The read side: parsing back is covered by the JSON config recipe, which validates the shape as it loads.