The previous lesson closed with some self-criticism: the title | price | stock format of our inventory is a homemade dialect that only our program understands, and it breaks the day a title contains the separator. Today Papyrus switches to CSV (Comma-Separated Values), the tabular text format spoken by Excel, Google Sheets, banks, book distributors and virtually any system Ana might want to exchange data with. You'll learn the standard library's csv module — reader/writer and, above all, DictReader/DictWriter —, you'll handle the twisted cases (commas and quotes inside a field) without writing a single line of parsing, and you'll complete two milestones in the Papyrus storyline: the catalog will travel to catalog.csv and come back as dict[str, Book], and the SaleLine objects from 05-06 will end up where that solution already announced they would — in a sales CSV.

Contents

  1. What CSV is and why it's the lingua franca of tabular data
  2. Why split(",") isn't enough
  3. Writing with csv.writer (and the mystery of newline="")
  4. Reading with csv.reader
  5. DictReader and DictWriter: the preferred way
  6. Full round trip: the Papyrus catalog to catalog.csv and back
  7. The sales record: SaleLine fulfills its destiny
  8. Delimiters, quotes and real-world cases
  9. CSV at scale: a mention of pandas
  10. Common mistakes and tips
  11. Exercises with solutions

What CSV is and why it's the lingua franca of tabular data

A CSV is a plain text file — you can open it with any editor, like the files in 06-01 — that represents a table: one line per row, fields separated by commas, and usually a first header line with the column names:

title,price,stock
The Odyssey,12.50,4
Hamlet,9.95,6
Don Quixote,15.90,8
Faust,21.00,0

Its strength isn't technical but social: everyone understands it. Ana can open catalog.csv in Excel or Google Sheets and see a spreadsheet; the distributor can send her new releases as CSV; and your Python program can read and write both. When two systems that have never met need to exchange tables, CSV is the meeting point — which is why it's called a lingua franca.

Homemade plain text (06-01) CSV
Separator Invented (|) Universal convention (,)
Header with names No Yes (customary)
Excel/Sheets open it As unstructured text As a table
Fields containing the separator Breaks Solved (quoting)
Python support By hand (split) Standard library csv module

Why split(",") isn't enough

The temptation is obvious: if fields are separated by commas, line.strip().split(",") (04-05) seems sufficient. And it is… until the first title with a comma:

title,price,stock
"The Lion, the Witch and the Wardrobe",11.75,3

That book sits on the Papyrus shelves, and its title contains a comma. The CSV convention solves it by quoting the field, but then split(",") returns ['"The Lion', ' the Witch and the Wardrobe"', '11.75', '3'] — four chunks where there were three fields, and with quotes glued on. Add quotes inside quotes and homemade parsing turns into a swamp. The lesson is a general one: don't parse standard formats by hand; use the module that already knows how. For CSV, that module is called csv and it ships with Python.

Writing with csv.writer (and the mystery of newline="")

import csv

books = [
    ("The Odyssey", 12.50, 4),
    ("Hamlet", 9.95, 6),
    ("Don Quixote", 15.90, 8),
    ("Faust", 21.00, 0),
]

with open("catalog.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["title", "price", "stock"])   # the header
    for title, price, stock in books:
        writer.writerow([title, price, stock])     # one row per book

Step by step:

  • csv.writer(f) wraps the open file (the with and the encoding="utf-8" from 06-01 still rule) and returns a writer.
  • writerow(list) writes one row: it converts each element to text, inserts the commas, quotes whatever needs quoting and adds the line ending. There's also writerows(list_of_rows) for dumping many at once.
  • newline="" is mandatory when opening a file for the csv module, both for writing and for reading. Why? The csv module manages line endings itself (the CSV standard uses \r\n); if you don't disable the automatic newline translation Python performs in text mode, on Windows both layers add their own and blank lines appear between the rows. Don't memorize it as magic: open(..., newline="") means "leave the newlines alone; csv handles those".

Notice that we wrote 12.50 as a number and csv.writer converted it to text for us. The outbound conversion is automatic; the return conversion, as you'll see, is not.

Reading with csv.reader

import csv

with open("catalog.csv", "r", encoding="utf-8", newline="") as f:
    reader = csv.reader(f)
    header = next(reader)            # ['title', 'price', 'stock'] — we skip it
    for row in reader:               # the reader is iterable, line by line (constant memory)
        print(row)

# ['The Odyssey', '12.50', '4']
# ['Hamlet', '9.95', '6']
# ...

Each row arrives as a list of strings — all strings, price and stock included. It's the same toll as in 06-01: text doesn't remember types, and csv won't guess whether "4" was an integer or a postal code. Converting (float(row[1]), int(row[2])) is still your job. And here reader's weakness shows: fields go by position (row[0], row[1]…), so if someone reorders the CSV's columns, your code reads prices where it expected stocks without complaining. The fix is to read by column name.

DictReader and DictWriter: the preferred way

DictReader uses the file's header to hand you each row as a column → value dictionary; DictWriter does the mirror image when writing. They're this course's preferred option: the code reads itself and survives column reordering.

import csv

with open("catalog.csv", "r", encoding="utf-8", newline="") as f:
    reader = csv.DictReader(f)             # uses the first line as the header
    for row in reader:
        print(f'{row["title"]}: {float(row["price"]):.2f} EUR — stock {row["stock"]}')

# The Odyssey: 12.50 EUR — stock 4
# Hamlet: 9.95 EUR — stock 6
# ...

And writing, declaring the columns in fieldnames:

with open("catalog.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["title", "price", "stock"])
    writer.writeheader()                                  # writes the header for you
    writer.writerow({"title": "The Odyssey", "price": 12.50, "stock": 4})
reader/writer DictReader/DictWriter
Each row is… list (access by index) dict (access by name)
Header You manage it (next, writerow) Automatic (fieldnames, writeheader)
If columns get reordered Silent bug Keeps working
Readability row[1] row["price"]
When to use it Headerless CSVs, minimal scripts By default

Full round trip: the Papyrus catalog to catalog.csv and back

On to the lesson's milestone: saving the canonical dict[str, Book] catalog (05-06) when the shop closes and rebuilding it — objects included — when it opens. It's the same round trip as exercise 2 of 06-01, but in a format Excel understands and with no homemade separators:

import csv
from pathlib import Path
from dataclasses import dataclass

def normalize_title(text):
    return text.strip().casefold()          # the canonical key from 04-05/05-06

@dataclass
class Book:                                 # the dataclass from 05-06
    title: str
    price: float
    stock: int = 0

    BOOK_VAT = 0.04
    MEMBER_DISCOUNT = 0.05

    def __post_init__(self):
        if self.price < 0:
            raise ValueError(f"Negative price: {self.price}")
        if self.stock < 0:
            raise ValueError(f"Negative stock: {self.stock}")

    def final_price(self, member=False):
        discount = Book.MEMBER_DISCOUNT if member else 0
        return round(self.price * (1 - discount) * (1 + Book.BOOK_VAT), 2)

    def in_stock(self):
        return self.stock > 0


CATALOG_COLUMNS = ["title", "price", "stock"]

def save_catalog(catalog, path="catalog.csv"):
    """Dumps the entire catalog to CSV. Mode 'w': regenerated whole."""
    with open(path, "w", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=CATALOG_COLUMNS)
        writer.writeheader()
        for book in catalog.values():
            writer.writerow({"title": book.title,
                             "price": book.price,
                             "stock": book.stock})

def load_catalog(path="catalog.csv"):
    """Rebuilds the dict[str, Book] from CSV. str → float/int: the return toll."""
    if not Path(path).exists():              # 06-01's stopgap; the real thing arrives in module 7
        return {}
    catalog = {}
    with open(path, "r", encoding="utf-8", newline="") as f:
        for row in csv.DictReader(f):
            book = Book(row["title"], float(row["price"]), int(row["stock"]))
            catalog[normalize_title(book.title)] = book
    return catalog

And the acid test, with the usual canonical figures:

books = [Book("The Odyssey", 12.50, 4), Book("Hamlet", 9.95, 6),
         Book("Don Quixote", 15.90, 8), Book("Faust", 21.00, 0)]
catalog = {normalize_title(b.title): b for b in books}

save_catalog(catalog)                    # ... the shop closes, the program ends ...

reloaded = load_catalog()                # ... the next morning ...
print(reloaded["hamlet"].final_price(member=True))    # 9.83
print(reloaded["faust"].in_stock())                   # False — Faust is still sold out
print(reloaded == catalog)                            # True — the dataclass's __eq__ certifies it

That final True is the key moment of the module: the catalog reborn from disk is indistinguishable from the one that died last night, field by field, thanks to the __eq__ generated by @dataclass (05-06). What's more, as each Book is rebuilt, __post_init__ validates again: if someone edited the CSV in Excel and put in a negative price, the program catches it at load time, not three sales later.

The sales record: SaleLine fulfills its destiny

Solution 2 of 05-06 ended with a prophecy: "in module 6, these sale lines will be exactly what we write into a CSV". Let's fulfill it. SaleLine is the frozen, orderable dataclass from back then, and the sales record — unlike the catalog — is a historical log: it grows with every sale and is never rewritten, so it calls for mode "a" (06-01):

import csv
from pathlib import Path
from dataclasses import dataclass

SALES = "sales.csv"
SALES_COLUMNS = ["date", "title", "amount"]

@dataclass(frozen=True, order=True)
class SaleLine:                      # exactly the one from 05-06
    date: str
    title: str
    amount: float

def record_sale(line, path=SALES):
    """Appends a sale to the history. Header only if the file doesn't exist yet."""
    new = not Path(path).exists()
    with open(path, "a", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=SALES_COLUMNS)
        if new:
            writer.writeheader()   # the header is written once in the file's lifetime
        writer.writerow({"date": line.date, "title": line.title, "amount": line.amount})

record_sale(SaleLine("2026-07-13", "Hamlet", 9.83))         # Luis, member card
record_sale(SaleLine("2026-07-13", "The Odyssey", 12.35))   # Marta, member card
record_sale(SaleLine("2026-07-13", "Don Quixote", 16.54))   # Julia, no card

Contents of sales.csv after the day's trading:

date,title,amount
2026-07-13,Hamlet,9.83
2026-07-13,The Odyssey,12.35
2026-07-13,Don Quixote,16.54

And the till report boils down to read, rebuild and aggregate — sum with a generator expression (02-04) over the day's lines:

def day_total(day, path=SALES):
    if not Path(path).exists():
        return 0.0
    with open(path, "r", encoding="utf-8", newline="") as f:
        lines = [SaleLine(row["date"], row["title"], float(row["amount"]))
                 for row in csv.DictReader(f)]
    return round(sum(l.amount for l in lines if l.date == day), 2)

print(day_total("2026-07-13"))    # 38.72

Delimiters, quotes and real-world cases

Not every "CSV" uses commas. In countries where the decimal separator is a comma (12,50), Excel exports with semicolons; other systems use tabs (the TSV format). The csv module parameterizes this with delimiter:

with open("distributor_releases.csv", "r", encoding="utf-8", newline="") as f:
    reader = csv.DictReader(f, delimiter=";")    # the distributor's "European-style" CSV

As for quotes: when a field contains the delimiter, line breaks or quotes, the standard wraps it in double quotes, and any internal quotes are doubled (""). The good news is that you don't have to manage any of it: writer quotes when needed and reader unquotes on the way back. Check it with Papyrus's two troublesome books:

with open("tricky.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["title", "price", "stock"])
    writer.writerow(["The Lion, the Witch and the Wardrobe", 11.75, 3])   # comma inside
    writer.writerow(['A History of the Apocryphal "Quixote"', 18.20, 2])  # quotes inside

The resulting file (open it in your editor, as in 06-01):

title,price,stock
"The Lion, the Witch and the Wardrobe",11.75,3
"A History of the Apocryphal ""Quixote""",18.20,2

And on re-reading it with csv.reader, each title comes back exact, comma and inner quotes intact — the swamp split(",") couldn't cross, solved out of the box. If some system demands quoting everything, there's quoting=csv.QUOTE_ALL as a writer argument; for this course, the default behavior (QUOTE_MINIMAL) is the right one.

CSV at scale: a mention of pandas

The csv module reads row by row and is perfect for Papyrus-scale volumes. When we analyze real data in module 11 — thousands or millions of rows, filters, groupings, statistics — we'll use pandas, whose read_csv() function loads a whole CSV into a tabular structure (the DataFrame) with automatic type conversion included. It's the analysis tool; csv is the light plumbing. Each in its own time: for now it's enough to know it exists and that you'll learn it in 11-03.

Common Mistakes and Tips

  • Forgetting newline="" when opening: on Windows you get blank lines between rows (when writing) or ghost rows (when reading). Mechanical rule: an open() for csv always carries encoding="utf-8" and newline="".
  • Parsing CSV with split(","): works until the first field with a comma or quotes. The csv module exists precisely for this; use it always, for reading too.
  • Forgetting the conversions when reading: row["price"] is a str. Adding "9.83" + "12.35" concatenates ("9.8312.35") instead of adding — an especially treacherous bug because it raises no error. Convert while rebuilding the object, as load_catalog() does.
  • Writing the header on every append: if every record_sale() called writeheader(), the history would fill up with interleaved headers. Header only when the file is created (the Path.exists() trick).
  • DictWriter with keys not in fieldnames: writerow({"title": ..., "author": ...}) raises ValueError if author wasn't declared. The columns are agreed once, in the constant (CATALOG_COLUMNS), and every writer respects it.
  • Opening the CSV in Excel while your program writes it: Excel locks the file on Windows and your open() will fail with PermissionError. Close the spreadsheet before running.
  • Tip: define the columns as a shared constant (SALES_COLUMNS) and use it both in fieldnames and when building the dictionaries. One single place to change if the format evolves.

Exercises

Exercise 1: the distributor's restock

The distributor sends restock.csv with header title;units and delimiter ; (create it by hand with these rows: Faust;5 and Hamlet;2). Write apply_restock(catalog, path) that reads it with DictReader, adds the units to the matching book's stock using normalize_title() to match titles, and prints a warning for every title not in the catalog. Check that Faust goes from 0 to 5 copies — and that in_stock() finally says True.

Exercise 2: the till report as CSV

Write till_report(day, sales_path, report_path) that reads sales.csv, keeps the lines for the given date and writes a new CSV with columns title,units,total_amount — one row per title, aggregated with a Counter or a defaultdict(float) (04-06). Test it with the three canonical sales of 2026-07-13 plus a second sale of Hamlet at 10.35 (Omar, no card): Hamlet should come out with 2 units and 20.18 in total amount.

Exercise 3: the indestructible catalog

Extend the round trip: add to the catalog the books The Lion, the Witch and the Wardrobe (11.75, 3 copies) and A History of the Apocryphal "Quixote" (18.20, 2), run save_catalog() + load_catalog() and check with == that the reloaded catalog is identical to the original. Open catalog.csv in your editor and find how each case ended up quoted.

Solutions

Solution 1:

import csv

def apply_restock(catalog, path="restock.csv"):
    with open(path, "r", encoding="utf-8", newline="") as f:
        for row in csv.DictReader(f, delimiter=";"):
            key = normalize_title(row["title"])
            book = catalog.get(key)              # the usual find_book lookup (05-06)
            if book is None:
                print(f"WARNING: {row['title']!r} is not in the catalog")
            else:
                book.stock += int(row["units"])

apply_restock(catalog)
print(catalog["faust"].stock)      # 5
print(catalog["faust"].in_stock()) # True — Faust's reservation queue finally moves

Same DictReader, different dialect: delimiter=";" absorbs the difference and the rest of the code never notices.

Solution 2:

import csv
from collections import defaultdict

def till_report(day, sales_path="sales.csv", report_path="report.csv"):
    units = defaultdict(int)
    amounts = defaultdict(float)
    with open(sales_path, "r", encoding="utf-8", newline="") as f:
        for row in csv.DictReader(f):
            if row["date"] == day:
                units[row["title"]] += 1
                amounts[row["title"]] += float(row["amount"])

    with open(report_path, "w", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "units", "total_amount"])
        writer.writeheader()
        for title in units:
            writer.writerow({"title": title,
                             "units": units[title],
                             "total_amount": round(amounts[title], 2)})

record_sale(SaleLine("2026-07-13", "Hamlet", 10.35))   # Omar, no card
till_report("2026-07-13")
# In report.csv: Hamlet,2,20.18 · The Odyssey,1,12.35 · Don Quixote,1,16.54

One CSV read and another written in the same function: the input → aggregation → output pattern you'll see a thousand times.

Solution 3:

catalog[normalize_title("The Lion, the Witch and the Wardrobe")] = Book("The Lion, the Witch and the Wardrobe", 11.75, 3)
catalog[normalize_title('A History of the Apocryphal "Quixote"')] = Book('A History of the Apocryphal "Quixote"', 18.20, 2)

save_catalog(catalog)
print(load_catalog() == catalog)    # True — commas and quotes included

In the file you'll see "The Lion, the Witch and the Wardrobe" (quoted because of the comma) and "A History of the Apocryphal ""Quixote""" (doubled quotes). You didn't type a single quote mark: csv applied the standard and undid it on reading.

Conclusion

Papyrus now speaks the universal tabular language: csv.writer/csv.reader for the basics and DictWriter/DictReader as the preferred option — columns by name, automatic header —, always with encoding="utf-8" and newline="", with delimiter for the European dialects and quoting handled out of the box. The catalog completes its round trip (save_catalog()/load_catalog(), with the str → float/int toll paid in a single place) and the SaleLine objects from 05-06 fulfilled their announced destiny in sales.csv, an append-mode history. But CSV has a ceiling: it only knows flat tables. How would you store Papyrus's members, each with a list of purchases inside, or the shop's configuration with values of different types? You need a format that understands nested structures — dictionaries inside lists inside dictionaries — and that also remembers whether a value was a number, text or a boolean. That format is JSON, the native language of web APIs and configuration files, and it's the next lesson.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved