Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Python Write JSON to File: dump, dumps, and the Encoding That Bites

Sean

Platform Writer

Aug 20, 2026
7 min read

json.dump(data, file) writes JSON straight to an open file. json.dumps(data) returns it as a string. The trailing s stands for string, which is the whole distinction and the reason the two get mixed up constantly.

Python Write JSON to File: dump, dumps, and the Encoding That Bites

The base case is one line and works. What follows is everything the one-liner does not handle: non-ASCII text, types the encoder refuses, and the file that ends up half-written when something goes wrong partway through.

Table of contents

The two functions

import json

data = {"name": "Sam", "roles": ["admin", "editor"], "active": True}

# Write directly to a file
with open("data.json", "w", encoding="utf-8") as f:
    json.dump(data, f, indent=4)

# Get a string instead
text = json.dumps(data, indent=4)

Open the file in text mode — "w", not "wb". The encoder produces str, and a binary handle raises a type error that reads confusingly if you have not seen it before.

Two arguments earn their place in almost every call:

  • indent=2 or indent=4 makes the output readable and diff-friendly. Without it everything is one line, which is fine for machines and painful in a pull request.
  • sort_keys=True gives deterministic key order, so two runs over equivalent data produce identical files. That turns a config or fixture file into something version control can show meaningful diffs for.

For compactness where size matters, override the separators — the defaults include a space after each delimiter:

json.dumps(data, separators=(",", ":"))
# {"name":"Sam","roles":["admin","editor"],"active":true}

Reading back is symmetrical: json.load(f) from a file, json.loads(s) from a string.

Encoding, and the mojibake default

Two separate encoding decisions interact here, and getting either wrong produces unreadable text.

The file handle. Always pass encoding="utf-8". Without it, Python uses the platform default, which on some Windows configurations is a legacy codepage. The same script then produces different bytes on different machines, and a name containing an accent fails to round-trip.

The encoder. ensure_ascii defaults to True, which escapes every non-ASCII character into a \u sequence. The file is valid JSON and completely unreadable:

data = {"city": "München", "note": "café"}

json.dumps(data)
# {"city": "M\u00fcnchen", "note": "caf\u00e9"}

json.dumps(data, ensure_ascii=False)
# {"city": "München", "note": "café"}

Both parse back to the same data, so this is a readability choice rather than a correctness one — but a config file full of escape sequences is a config file nobody will edit by hand.

The combination worth defaulting to:

with open("data.json", "w", encoding="utf-8") as f:
    json.dump(data, f, ensure_ascii=False, indent=2)

One caveat: ensure_ascii=False requires the file to actually be UTF-8. Combined with a handle that defaulted to a legacy codepage, it raises an encoding error — which is at least a loud failure rather than a silent corruption.

Types the encoder refuses

The encoder handles dict, list, tuple, str, int, float, bool and None. Everything else raises TypeError: Object of type X is not JSON serializable, and the usual culprits are datetimes, decimals, sets, UUIDs and NumPy scalars.

The default parameter takes a function called for anything the encoder cannot handle:

import json
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID

def encode(obj):
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    if isinstance(obj, Decimal):
        return str(obj)          # str, not float -- see below
    if isinstance(obj, UUID):
        return str(obj)
    if isinstance(obj, set):
        return sorted(obj)
    raise TypeError(f"Cannot serialize {type(obj).__name__}")

with open("data.json", "w", encoding="utf-8") as f:
    json.dump(data, f, default=encode, ensure_ascii=False, indent=2)

Decimal to str rather than float is deliberate. Converting a decimal to a float introduces binary floating-point error, which is exactly what Decimal exists to avoid — and if the value is money, that error is a bug with financial consequences. Serialise as a string and parse back as a Decimal.

Keep the final raise. Returning str(obj) for anything unrecognised makes the encoder silently succeed on objects it does not understand, producing output like "<MyClass object at 0x7f...>" that is valid JSON and useless data.

Writing a file that survives a crash

open(path, "w") truncates the file immediately, before anything is written. If serialisation raises halfway through, or the process is killed, or the disk fills, you are left with a truncated file where the good one used to be.

For anything you would be unhappy to lose — a config file, a state file, a cache your application reads at startup — write to a temporary file and rename:

import json
import os
import tempfile

def write_json(path, data):
    directory = os.path.dirname(os.path.abspath(path))
    fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, path)
    except BaseException:
        os.unlink(tmp)
        raise

Three details do the work. The temporary file is created in the same directory, because os.replace is only atomic within a filesystem. The fsync forces the data to disk before the rename, so a power loss cannot leave a renamed-but-empty file. And os.replace overwrites atomically on both POSIX and Windows — readers see either the old file or the new one, never a partial.

This is roughly fifteen lines and it eliminates an entire category of corruption bug. Worth having in a utility module.

When JSON is the wrong format

JSON is a good default and a poor fit for several common cases:

  • Large datasets. json.load builds the whole structure in memory. A one-gigabyte file needs several gigabytes of RAM. Use JSON Lines — one object per line — so you can stream it record by record.
  • Append-only logs. JSON is a single document; appending means rewriting the whole file. JSON Lines appends naturally.
  • Human-edited configuration. No comments, no trailing commas, strict quoting. TOML or YAML is kinder to whoever has to edit it.
  • Binary data. Base64 in JSON inflates it by a third and is slow to encode. Store the bytes separately and keep a reference.
  • Data where types matter exactly. JSON has one number type. Integers beyond the safe range lose precision in some consumers, and there is no native date.

JSON Lines is the one worth reaching for most often, and it needs no library:

# Write incrementally
with open("events.jsonl", "a", encoding="utf-8") as f:
    for event in events:
        f.write(json.dumps(event, ensure_ascii=False) + "\n")

# Read one record at a time, constant memory
with open("events.jsonl", encoding="utf-8") as f:
    for line in f:
        event = json.loads(line)

How this fits the rest of the stack

Most JSON-writing bugs are not about JSON. They are about encoding defaults that differ between machines, and about a write that was interrupted partway through — both of which show up as a file that works on your laptop and fails in production.

That is also the argument against keeping state in files on a server at all. A JSON file on disk cannot be queried, is not backed up unless you arranged it, and disappears when the container is replaced. RunxBuild gives you the alternatives directly: managed MySQL and Postgres with backups, connection limits and private networking, and persistent storage that attaches to a service for the files that genuinely belong on disk. Services in Node, Next.js, Python, Go, Ruby, Java, .NET or Docker deploy from your GitHub repository with build and runtime logs in one place and rollback to the previous deploy. To see what a service, a database and storage add up to, the RunxBuild hosting calculator lists them as separate line items.

Useful related references:

FAQ

What is the difference between json.dump and json.dumps?

json.dump(data, file) writes JSON to an open file object. json.dumps(data) returns it as a string — the trailing s stands for string. Their reading counterparts follow the same rule: json.load reads from a file, json.loads from a string.

Why does my JSON file show escape sequences instead of accented characters?

Because ensure_ascii defaults to True, escaping every non-ASCII character into a \u sequence. Pass ensure_ascii=False for readable output, and make sure the file handle was opened with encoding="utf-8" — otherwise you get an encoding error, or platform-dependent bytes.

How do I write a datetime to JSON?

Pass a default function that converts unsupported types, typically returning obj.isoformat() for dates and datetimes. Convert Decimal to a string rather than a float, since float conversion reintroduces the binary rounding error that Decimal exists to prevent. Keep a raise at the end so unknown types fail loudly.

How do I avoid corrupting the file if the write fails?

Write to a temporary file in the same directory, flush and fsync it, then use os.replace to move it over the target. Opening the destination in write mode truncates it immediately, so a crash partway through leaves a truncated file where the good one was. The rename is atomic, so readers see one version or the other.

Should I use JSON for large datasets?

Not as a single document — json.load builds the entire structure in memory, so a large file needs several times its size in RAM. Use JSON Lines instead, with one object per line: it streams record by record at constant memory and appends without rewriting the file.

#python write json to file#python#json#file io#serialization