Handling Bad Data Gracefully
Real files are messy. A bank export, a spreadsheet someone edited by hand, a list copied from an email: sooner or later one row is not what it should be. The question is what your program does next. It should not crash and throw away a whole report over one row. It should not quietly drop the row either. The mature answer is in between: skip the bad row, keep going, and say exactly which row was skipped and why.
Think of a cashier who finds one item with an unreadable barcode. They do not close the shop, and they do not quietly put the item in the bin. They set it aside, finish the rest of the shopping, and tell you about the one they could not scan.
The Bad Row
Here is Chapter 7's statement with one more row, as it might arrive. The pharmacy payment on line 8 has no amount: two commas with nothing between them.
date,description,amount,category 2026-09-01,Grocery store,-54.30,food 2026-09-02,Monthly bus pass,-45.00,transport 2026-09-03,Coffee,-4.20,food 2026-09-05,Salary,2400.00,income 2026-09-07,Electricity bill,-61.75,home 2026-09-09,Cinema,-12.50,fun 2026-09-10,Pharmacy,,health
Run Chapter 7's totalling program on it and the whole report is lost. Decimal of an empty string raises an InvalidOperation, the decimal module's own exception, and the traceback shows the conversion line.
import csv from decimal import Decimal spent = Decimal("0") with open("statement.csv", encoding="utf-8", newline="") as f: for row in csv.DictReader(f): amount = Decimal(row["amount"]) if amount < 0: spent = spent - amount print(f"Spent in total: {spent}")
Traceback (most recent call last):
File "/Users/you/python-course/statement_crash.py", line 7, in <module>
amount = Decimal(row["amount"])
decimal.InvalidOperation: [<class 'decimal.ConversionSyntax'>]
That last line is not a friendly message. It is the decimal module saying the text could not be read as a number, and it is exactly the kind of message your program should translate for the person using it.
Three Bad Responses
There are three tempting ways to deal with the bad row, and all three are wrong. Crash, as above: one bad row costs you the whole report. Skip silently, catching the error and moving on without a word: the report appears, but its total is quietly wrong and nobody knows. Guess, treating the empty amount as zero: the total looks right and is not, which is the most dangerous of the three.
| Response | Report produced? | Total honest? | Problem visible? |
|---|---|---|---|
| Crash | no | no total at all | yes |
| Skip silently | yes | no | no |
| Guess a value | yes | no | no |
| Skip and report | yes | yes, for the rows read | yes |
The Good Response
The version below does the fourth thing. A small function, parse_amount, turns the text of an amount into a Decimal, and raises a ValueError with a plain-English reason when it cannot: an empty amount, or text that is not a number. The main loop tries each row. When a row fails, it records the line number and the reason in a list and carries on. At the end it prints the report and then, separately, every row it skipped.
import csv from decimal import Decimal, InvalidOperation def parse_amount(text): """Return the amount as a Decimal, or raise ValueError saying why not.""" if not text.strip(): raise ValueError("the amount is empty") try: return Decimal(text) except InvalidOperation: raise ValueError(f"'{text}' is not an amount") spent = Decimal("0") skipped = [] with open("statement.csv", encoding="utf-8", newline="") as f: for line, row in enumerate(csv.DictReader(f), start=2): try: amount = parse_amount(row["amount"]) except ValueError as error: skipped.append(f"line {line}: {error}") continue if amount < 0: spent = spent - amount print(f"Spent in total: {spent}") if skipped: print(f"Skipped {len(skipped)} row(s):") for reason in skipped: print(f" {reason}")
Spent in total: 177.75 Skipped 1 row(s): line 8: the amount is empty
The total is honest about what it covers, and the one problem is in plain view, with the exact line to go and fix.
Line Numbers People Can Find
The report says line 8 because that is where a person will find the row when they open the file in a text editor or a spreadsheet. The header is line 1, so the first data row is line 2, which is why enumerate starts counting at 2. A message that pointed at "row 7" would send the reader to the wrong place.
Notice also what the loop does not catch. It catches only ValueError, the one parse_amount raises on purpose. If the file had no amount column at all, the KeyError from row["amount"] would still stop the program, which is right: that is a different problem, with the whole file, not one row.
- "Skipping bad rows is enough." Without a report, the total is silently wrong. Skip, and say what you skipped.
- "Treating a missing amount as zero is a safe default." It produces a total that looks right and is not. Report the row instead.
- "Catch every exception per row, to be safe." Catch the one you expect. A different error, such as a missing column, is a different problem and should still stop the program.
- "The loop counter is the line number." The header is line 1, so data starts at line 2. Count so that people can find the row.
- Graceful and loud is the right default for any data from outside: files here, the network in Chapter 10.
- People trust a report that says what it could not read far more than one that pretends it read everything.
Knowledge Check
Which is the right response to one malformed row in a statement?
- Stop the whole program so nothing wrong is ever printed
- Skip the row quietly so the report still appears
- Skip the row and report exactly which one and why
- Treat the missing amount as zero and simply carry on
Why does the report say line 8 rather than row 7?
- Because Python numbers the rows of a list from 1
- The header is line 1, so line 8 is where a person finds it
- Because the empty amount takes up an extra line
- Because enumerate always counts the header row twice over by default
Why does the loop catch only ValueError?
- Because Python allows only one type in each except
- Because every other exception is prevented by csv
- Because ValueError is the name for every kind of data error
- It is the one parse_amount raises; other errors should still stop it
What does parse_amount do with the text " "?
- Raises ValueError saying the amount is empty
- Returns Decimal zero for the blank amount
- Raises InvalidOperation straight from Decimal
- Returns the text unchanged for the caller to handle
You got correct