Chapter 7
CSV Files
Since Chapter 2, the reading log has had a book whose title contains a comma, and
every time we've split a line on commas that title has come out wrong. This
chapter fixes it. The csv module reads and writes comma-separated files
correctly, handles the quoting, and lets you work with columns by name instead of
by position.
Why not split on commas
The reading log looks simple:
title,author,rating,finished
The Left Hand of Darkness,Ursula K. Le Guin,5,2025-11-02Until a value has a comma in it. Then the file wraps that value in double quotes:
"Rendezvous with Rama, and Other Stories",Arthur C. Clarke,4,2026-02-20line.split(",") knows nothing about the quotes. It sees five commas and gives
you five pieces, splitting the title in half. CSV is a real format with rules
about quoting, escaping, and embedded newlines, and the csv module is what
knows those rules.
csv.reader
csv.reader wraps a file object and yields each row as a list of strings:
import csv
with open("reading-log.csv", newline="", encoding="utf-8") as f:
for row in csv.reader(f):
print(row[0])
title
The Left Hand of Darkness
Project Hail Mary
Rendezvous with Rama, and Other Stories
One Hundred Years of Solitude
The Peripheral
Klara and the Sun
Piranesi
Recursion
A Memory Called Empire
The Three-Body ProblemThere it is: Rendezvous with Rama, and Other Stories in one piece. The csv
module saw the quotes, understood that the comma inside them was part of the
value, and gave you the whole title.
Two things about that open call.
newline="" is not optional when you open a file for the csv module. The
module handles line endings itself, and if you let text mode also translate them
you get a blank line between every row on Windows. That doubled-newline problem is
the single most common csv complaint there is, and newline="" is the whole
fix. Pass it every time, for reading and for writing.
encoding="utf-8" is the same rule as every other text file, from Chapter 4.
The first row is the header, and csv.reader hands it to you like any other row.
You skip it with next(), or you use the next tool, which handles it for you.
csv.DictReader
csv.DictReader reads the header row once, then yields each following row as a
dict keyed by the column names:
import csv
with open("reading-log.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
rating = row["rating"] or "unrated"
print(f"{rating:>7} {row['title']}")
5 The Left Hand of Darkness
4 Project Hail Mary
4 Rendezvous with Rama, and Other Stories
5 One Hundred Years of Solitude
3 The Peripheral
4 Klara and the Sun
5 Piranesi
unrated Recursion
4 A Memory Called Empire
unrated The Three-Body ProblemNow you ask for row["title"] and row["rating"] by name. If someone adds a
column to the file or reorders them, your code still works. A row like this, one
dict per line of the file, is a record.
The blank ratings come through as empty strings, "", which is why row["rating"]
or "unrated" works: an empty string is falsy.
Doing something with the columns
Average the ratings, skipping the books you haven't rated:
import csv
total = 0
count = 0
with open("reading-log.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if row["rating"]:
total += int(row["rating"])
count += 1
print(f"{count} rated books, average {total / count:.2f}")
8 rated books, average 4.25row["rating"] is always a string, even when it holds "5". Convert it with
int() when you need the number, and check that it isn't empty first, or int("")
raises ValueError.
Writing CSV
csv.writer is the mirror of csv.reader. Give it a row as a list and it writes
the line, quoting anything that needs it:
import csv
rows = [
["title", "rating"],
["Piranesi", "5"],
["Rendezvous with Rama, and Other Stories", "4"],
]
with open("ratings.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerows(rows)
with open("ratings.csv", encoding="utf-8") as f:
print(f.read(), end="")
title,rating
Piranesi,5
"Rendezvous with Rama, and Other Stories",4The writer put the quotes back around the title with the comma. You never think about quoting; the module does it.
csv.DictWriter takes dicts and needs to know the column order up front:
import csv
with open("reading-log.csv", newline="", encoding="utf-8") as src, \
open("finished.csv", "w", newline="", encoding="utf-8") as dst:
reader = csv.DictReader(src)
writer = csv.DictWriter(dst, fieldnames=reader.fieldnames)
writer.writeheader()
for row in reader:
if row["finished"]:
writer.writerow(row)
with open("finished.csv", newline="", encoding="utf-8") as f:
print(len(f.readlines()), "lines (header + finished books)")
9 lines (header + finished books)reader.fieldnames is the header list DictReader read, so the new file gets the
same columns in the same order. writeheader() writes that row; forget it and
your file has data but no column names.
Other delimiters
"CSV" is a loose term. Some files use tabs, and some, from spreadsheets set to a
European locale, use semicolons. Pass delimiter=:
import csv
import io
tab_data = "title\trating\nPiranesi\t5\n"
for row in csv.reader(io.StringIO(tab_data), delimiter="\t"):
print(row)
['title', 'rating']
['Piranesi', '5']io.StringIO wraps a string so it behaves like an open file, which is handy for
a quick example. A named bundle of settings like "tab-separated, quote with
", end lines with \r\n" is a dialect; csv ships with a couple and lets
you register your own, but delimiter= covers most of what you'll meet.
Messy files
Real CSV files have gaps in them. Two to expect:
A blank line comes through csv.reader as an empty list, []. Skip it:
import csv
import io
data = "title,rating\nPiranesi,5\n\nRecursion,\n"
for row in csv.reader(io.StringIO(data)):
if not row:
continue
print(row)
['title', 'rating']
['Piranesi', '5']
['Recursion', '']A row with fewer fields than the header comes through DictReader with None
for the missing values. A row with extra fields puts the leftovers in a list
under the key None. If your files are ragged, check row.get("rating") rather
than row["rating"], and decide what a missing value should mean.
Common mistakes
Leaving out newline="". Blank lines between rows on Windows. This is the
one to remember.
Treating the header as data. csv.reader yields the header row like any
other. Skip it with next(reader), or use DictReader.
int(row["rating"]) on a blank. An empty string raises ValueError. Check
if row["rating"]: first.
Forgetting writeheader(). A DictWriter file with no header row is hard for
the next program, and for future you, to read.
Practice
Try each of these before you read the solution under it.
- Write
read_log()that returns a list of record dicts fromreading-log.csv. - Write a function that writes
top-books.csvwith justtitleandratingfor every book rated 4 or 5. - Parse this semicolon-separated string and print each row as a list:
"name;score\nAda;9\nGrace;10\n".
Solutions
1. list() on a DictReader gives you all the records at once.
import csv
def read_log():
with open("reading-log.csv", newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
log = read_log()
print(len(log), "records")
print(log[0]["title"])
10 records
The Left Hand of Darkness2. Filter on the rating, write two columns with DictWriter.
import csv
def write_top_books():
with open("reading-log.csv", newline="", encoding="utf-8") as src, \
open("top-books.csv", "w", newline="", encoding="utf-8") as dst:
writer = csv.DictWriter(dst, fieldnames=["title", "rating"])
writer.writeheader()
for row in csv.DictReader(src):
if row["rating"] in ("4", "5"):
writer.writerow({"title": row["title"],
"rating": row["rating"]})
write_top_books()
with open("top-books.csv", newline="", encoding="utf-8") as f:
print(len(f.readlines()) - 1, "books")
7 books3. delimiter=";" and an io.StringIO.
import csv
import io
data = "name;score\nAda;9\nGrace;10\n"
for row in csv.reader(io.StringIO(data), delimiter=";"):
print(row)
['name', 'score']
['Ada', '9']
['Grace', '10']Where this leaves you
You can read a CSV file into records keyed by column, do arithmetic on the
values, and write a CSV back out with the quoting handled for you. The
split-on-commas problem is gone for good. Chapter 8 is the other format you'll
meet constantly: JSON, the one your settings.json is written in.