⏱ 21 min readTopics chapter readerLevel · Intermediate
01 · Overview
Start here
⏱ 5 min · Topic 1 of 17
In chapter 1 you wrote check_order. It raised ValueError for a bad record, and a loop moved bad records to a rejected list. This chapter builds the full version for a real CSV file.
The file is the shop again, with five bad rows added at the end. Line 1 is the header: the line with the column names. The loader gives each row its line number:
DictReader gives each row as a dict. Every value is text, even 101. enumerate(reader, start=2) counts the rows from 2, because line 1 is the header. Later sections use this list called rows.
Here are the six habits this chapter teaches:
Check each column with its own small function.
Collect every problem in a row, not only the first.
Sort problems into warnings, rejections and fatal errors.
Catch only the errors you expect.
Retry only temporary errors.
Report and count what you rejected.
02 · Contract
Rules as a table
⏱ 5 min · Topic 2 of 17
A validation rule is a promise about good data. A table of rules is the contract from chapter 1: what must be true, and what happens if it is not.
These are the rules for the shop file:
Where
Rule
If it breaks
header
has order_id, customer_id, status and amount
stop the run
order_id
a whole number, not empty
reject the row
customer_id
a whole number, not empty
reject the row
status
paid, refunded or pending
reject the row
amount
a number, not below 0, may be empty
reject the row
paid order
must have an amount
reject the row
amount
above 70 is unusual
keep the row, add a warning
order_id
appears once in the file
stop the run
The next sections turn each line of this table into code. A reviewer can read the table and predict what the code rejects.
Watch out
A rule that lives only in a comment checks nothing, because Python never reads it.
03 · Header
Check the header first
⏱ 5 min · Topic 3 of 17
Check the header before any row. If a column is missing, every row fails for the same reason. One clear stop is better than thousands of row errors.
We make our own kind of error for this. The code defines a class, which is a new kind of object.
(Exception) says: this is a kind of error. pass means: nothing more to add. The name BatchError means the whole batch is broken:
reader.fieldnames is the list of column names from line 1. The short file has no amount column, so the check stops. The real file passes.
An empty file has no header at all, and fieldnames is None. The text (fieldnames or []) turns None into an empty list, so the check reports every column as missing.
An extra column is usually harmless. Decide in the table whether it is ignored or gets a warning.
04 · Fields
One checker per column
⏱ 6 min · Topic 4 of 17
Give each column its own small function. It takes the text from the file. It returns a clean value, or raises ValueError with a short reason.
The file holds only text, so each function also converts the value. Whole numbers become int. Money becomes Decimal, which is exact, unlike a float.
Decimal("twelve") fails with its own error, InvalidOperation. We catch it and raise a ValueError with a clear reason.
In except InvalidOperation as exc, as exc gives the caught error a name. In raise ... from exc, from exc keeps it as the cause. The section "Keep the original cause" explains this.
one_of is a function that returns a function. You give it a set, which is a group of values in { }, and you get back a checker for those values.
The test loop holds pairs of a checker and a text. A function is a value, so it can sit in a list. check.__name__ is the name of the function.
Two checkers are tested here: whole_number and money. one_of is used in the next section. Here is the code, with seven test calls:
Three small checkersworked example
Python
fromdecimalimportDecimal,InvalidOperationdefwhole_number(raw):ifraw=="":raiseValueError("is required")try:returnint(raw)exceptValueErrorasexc:raiseValueError("must be a whole number")fromexcdefone_of(allowed):defcheck(raw):ifrawnotinallowed:raiseValueError("is not allowed")returnrawreturncheckdefmoney(raw):ifraw=="":returnNonetry:value=Decimal(raw)exceptInvalidOperationasexc:raiseValueError("must be a number")fromexcifvalue<0:raiseValueError("must not be negative")returnvalueforcheck,rawin[(whole_number,"101"),(whole_number,""),(whole_number,"1O1"),(money,"40.0"),(money,""),(money,"twelve"),(money,"-5.0")]:try:print(check.__name__,repr(raw),"->",repr(check(raw)))exceptValueErroraserror:print(check.__name__,repr(raw),"-> error:",error)
Result · 7 rows
output
whole_number '101' -> 101
whole_number '' -> error: is required
whole_number '1O1' -> error: must be a whole number
money '40.0' -> Decimal('40.0')
money '' -> None
money 'twelve' -> error: must be a number
money '-5.0' -> error: must not be negative
The third call has a letter O instead of a zero. An empty amount becomes None, because a pending order has no amount yet. A reason is a few words that name the problem.
05 · Problems
Collect every problem
⏱ 5 min · Topic 5 of 17
If you stop at the first problem, the source owner fixes one thing, reruns, and finds the next. Collect all problems in one pass instead.
A spec is a dict from column name to checker. The function check_row runs every checker and keeps every problem. It uses whole_number, one_of and money from the last section:
{'line': 10, 'field': 'amount', 'value': 'twelve', 'reason': 'must be a number'}
Line 2 has no problems. Line 9 has three, and all three are reported. clean holds only the values that passed.
The try block holds one checker call, and it catches only ValueError. A missing column would raise KeyError, which is not caught on purpose: the header check should have stopped the run already.
A row with too few cells gives None, and int(None) raises TypeError, which is not caught here. Catch short rows when you read the file, as in the files chapter.
06 · Levels
Row rules, batch rules
⏱ 5 min · Topic 6 of 17
A field rule looks at one value. A row rule looks at several values in one row. A batch rule looks at many rows at once.
Our table has a row rule: a paid order must have an amount. Each field is fine alone, but the pair is wrong. This function checks it after check_row passes:
A rule across two fieldsworked example
Python
Input data
lines 5 to 84 rows
line
order_id
status
amount
5
104
paid
15.5
6
105
pending
7
106
paid
80.0
8
107
paid
defcheck_record(line_number,clean):ifclean["status"]=="paid"andclean["amount"]isNone:return[{"line":line_number,"field":"amount","value":"","reason":"is required when paid"}]return[]forline_number,rowinrows[3:7]:clean,problems=check_row(line_number,row)ifnotproblems:problems=check_record(line_number,clean)result="ok"ifnotproblemselseproblems[0]["reason"]print(line_number,row["status"],repr(row["amount"]),"->",result)
Result · 4 rows
output
5 paid '15.5' -> ok
6 pending '' -> ok
7 paid '80.0' -> ok
8 paid '' -> is required when paid
An empty amount is fine for pending order 105. The same empty amount is a problem for paid order 107.
A batch rule needs all the rows. One row cannot know that its order_id appears twice.
Count the ids with Counter, the tool from the keyed-data chapter. A duplicate is fatal, so the check raises BatchError:
These two lists are made up for the demo. Our file has no duplicate ids. In a real run, pass the ids of all rows, after the row loop.
07 · Outcomes
Warnings, rejections, fatal errors
⏱ 6 min · Topic 7 of 17
A problem can have three answers. Here they are, with an example from the shop file:
Answer
What happens
Example
Warning
Keep the row. Report it.
Line 7: amount 80.0 is above 70
Rejection
Leave the row out. Report it.
Line 8: a paid order has no amount
Fatal error
Stop the whole run.
The header has no amount column
You met three choices in chapter 1: skip, quarantine, fail loud. A rejection is quarantine. A fatal error is fail loud.
A warning is new: you keep the row. We do not use skip, because it hides bad rows.
The answer is a rule you choose, not a guess. An unknown status could be fatal in a finance job and only a warning in a test run. Choose one answer per rule, write it in code, and count each answer.
The function run below applies the policy to every row. It uses check_row and check_record from the earlier sections. A new rule adds a warning for amounts above 70:
Orders 101 to 106 are accepted. Order 106 is also in warnings: a warning keeps the row. Lines 8 to 12 give 7 problems on 5 rows.
Nothing in this chapter catches BatchError. If check_header fails, the error stops the job before run starts. A fatal error must stop the job, not become one more rejected row.
08 · Context
Say where it went wrong
⏱ 5 min · Topic 8 of 17
An error that says only "invalid row" is hard to use. A good error says where, what and why: the file, the line, the field, the value and the reason.
Add the context when the problem is created, not later. Our problems already hold the line, field, value and reason.
In the f-string below, {x!r} shows the value x with quotes, so an empty value looks like two quotes. The loop also adds the file name:
Turn a problem into a messageworked example
Python
forprobleminrejected[:3]:print(f"orders.csv line {problem['line']}, {problem['field']} "f"{problem['reason']} (value {problem['value']!r})")
Result · 3 rows
output
orders.csv line 8, amount is required when paid (value '')
orders.csv line 9, customer_id is required (value '')
orders.csv line 9, status is not allowed (value 'shipped')
Line 8 shows two quotes for the empty value. Without !r it would show a blank.
The reason is for the person who fixes the source file. Write it so they can fix the row without reading your code.
For private data, such as an email address, hide the value in the report. Keep the line number and the field.
09 · Exceptions
Catch only what you expect
⏱ 8 min · Topic 9 of 17
The try block runs its steps. If an error of the named kind happens, the except block runs instead. The wider the named kind, the more it hides.
A bare except names no kind, so it catches every error, even a typo. except Exception is almost as wide.
A bare except also catches KeyboardInterrupt, the signal that Ctrl+C sends, so Ctrl+C may not stop the job. In this loop, amout is a typo:
The typo now stops the job on the first row, with the name of the missing key.
Optional: a try block that grew too long
Every step inside a long try block is blamed for the error. Here the rate table has no pending status, so tax_wide gives the wrong reason:
Keep the try block smallworked example
Python
RATES={"refunded":Decimal("0"),"paid":Decimal("0.20")}deftax_wide(row):try:amount=Decimal(row["amount"])returnamount*RATES[row["status"]]exceptException:return"reject: bad amount"deftax_narrow(row):try:amount=Decimal(row["amount"])exceptInvalidOperation:return"reject: bad amount"returnamount*RATES[row["status"]]paid_row=rows[3][1]pending={"status":"pending","amount":"5.0"}print("wide:",tax_wide(paid_row),"|",tax_wide(pending))print("narrow:",tax_narrow(paid_row))try:tax_narrow(pending)exceptKeyErroraserror:print("narrow: KeyError",error)
Result · 3 rows
output
wide: 3.100 | reject: bad amount
narrow: 3.100
narrow: KeyError 'pending'
The pending row has a good amount of 5.0. The wide version still says "bad amount". The real problem is a missing rate, which is a bug in our code. The narrow version shows it.
Interview question
What is wrong with except: pass? Say: it catches every error, so bugs and Ctrl+C disappear with the bad data. Catch the one error you expect, keep the try block to one step, and record the rejected row with a reason.
10 · Optional
Keep the original cause
⏱ 6 min · Topic 10 of 17
Optional: you can skip this section on a first read.
Sometimes you catch a low-level error and raise a clearer one. Write raise NewError(...) from exc. It keeps the first error as the cause.
A traceback is the list of lines Python prints when a program stops with an error. Here the first error is InvalidOperation from Decimal, and the caller sees a ValueError in its own words:
The cause is None. The first error survives only as __context__: the error that was being handled. The traceback says "During handling of the above exception, another exception occurred", which looks like a bug in your handler.
Pick the form by what you want the caller to see:
You want to
Write
The caller sees
Log, then pass the same error up
raise, alone, inside except
The original error
Say it in your own words
raise MyError(...) from exc
Your error, with the original as its cause
Hide the first error from the traceback
raise MyError(...) from None
Your error only. Use rarely.
11 · Retries
Retry temporary errors only
⏱ 9 min · Topic 11 of 17
Some errors go away if you try again. A server may be busy for one second. This is a temporary error, also called transient.
Other errors never go away. A file you may not read stays unreadable. A retry of this permanent error only repeats the failure.
So the rule is: retry temporary errors only. This table sorts four errors you will meet:
Error
Temporary?
What to do
TimeoutError: the server did not answer
Yes
Wait, then try again
ConnectionError: the network was cut
Yes
Wait, then try again
PermissionError: no permission to read the file
No
Stop and report
ValueError: a bad row
No
Reject the row
Wait longer after each failure. This is called backoff, and it gives a busy server time to recover.
A common plan doubles the wait each time, up to a maximum. Here 2 ** n means 2 to the power n. These lines build that schedule:
A backoff scheduleworked example
Python
delays=[min(30,2**n)forninrange(6)]print(delays)
Result · 1 row
output
[1, 2, 4, 8, 16, 30]
The first five waits are 1, 2, 4, 8 and 16 seconds. The next doubling gives 32, so min cuts it to the maximum of 30. Many real jobs also add a small random time, called jitter. It stops many jobs from retrying at the same moment.
The function with_retries uses a schedule like this. It retries only TimeoutError and ConnectionError. The last try sits outside the loop, so its error goes up to the caller.
The demo passes sleep as a parameter and saves the waits in a list, so it runs fast:
Retry only a temporary errorworked example
Python
calls={"n":0}defflaky_fetch():calls["n"]+=1ifcalls["n"]<3:raiseTimeoutError("server did not answer")return"11 rows"defwith_retries(action,delays,sleep):fordelayindelays:try:returnaction()except(TimeoutError,ConnectionError)aserror:print(f"try failed: {error}. wait {delay}s")sleep(delay)returnaction()waited=[]print(with_retries(flaky_fetch,[1,2,4],waited.append))print("calls",calls["n"],"waited",waited)defno_access():raisePermissionError("no permission to read the file")try:with_retries(no_access,[1,2,4],waited.append)exceptPermissionErroraserror:print("stopped:",error)defalways_down():raiseTimeoutError("server did not answer")try:with_retries(always_down,[1,2,4],waited.append)exceptTimeoutError:print("gave up")
Result · 9 rows
output
try failed: server did not answer. wait 1s
try failed: server did not answer. wait 2s
11 rows
calls 3 waited [1, 2]
stopped: no permission to read the file
try failed: server did not answer. wait 1s
try failed: server did not answer. wait 2s
try failed: server did not answer. wait 4s
gave up
The first action fails twice, then works on call 3. The file with no permission is not retried: it stops at once. The server that never answers is tried 4 times, then the error goes up.
Watch out
A retry runs the action again. If the action adds rows to a table, a retry can add them twice. Make the write safe to repeat before you retry it. A later chapter shows how.
Interview question
Which errors do you retry? Only temporary ones, such as a timeout. Use a limited number of tries, growing waits and an action that is safe to repeat.
12 · Optional
Summarise the rejections
⏱ 6 min · Topic 12 of 17
Optional: you can skip this section on a first read, but exercise 3 uses it.
A list of 7 problems is easy to read. A real file can have a million. Group the problems by field and reason, and put the biggest group first.
The function summarise counts each group with Counter. It keeps only 2 line numbers per group.
sorted(..., key=sort_key) calls sort_key on each item and sorts by what it returns. The key is a tuple: Python compares the first value, and uses the next only on a tie. The minus puts big counts first, and the names break ties, so the report is the same on every run:
11 = 6 + 5. The set of line numbers counts rows, not problems: line 9 has three problems but is one rejected row. Do not add warnings to the sum, because a warned row is also accepted.
Save the manifest next to the output. A sudden jump in rejected rows is often the first sign that the source changed its format.
14 · Optional
Check before you write
⏱ 5 min · Topic 14 of 17
Optional: you can skip this section on a first read.
Before you write the output, ask if the whole run looks right. Zero accepted rows is wrong. So is a very high share of rejected rows.
Both mean the source probably broke. The limit of 20% in the code is only an example. Choose the limit for each job.
In the f-string, :.0% shows a share as a percent, so 0.4545 becomes 45%. This function raises BatchError in those cases:
Stop if too many rows failworked example
Python
defcheck_before_write(rows_seen,accepted,rejected_rows,max_share=0.2):ifnotaccepted:raiseBatchError("no rows were accepted")share=rejected_rows/rows_seenifshare>max_share:raiseBatchError(f"{rejected_rows} of {rows_seen} rows rejected "f"({share:.0%}), limit {max_share:.0%}")try:check_before_write(manifest["rows_seen"],accepted,manifest["rejected_rows"])exceptBatchErroraserror:print("stop:",error)check_before_write(100,accepted,3)print("ok")
Result · 2 rows
output
stop: 5 of 11 rows rejected (45%), limit 20%
ok
Our file has 5 rejected rows of 11, which is 45%, so the run stops. The second call pretends a file of 100 rows with 3 rejected. That is 3%, so it passes.
Run this check after the row loop and before the write. Stopping early is cheap. Writing bad output over good data is not.
15 · Optional
Test each rule
⏱ 5 min · Topic 15 of 17
Optional: you can skip this section on a first read.
A test is a small function that checks one rule. The tool pytest runs every function whose name starts with test. Each test uses assert, as in the manifest section.
Save the tests in a file named test_rules.py, after the code from this chapter. Run them with the command pytest -q.
Test one good case and one bad case for each rule. Check the exact reason and field, not only that something failed. The line with pytest.raises passes only if the code inside it raises that error, with a message that matches the text:
Three small testsworked example
Python
importpytestdeftest_money_reads_a_decimal():assertmoney("40.0")==Decimal("40.0")deftest_money_rejects_text():withpytest.raises(ValueError,match="must be a number"):money("twelve")deftest_row_collects_every_problem():row={"order_id":"108","customer_id":"","status":"shipped","amount":"-5.0"}clean,problems=check_row(9,row)assert[p["field"]forpinproblems]==["customer_id","status","amount"]
Result · 2 rows
output
... [100%]
3 passed in 0.10s
The third test uses line 9 from the file, with its three problems.
16 · Practice
Practice lab
⏱ 7 min · Topic 16 of 17
Each exercise opens in the Python workspace. You write a function, run it on sample rows, and check the result.
The first three use the habits of this chapter. The fourth is harder: it sorts failures and decides what is safe to retry. The retry section is a good first step for it.
Some sections were optional. If you skipped one, read it before the exercise that needs it: "Summarise the rejections" before exercise 3.
How to practise
Before you code, write down one bad row and the reason you expect. Then run your function and compare.
You now have a safe way to read a messy file and to say what went wrong.
⏱ 3 min · Topic 17 of 17
Next chapter
APIs, Databases & External Boundaries
You now have a safe way to read a messy file and to say what went wrong.
The next chapter moves to web services and databases. You will call an API that returns pages of data, keep secrets out of your code, and read a source twice without loading rows twice.