Python – Debugging

August 15, 20264 min readUpdated 8/20/2026

Most debugging is reading. The traceback usually contains the answer, and the fastest way to get better at this is to stop skimming it. This post covers reading one properly, then breakpoint(), logging, and the error messages that mean something specific.

Read the traceback from the bottom

Traceback (most recent call last):
  File "bank.py", line 40, in <module>
    main()
  File "bank.py", line 31, in main
    total = summarise(accounts)
  File "bank.py", line 22, in summarise
    return sum(a["balance"] for a in accounts)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyError: 'balance'

Three lines matter, in this order:

  1. The last line — what went wrong. KeyError: 'balance' means a dict had no such key.
  2. The bottom frame — where. Line 22, inside summarise.
  3. The frames above — how you got there, oldest first.

From 3.11 the ^^^^ markers underline the exact expression rather than the whole line, which matters on a line with three calls on it.

When the bottom frame is inside a library, scan upwards for the topmost frame that is your code. That is nearly always where the fix belongs — the library is usually working correctly on the wrong input.

accounts = [{"type": "Checking", "balance": 1250}, {"type": "Savings"}]

for i, account in enumerate(accounts):
    print(f"{i=} {account=}")

# Output: i=0 account={'type': 'Checking', 'balance': 1250}
# Output: i=1 account={'type': 'Savings'}

There is nothing wrong with print debugging — it is fast and it always works. The = suffix in an f-string prints the expression and its value, so the label cannot drift out of step with the variable. It is strictly better than print("account:", account).

One line of that output already solves the KeyError above: the second account has no balance key.

breakpoint()

def summarise(accounts):
    total = 0
    for account in accounts:
        # breakpoint()      <- uncomment to stop here
        total += account.get("balance", 0)
    return total

print(summarise([{"balance": 100}, {}]))     # Output: 100

Put breakpoint() on a line and the program stops there and drops you into a prompt with every local variable available. It is built in since 3.7 — no import, and it respects the PYTHONBREAKPOINT environment variable, so setting PYTHONBREAKPOINT=0 disables every one of them without editing the code.

Seven commands cover almost everything:

CommandDoes
nNext line, stepping over calls
sStep into the call
cContinue until the next breakpoint
lList the code around here
p exprPrint an expression
wWhere — the current stack
qQuit

Anything that is not a command is evaluated as Python, so you can call functions and inspect objects at the point of failure. That is the advantage over print: you decide what to look at after seeing the state, rather than guessing in advance.

To land in the debugger at the moment of a crash without editing anything, run python3 -m pdb -c continue yourscript.py — it runs normally and opens a prompt on the exception, with the failing frame's locals intact.

Logging instead of print

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("bank")

log.debug("not shown — level is INFO")
log.info("signed in as %s", "alice@bank.test")
log.warning("balance is low: %s", 4.20)

# Output: INFO bank: signed in as alice@bank.test
# Output: WARNING bank: balance is low: 4.2

Note those lines go to stderr, not stdout — that is the default, and it is why piping a script's output to a file can appear to lose every log line while the errors still show on your terminal.

Once code leaves your machine, print stops being enough — you want a level, a timestamp and the ability to turn detail on without a redeploy. Logging gives you all three, and basicConfig(level=...) is the one dial.

Note the commas rather than an f-string. log.info("signed in as %s", email) only builds the message if that level is enabled; log.info(f"signed in as {email}") formats it every time, including the thousands of debug lines you switched off.

Inside an except block use log.exception("..."), which logs at error level and attaches the full traceback automatically.

Errors that mean something specific

MessageAlmost always means
'NoneType' object has no attribute 'x' A function returned None — often one that mutates in place, like list.sort()
list indices must be integers, not str You have a list where you expected a dict. Check what the parser returned
'dict' object is not callable d(key) instead of d[key]
local variable 'x' referenced before assignment You assigned to x somewhere in the function, so it is local everywhere in it
takes 1 positional argument but 2 were given A method missing self in its definition
ModuleNotFoundError for something you installed Wrong virtual environment, or the file is named after the module

Narrow it down before you look

When the traceback does not tell you enough, the fastest route is not more printing — it is a smaller problem:

def summarise(accounts):
    return sum(a["balance"] for a in accounts)

rows = [{"balance": 100}, {"type": "Savings"}, {"balance": 250}]

for i, row in enumerate(rows):
    try:
        summarise([row])
    except KeyError as error:
        print(f"row {i} is missing {error}")     # Output: row 1 is missing 'balance'

Feed the failing function one item at a time and the guilty input names itself. The same instinct scales: halve the input, halve the code path, comment out half the pipeline. Bisecting beats staring.

If it worked yesterday, git bisect does exactly this over commits and will find the one that broke it in a handful of steps.

Two habits worth having

Reproduce it first. A bug you can trigger on demand is nearly fixed; one you cannot is a rumour. Getting to a reliable reproduction is usually most of the work.

Then write the test. Turn the reproduction into a test that fails, and fix it until the test passes. You get a verified fix and a permanent guard against the bug returning, for about a minute of extra effort.

Next: Best Practices.