Need help with your JSON?
Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool
Zig Language JSON Formatter Implementations
If you are building a JSON formatter in Zig today, the most practical default is to parse arbitrary input into std.json.Value and write it back out with std.json.Stringify.value(...). As of Zig 0.15.2, the standard library already gives you the core pieces you need: RFC 8259 parsing, pretty-printing and minifying via whitespace options, and a lower-level streaming writer when you need manual control.
The old advice to hand-roll a formatter with string manipulation is still bad advice. In Zig, the real choice is between three implementations: parse and re-serialize arbitrary JSON, parse into a typed schema, or emit JSON directly with the streaming writer.
1. Best Default for User-Supplied JSON
For a formatter CLI, web handler, or editor integration, parse into std.json.Value. That gives you full JSON validation and lets the formatter handle unknown shapes without needing a compile-time Zig type.
The most important current detail is numeric round-tripping. When you parse into std.json.Value, ParseOptions.parse_numbers defaults to true, which may turn some numbers into integer or float values. For a formatter, that can normalize numeric spelling. If you want safer round-tripping for arbitrary JSON, set parse_numbers = false so numbers stay as number_string.
Pretty-print Arbitrary JSON
const std = @import("std");
pub fn formatAnyJson(
allocator: std.mem.Allocator,
input: []const u8,
writer: *std.io.Writer,
) !void {
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, input, .{
.parse_numbers = false,
});
defer parsed.deinit();
try std.json.Stringify.value(parsed.value, .{
.whitespace = .indent_2,
}, writer);
}
For minified output, keep the same implementation and switch .whitespace to .minified, which is the default.
2. Typed Parsing When You Want Validation
If the formatter is really a normalizer for a known config format, parse straight into a Zig struct instead of std.json.Value. This is the right implementation when you want schema checks, default values, and explicit unknown-field handling.
Format a Known JSON Schema
const std = @import("std");
const AppConfig = struct {
name: []const u8,
port: u16 = 8080,
features: []const []const u8,
log_file: ?[]const u8 = null,
};
pub fn normalizeConfig(
allocator: std.mem.Allocator,
input: []const u8,
writer: *std.io.Writer,
) !void {
var parsed = try std.json.parseFromSlice(AppConfig, allocator, input, .{
.ignore_unknown_fields = false,
});
defer parsed.deinit();
try std.json.Stringify.value(parsed.value, .{
.whitespace = .indent_2,
.emit_null_optional_fields = false,
}, writer);
}
This approach is cleaner than using std.json.Value when the input shape is fixed, but it is not a drop-in replacement for a general formatter. You lose the ability to preserve unexpected fields, and your output is constrained by the Zig type you chose.
3. Streaming Output for Large or Generated Payloads
When you are generating JSON yourself, or you need to avoid building a full tree before writing output, use the low-level std.json.Stringify writer directly. This is the closest thing Zig currently has to a manual JSON write stream in the standard library.
Manual Streaming Writer
const std = @import("std");
pub fn writeResponse(writer: *std.io.Writer) !void {
var jw: std.json.Stringify = .{
.writer = writer,
.options = .{ .whitespace = .indent_2 },
};
try jw.beginObject();
try jw.objectField("ok");
try jw.write(true);
try jw.objectField("items");
try jw.beginArray();
try jw.write("alpha");
try jw.write("beta");
try jw.endArray();
try jw.endObject();
}
The same writer also exposes print, beginWriteRaw, and beginObjectFieldRaw for edge cases such as custom number formatting or very long streamed strings. Use those only when you truly need them; for most formatter code, write and objectField are enough.
Current Options Worth Exposing
A useful formatter usually needs only a small set of options. In current Zig, the highest-value ones are:
- Whitespace mode:
.minified,.indent_1,.indent_2,.indent_3,.indent_4,.indent_8, or.indent_tab. - Optional null output:
emit_null_optional_fieldscontrols whether null optional fields are emitted when serializing Zig structs. - Unicode escaping:
escape_unicodeforces non-ASCII characters to be escaped. - Byte-slice behavior:
emit_strings_as_arrayscan serialize[]u8as numeric arrays instead of JSON strings. - Large-number portability:
emit_nonportable_numbers_as_stringscan quote numbers outside the precise integer range off64. - Strictness during parsing:
ignore_unknown_fields,duplicate_field_behavior, andallocatematter when you are parsing into a typed Zig value.
Practical Caveats
- Always call
deinit():parseFromSlicereturns astd.json.Parsed(T)wrapper that owns arena-allocated memory. - Formatting is not canonicalization: the standard library pretty-prints and minifies, but it does not add built-in key sorting for canonical JSON output.
- Comments and trailing commas are not JSON: current
std.jsonis aimed at RFC 8259 JSON, so invalid input should be rejected instead of guessed at. - Parse-and-rewrite still uses memory: for arbitrary user input, it is the simplest correct choice; for massive generated output, manual streaming is the better fit.
Recommendation
If your goal is a real Zig JSON formatter, start with std.json.Value plus std.json.Stringify.value. Add parse_numbers = false when you care about preserving numeric lexemes, expose a whitespace mode flag, and only move to the lower-level writer when you need streaming output or custom emission behavior.
Need help with your JSON?
Try our JSON Formatter tool to automatically identify and fix syntax errors in your JSON. JSON Formatter tool