Module 5: Errors, Debugging, and Testing
Reading a Traceback
What a traceback is actually telling you, why the last line matters most, and how to turn a crash into a hypothesis you can test in one run.
Lesson 16 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can read a traceback and say, without guessing, which line failed, what kind of failure it was, and what to check first.
Why it matters
A traceback looks like a wall of text, so people skim it, see red, and start changing things. Almost all of the information needed to fix the problem is in two lines of it, and reading those two lines is faster than any amount of experimentation.
This also matters when working with an assistant. Pasting the traceback along with the relevant code gets a useful answer; pasting "it doesn't work" gets a guess. Being able to say "it raises a KeyError for agency on the loop line" is often enough to fix it yourself before you finish typing the question.
Concept
A traceback is the stack of calls that were in progress when the failure happened, printed oldest first. Read it from the bottom up.
- The last line is the exception type and its message. That is what went wrong.
- The lines just above it are the innermost frame: the file, the line number, and the source of the line that raised.
- Each block above that is the caller of the block below it, back to where the program started.
Your own code is usually the lowest block whose path you recognise. When a failure happens inside a library, the frame you can act on is normally the last one of yours before the library takes over.
Five exception types cover most early failures:
KeyError— a dictionary key that does not exist. The message names the key.IndexError— a list position beyond the end.TypeError— an operation between incompatible types, such as adding text to a number.ValueError— the right type but an unusable value, such asint("12 apples").AttributeError— a method or attribute the object does not have; often means the value is not the type you assumed, andNoneTypein the message almost always means something returnedNone.
Then form one hypothesis, in the shape "I believe X, because the traceback says Y", and test exactly that. Print the suspect value, or check the record the loop was on. Changing several things at once means that when the error goes away you will not know which change did it, and whether the others introduced something new.
Read the code
def response_window(record):
return record["response_due"] - record["posted_date"]
def summarise(batch):
for record in batch:
print(record["notice_id"], response_window(record))
summarise([{"notice_id": "A-1", "posted_date": "2026-03-04", "response_due": "2026-04-01"}])
The traceback this produces:
Traceback (most recent call last):
File "report.py", line 10, in <module>
summarise([{"notice_id": "A-1", ...}])
File "report.py", line 7, in summarise
print(record["notice_id"], response_window(record))
File "report.py", line 2, in response_window
return record["response_due"] - record["posted_date"]
TypeError: unsupported operand type(s) for -: 'str' and 'str'
Read it bottom-up. The last line says a subtraction was attempted between two strings, which Python does not define. The frame above it points at line 2, inside response_window, and shows the exact expression.
The two frames above that are the path that got there: summarise called it from line 7, and the top-level call is on line 10. That path is context, not the fault. The fault is that both dates are text, and text cannot be subtracted; they need parsing into date values first, which is Module 6's work.
Inspect the numbered traceback
Read the error first; trace the calls upward
Traceback (most recent call last):
Displayed oldest first. Start at 1 below, then read upward.
File "report.py", line 10, in <module>
summarise([{"notice_id": "A-1", "posted_date": "2026-03-04", "response_due": "2026-04-01"}])The top-level statement supplies the batch to summarise.
File "report.py", line 7, in summarise
print(record["notice_id"], response_window(record))Evaluating this print argument calls response_window before print itself can run.
File "report.py", line 2, in response_window
return record["response_due"] - record["posted_date"]This subtraction raises the error. Inspect the values and types of these two fields.
TypeError: unsupported operand type(s) for -: 'str' and 'str'Both operands are strings. Hypothesis: the dates still need parsing before subtraction; changing string lengths would hide the error without calculating a date interval.
A testable next step
Check- Inspect both date fields and their types.
Expected evidence- The values are date-shaped text; both types are str.
Output boundary- No partial notice ID is printed: argument evaluation fails before print runs.
The traceback locates the failure, but the intended date calculation determines the repair.
Predict the output
Does the program print anything before it fails? Predict what appears, and in what order.
Check your prediction
Nothing is printed by the program before the traceback.
That surprises people, because print(record["notice_id"], response_window(record)) looks as though the identifier goes out first. Python evaluates all of a call's arguments before calling it, so response_window(record) runs, and raises, before print is ever invoked. No partial line appears.
The general lesson is worth more than this example: the failing line is not always where output stopped, because arguments are evaluated before the call that would have displayed them.
Modify the code
Change response_window to return len(record["response_due"]) - len(record["posted_date"]). Predict what the program prints now.
What changes, and why
It prints A-1 0, and does not raise.
Both dates are ten-character strings, so the lengths are equal and the difference is zero. The crash is gone and the answer is meaningless, which is exactly the trap of fixing an error rather than the cause. The program no longer complains, and it now silently reports that every opportunity has a zero-day response window.
An error that stops the program is a gift. Wrong output that runs cleanly is the expensive kind, and "the traceback went away" is not evidence that the bug did.
Debug the bug
An assistant was given a NoneType error and produced this "fix", saying the problem was a missing default.
def find_record(batch, notice_id):
for record in batch:
if record["notice_id"] == notice_id:
return record
batch = [{"notice_id": "A-1", "estimated_value": 300000}]
record = find_record(batch, "A-9")
value = record.get("estimated_value", 0)
print(value)
What's actually wrong
It raises:
AttributeError: 'NoneType' object has no attribute 'get'
The .get(..., 0) default cannot help, because the failure is one step earlier. find_record searched for "A-9", found nothing, fell off the end of the loop, and returned None. The next line then called a dictionary method on None.
NoneType in an AttributeError is nearly always this shape: a function that returns nothing on some path, and a caller that assumed it always returns something.
There are two honest repairs, and choosing between them is a design decision:
- Make the caller handle absence:
if record is None: print("not found"). - Make the function refuse to return silently: raise a clear error, or document and return an explicit default.
What is not a repair is defending the symptom at the call site while leaving the contract undefined. find_record's docstring should state what it does when nothing matches, and Module 5's tests should include that case.
Try it yourself
Run the program below first. Read the traceback, identify the offending line, and repair it so every record prints, with a missing agency shown as unknown agency.
Loading this exercise…
Practical challenge (optional)
Optional: reintroduce the crash, then add print(record) as the first line of the loop body and run it again. Confirm that the last record printed before the traceback is the one that caused it. Then remove the debugging line. Learning to narrow a data-dependent failure to a specific record, and then to clean up after yourself, is the whole loop of practical debugging.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
Match each message to its most likely cause:
KeyError: 'response_due'TypeError: '>' not supported between instances of 'str' and 'int'AttributeError: 'NoneType' object has no attribute 'append'ValueError: invalid literal for int() with base 10: '96,500'IndexError: list index out of range
Answers
- A dictionary was read with square brackets for a key it does not have. Use
getwith a default if the field is genuinely optional. - A published value is still text and is being compared to a number. Convert before comparing.
- Something returned
Nonewhere a list was expected, most often a name assigned from an in-place method such assort()orappend(). - The text has a thousands separator that
intwill not accept. Strip separators during normalisation before converting. - A position past the end of the list, usually a loop counter that runs one step too far or an assumption that a list is non-empty.
Sign in to track your progress on this exercise.
Summary and next step
Read a traceback bottom-up, the last line names the failure, the frame above it names your line, five exception types cover most early bugs, NoneType means something returned nothing, and a disappearing error is not proof of a fixed cause. Next: handling the failures worth handling, and refusing to hide the rest.