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
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
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
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.
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
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.
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
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.
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.
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)
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
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.
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, orPath.read_text/write_textfor small files. - Iterate the file object for large files;
read()loads everything. "w"truncates immediately. Write to a temporary file andos.replacewhen the data matters.- Build paths with
pathliband/, never string concatenation. - Relative paths resolve against the working directory. Use
Path(__file__).parentto 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
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.
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.
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
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'
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.
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.
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)
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.
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
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.
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/loadsfor strings,dump/loadfor 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
NaNandInfinity, which are not valid JSON. - Never parse CSV by splitting on commas. Use the
csvmodule, withnewline="". - 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
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
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.
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'
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.
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
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.
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.
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.
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
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.
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'
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
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
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 — usedatetime.now(UTC).- Python refuses to compare naive and aware datetimes, which is a feature.
- Use
zoneinfofor 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/04is ambiguous. - Measure durations with
time.perf_counter(), nottime.time().