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
- What CSV is and why it's the lingua franca of tabular data
- Why
split(",")isn't enough - Writing with
csv.writer(and the mystery ofnewline="") - Reading with
csv.reader DictReaderandDictWriter: the preferred way- Full round trip: the Papyrus catalog to
catalog.csvand back - The sales record:
SaleLinefulfills its destiny - Delimiters, quotes and real-world cases
- CSV at scale: a mention of pandas
- Common mistakes and tips
- 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:
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:
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 bookStep by step:
csv.writer(f)wraps the open file (thewithand theencoding="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 alsowriterows(list_of_rows)for dumping many at once.newline=""is mandatory when opening a file for thecsvmodule, both for writing and for reading. Why? Thecsvmodule 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;csvhandles 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 catalogAnd 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 itThat 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 cardContents of sales.csv after the day's trading:
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.72Delimiters, 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" CSVAs 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 insideThe 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: anopen()forcsvalways carriesencoding="utf-8"andnewline="". - Parsing CSV with
split(","): works until the first field with a comma or quotes. Thecsvmodule exists precisely for this; use it always, for reading too. - Forgetting the conversions when reading:
row["price"]is astr. 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, asload_catalog()does. - Writing the header on every append: if every
record_sale()calledwriteheader(), the history would fill up with interleaved headers. Header only when the file is created (thePath.exists()trick). DictWriterwith keys not infieldnames:writerow({"title": ..., "author": ...})raisesValueErrorifauthorwasn'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 withPermissionError. Close the spreadsheet before running. - Tip: define the columns as a shared constant (
SALES_COLUMNS) and use it both infieldnamesand 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 movesSame 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.54One 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 includedIn 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
