Emmanuel Akinfulubi

Files and data all 3 written

Reading and writing real files, the text encoding problem, and the two data formats you will meet constantly.

25 Files, pathlib, and the encoding you must name

Reading and writing files is straightforward. The two things that cause real trouble are paths — which people build by gluing strings together — and text encoding, which people leave to a default that differs between machines. Both have one-line fixes worth adopting permanently.

Read and write, with with

Writing and reading a text file

ran at build time
from pathlib import Path

# Write, then read. The with block closes the file either way (lesson 24).
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("first line\n")
    f.write("second line\n")

with open("notes.txt", encoding="utf-8") as f:
    content = f.read()

print("whole file:", repr(content))

# For anything this small, pathlib does it in one call.
Path("notes2.txt").write_text("via pathlib\n", encoding="utf-8")
print("pathlib read:", repr(Path("notes2.txt").read_text(encoding="utf-8")))

test("the file round-tripped", content, "first line\nsecond line\n")
test("pathlib's one-liner is equivalent", Path("notes2.txt").read_text(encoding="utf-8"), "via pathlib\n")

What it printed, when the build ran it

whole file: 'first line\nsecond line\n'
pathlib read: 'via pathlib\n'
ok   the file round-tripped
ok   pathlib's one-liner is equivalent

The four ways to read, and which to use

read, readlines, iteration, splitlines

ran at build time
from pathlib import Path

Path("lines.txt").write_text("alpha\nbeta\ngamma\n", encoding="utf-8")

with open("lines.txt", encoding="utf-8") as f:
    whole = f.read()                       # one string, newlines included

with open("lines.txt", encoding="utf-8") as f:
    as_list = f.readlines()                # a list, newlines still attached

with open("lines.txt", encoding="utf-8") as f:
    stripped = [line.rstrip("\n") for line in f]   # iterate: one line at a time

print("read():     ", repr(whole))
print("readlines():", as_list)
print("iterating:  ", stripped)
print()
print("splitlines() is usually what you actually wanted:")
print("  ", Path("lines.txt").read_text(encoding="utf-8").splitlines())

test("read gives one string", whole.count("\n"), 3)
test("readlines keeps the newlines", as_list[0], "alpha\n")
test("iterating and stripping gives clean lines", stripped, ["alpha", "beta", "gamma"])
test("splitlines does the stripping for you",
     Path("lines.txt").read_text(encoding="utf-8").splitlines(), ["alpha", "beta", "gamma"])

What it printed, when the build ran it

read():      'alpha\nbeta\ngamma\n'
readlines(): ['alpha\n', 'beta\n', 'gamma\n']
iterating:   ['alpha', 'beta', 'gamma']

splitlines() is usually what you actually wanted:
   ['alpha', 'beta', 'gamma']
ok   read gives one string
ok   readlines keeps the newlines
ok   iterating and stripping gives clean lines
ok   splitlines does the stripping for you

Iterating the file object is the one that scales. read() and readlines() both load the whole file into memory; iterating reads a line at a time. On a 10 GB log the difference is between working and being killed by the operating system. When the file is small and you want a list, read_text().splitlines() is the tidiest form.

Modes: the difference between w and a

w truncates, a appends, x refuses

ran at build time
from pathlib import Path

Path("log.txt").write_text("original\n", encoding="utf-8")

# "w" truncates the file to nothing before writing. This is the destructive one.
with open("log.txt", "w", encoding="utf-8") as f:
    f.write("replaced\n")
print('after "w": ', repr(Path("log.txt").read_text(encoding="utf-8")))

# "a" appends.
with open("log.txt", "a", encoding="utf-8") as f:
    f.write("appended\n")
print('after "a": ', repr(Path("log.txt").read_text(encoding="utf-8")))

# "x" refuses to overwrite, which is what you want for "must not already exist".
try:
    with open("log.txt", "x", encoding="utf-8") as f:
        f.write("never happens")
except FileExistsError as exc:
    print()
    print('mode "x" on an existing file:', type(exc).__name__)

test("w replaced the contents", "original" not in Path("log.txt").read_text(encoding="utf-8"), True)
test("a added to them", Path("log.txt").read_text(encoding="utf-8"), "replaced\nappended\n")

What it printed, when the build ran it

after "w":  'replaced\n'
after "a":  'replaced\nappended\n'

mode "x" on an existing file: FileExistsError
ok   w replaced the contents
ok   a added to them

Opening for writing destroys the file immediately. open(path, "w") truncates before you write a single byte — so a program that opens for writing and then crashes has already emptied the file. If the data matters, write to a temporary file and rename it over the original, which is atomic on every mainstream filesystem.

Replacing a file without risking it

ran at build time
from pathlib import Path
import os

target = Path("important.txt")
target.write_text("valuable data\n", encoding="utf-8")

# The safe replace: write beside it, then rename over it.
temp = Path("important.txt.tmp")
temp.write_text("new valuable data\n", encoding="utf-8")
os.replace(temp, target)          # atomic: the file is never half-written

print("after an atomic replace:", repr(target.read_text(encoding="utf-8")))
print("the temporary file is gone:", not temp.exists())

test("the replacement succeeded", target.read_text(encoding="utf-8"), "new valuable data\n")
test("and left no temporary behind", temp.exists(), False)

What it printed, when the build ran it

after an atomic replace: 'new valuable data\n'
the temporary file is gone: True
ok   the replacement succeeded
ok   and left no temporary behind

Paths: use pathlib, not string concatenation

Building and taking apart paths

ran at build time
from pathlib import Path

# The / operator joins path segments, correctly, on any platform.
base = Path("project")
config = base / "settings" / "app.toml"
print("joined:", config)

# Every part of a path is available without string surgery.
print()
print("name:  ", config.name)
print("stem:  ", config.stem)
print("suffix:", config.suffix)
print("parent:", config.parent)
print("parts: ", config.parts)

# Changing pieces returns a new path.
print()
print("with_suffix('.json'):", config.with_suffix(".json"))
print("with_name('other.toml'):", config.with_name("other.toml"))

test("/ joins segments", str(Path("a") / "b" / "c.txt"), "a/b/c.txt")
test("suffix is the extension", config.suffix, ".toml")
test("stem is the name without it", config.stem, "app")
test("with_suffix replaces the extension", Path("a/b.txt").with_suffix(".md").name, "b.md")

What it printed, when the build ran it

joined: project/settings/app.toml

name:   app.toml
stem:   app
suffix: .toml
parent: project/settings
parts:  ('project', 'settings', 'app.toml')

with_suffix('.json'): project/settings/app.json
with_name('other.toml'): project/settings/other.toml
ok   / joins segments
ok   suffix is the extension
ok   stem is the name without it
ok   with_suffix replaces the extension

The reason to prefer this over "dir" + "/" + "file" is not elegance. String joining produces doubled or missing separators depending on what its inputs happen to look like, and those bugs only appear with certain data.

Why string joining is the wrong tool

ran at build time
from pathlib import Path

directory = "project/"          # note the trailing slash
filename = "app.toml"

by_string = directory + "/" + filename
by_path = str(Path(directory) / filename)

print("string concatenation:", repr(by_string), "<- doubled separator")
print("pathlib:            ", repr(by_path))

# And pathlib normalises the messy cases.
print()
print("Path('a//b/./c'):", Path("a//b/./c"))

test("string joining produced a doubled separator", "//" in by_string, True)
test("pathlib did not", "//" in by_path, False)

What it printed, when the build ran it

string concatenation: 'project//app.toml' <- doubled separator
pathlib:             'project/app.toml'

Path('a//b/./c'): a/b/c
ok   string joining produced a doubled separator
ok   pathlib did not

Existence, listing, and creating directories

mkdir, iterdir, glob, rglob, stat

ran at build time
from pathlib import Path

Path("data/reports").mkdir(parents=True, exist_ok=True)
for name in ["a.csv", "b.csv", "notes.txt"]:
    (Path("data/reports") / name).write_text("x", encoding="utf-8")

reports = Path("data/reports")
print("exists:  ", reports.exists())
print("is_dir:  ", reports.is_dir())
print("is_file: ", (reports / "a.csv").is_file())

print()
print("everything in it:", sorted(p.name for p in reports.iterdir()))
print("only the csvs:   ", sorted(p.name for p in reports.glob("*.csv")))

# rglob searches recursively.
print("recursive search:", sorted(str(p) for p in Path("data").rglob("*.csv")))

# Sizes and other metadata.
print()
print("size of a.csv:", (reports / "a.csv").stat().st_size, "bytes")

test("parents=True created the whole chain", reports.is_dir(), True)
test("glob filters by pattern", sorted(p.name for p in reports.glob("*.csv")), ["a.csv", "b.csv"])
test("rglob searches subdirectories", len(list(Path("data").rglob("*.csv"))), 2)
test("exist_ok makes mkdir idempotent", Path("data/reports").mkdir(parents=True, exist_ok=True), None)

What it printed, when the build ran it

exists:   True
is_dir:   True
is_file:  True

everything in it: ['a.csv', 'b.csv', 'notes.txt']
only the csvs:    ['a.csv', 'b.csv']
recursive search: ['data/reports/a.csv', 'data/reports/b.csv']

size of a.csv: 1 bytes
ok   parents=True created the whole chain
ok   glob filters by pattern
ok   rglob searches subdirectories
ok   exist_ok makes mkdir idempotent

Encoding: the argument you should never omit

Bytes on disk are not text until something decodes them. If you do not say which encoding, Python uses a platform default — which has historically differed between operating systems, so the same code reads correctly on one machine and produces mojibake or an exception on another.

Text, bytes, and the difference between counts

ran at build time
from pathlib import Path

text = "café costs €3"
Path("menu.txt").write_text(text, encoding="utf-8")

# Reading it back with the right encoding.
print("as utf-8:  ", Path("menu.txt").read_text(encoding="utf-8"))

# The raw bytes: the accented characters take more than one byte each.
raw = Path("menu.txt").read_bytes()
print()
print("characters:", len(text))
print("bytes:     ", len(raw))
print("the é is:  ", text[3].encode("utf-8"))

test("utf-8 round-trips the text exactly", Path("menu.txt").read_text(encoding="utf-8"), text)
test("non-ASCII characters take more than one byte", len(raw) > len(text), True)

What it printed, when the build ran it

as utf-8:   café costs €3

characters: 13
bytes:      16
the é is:   b'\xc3\xa9'
ok   utf-8 round-trips the text exactly
ok   non-ASCII characters take more than one byte

Read UTF-8 bytes as a different encoding and one of two things happens: an exception, or — worse — silently wrong text.

Reading UTF-8 as ASCII

raises UnicodeDecodeError
from pathlib import Path

Path("menu.txt").write_text("café costs €3", encoding="utf-8")

# The euro sign is three bytes in UTF-8, and one of them is not valid Latin-1.
print(Path("menu.txt").read_text(encoding="latin-1"))
print(Path("menu.txt").read_text(encoding="ascii"))

The traceback it really produced, when the build ran it

café costs €3
Traceback (most recent call last):
  File "example.py", line 7, in <module>
    print(Path("menu.txt").read_text(encoding="ascii"))
          ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File "<python>/lib/python3.14/pathlib/__init__.py", line 788, in read_text
    return f.read()
           ~~~~~~^^
  File "<python>/lib/python3.14/encodings/ascii.py", line 26, in decode
    return codecs.ascii_decode(input, self.errors)[0]
           ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3: ordinal not in range(128)

The silent failure, and the errors= argument

ran at build time
from pathlib import Path

Path("menu.txt").write_text("café", encoding="utf-8")

# latin-1 can decode ANY byte, so it never errors -- it just produces nonsense.
as_latin1 = Path("menu.txt").read_text(encoding="latin-1")
print("decoded as latin-1:", repr(as_latin1), "<- no error, wrong text")

# errors= controls what happens on undecodable bytes.
Path("mixed.bin").write_bytes(b"caf\xff\xfe")
print()
print("errors='replace':", repr(Path("mixed.bin").read_text(encoding="utf-8", errors="replace")))
print("errors='ignore': ", repr(Path("mixed.bin").read_text(encoding="utf-8", errors="ignore")))

test("latin-1 decodes anything, correctly or not", as_latin1 != "café", True)
test("replace substitutes a marker", "\ufffd" in Path("mixed.bin").read_text(encoding="utf-8", errors="replace"), True)
test("ignore silently drops the bad bytes", Path("mixed.bin").read_text(encoding="utf-8", errors="ignore"), "caf")

What it printed, when the build ran it

decoded as latin-1: 'café' <- no error, wrong text

errors='replace': 'caf��'
errors='ignore':  'caf'
ok   latin-1 decodes anything, correctly or not
ok   replace substitutes a marker
ok   ignore silently drops the bad bytes

Always pass encoding="utf-8" when opening text. It is the one habit from this lesson that prevents a whole category of bug. Use errors="replace" only when you would rather see damaged text than stop — for log parsing, say — and never errors="ignore" on data you care about, because it deletes bytes without telling you.

Binary mode, for data that is not text

Bytes, indexing, and magic numbers

ran at build time
from pathlib import Path

# "b" modes deal in bytes and take no encoding argument.
Path("blob.bin").write_bytes(bytes([0x89, 0x50, 0x4E, 0x47]))
raw = Path("blob.bin").read_bytes()

print("bytes:", raw)
print("as hex:", raw.hex())
print("first byte:", raw[0], "<- indexing bytes gives an int")
print("slicing bytes gives bytes:", raw[:2])

# A real use: identifying a file by its magic number rather than its extension.
print()
print("looks like a PNG:", raw.startswith(b"\x89PNG"))

test("read_bytes gives a bytes object", isinstance(raw, bytes), True)
test("indexing one byte gives an int", raw[0], 137)
test("slicing gives bytes", raw[:2], b"\x89P")
test("the magic number identifies the format", raw.startswith(b"\x89PNG"), True)

What it printed, when the build ran it

bytes: b'\x89PNG'
as hex: 89504e47
first byte: 137 <- indexing bytes gives an int
slicing bytes gives bytes: b'\x89P'

looks like a PNG: True
ok   read_bytes gives a bytes object
ok   indexing one byte gives an int
ok   slicing gives bytes
ok   the magic number identifies the format

A relative path is relative to the process, not the script

This is the cause of most FileNotFoundErrors. open("data.txt") looks in the current working directory — wherever the program was started from — not in the folder the script lives in.

cwd versus the script directory

ran at build time
from pathlib import Path

print("the working directory is:", Path.cwd())
print("this script lives in:    ", Path(__file__).resolve().parent)
print("they happen to match here, but in general they do not")

# The robust way to reach a file next to the script.
beside_script = Path(__file__).resolve().parent / "config.toml"
beside_script.write_text("setting = 1\n", encoding="utf-8")
print()
print("built from the script's location:", beside_script.name)
print("and it is findable regardless of cwd:", beside_script.exists())

test("__file__ locates the script", Path(__file__).name, "example.py")
test("a path built from it resolves", beside_script.exists(), True)
test("cwd and the script directory are separate ideas",
     isinstance(Path.cwd(), Path) and isinstance(Path(__file__).parent, Path), True)

What it printed, when the build ran it

the working directory is: .
this script lives in:     .
they happen to match here, but in general they do not

built from the script's location: config.toml
and it is findable regardless of cwd: True
ok   __file__ locates the script
ok   a path built from it resolves
ok   cwd and the script directory are separate ideas

What to take from this lesson

  • Always open text with encoding="utf-8". The default varies by platform.
  • Use with, or Path.read_text / write_text for small files.
  • Iterate the file object for large files; read() loads everything.
  • "w" truncates immediately. Write to a temporary file and os.replace when the data matters.
  • Build paths with pathlib and /, never string concatenation.
  • Relative paths resolve against the working directory. Use Path(__file__).parent to reach files next to your script.

26 JSON and CSV, including the parts that bite

These are the two formats you will read and write most, and both have surprises. JSON does not round-trip Python values faithfully. CSV is not a format you can parse by splitting on commas. Both have a standard library module that handles the cases your own code would get wrong.

JSON: the four calls

dumps/loads for strings, dump/load for files

ran at build time
import json
from pathlib import Path

data = {"name": "Ada", "tags": ["maths", "logic"], "active": True, "score": None}

# To and from a string: dumps / loads (the 's' is for string).
text = json.dumps(data)
back = json.loads(text)
print("dumps:", text)
print("loads:", back)

# To and from a file: dump / load (no 's').
with open("record.json", "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2)

with open("record.json", encoding="utf-8") as f:
    from_file = json.load(f)

print()
print("the file contains:")
print(Path("record.json").read_text(encoding="utf-8"))

test("dumps then loads returns an equal dict", back, data)
test("dump then load does too", from_file, data)
test("indent= formats the output across lines",
     Path("record.json").read_text(encoding="utf-8").count("\n") > 3, True)

What it printed, when the build ran it

dumps: {"name": "Ada", "tags": ["maths", "logic"], "active": true, "score": null}
loads: {'name': 'Ada', 'tags': ['maths', 'logic'], 'active': True, 'score': None}

the file contains:
{
  "name": "Ada",
  "tags": [
    "maths",
    "logic"
  ],
  "active": true,
  "score": null
}
ok   dumps then loads returns an equal dict
ok   dump then load does too
ok   indent= formats the output across lines

JSON does not round-trip Python

JSON has six types: object, array, string, number, boolean, null. Python has far more, so the conversion is lossy in specific and predictable ways.

Tuples become lists; integer keys become strings

ran at build time
import json

original = {
    "tuple": (1, 2),
    1: "an integer key",
    "float_whole": 1.0,
}

text = json.dumps(original)
back = json.loads(text)

print("original:", original)
print("as JSON :", text)
print("back    :", back)
print()
print("the tuple came back as a:", type(back["tuple"]).__name__)
print("the integer key came back as:", [repr(k) for k in back if k != "tuple" and k != "float_whole"])
print("round-trips exactly?", back == original)

test("a tuple becomes a list", type(back["tuple"]).__name__, "list")
test("an integer key becomes a string", "1" in back, True)
test("and the integer key is gone", 1 in back, False)
test("so the round trip is not exact", back == original, False)

What it printed, when the build ran it

original: {'tuple': (1, 2), 1: 'an integer key', 'float_whole': 1.0}
as JSON : {"tuple": [1, 2], "1": "an integer key", "float_whole": 1.0}
back    : {'tuple': [1, 2], '1': 'an integer key', 'float_whole': 1.0}

the tuple came back as a: list
the integer key came back as: ["'1'"]
round-trips exactly? False
ok   a tuple becomes a list
ok   an integer key becomes a string
ok   and the integer key is gone
ok   so the round trip is not exact

The integer-key one causes real bugs. Save {1: "a", 2: "b"}, load it back, and data[1] is a KeyError — you now need data["1"]. JSON object keys are strings by specification, so there is nowhere for the type to be recorded. If you need integer keys, convert them back explicitly on load, or store a list of pairs instead.

The integer-key bug, and the repair

ran at build time
import json

scores = {1: "gold", 2: "silver"}
loaded = json.loads(json.dumps(scores))

# The obvious lookup now fails.
try:
    loaded[1]
except KeyError as exc:
    print("loaded[1] ->", type(exc).__name__, exc)

print("loaded['1'] ->", loaded["1"])

# Converting back on load, which has to be deliberate.
repaired = {int(k): v for k, v in loaded.items()}
print()
print("after repairing the keys:", repaired, " and repaired[1] =", repaired[1])

test("the integer key is unusable after a round trip", 1 in loaded, False)
test("the string version works", loaded["1"], "gold")
test("converting back restores the original", repaired, scores)

What it printed, when the build ran it

loaded[1] -> KeyError 1
loaded['1'] -> gold

after repairing the keys: {1: 'gold', 2: 'silver'}  and repaired[1] = gold
ok   the integer key is unusable after a round trip
ok   the string version works
ok   converting back restores the original

Types JSON cannot take at all

A date is not JSON serialisable

raises TypeError
import json
from datetime import date

json.dumps({"when": date(2026, 1, 1)})

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 4, in <module>
    json.dumps({"when": date(2026, 1, 1)})
    ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<python>/lib/python3.14/json/__init__.py", line 235, in dumps
    return _default_encoder.encode(obj)
           ~~~~~~~~~~~~~~~~~~~~~~~^^^^^
  File "<python>/lib/python3.14/json/encoder.py", line 202, in encode
    chunks = self.iterencode(o, _one_shot=True)
  File "<python>/lib/python3.14/json/encoder.py", line 263, in iterencode
    return _iterencode(o, 0)
  File "<python>/lib/python3.14/json/encoder.py", line 182, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
                    f'is not JSON serializable')
TypeError: Object of type date is not JSON serializable
when serializing dict item 'when'

default= for types JSON does not know

ran at build time
import json
from datetime import date, datetime
from decimal import Decimal

# default= is called for anything json does not recognise.
def encode_unknown(obj):
    if isinstance(obj, (date, datetime)):
        return obj.isoformat()
    if isinstance(obj, Decimal):
        return str(obj)
    if isinstance(obj, set):
        return sorted(obj)
    raise TypeError(f"cannot encode {type(obj).__name__}")

payload = {
    "when": date(2026, 1, 1),
    "price": Decimal("19.99"),
    "tags": {"b", "a"},
}

text = json.dumps(payload, default=encode_unknown)
print("encoded:", text)

# Note what this means on the way back: they are strings now, not dates.
back = json.loads(text)
print("decoded:", back)
print("the date is a", type(back["when"]).__name__, "again")

test("default= handles the unsupported types", json.loads(text)["when"], "2026-01-01")
test("Decimal survives as a string, keeping its precision", json.loads(text)["price"], "19.99")
test("a set becomes a sorted list", json.loads(text)["tags"], ["a", "b"])
test("but nothing is a date on the way back", type(back["when"]).__name__, "str")

What it printed, when the build ran it

encoded: {"when": "2026-01-01", "price": "19.99", "tags": ["a", "b"]}
decoded: {'when': '2026-01-01', 'price': '19.99', 'tags': ['a', 'b']}
the date is a str again
ok   default= handles the unsupported types
ok   Decimal survives as a string, keeping its precision
ok   a set becomes a sorted list
ok   but nothing is a date on the way back

NaN and Infinity: Python emits them, JSON does not allow them

A genuine interoperability trap. json.dumps will happily write NaN and Infinity, which are not in the JSON specification — so the output parses in Python and is rejected by strict parsers in other languages.

Valid Python, invalid JSON

ran at build time
import json

print("dumps(nan):", json.dumps(float("nan")))
print("dumps(inf):", json.dumps(float("inf")))
print()
print("Neither NaN nor Infinity appears anywhere in the JSON spec,")
print("so a strict parser in another language will reject this output.")

# allow_nan=False makes Python refuse instead.
try:
    json.dumps({"value": float("nan")}, allow_nan=False)
except ValueError as exc:
    print()
    print("with allow_nan=False:", exc)

test("Python emits NaN by default", json.dumps(float("nan")), "NaN")
test("and reads it back as a float", isinstance(json.loads("NaN"), float), True)
test("allow_nan=False refuses instead",
     "NaN" not in json.dumps({"a": 1}, allow_nan=False), True)

What it printed, when the build ran it

dumps(nan): NaN
dumps(inf): Infinity

Neither NaN nor Infinity appears anywhere in the JSON spec,
so a strict parser in another language will reject this output.

with allow_nan=False: Out of range float values are not JSON compliant: nan
ok   Python emits NaN by default
ok   and reads it back as a float
ok   allow_nan=False refuses instead

Reading JSON you did not produce

Never assume input is valid. The error carries a line, a column and a character offset, which is the fastest way into a large document.

Malformed JSON, with a position

raises JSONDecodeError
import json

json.loads('{"name": "Ada", "age": }')

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 3, in <module>
    json.loads('{"name": "Ada", "age": }')
    ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<python>/lib/python3.14/json/__init__.py", line 352, in loads
    return _default_decoder.decode(s)
           ~~~~~~~~~~~~~~~~~~~~~~~^^^
  File "<python>/lib/python3.14/json/decoder.py", line 345, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
               ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "<python>/lib/python3.14/json/decoder.py", line 363, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 24 (char 23)

Handling malformed input

ran at build time
import json

def parse_safely(text):
    try:
        return json.loads(text), None
    except json.JSONDecodeError as exc:
        return None, f"invalid JSON at line {exc.lineno} column {exc.colno}: {exc.msg}"

good, err = parse_safely('{"ok": true}')
print("valid  :", good, err)

bad, err = parse_safely('{"broken": }')
print("invalid:", bad)
print("        ", err)

# JSONDecodeError is a subclass of ValueError, so either catch works.
print()
print("is a ValueError:", issubclass(json.JSONDecodeError, ValueError))

test("valid input parses", good, {"ok": True})
test("invalid input reports a position", "line 1" in err, True)
test("JSONDecodeError is a ValueError", issubclass(json.JSONDecodeError, ValueError), True)

What it printed, when the build ran it

valid  : {'ok': True} None
invalid: None
         invalid JSON at line 1 column 12: Expecting value

is a ValueError: True
ok   valid input parses
ok   invalid input reports a position
ok   JSONDecodeError is a ValueError

CSV: why split(",") is not enough

A CSV field may contain a comma if it is quoted, and may contain a quote if it is doubled. Splitting on commas gets both wrong — and on the data where it matters, not on your test data.

The same file, parsed twice

ran at build time
from pathlib import Path
import csv

# A perfectly ordinary CSV file with a comma inside a quoted field.
Path("people.csv").write_text(
    'name,role,note\n'
    'Ada,"Analyst, Senior",first\n'
    'Alan,"Said ""hello""",second\n',
    encoding="utf-8",
)

print("the raw file:")
print(Path("people.csv").read_text(encoding="utf-8"))

# Splitting by hand.
lines = Path("people.csv").read_text(encoding="utf-8").splitlines()
by_hand = [line.split(",") for line in lines]
print("split(','):")
for row in by_hand:
    print("  ", row)

# The csv module.
with open("people.csv", newline="", encoding="utf-8") as f:
    proper = list(csv.reader(f))
print()
print("csv.reader:")
for row in proper:
    print("  ", row)

test("splitting produced the wrong number of fields", len(by_hand[1]), 4)
test("the csv module got it right", len(proper[1]), 3)
test("and kept the comma inside the field", proper[1][1], "Analyst, Senior")
test("and un-doubled the escaped quotes", proper[2][1], 'Said "hello"')

What it printed, when the build ran it

the raw file:
name,role,note
Ada,"Analyst, Senior",first
Alan,"Said ""hello""",second

split(','):
   ['name', 'role', 'note']
   ['Ada', '"Analyst', ' Senior"', 'first']
   ['Alan', '"Said ""hello"""', 'second']

csv.reader:
   ['name', 'role', 'note']
   ['Ada', 'Analyst, Senior', 'first']
   ['Alan', 'Said "hello"', 'second']
ok   splitting produced the wrong number of fields
ok   the csv module got it right
ok   and kept the comma inside the field
ok   and un-doubled the escaped quotes

Pass newline="" when opening a file for the csv module. The module does its own line-ending handling, and letting Python translate newlines as well corrupts fields that legitimately contain a line break. It is in the documentation and almost always omitted.

DictReader and DictWriter, which you should prefer

DictReader, DictWriter, and the fact that CSV has no types

ran at build time
from pathlib import Path
import csv

Path("sales.csv").write_text(
    "region,units,price\n"
    "north,10,2.50\n"
    "south,5,3.00\n",
    encoding="utf-8",
)

# Rows arrive as dicts keyed by the header, so no index numbers appear anywhere.
with open("sales.csv", newline="", encoding="utf-8") as f:
    rows = list(csv.DictReader(f))

for row in rows:
    print(row)

# Everything is a string: CSV has no types.
print()
print("units is a", type(rows[0]["units"]).__name__, "not an int")

revenue = sum(int(r["units"]) * float(r["price"]) for r in rows)
print("revenue:", revenue)

# Writing dicts back out.
with open("out.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["region", "units", "price"])
    writer.writeheader()
    writer.writerows(rows)

print()
print("written back:")
print(Path("out.csv").read_text(encoding="utf-8"))

test("DictReader keys rows by the header", rows[0]["region"], "north")
test("every value is a string", type(rows[0]["units"]).__name__, "str")
test("so numbers must be converted explicitly", revenue, 40.0)
test("DictWriter round-trips the file", Path("out.csv").read_text(encoding="utf-8"),
     Path("sales.csv").read_text(encoding="utf-8"))

What it printed, when the build ran it

{'region': 'north', 'units': '10', 'price': '2.50'}
{'region': 'south', 'units': '5', 'price': '3.00'}

units is a str not an int
revenue: 40.0

written back:
region,units,price
north,10,2.50
south,5,3.00

ok   DictReader keys rows by the header
ok   every value is a string
ok   so numbers must be converted explicitly
ok   DictWriter round-trips the file

CSV has no types at all. Every field arrives as a string, including something that looks exactly like a number. "10" + "5" is "105", so a missing int() gives you a wrong answer rather than an error. Convert on the way in, and decide what an empty field should mean.

Empty and unparseable fields

ran at build time
import csv
from pathlib import Path

Path("gaps.csv").write_text(
    "name,units\n"
    "north,10\n"
    "south,\n"
    "east,not-a-number\n",
    encoding="utf-8",
)

def to_int(value, default=0):
    try:
        return int(value)
    except ValueError:
        return default

with open("gaps.csv", newline="", encoding="utf-8") as f:
    rows = list(csv.DictReader(f))

for row in rows:
    print(f"  {row['name']:6} raw={row['units']!r:15} parsed={to_int(row['units'])}")

total = sum(to_int(r["units"]) for r in rows)
print()
print("total, with unparseable values treated as zero:", total)

test("an empty field is an empty string, not None", rows[1]["units"], "")
test("a non-numeric field is just a string", rows[2]["units"], "not-a-number")
test("and the conversion has to decide what to do", total, 10)

What it printed, when the build ran it

  north  raw='10'            parsed=10
  south  raw=''              parsed=0
  east   raw='not-a-number'  parsed=0

total, with unparseable values treated as zero: 10
ok   an empty field is an empty string, not None
ok   a non-numeric field is just a string
ok   and the conversion has to decide what to do

Which format for what

  • JSON for nested or structured data, configuration, and APIs. It keeps types (within its six) and handles nesting naturally.
  • CSV for flat, tabular, row-per-record data — and because spreadsheets and every data tool read it. It has no types and no nesting.
  • Neither is a database. Once you are reading a whole file to update one record, or need two processes writing at once, you want sqlite3 — which is also in the standard library.

What to take from this lesson

  • dumps/loads for strings, dump/load for files.
  • JSON round trips are lossy: tuples become lists and integer keys become strings.
  • Use default= for types JSON does not know, and remember they come back as whatever you encoded them to.
  • Python emits NaN and Infinity, which are not valid JSON.
  • Never parse CSV by splitting on commas. Use the csv module, with newline="".
  • Every CSV field is a string. Convert deliberately and decide what blank means.

27 Dates, times and timezones

Dates look simple and are not. Days are not all 24 hours long, the same wall clock time means different instants in different places, and "now" is ambiguous unless you say where. Python's answer is to distinguish times that know their timezone from times that do not, and to refuse to mix them.

The four types

date, time, datetime, timedelta

ran at build time
from datetime import date, time, datetime, timedelta

d = date(2026, 3, 14)
t = time(13, 45, 30)
dt = datetime(2026, 3, 14, 13, 45, 30)
delta = timedelta(days=7, hours=3)

print("date:     ", d)
print("time:     ", t)
print("datetime: ", dt)
print("timedelta:", delta)

# date and time can be combined, and a datetime can be split apart.
print()
print("combined: ", datetime.combine(d, t))
print("just the date part:", dt.date())
print("just the time part:", dt.time())

# Components are attributes.
print()
print(f"{dt.year}-{dt.month:02d}-{dt.day:02d}  weekday={dt.weekday()} (0=Monday)")

test("a datetime carries both halves", datetime.combine(d, t), dt)
test("and can be split back", (dt.date(), dt.time()), (d, t))
test("weekday counts from Monday as zero", date(2026, 3, 14).weekday(), 5)

What it printed, when the build ran it

date:      2026-03-14
time:      13:45:30
datetime:  2026-03-14 13:45:30
timedelta: 7 days, 3:00:00

combined:  2026-03-14 13:45:30
just the date part: 2026-03-14
just the time part: 13:45:30

2026-03-14  weekday=5 (0=Monday)
ok   a datetime carries both halves
ok   and can be split back
ok   weekday counts from Monday as zero

Arithmetic gives you a timedelta

Differences and offsets

ran at build time
from datetime import date, datetime, timedelta

start = date(2026, 1, 1)
end = date(2026, 3, 14)

gap = end - start
print("end - start:", gap, " which is", gap.days, "days")

# Adding a timedelta moves a date or datetime.
print("start + 30 days:", start + timedelta(days=30))

# timedelta stores days, seconds and microseconds -- not months, because a
# month is not a fixed length.
span = timedelta(weeks=2, days=3, hours=6, minutes=30)
print()
print("weeks=2, days=3, hours=6, minutes=30 is stored as:")
print("   days:", span.days, " seconds:", span.seconds)
print("   total_seconds():", span.total_seconds())

test("subtracting dates gives a timedelta", (end - start).days, 72)
test("adding one moves the date", start + timedelta(days=30), date(2026, 1, 31))
test("weeks are folded into days", timedelta(weeks=2).days, 14)
test("total_seconds flattens the whole span", timedelta(hours=1, minutes=30).total_seconds(), 5400.0)

What it printed, when the build ran it

end - start: 72 days, 0:00:00  which is 72 days
start + 30 days: 2026-01-31

weeks=2, days=3, hours=6, minutes=30 is stored as:
   days: 17  seconds: 23400
   total_seconds(): 1492200.0
ok   subtracting dates gives a timedelta
ok   adding one moves the date
ok   weeks are folded into days
ok   total_seconds flattens the whole span

There is no timedelta(months=1), and that is deliberate. A month has 28 to 31 days, so "one month from 31 January" has no single correct answer. The standard library refuses to guess. If you need calendar arithmetic, use calendar.monthrange and decide the rule yourself, or reach for the third-party dateutil — and either way make the choice explicit.

No months, because a month is not a fixed length

raises TypeError
from datetime import timedelta

timedelta(months=1)

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 3, in <module>
    timedelta(months=1)
    ~~~~~~~~~^^^^^^^^^^
TypeError: __new__() got an unexpected keyword argument 'months'

Adding a month, with the rule stated explicitly

ran at build time
import calendar
from datetime import date

def add_month(d):
    """Move to the same day next month, clamped to that month's length."""
    year = d.year + (d.month // 12)
    month = d.month % 12 + 1
    last_day = calendar.monthrange(year, month)[1]
    return date(year, month, min(d.day, last_day))

for start in [date(2026, 1, 31), date(2026, 1, 15), date(2026, 12, 31)]:
    print(f"  {start} + one month -> {add_month(start)}")

print()
print("The clamping rule is a decision. Another program might reasonably")
print("choose to land on 3 March instead, and neither is wrong.")

test("31 January clamps to the end of February", add_month(date(2026, 1, 31)), date(2026, 2, 28))
test("a mid-month date is unaffected", add_month(date(2026, 1, 15)), date(2026, 2, 15))
test("December rolls into the next year", add_month(date(2026, 12, 31)), date(2027, 1, 31))

What it printed, when the build ran it

  2026-01-31 + one month -> 2026-02-28
  2026-01-15 + one month -> 2026-02-15
  2026-12-31 + one month -> 2027-01-31

The clamping rule is a decision. Another program might reasonably
choose to land on 3 March instead, and neither is wrong.
ok   31 January clamps to the end of February
ok   a mid-month date is unaffected
ok   December rolls into the next year

Naive and aware: the distinction that matters most

A naive datetime has no timezone — it is a wall clock reading with no indication of where. An aware one knows its offset, so it identifies a real instant. Python will not compare or subtract one from the other, which turns a silent wrong answer into an error.

The same wall clock, with and without a timezone

ran at build time
from datetime import datetime, timezone, timedelta

naive = datetime(2026, 1, 1, 12, 0)
aware = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)

print("naive:", naive, " tzinfo:", naive.tzinfo)
print("aware:", aware, " tzinfo:", aware.tzinfo)
print()
print("isoformat makes the difference visible:")
print("   naive:", naive.isoformat())
print("   aware:", aware.isoformat(), "<- the +00:00 is the offset")

test("a naive datetime has no tzinfo", naive.tzinfo, None)
test("an aware one does", aware.tzinfo, timezone.utc)
test("and says so in its isoformat", aware.isoformat().endswith("+00:00"), True)

What it printed, when the build ran it

naive: 2026-01-01 12:00:00  tzinfo: None
aware: 2026-01-01 12:00:00+00:00  tzinfo: UTC

isoformat makes the difference visible:
   naive: 2026-01-01T12:00:00
   aware: 2026-01-01T12:00:00+00:00 <- the +00:00 is the offset
ok   a naive datetime has no tzinfo
ok   an aware one does
ok   and says so in its isoformat

Python refuses to compare them

raises TypeError
from datetime import datetime, timezone

naive = datetime(2026, 1, 1, 12, 0)
aware = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)

naive < aware

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 6, in <module>
    naive < aware
TypeError: can't compare offset-naive and offset-aware datetimes

The rule that avoids nearly every datetime bug. Store and compute in UTC, always aware. Convert to a local timezone only when displaying to a person. A naive datetime is acceptable for something genuinely timezone-free — a birthday, an opening time that means 09:00 wherever you are — and for nothing else.

Getting the current time, and the call that is now deprecated

datetime.utcnow() returns a naive datetime holding UTC — which is the worst of both worlds, because nothing records that it is UTC. It is deprecated, and the warning says exactly what to use instead.

utcnow() is deprecated; now(UTC) is the replacement

ran at build time
import warnings
from datetime import datetime, UTC

# Turn the deprecation warning into something visible.
warned = ""
with warnings.catch_warnings():
    warnings.simplefilter("error", DeprecationWarning)
    try:
        datetime.utcnow()
    except DeprecationWarning as exc:
        warned = str(exc)
        print("utcnow() warns:")
        print("  ", warned)

# The replacement: explicitly aware.
now = datetime.now(UTC)
print()
print("datetime.now(UTC):", now.tzinfo)
print("aware?            ", now.tzinfo is not None)

# And local time, when you actually want the machine's zone.
local = datetime.now().astimezone()
print("local offset:     ", local.utcoffset() is not None)

test("utcnow() really did warn", warned.startswith("datetime.datetime.utcnow() is deprecated"), True)
test("and the warning names the replacement", "datetime.datetime.now(datetime.UTC)" in warned, True)
test("now(UTC) is aware", datetime.now(UTC).tzinfo is not None, True)
test("plain now() is naive", datetime.now().tzinfo, None)
test("astimezone() attaches the local offset", datetime.now().astimezone().tzinfo is not None, True)

What it printed, when the build ran it

utcnow() warns:
   datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).

datetime.now(UTC): UTC
aware?             True
local offset:      True
ok   utcnow() really did warn
ok   and the warning names the replacement
ok   now(UTC) is aware
ok   plain now() is naive
ok   astimezone() attaches the local offset

Real timezones with zoneinfo

timezone.utc is a fixed offset. Real timezones change offset through the year, and zoneinfo (standard library since 3.9) reads the system's timezone database to know when.

One instant, four timezones

ran at build time
from datetime import datetime
from zoneinfo import ZoneInfo

# One instant, described in three places.
instant = datetime(2026, 7, 1, 12, 0, tzinfo=ZoneInfo("UTC"))

for name in ["UTC", "Europe/London", "America/New_York", "Asia/Tokyo"]:
    local = instant.astimezone(ZoneInfo(name))
    print(f"  {name:18} {local.strftime('%Y-%m-%d %H:%M')} (offset {local.utcoffset()})")

print()
print("Same moment in time. Different wall clocks.")

test("converting does not change the instant",
     instant.astimezone(ZoneInfo("Asia/Tokyo")) == instant, True)
test("but it does change the displayed hour",
     instant.astimezone(ZoneInfo("Asia/Tokyo")).hour != instant.hour, True)

What it printed, when the build ran it

  UTC                2026-07-01 12:00 (offset 0:00:00)
  Europe/London      2026-07-01 13:00 (offset 1:00:00)
  America/New_York   2026-07-01 08:00 (offset -1 day, 20:00:00)
  Asia/Tokyo         2026-07-01 21:00 (offset 9:00:00)

Same moment in time. Different wall clocks.
ok   converting does not change the instant
ok   but it does change the displayed hour

And the reason fixed offsets are not good enough: a real timezone's offset changes. Adding two hours across a daylight-saving transition moves the wall clock by two hours, and the offset changes underneath it.

A real daylight-saving transition

ran at build time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

london = ZoneInfo("Europe/London")

# 29 March 2026 is when British Summer Time starts.
before = datetime(2026, 3, 29, 0, 30, tzinfo=london)
after = before + timedelta(hours=2)

print("before:", before, " offset", before.utcoffset())
print("after: ", after, " offset", after.utcoffset())
print()
print("wall-clock difference:", after - before)
print("but the offset changed from", before.utcoffset(), "to", after.utcoffset())

# In UTC there is no ambiguity at all.
utc = ZoneInfo("UTC")
print()
print("the same two moments in UTC:")
print("  ", before.astimezone(utc))
print("  ", after.astimezone(utc))

test("the offset really changes across the transition",
     before.utcoffset() != after.utcoffset(), True)
test("summer time is one hour ahead", after.utcoffset(), timedelta(hours=1))
test("and winter is at UTC", before.utcoffset(), timedelta(0))

What it printed, when the build ran it

before: 2026-03-29 00:30:00+00:00  offset 0:00:00
after:  2026-03-29 02:30:00+01:00  offset 1:00:00

wall-clock difference: 2:00:00
but the offset changed from 0:00:00 to 1:00:00

the same two moments in UTC:
   2026-03-29 00:30:00+00:00
   2026-03-29 01:30:00+00:00
ok   the offset really changes across the transition
ok   summer time is one hour ahead
ok   and winter is at UTC

Parsing and formatting

ISO format for machines, strftime for people

ran at build time
from datetime import datetime, date

# ISO 8601 is the format to use for anything machine-readable.
stamp = datetime(2026, 3, 14, 13, 45, 30)
print("isoformat():", stamp.isoformat())
print("parsed back:", datetime.fromisoformat("2026-03-14T13:45:30"))

# fromisoformat handles offsets too, and since 3.11 the 'Z' suffix.
print("with an offset:", datetime.fromisoformat("2026-03-14T13:45:30+00:00"))
print("with a Z:      ", datetime.fromisoformat("2026-03-14T13:45:30Z"))

# strftime formats for humans; strptime parses a known layout.
print()
print("strftime:", stamp.strftime("%A %d %B %Y at %H:%M"))
print("strptime:", datetime.strptime("14/03/2026", "%d/%m/%Y").date())

test("isoformat round-trips", datetime.fromisoformat(stamp.isoformat()), stamp)
test("a Z suffix is accepted", datetime.fromisoformat("2026-03-14T13:45:30Z").tzinfo is not None, True)
test("strptime reads a stated layout", datetime.strptime("14/03/2026", "%d/%m/%Y").date(), date(2026, 3, 14))

What it printed, when the build ran it

isoformat(): 2026-03-14T13:45:30
parsed back: 2026-03-14 13:45:30
with an offset: 2026-03-14 13:45:30+00:00
with a Z:       2026-03-14 13:45:30+00:00

strftime: Saturday 14 March 2026 at 13:45
strptime: 2026-03-14
ok   isoformat round-trips
ok   a Z suffix is accepted
ok   strptime reads a stated layout

strptime is strict: the layout must match exactly, which is a feature. It refuses ambiguous input rather than guessing, and guessing is how 03/04 becomes the wrong month.

A layout that does not match the data

raises ValueError
from datetime import datetime

datetime.strptime("2026-03-14", "%d/%m/%Y")

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 3, in <module>
    datetime.strptime("2026-03-14", "%d/%m/%Y")
    ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<python>/lib/python3.14/_strptime.py", line 815, in _strptime_datetime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
                                    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
  File "<python>/lib/python3.14/_strptime.py", line 555, in _strptime
    raise ValueError("time data %r does not match format %r" %
                     (data_string, format))
ValueError: time data '2026-03-14' does not match format '%d/%m/%Y'

Why ambiguous date formats should never be stored

ran at build time
from datetime import datetime

# The genuine ambiguity strptime protects you from.
text = "03/04/2026"
uk = datetime.strptime(text, "%d/%m/%Y").date()
us = datetime.strptime(text, "%m/%d/%Y").date()

print("the same text, two layouts:")
print("   as day/month:", uk)
print("   as month/day:", us)
print()
print("Nothing in the data says which is meant. This is why ISO 8601 exists:")
print("   unambiguous:", datetime.fromisoformat("2026-04-03").date())

test("the two readings give different dates", uk != us, True)
test("day/month reads it as April", uk.month, 4)
test("month/day reads it as March", us.month, 3)

What it printed, when the build ran it

the same text, two layouts:
   as day/month: 2026-04-03
   as month/day: 2026-03-04

Nothing in the data says which is meant. This is why ISO 8601 exists:
   unambiguous: 2026-04-03
ok   the two readings give different dates
ok   day/month reads it as April
ok   month/day reads it as March

Timestamps, for storage and interchange

Unix timestamps, and the argument not to omit

ran at build time
from datetime import datetime, UTC

moment = datetime(2026, 3, 14, 12, 0, tzinfo=UTC)

# A Unix timestamp: seconds since 1970-01-01 UTC. Just a number, no timezone.
stamp = moment.timestamp()
print("timestamp:", stamp)
print("back again:", datetime.fromtimestamp(stamp, UTC))

# Without a timezone argument, fromtimestamp uses the LOCAL zone, which is
# a common source of off-by-hours bugs.
naive_local = datetime.fromtimestamp(stamp)
aware_utc = datetime.fromtimestamp(stamp, UTC)
print()
print("fromtimestamp(stamp)      ->", naive_local, "(naive, local zone)")
print("fromtimestamp(stamp, UTC) ->", aware_utc, "(aware, unambiguous)")

test("a timestamp round-trips through UTC", datetime.fromtimestamp(stamp, UTC), moment)
test("without a tz argument the result is naive", naive_local.tzinfo, None)
test("with one it is aware", aware_utc.tzinfo is not None, True)

What it printed, when the build ran it

timestamp: 1773489600.0
back again: 2026-03-14 12:00:00+00:00

fromtimestamp(stamp)      -> 2026-03-14 13:00:00 (naive, local zone)
fromtimestamp(stamp, UTC) -> 2026-03-14 12:00:00+00:00 (aware, unambiguous)
ok   a timestamp round-trips through UTC
ok   without a tz argument the result is naive
ok   with one it is aware

Measuring elapsed time

perf_counter for durations, not time()

ran at build time
import time

# The wrong tool: a wall clock can jump backwards (NTP, DST, a user changing it).
wall_start = time.time()

# The right tool: a monotonic clock, which only ever moves forward.
mono_start = time.perf_counter()
total = sum(range(200_000))
mono_elapsed = time.perf_counter() - mono_start

print("computed:", total)
print(f"elapsed (perf_counter): {mono_elapsed:.6f}s")
print()
print("time.time()      is a wall clock: can jump, has no guaranteed resolution")
print("time.perf_counter() is monotonic: for measuring durations")

test("the computation is correct", total, sum(range(200_000)))
test("the elapsed time is positive", mono_elapsed > 0, True)
test("perf_counter is monotonic", time.perf_counter() >= mono_start, True)

What it printed, when the build ran it

computed: 19999900000
elapsed (perf_counter): 0.003120s

time.time()      is a wall clock: can jump, has no guaranteed resolution
time.perf_counter() is monotonic: for measuring durations
ok   the computation is correct
ok   the elapsed time is positive
ok   perf_counter is monotonic

What to take from this lesson

  • Store and compute in UTC with aware datetimes. Convert to local only for display.
  • datetime.utcnow() is deprecated — use datetime.now(UTC).
  • Python refuses to compare naive and aware datetimes, which is a feature.
  • Use zoneinfo for real timezones; a fixed offset cannot represent daylight saving.
  • There is no timedelta(months=1). Calendar arithmetic needs an explicit rule.
  • Use ISO 8601 for anything stored or exchanged; 03/04 is ambiguous.
  • Measure durations with time.perf_counter(), not time.time().

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.