Skip to content
← All articles

Reading modern Python error messages

A version tour of what 3.10 through 3.15 added to tracebacks — specialised syntax errors, PEP 657 fine-grained carets, import and keyword suggestions, colour, stdlib-shadowing detection — and the half you have to write yourself: `add_note()`.

Somewhere between 3.9 and 3.15, Python quietly became a language with good error messages. If your mental model of a traceback was formed on AttributeError: 'NoneType' object has no attribute 'x' with a caret pointing at an entire line, you are leaving a lot of free debugging on the table.

This is a tour of what each version added, and then the part that is actually about you: the messages you can put into your own tracebacks.

3.10 — specialised syntax errors and “Did you mean”

Before 3.10, a missing bracket gave you SyntaxError: invalid syntax pointing at the line after the mistake, which is the single most-mocked error message in the language. 3.10’s new PEG-based parser could finally say what it meant:

SyntaxError: '(' was never closed
SyntaxError: expected ':'
SyntaxError: Perhaps you forgot a comma?

And the same release added suggestions for NameError and AttributeError:

NameError: name 'reuslt' is not defined. Did you mean: 'result'?
AttributeError: 'Config' object has no attribute 'tiemout'. Did you mean: 'timeout'?

These are computed with a Levenshtein-style comparison against names actually in scope, so a suggestion is real evidence, not a guess about English.

3.11 — PEP 657 fine-grained caret positions

The biggest single improvement. Tracebacks now underline the exact sub-expression that failed:

Traceback (most recent call last):
  File "app.py", line 12, in <module>
    total = order.items[0].price * order.discount.rate
                                   ^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'rate'

On a line with four attribute accesses, pre-3.11 you knew one of them was None and had to bisect. Now you know which. This alone justifies the upgrade for anyone maintaining code with chained expressions — which is everyone.

3.11 also introduced add_note(), which is the half of this article that is about your code rather than about the interpreter.

3.12 — “Did you forget to import?”

NameError: name 'sys' is not defined. Did you forget to import 'sys'?

Small, and it removes a real category of confusion where a name that exists in the standard library but not in your module produced a message indistinguishable from a typo.

3.13 — colour, keyword suggestions, and shadowing

Tracebacks are coloured by default in an interactive terminal: the error type, the location line and the caret markers are highlighted. Controlled by PYTHON_COLORS=0 to disable, or the cross-tool NO_COLOR convention. Worth knowing when your CI log looks like it has been attacked by escape sequences — the fix is an environment variable, not a code change.

Suggestions extended to keyword arguments:

TypeError: add() got an unexpected keyword argument 'nubmer'. Did you mean 'number'?

And the one that saves the most time in practice, stdlib shadowing: name a file random.py or json.py in your project and the resulting import failure now tells you that your own file is shadowing a standard library module. Generations of Python programmers have lost an afternoon to that.

💡A colleague's traceback shows a ModuleNotFoundError for a package that is definitely installed. What are you looking for first, and what does 3.13 do for you? click to reveal

Shadowing, or an environment mismatch — in that order, because shadowing is both more common and more confusing.

If there is a file in the working directory whose name collides with the module being imported (or a __pycache__ left over from one), the local file wins and the real package is unreachable. On 3.13+ the error message says so directly, which turns a thirty-minute mystery into a ten-second fix. Before 3.13 you had to know to look.

If it is not shadowing, it is almost always two environments: pip install went to one interpreter and the script is running under another. python -c "import sys; print(sys.executable)" next to pip -V settles it immediately.

The reason this is worth a habit rather than a lookup: both failure modes produce an error message that sounds like the package is missing, and the instinct it triggers — reinstall the package — is wrong for both.

3.14 — keyword typos, elif after else, unclosed strings

More specialised syntax errors, each one retiring a specific afternoon:

  • Typos in keywords are now suggested (whille produces a suggestion of while), which previously produced a bare SyntaxError: invalid syntax because the parser could not know what you meant.
  • 'elif' after 'else' is called out by name rather than reported as generic invalid syntax.
  • Unclosed string literals are detected and reported at the string’s opening quote, instead of producing a cascade of nonsense errors at the end of the file.

3.14 also brings PEP 765: a return inside a finally block now emits a SyntaxWarning, because it silently discards an in-flight exception. That one is covered in this track’s item on where to catch — it is a diagnostic for a genuine, long-standing bug pattern.

3.15 — inner members and other languages

Two additions worth knowing about, though 3.15 is in beta as this is written and final release is scheduled for October 2026.

Inner-member suggestions go a level deeper than “did you mean”:

AttributeError: module 'os' has no attribute 'walk_path'.
Did you mean 'os.path.walk'?

Cross-language suggestions catch the mistake of someone arriving from another language and reaching for its method name:

AttributeError: 'list' object has no attribute 'push'. Did you mean '.append'?

Which is a nice piece of empathy from a language whose error messages used to be a running joke.

The half that is about your code

Everything above is free. add_note() is the part you have to write, and it is the highest-leverage thing in this article.

Consider a batch job that processes forty thousand records through a retry layer. Record 8 421 fails. The traceback you get names the HTTP client, the retry decorator and the batch loop — and nothing about which record, because the loop variable that knew is three frames down and long gone.

for record in records:
    try:
        handle(record)
    except Exception as exc:
        exc.add_note(f"record={record.id} batch={batch_id}")
        raise

Now the traceback ends with:

ValueError: unparseable amount
record=8421 batch=2026-07-28T03:00Z

The difference between a triageable and an untriageable incident is usually exactly that one line. Notes survive re-raising, survive travelling inside an ExceptionGroup, and are copied onto derived groups by split() — so context attached deep in a worker arrives intact at the top of a TaskGroup.

💡Why add_note rather than wrapping the exception in your own type with the record id as an attribute? click to reveal

Because wrapping changes what callers can catch, and annotating does not.

If handle() raises ValueError and you wrap it in BatchError, every caller that was catching ValueError stops working. You have made a breaking change to the error contract in order to attach a debugging string. If the retry layer above you branches on TransientError, wrapping breaks that too — your new type is not transient, so the retry silently stops happening.

add_note mutates the exception in place. The class, the __cause__, the args and every isinstance check are untouched; you have added information without changing meaning. That is exactly the right trade for context that is only ever read by a human.

Wrap when you are genuinely translating across a boundary and want to change what callers catch — that is item 6.2’s raise ... from. Annotate when you only want to say more about the same failure.

Practical takeaways

  • Upgrade for 3.11 if you have not: fine-grained carets pay for themselves in a week.
  • PYTHON_COLORS=0 / NO_COLOR=1 when a log pipeline mangles coloured tracebacks.
  • Read the whole suggestion. “Did you mean” is computed from names actually in scope, so it is usually right.
  • A ModuleNotFoundError for something you installed is shadowing or the wrong interpreter, and on 3.13+ the message will often tell you which.
  • Attach add_note() at the layer that has the context and nowhere else, then re-raise bare.

Failure by Design · step 18 of 18

That's the end of this track. Review it or pick another.

← Back to Failure by Design