Write Deterministic JSON for Local CLI Reports

Batu Lab Notes · Batu

Unstable serialization can create noisy diffs and brittle golden-output comparisons. Define a report policy for indentation, object-key ordering, character encoding, numeric values, array order, and trailing newlines.

Quick answer

For repeatable report files, use fixed indentation, sort object keys, preserve a defined array order, write UTF-8 with LF line endings, and reject non-finite numbers. Check bytes when exact output formatting is part of your CLI contract.

Runnable example

Save this as write_report.py in a temporary working directory. It creates or overwrites report.json in that directory. The filenames inside the report are synthetic strings; the script does not read those files. Run:

python3 write_report.py
import json
from pathlib import Path


def write_report(path: Path, report: dict) -> None:
    rendered = json.dumps(
        report,
        indent=2,
        sort_keys=True,
        ensure_ascii=False,
        allow_nan=False,
    )

    with path.open("w", encoding="utf-8", newline="\n") as output:
        output.write(rendered)
        output.write("\n")


report = {
    "summary": {"warnings": 1, "files_checked": 3},
    "tool": "local-report",
    "findings": [
        {"path": "README.md", "status": "ok"},
        {"path": ".env", "status": "warning"},
    ],
}

write_report(Path("report.json"), report)
print("Wrote report.json")

Expected output

The command prints:

Wrote report.json

The resulting report.json is:

{
  "findings": [
    {
      "path": "README.md",
      "status": "ok"
    },
    {
      "path": ".env",
      "status": "warning"
    }
  ],
  "summary": {
    "files_checked": 3,
    "warnings": 1
  },
  "tool": "local-report"
}

The file ends with one newline after the closing brace.

Serialization policy

  • indent=2 fixes the pretty-printed layout.
  • sort_keys=True sorts dictionary keys, reducing differences caused only by construction order.
  • ensure_ascii=False preserves non-ASCII characters when the file is written as UTF-8.
  • allow_nan=False raises ValueError for NaN, Infinity, and -Infinity, which are outside strict JSON.
  • newline="\n" and the explicit final "\n" establish the intended line-ending and trailing-newline policy.

Python documents these json options and their behavior in the standard-library reference. [1]

Golden-output fixture

If formatting is part of the CLI contract, compare complete bytes. Create tests/fixtures/report.expected.json, copy the expected JSON shown above, and save it as UTF-8 with LF line endings and one final newline. Then run this check from the working directory:

from pathlib import Path

actual = Path("report.json").read_bytes()
expected = Path("tests/fixtures/report.expected.json").read_bytes()

assert actual == expected

This comparison checks the complete encoded result, including key order, indentation, line endings, and the trailing newline. A text reader may normalize line endings; a byte comparison keeps that difference visible. Review a fixture update against the intended output rather than replacing it automatically when a test fails.

Limits

  • Sorting dictionary keys does not sort arrays. Define array order in the report schema, and sort records only when their order has no semantic meaning.
  • JSON consumers should not depend on object-member order for data semantics, even when stable ordering is useful for readable diffs.
  • Use consistent string keys when relying on sorted output.
  • Exact-text fixtures will change when the serialization policy changes; review such changes intentionally.
  • allow_nan=False rejects non-finite floating-point values but does not validate the broader meaning or quality of the report.
  • This is a report-formatting policy, not a cross-language canonical JSON format for cryptographic signatures. Keep the schema, serializer settings and runtime under review.
  • Writing is not atomic and the full report is built in memory. A failed write can leave a partial file. Large reports or crash-safe persistence need a different storage design.

Related guide

Use the bounded Python text-file comparison guide when you need a readable diff after a byte comparison fails. Avoid sharing a report diff before checking it for private data.

Sources

[1] Python Software Foundation, “json — JSON encoder and decoder,” verified source packet retrieved 2026-09-08. The reference documents indent, sort_keys, ensure_ascii, allow_nan, separators, output ordering, and JSON serialization behavior.

[2] Python documentation: Path.open and file access.

[3] Python documentation: open encoding, newline handling and write mode.

Disclosure: Prepared with AI assistance. The example and expected output were independently checked with synthetic data on Python 3.14.7 on 8 September 2026. This validates the stated cases, not every possible input or operating system.

Comments

Popular posts from this blog

Compare Two Text Files in Python Without Modifying Them

Seven CSV Quality Checks to Run Before Importing Data

Explain why SQLite transactions cannot make an HTTP call atomic