Part 1 · Foundations · Module 09
Files & Data Formats
Programs become useful when they can read data that already exists and save results that
outlive them. This module covers paths, text and bytes, encodings, the with statement, and the
everyday formats (CSV, JSON, TOML, and compressed files) that you will meet in every job.
- Build, inspect, and walk file-system paths with
pathlib, including 3.14'sPath.copy()andPath.move() - Open files in the right mode, and explain the difference between text and bytes
- Choose an encoding deliberately and handle encoding errors
- Use
withso files are always closed, and read large files line by line - Read and write CSV with
DictReader/DictWriter, and JSON with custom encoders - Read configuration with
tomllib - Compress data with
gzipand 3.14'scompression.zstd, and manage temporary files withtempfileandshutil
Paths with pathlib
A path names a location in the file system, such as /home/ada/reports/jan.csv
or C:\Users\ada\reports\jan.csv. Paths are not just strings: they have parts (folders, a file
name, an extension), they differ between operating systems, and joining them by hand with + "/" +
is a classic source of bugs. The pathlib module represents a path as a Path object
with methods for everything you need.
A relative path such as data/jan.csv is interpreted relative to the current working
directory, the folder your program was started from, which is not necessarily the folder containing
your script. When a script needs files next to itself, build the path from Path(__file__).parent.
Asking the file system
The browser runtime has a real (in-memory) file system, so the following code creates folders and files.
Python 3.14 adds two long-requested methods. Path.copy(target) copies a file or a whole directory
tree and returns the new path; Path.move(target) moves (renames) it, even across file systems.
Before 3.14 you needed shutil.copy2, shutil.copytree, and shutil.move.
Opening files: modes, text, and bytes
Path.read_text() and write_text() are perfect for small files. For anything more,
open a file object with open() (or path.open()) and a
mode:
| Mode | Meaning | If the file exists | If it doesn't |
|---|---|---|---|
"r" | read (default) | read from the start | FileNotFoundError |
"w" | write | erased first | created |
"a" | append | writes go to the end | created |
"x" | exclusive create | FileExistsError | created |
add "b" | binary: "rb", "wb" | read and write bytes instead of str | |
add "+" | read and write: "r+" | rarely needed | |
Text mode (the default) decodes bytes to str using an encoding, and translates
line endings so you always see "\n". Binary mode gives you the raw
bytes, untouched. Use binary for images, archives, compressed data, and anything that is not
text, and for formats whose library insists on it (tomllib).
Eleven characters of text became thirteen bytes, because é and ï each take two bytes in
UTF-8. Text is characters; files store bytes; an encoding is the rule between them.
Encodings
Recall from M03 that a str is a sequence of Unicode code points and bytes are raw
numbers from 0 to 255. UTF-8 is the universal encoding today: it handles every language and
emoji, and plain ASCII text is unchanged. Other encodings still appear in old files: latin-1,
cp1252 (old Windows), utf-16.
If you do not pass encoding=, Python 3.14 uses the locale's preferred encoding, which is
UTF-8 on macOS and Linux but may be something else on Windows. The same program can then read a file
correctly on one machine and garble it on another. Always pass encoding="utf-8"
for text files.
The errors= argument decides what happens with undecodable bytes: "strict" (the
default, raise), "replace", "ignore", or "backslashreplace". Prefer
"strict" so problems are noticed. Some programs, notably Excel, write a byte-order mark
(BOM) at the start of UTF-8 files; the "utf-8-sig" encoding removes it when reading.
The with statement and context managers
An open file holds an operating-system resource. If you forget to close() it, written data may
never reach the disk, and long-running programs can run out of file handles. Calling close()
yourself is fragile: an exception before that line skips it.
The with statement solves this. The object after with is a context
manager: it sets something up on entry and is guaranteed to clean up on exit, whether the
block finishes normally, returns, or raises. It is the try/finally from M07, packaged.
f = open(path, encoding="utf-8")
try:
text = f.read()
finally:
f.close()with open(path, encoding="utf-8") as f:
text = f.read()
# f is closed here, even after an exceptionStep through and watch closed change when the block ends, even though the second block raises:
One with can manage several resources, and you can write your own context managers with
contextlib.contextmanager (the full story is in M15):
Reading large files line by line
f.read() loads the whole file into memory. That is fine for kilobytes, not for a 20 GB log file.
A text file object is an iterator over lines: a for loop reads one line at a time,
so memory use stays tiny no matter how big the file is.
- Each line includes its trailing
"\n"(except possibly the last); use.rstrip("\n"). f.readline()reads one line;f.readlines()reads all lines into a list (avoid for big files).- For binary data, read fixed-size chunks:
while chunk := f.read(65536): ....
CSV files
CSV (comma-separated values) is the lingua franca of spreadsheets and bank exports. It looks
trivial, but values can contain commas, quotes, and even newlines, so never split lines on
"," yourself. Use the csv module, and always open CSV files with
newline="" so the module can handle line endings itself (the docs require it).
csv.reader/csv.writerwork with lists;DictReader/DictWriteruse the header row as dict keys, which is clearer and survives column reordering.- Every value read is a string. Convert numbers and dates yourself, and validate them.
- Other dialects:
delimiter=";"(common in Europe) ordelimiter="\t"for TSV. reader.line_numgives the current line number for error messages.
The pipeline visualizer shows the shape of almost every data job, including this module's
mini-project and Project 1: read, parse, transform, write.
JSON
JSON (JavaScript Object Notation) is the standard format for web APIs and structured data. It maps neatly onto Python:
| JSON | Python |
|---|---|
object {"a": 1} | dict (keys must be strings) |
array [1, 2] | list (tuples are written as arrays) |
| string, number | str, int/float |
true/false/null | True/False/None |
Four functions: dumps (to string) and loads (from string), dump (to a
file) and load (from a file). The "s" means string.
Custom types: default=
JSON has no dates, decimals, sets, or paths. json.dumps raises TypeError for them
unless you pass default=, a function called for each object JSON cannot encode. It returns
something JSON can encode, or raises TypeError itself.
TOML configuration with tomllib
TOML is a configuration format designed to be easy for humans to read and write. You have
already seen it: pixi.toml and pyproject.toml. The standard library's
tomllib reads TOML (writing needs a third-party package). It requires the file to be
opened in binary mode, because TOML is defined to be UTF-8 and the parser decodes it itself.
Compression: gzip and compression.zstd
Text data compresses extremely well. The gzip module reads and writes .gz files
with the same open() interface as normal files, so streaming code barely changes. Use
"wt"/"rt" for text and "wb"/"rb" for bytes.
Temporary files and shutil
Tests and data jobs often need scratch space that cleans itself up. tempfile.TemporaryDirectory()
is a context manager that creates a fresh directory and deletes it, with everything inside, on exit.
NamedTemporaryFile does the same for a single file. (pytest gives tests a tmp_path
fixture built on the same idea; Project 1's tests use it.)
shutil ("shell utilities") handles whole-file and whole-tree operations: copy2
(copy with metadata), copytree, move, rmtree (delete a tree, carefully!),
make_archive/unpack_archive, and disk_usage.
Practice
These exercises write to temporary directories; the tests create the input files for you.
Write count_file(path) returning a tuple (lines, words, chars) for a UTF-8 text
file, like the Unix wc command. Read the file line by line with with; characters
include newlines.
Hint
Loop for line in f: and add 1,
len(line.split()), and len(line).
Show solution
from pathlib import Path
def count_file(path):
lines = words = chars = 0
with open(path, encoding="utf-8") as f:
for line in f:
lines += 1
words += len(line.split())
chars += len(line)
return lines, words, chars
Write load_scores(path) that reads a CSV file with columns name,score using
csv.DictReader and returns a dict name → int score. Strip whitespace around names. If a score
is not an integer, raise ValueError whose message contains "line N", where N is
the line number in the file (the header is line 1).
Hint
Inside the loop, reader.line_num is the line just
read. Catch the ValueError from int() and raise a new one from it.
Show solution
import csv
def load_scores(path):
scores = {}
with open(path, encoding="utf-8", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
try:
scores[row["name"].strip()] = int(row["score"])
except ValueError as e:
raise ValueError(f"line {reader.line_num}: bad score {row['score']!r}") from e
return scores
Write save_rows(path, rows). rows is a non-empty list of dicts with the same
keys. Write a CSV with a header in the key order of the first row, using csv.DictWriter and
newline="". Return the number of data rows written. Values containing commas or quotes must
survive a round trip.
Hint
csv.DictWriter(f, fieldnames=list(rows[0])), then
writeheader() and writerows(rows).
Show solution
import csv
def save_rows(path, rows):
with open(path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
return len(rows)
Write to_json(data) returning a JSON string with indent=2 and
sort_keys=True. Encode date as ISO text ("2026-01-31"),
Decimal as its exact string, and set as a sorted list. Any other unsupported type
must still raise TypeError.
Hint
Write a helper and pass it as default=. Use
obj.isoformat() for dates.
Show solution
import json
from datetime import date
from decimal import Decimal
def _encode(obj):
if isinstance(obj, date):
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__}")
def to_json(data):
return json.dumps(data, indent=2, sort_keys=True, default=_encode)
Write read_server_config(path) that loads a TOML file with tomllib and returns a
dict with keys host, port, and debug. Start from the defaults
{"host": "127.0.0.1", "port": 8000, "debug": False} and override them with whatever is in the
file's [server] table. A missing [server] table means all defaults.
Hint
Open with "rb", then DEFAULTS | data.get("server", {}).
Show solution
import tomllib
DEFAULTS = {"host": "127.0.0.1", "port": 8000, "debug": False}
def read_server_config(path):
with open(path, "rb") as f:
data = tomllib.load(f)
return DEFAULTS | data.get("server", {})
Write backup_text_files(src, dest), where both are Paths. For every
.txt file anywhere under src, write a gzip-compressed copy to the same relative
location under dest with .gz appended (notes/a.txt →
notes/a.txt.gz), creating folders as needed. Return a sorted list of the relative paths written,
as POSIX strings ("notes/a.txt.gz").
Hint
Use src.rglob("*.txt"),
path.relative_to(src), target.parent.mkdir(parents=True, exist_ok=True), and
gzip.open(target, "wb") with shutil.copyfileobj.
Show solution
import gzip
import shutil
from pathlib import Path
def backup_text_files(src, dest):
written = []
for path in src.rglob("*.txt"):
rel = path.relative_to(src)
target = dest / rel.with_name(rel.name + ".gz")
target.parent.mkdir(parents=True, exist_ok=True)
with path.open("rb") as fin, gzip.open(target, "wb") as fout:
shutil.copyfileobj(fin, fout)
written.append(target.relative_to(dest).as_posix())
return sorted(written)
Mini-project: a CSV → JSON report converter
Quiz
Recap
pathlib.Pathfor every path:/to join, properties to inspect,glob/rglobto search, 3.14copy/move.- Modes:
r,w(erases),a,x; addbfor bytes. - Always pass
encoding="utf-8";utf-8-sigfor BOMs; UTF-8 becomes the default in 3.15. withguarantees cleanup; iterate files to stream lines.- CSV:
newline="", DictReader/DictWriter, values are strings. - JSON:
dump/load/dumps/loads,default=for custom types. - TOML:
tomllib.load(open(p, "rb")), read-only. gzip.openand 3.14'scompression.zstd.openwork likeopen;tempfileandshutilhandle scratch space and trees.