November 8, 2026•7 min read

Python stdlib: the datetime json.dumps Refused to Write

A script that totals minutes per day dies on the line that writes the report. Four stdlib modules carry it, and one TypeError stands in the way.

Read in Indonesian

You point the script at a folder of three log files. It reads them, prints nothing, and dies with a TypeError on the line that writes the report. The last two lines of the error are these:

TEXT
$ python3 daily.py logs
TypeError: Object of type datetime is not JSON serializable
when serializing dict item 'generated_at'

The second line names the dict key that held the value json refused. The numbers here come from Python 3.14.9 on Linux.

Key takeaways

  • json.dumps writes a fixed set of basic types, and a datetime is not one of them, so the encoder raises instead of choosing a format for you.
  • datetime.now() returns the machine's local time with no timezone, and isoformat(timespec="seconds") is the documented way to turn it into the string json can write.
  • argparse answers -h without being asked, pathlib turns a folder into paths and text, and json turns text into lists and dicts.
  • A checker that only asserts the stamp parses cannot tell timespec="hours" from timespec="seconds", so it proves the shape, not the precision.

Four modules, one small program

daily.py adds up minutes per day from a folder of session logs. Each of four standard-library modules has one job.

PYTHON
# daily.py
import argparse
import json
from datetime import datetime
from pathlib import Path

parser = argparse.ArgumentParser(description="Minutes per day from a folder of session logs")
parser.add_argument("folder")
args = parser.parse_args()

total = {}
for path in sorted(Path(args.folder).glob("*.json")):
    if path.name == "summary.json":
        continue
    for session in json.loads(path.read_text()):
        total[session["date"]] = total.get(session["date"], 0) + session["minutes"]

report = {
    "generated_at": datetime.now(),
    "days": [{"date": day, "minutes": minutes} for day, minutes in sorted(total.items())],
}

out = Path(args.folder) / "summary.json"
out.write_text(json.dumps(report, indent=2))
print(f"{len(report['days'])} days from {len(list(Path(args.folder).glob('*.json'))) - 1} files")

argparse reads one argument, the folder. You never asked for -h, but it exists anyway:

TEXT
$ python3 daily.py --help
usage: daily.py [-h] folder

Minutes per day from a folder of session logs

positional arguments:
  folder

options:
  -h, --help  show this help message and exit

pathlib builds the paths. glob("*.json") lists the log files, and read_text() hands you each one as a string. json turns that text into lists and dicts, then turns the report back into text. datetime supplies the stamp.

The glob also matches summary.json, the file the script is about to write. The if skips it. Without that, a second run would read its own output as a session log. The - 1 in the last line removes it from the file count, since it exists by then.

Why json will not take a datetime

The json module writes a short list of types: string, number, boolean, None, list and dict. A datetime is not on it.

When json.dumps meets a type outside that list, it raises a TypeError. It does not guess a format. A date can be written many ways, so someone has to choose one. ISO 8601 is the choice here: year, month, day, a T, then the time. The documented way to get that string is isoformat().

The checker

check_daily.py is a plain script with no test framework. Run it from the folder that holds daily.py.

PYTHON
# check_daily.py
import json
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path

FIXTURE = {
    "mon.json": [{"date": "2026-11-06", "minutes": 25}, {"date": "2026-11-06", "minutes": 35}],
    "tue.json": [{"date": "2026-11-07", "minutes": 40}],
    "wed.json": [{"date": "2026-11-02", "minutes": 15}, {"date": "2026-11-08", "minutes": 50}],
}
EXPECTED = {"2026-11-02": 15, "2026-11-06": 60, "2026-11-07": 40, "2026-11-08": 50}


def report(results):
    for label, ok, detail in results:
        print(f"{'PASS' if ok else 'FAIL'}  {label}" + (f"  <- {detail}" if not ok and detail else ""))
    passed = sum(1 for _, ok, _ in results if ok)
    print(f"\n{passed} of {len(results)} checks passed")
    return passed == len(results)


def main():
    folder = Path(tempfile.mkdtemp())
    for name, sessions in FIXTURE.items():
        (folder / name).write_text(json.dumps(sessions))

    run = subprocess.run([sys.executable, "daily.py", str(folder)], capture_output=True, text=True)
    crash = ""
    for line in reversed(run.stderr.strip().splitlines()):
        if line.split(":")[0].endswith("Error"):
            crash = line
            break
    if run.returncode != 0:
        report([("the script runs without crashing", False, crash)])
        return 1

    results = [("the script runs without crashing", True, "")]
    out = folder / "summary.json"
    results.append(("summary.json is written", out.exists(), "no file"))
    if not out.exists():
        report(results)
        return 1

    text = out.read_text()
    try:
        summary = json.loads(text)
    except json.JSONDecodeError as exc:
        report(results + [("summary.json is valid JSON", False, str(exc))])
        return 1
    results.append(("summary.json is valid JSON", True, ""))

    days = summary.get("days", [])
    results.append(("one row per calendar day", len(days) == 4, f"got {len(days)}"))
    minutes = {row["date"]: row["minutes"] for row in days if isinstance(row.get("date"), str)}
    results.append(("the two sessions on 2026-11-06 are added up", minutes.get("2026-11-06") == 60, f"got {minutes.get('2026-11-06')}"))
    results.append(("every day carries its own total", minutes == EXPECTED, str(minutes)))
    results.append(("the rows come out in date order", [row["date"] for row in days] == sorted(EXPECTED), str([row["date"] for row in days])))

    stamp = summary.get("generated_at")
    iso = False
    if isinstance(stamp, str):
        try:
            iso = datetime.fromisoformat(stamp) is not None
        except ValueError:
            iso = False
    results.append(("generated_at is an ISO 8601 string", iso, repr(stamp)))
    results.append(("the script prints its own summary line", run.stdout.strip() == "4 days from 3 files", repr(run.stdout.strip())))

    return 0 if report(results) else 1


if __name__ == "__main__":
    sys.exit(main())

It writes a fixed fixture folder: three files, with two sessions on 2026-11-06 in one of them. It runs daily.py on that folder, then asserts on what the script printed and on the JSON it wrote. Against the starter script, the result is red:

TEXT
$ python3 check_daily.py
FAIL  the script runs without crashing  <- TypeError: Object of type datetime is not JSON serializable

0 of 1 checks passed

The exit code is 1. The text after the arrow is the error line the checker pulled from stderr. It stops at the first failure on purpose. A crashed script writes no summary.json, so the other checks would have nothing to look at.

The one line that fixes it

datetime.now() returns the machine's local time with no timezone attached. Add .isoformat(timespec="seconds") and you get a string, which json already knows how to write. Only the generated_at line changes.

PYTHON
# daily.py
import argparse
import json
from datetime import datetime
from pathlib import Path

parser = argparse.ArgumentParser(description="Minutes per day from a folder of session logs")
parser.add_argument("folder")
args = parser.parse_args()

total = {}
for path in sorted(Path(args.folder).glob("*.json")):
    if path.name == "summary.json":
        continue
    for session in json.loads(path.read_text()):
        total[session["date"]] = total.get(session["date"], 0) + session["minutes"]

report = {
    "generated_at": datetime.now().isoformat(timespec="seconds"),
    "days": [{"date": day, "minutes": minutes} for day, minutes in sorted(total.items())],
}

out = Path(args.folder) / "summary.json"
out.write_text(json.dumps(report, indent=2))
print(f"{len(report['days'])} days from {len(list(Path(args.folder).glob('*.json'))) - 1} files")

A real run on a folder with three log files printed exactly 4 days from 3 files. The checker agrees, with exit code 0:

TEXT
$ python3 check_daily.py
PASS  the script runs without crashing
PASS  summary.json is written
PASS  summary.json is valid JSON
PASS  one row per calendar day
PASS  the two sessions on 2026-11-06 are added up
PASS  every day carries its own total
PASS  the rows come out in date order
PASS  generated_at is an ISO 8601 string
PASS  the script prints its own summary line

9 of 9 checks passed

The summary.json from that real run began with the stamp, then four day rows:

JSON
{
  "generated_at": "2026-11-08T00:01:43",
  "days": [
    {
      "date": "2026-11-02",
      "minutes": 15
    },
    {
      "date": "2026-11-06",
      "minutes": 60
    },
    {
      "date": "2026-11-07",
      "minutes": 40
    },
    {
      "date": "2026-11-08",
      "minutes": 50
    }
  ]
}

The 60 is a sum. That file held two sessions, 25 and 35 minutes.

What the checker cannot see

Change timespec="seconds" to timespec="hours" and run the checker again. It still prints 9 of 9 checks passed and exits 0. datetime.fromisoformat accepts that shorter string. The date check proves the stamp parses. It does not prove the stamp has the precision you wanted.

Before the next article

Delete sorted() around total.items() and run the checker once more. You get 8 of 9 checks and exit code 1:

TEXT
$ python3 check_daily.py
PASS  the script runs without crashing
PASS  summary.json is written
PASS  summary.json is valid JSON
PASS  one row per calendar day
PASS  the two sessions on 2026-11-06 are added up
PASS  every day carries its own total
FAIL  the rows come out in date order  <- ['2026-11-06', '2026-11-07', '2026-11-02', '2026-11-08']
PASS  generated_at is an ISO 8601 string
PASS  the script prints its own summary line

8 of 9 checks passed

Dicts keep insertion order, so the rows come out in the order the files were read. A row order you did not sort is an order you did not choose.

Get better at shipping code.

Join the other developers getting tech updates, tips, and relaxed tutorials in their inbox every week.

Stay in the loop

Tech updates, coding tips, and relaxed insights straight to your inbox every week.

No spam. Unsubscribe anytime.

Read next

See all
Struktur DataWhat a Linked List Actually Is

A linked list is a linear chain of nodes, each holding data and a pointer to the next node. Here is what that chain looks like, what it costs next to an array, and the same minimal implementation in Java, Python, and JavaScript.

For developers who learn by struggling

© 2026 Inva.dev.