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:

Terminal
title,author,rating,finished
The Left Hand of Darkness,Ursula K. Le Guin,5,2025-11-02

Until a value has a comma in it. Then the file wraps that value in double quotes:

Terminal
"Rendezvous with Rama, and Other Stories",Arthur C. Clarke,4,2026-02-20

line.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])
Output
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 Problem

There 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']}")
Output
      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 Problem

Now 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}")
Output
8 rated books, average 4.25

row["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="")
Output
title,rating
Piranesi,5
"Rendezvous with Rama, and Other Stories",4

The 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)")
Output
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)
Output
['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)
Output
['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.

  1. Write read_log() that returns a list of record dicts from reading-log.csv.
  2. Write a function that writes top-books.csv with just title and rating for every book rated 4 or 5.
  3. 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"])
Output
10 records
The Left Hand of Darkness

2. 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")
Output
7 books

3. 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)
Output
['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.