Reading Python Stack Traces
Error Handling
Tracebacks 101
Stack traces show the call stack when an exception occurs. Learn how to read them and extract next steps.
Read it bottom-up
Save this as report.py and run it:
def average(values):
return sum(values) / len(values)
def summarize(rows):
return average([r["score"] for r in rows])
def main():
print(summarize([]))
main()
Traceback (most recent call last):
File "report.py", line 10, in <module>
main()
File "report.py", line 8, in main
print(summarize([]))
^^^^^^^^^^^^^
File "report.py", line 5, in summarize
return average([r["score"] for r in rows])
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "report.py", line 2, in average
return sum(values) / len(values)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero
Read it in this order:
- Last line first — the exception type and message: ZeroDivisionError, division by zero.
- Frame above it — where it was raised:
average, line 2, dividing bylen(values). - Walk upward — each earlier frame is the call site that led there:
summarizecalledaverage,maincalledsummarizewith[].
The bottom frame is where the code exploded, not necessarily where the mistake is. Nothing is wrong with average — the bug is that main passed an empty list. Walking up the frames is how you find that out.
Fine-grained markers (Python 3.11+)
Since Python 3.11, ~ and ^ markers pin down the exact expression, not just the line. Invaluable on chained subscripts:
data = {"user": {"name": "Ada"}}
print(data["user"]["email"]["domain"])
Traceback (most recent call last):
File "app.py", line 2, in <module>
print(data["user"]["email"]["domain"])
~~~~~~~~~~~~^^^^^^^^^
KeyError: 'email'
The carets sit under ["email"]: data["user"] worked, and the KeyError came from looking up "email" — not "domain".
Chained tracebacks
An exception raised while handling another produces two tracebacks in one report:
CONFIG = {}
def get_timeout():
try:
return CONFIG["timeout"]
except KeyError:
raise RuntimeError("config was never loaded")
get_timeout()
Traceback (most recent call last):
File "app.py", line 5, in get_timeout
return CONFIG["timeout"]
~~~~~~^^^^^^^^^^^
KeyError: 'timeout'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "app.py", line 9, in <module>
get_timeout()
File "app.py", line 7, in get_timeout
raise RuntimeError("config was never loaded")
RuntimeError: config was never loaded
Read the first traceback first: it holds the root cause (KeyError). The last one is only what your program ultimately died with. When code uses raise ... from exc, the separator reads "The above exception was the direct cause of the following exception" instead — see exception chaining for the difference.
Your frames vs library frames
Long tracebacks through third-party code are mostly noise. Scan for the deepest frame that is in your files:
File "app.py", line 12, in load
df = pd.read_csv("data.csv")
File ".../site-packages/pandas/io/parsers/readers.py", line 1026, in read_csv
...
FileNotFoundError: [Errno 2] No such file or directory: 'data.csv'
The site-packages frames describe how pandas failed; app.py line 12 is why — it passed a path that does not exist (FileNotFoundError). Library bugs are rare; start from your own deepest frame and assume the library is reporting your input honestly.
Logging
Never log just str(e) — you lose the entire stack. logger.exception records your message plus the full traceback (call it inside an except block, see try/except patterns):
import logging
logging.basicConfig()
logger = logging.getLogger(__name__)
try:
1 / 0
except ZeroDivisionError:
logger.exception("calculation failed")
ERROR:__main__:calculation failed
Traceback (most recent call last):
File "app.py", line 7, in <module>
1 / 0
~~^~~
ZeroDivisionError: division by zero
When you need the traceback as a string — for an alert, an HTTP response, a database row — use traceback.format_exc():
import traceback
try:
1 / 0
except ZeroDivisionError:
text = traceback.format_exc() # full traceback as one string
print(text.splitlines()[-1])
# ZeroDivisionError: division by zero
Frequently Asked Questions
Which line of the traceback actually has the bug?
Start at the bottom: the last line names the exception, and the frame above it is where it was raised. That is where execution failed, but the mistake is often higher up — a caller passing bad data. Walk up the frames until you reach the last one in your own code; that is usually where to fix things.
What does "During handling of the above exception, another exception occurred" mean?
A second exception was raised inside an except block while the first was being handled. The report shows both: the first traceback is the original error (the root cause), the second is what escaped afterwards. Debug the first one — fixing it usually makes the second disappear.
How do I log a full traceback instead of just the message?
Inside an except block, call logger.exception — it logs at ERROR level and appends the complete traceback automatically. If you need the traceback as a string (alerts, APIs), use traceback.format_exc().
try:
risky()
except Exception:
logger.exception("risky() failed") # message + full traceback