How to Read a Python Traceback Properly (Most Developers Read It Backwards)
The last line tells you what broke. The middle tells you why. Here is how to get a working hypothesis out of a traceback in about ten seconds.
The short answer
Read the last line to learn what went wrong. Then scan the frames from the bottom up and find the last frame that belongs to your own code before execution entered library code. That frame is where your wrong assumption lives, and it is usually not the frame the exception was raised in.
For chained exceptions, the block above During handling of the above exception, another exception occurred is the real cause. The block below it is usually noise from your error handler.
Tested on Python 3.13.2. The behaviour described here holds from 3.11 onward, which is when fine grained error locations landed.
Try it on this one
Give yourself ten seconds and decide where the bug is.
Traceback (most recent call last):
File "/app/main.py", line 84, in <module>
run()
~~~~^^
File "/app/main.py", line 71, in run
report = build_report(load_users(), load_orders())
~~~~~~~~~~~^^
File "/app/loaders.py", line 22, in load_users
return [parse_user(row) for row in read_csv("users.csv")]
~~~~~~~~~~^^^^^
File "/app/loaders.py", line 40, in parse_user
return User(name=row["name"], age=int(row["age"]))
~~~~^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: ''
Under time pressure most people look at the bottom, see int(), and decide the bug is in parse_user. Then they wrap it in a try block and move on. That is a patch, not a fix, and maybe a third of the time it buries a real data problem that resurfaces two weeks later as a report with missing rows.
A traceback is a stack printed oldest first
When an exception is raised, Python walks up the call stack looking for a handler. If nobody handles it, the default handler prints every frame it passed through, in call order: outermost caller first, the place the exception was raised last.
That is the opposite of the order you want, which is why the usual advice is to read from the bottom. But "read from the bottom" is imprecise, because it makes people fixate on the final frame.
Three passes works better.
Pass one: the last line
ValueError: invalid literal for int() with base 10: ''
Two pieces of information, both useful.
The type narrows the class of bug a lot. ValueError means a function got an argument of the right type with an unacceptable value, so this is a data problem rather than a structural one.
| Exception | Usually means |
|---|---|
AttributeError: 'NoneType' object has no attribute 'x' |
Something returned None on a path you did not expect |
KeyError |
The data shape differs from your assumption |
TypeError |
Wrong type, often a genuine code bug |
ValueError |
Right type, bad value, usually bad input data |
RecursionError |
A cycle in your data, or a missing base case |
The value is the other half, and here it does a lot of work. The offending string is ''. Not 'abc', not '12.5', an empty string. That is specific. An empty string in a CSV column does not mean somebody typed garbage, it means the field was blank. So the real question is not "how do I parse this" but "why does this row have no age, and what should the system do about it?"
Pass two: find the boundary
Now scan up from the bottom and find the last frame in code you own. In the example above every frame is yours. Real tracebacks usually look more like this:
File "/app/services/sync.py", line 55, in sync_all
client.post("/v2/records", json=payload)
File "/usr/lib/python3.13/site-packages/httpx/_client.py", line 1145, in post
return self.request("POST", url, ...)
File "/usr/lib/python3.13/site-packages/httpx/_client.py", line 812, in request
...
File "/usr/lib/python3.13/site-packages/httpx/_transports/default.py", line 236, in handle_request
raise mapped_exc(message) from exc
httpx.ConnectTimeout
Twelve frames, eleven of them httpx. Reading them is almost always wasted effort, because the library is not broken. The frame that matters is sync.py line 55, the boundary. That is where your assumption, that this host is reachable and answers within the timeout, meets reality.
I have watched a lot of people lose twenty minutes reading library internals here. The heuristic: library frames tell you what happened, your frames tell you why. Only go down into library code once you have a hypothesis about why your call was wrong and you need to confirm the mechanism.
Your debugger can filter this for you. Mark third party paths as "just my code", or the equivalent in your IDE.
Pass three: the caret line
Since 3.11 Python underlines the exact sub expression that failed:
return User(name=row["name"], age=int(row["age"]))
~~~~^^^^^^^^^^^
More useful than it looks. Before 3.11, a line like a["x"]["y"]["z"] raising KeyError: 'y' told you the line but not which subscript. Now the carets point straight at it. If you are on an older version and you write multi part expressions, that alone is a decent argument for upgrading.
The trap: chained exceptions
This is where most misreadings happen.
Traceback (most recent call last):
File "/app/cache.py", line 30, in get
return json.loads(self.redis.get(key))
TypeError: the JSON object must be str, bytes or bytearray, not NoneType
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/app/api.py", line 88, in handler
data = cache.get(user_key)
File "/app/cache.py", line 33, in get
raise CacheMiss(f"no entry for {key}") from None
app.cache.CacheMiss: no entry for user:8823
The instinct is to read the bottom block, because it is last and it has a friendly named exception. That is backwards. The bottom block is your error handler running. The top block is the actual failure.
And the top block is saying something important: self.redis.get(key) returned None and the code passed it straight into json.loads without checking. The cache miss being reported is real, but the path to reporting it runs through an accidental TypeError first. If Redis had returned malformed JSON instead of None, you would get the same CacheMiss and the corruption would be invisible.
Two connectors, two meanings:
During handling of the above exception, another exception occurred is implicit chaining. You raised inside an except block. Usually a bug in your error handling.
The above exception was the direct cause of the following exception is explicit, from raise ... from err. Deliberate, usually fine.
Note the from None in that snippet. It suppresses the chain, which means in the real version of this code you would only ever see CacheMiss and never learn about the TypeError. from None is occasionally right for a clean library API. Inside application code it destroys evidence, and I would treat it as something to justify in review.
Async tracebacks
Asyncio tracebacks are longer and full of event loop scaffolding:
File "/usr/lib/python3.13/asyncio/events.py", line 88, in _run
self._context.run(self._callback, *self._args)
File "/usr/lib/python3.13/asyncio/tasks.py", line 314, in __step
result = coro.send(None)
Same rule, skip it, find your boundary frame.
The one genuinely async specific thing worth knowing: when a task raises and nobody awaits it, you get Task exception was never retrieved, often long afterwards and sometimes at interpreter shutdown, with a timestamp unrelated to when the error actually happened. If you see that, the traceback's position in the log is misleading, so do not correlate it with nearby log lines.
Making tracebacks better before you need them
Add context at the boundary. A bare KeyError: 'age' from deep inside a parser is much less useful than:
try:
return parse_user(row)
except (KeyError, ValueError) as e:
raise UserParseError(f"row {i} in {path}: {e}") from e
Same information, plus the two things you will want: which row, which file. Note the from e, which keeps the chain.
Log tracebacks, do not print them. logger.exception("failed to sync") inside an except block captures the full traceback and routes it through your handlers. print(e) gives you the message with no frames at all, which is how you end up with '' in a log file and no idea where it came from.
Use a richer formatter in development. rich.traceback shows local variable values at each frame. Seeing row = {'name': 'Ada', 'age': ''} inline saves an entire round of re running with print statements. Do not enable it for production logs, because it will happily serialise your secrets.
Back to the original
So: ValueError, empty string, in parse_user, called from load_users, reading users.csv.
The wrong fix is a try block around int() that substitutes zero. Now every user with a blank age is zero years old, your average age metric is quietly wrong, and nobody notices for a quarter.
The right fix starts from a question the traceback already answered: blank values exist in this file. So decide explicitly. Is a missing age valid? If yes, the type is int | None and every consumer has to handle it. If no, the file is invalid and should be rejected loudly at the boundary, naming the row.
That decision is not in the traceback. But the traceback told you in ten seconds that you needed to make it.