Skip to content
Unit 14 of 28

Errors and exceptions

Contents · 18

Every real program meets bad input, missing files and failing networks. Python reports these problems by raising an exception: an object that describes what went wrong and travels up the chain of function calls until some code handles it. If nothing handles it, the program stops and prints a traceback.

This unit shows you how to read those tracebacks, how to handle the errors you expect, how to signal your own errors, and how to make sure cleanup code always runs.

You will be able to:

  • Read a traceback from the bottom up and find the line that failed
  • Handle specific exceptions with try, except, else and finally, and say when each block runs
  • Raise, reraise and chain exceptions, and define your own exception classes
  • Choose between the EAFP and LBYL styles, and use with to release resources
  • Report several errors at once with ExceptionGroup and except*
  • Use assert, warnings and logging.exception for the jobs they are meant for

Syntax errors and exceptions

There are two kinds of error. A syntax error means the code is not valid Python. The parser finds it before a single line runs, so nothing in the file executes:

Python points at the problem and stops with SyntaxError: '(' was never closed. Fix the code and run it again; you cannot catch a syntax error in the file that contains it.

An exception happens while valid code runs: dividing by zero, converting "abc" to int, opening a file that is not there. Exceptions are objects, and each one has a type such as ValueError or FileNotFoundError.

Reading a traceback

Saved as report.py and run, it prints this (the file path is shortened):

Read it from the bottom up:

  1. The last line gives the exception type and message: ZeroDivisionError: division by zero.
  2. The frame above it is where the error happened: line 3, inside average. The ~ and ^ marks underline the failing expression, and the ^ characters point at the operation that raised (here the /).
  3. Each earlier frame is a caller. "Most recent call last" means the outermost call is at the top.

The bug is usually in your own code nearest the bottom. Here average received an empty list, and the real question is whether report should ever pass one.

Handling exceptions with try and except

Put the code that might fail in a try block and the recovery code in an except block that names the exception type:

as err binds the exception object, so you can read its message. Python deletes the name err when the except block ends.

A try can have several except clauses. Python checks them top to bottom and runs only the first one that matches. A clause matches its own type and every subclass of it, so list specific types (KeyError) before their parents (LookupError).

To handle several types the same way, list them together. The parenthesised tuple works on every Python 3 version. Since Python 3.14 (PEP 758) you may leave the parentheses out, but only when there is no as. The example uses the newer form, so it needs Python 3.14 or later:

Note. Use the parenthesised form if your code must also run on Python 3.13 or earlier.

else and finally

A try statement has two optional extra blocks:

  • else runs only if the try block raised nothing.
  • finally runs always: after success, after a handled exception, when a handler or the else block raises, when an exception keeps propagating, and when the function returns early.
Rendering diagram…

Why else instead of putting more code inside try? Keep the try block as small as possible, so you only catch errors from the one operation you expect to fail. Code in else runs only on success, and its own errors are not swallowed by the handler above it.

finally even runs when the try block returns:

Pitfall. Never leave a finally block with return, break or continue. It silently throws away any exception in flight and overrides the try block's return value. Since Python 3.14 (PEP 765) the compiler warns about it.

When Python 3.14+ compiles this file, it prints a warning to stderr ending in SyntaxWarning: 'return' in a 'finally' block. The program still runs: the caller gets an empty dict and never learns the file was corrupt.

Catch what you can handle

All built in exceptions form a class hierarchy. An except clause for a class also catches every subclass.

except Exception: catches everything under Exception. It does not catch Ctrl+C (KeyboardInterrupt) or sys.exit() (SystemExit). A bare except: catches those too, which is why PEP 8 warns against it.

Rendering diagram…

A bare except: would have caught the SystemExit and kept a program running that was asked to stop. Even except Exception is too wide most of the time, because it also hides your own bugs:

The NameError from the typo is swallowed, and the function returns a wrong answer without complaint. Catch the narrowest type you can actually handle (ZeroDivisionError here). Reserve except Exception for the outermost layer of a program, where you log the error and carry on with the next request or job.

Raising exceptions

Use raise to signal that your function cannot do its job. Pick the built in type that fits (ValueError for a bad value, TypeError for a wrong type) and write a message that says what was expected and what arrived:

Notice the split of jobs: the function raises, and its caller decides what to do. A function that raises and catches its own exception gains nothing over an if.

Reraising and chaining

Inside an except block you have four ways to raise:

You writeEffectThe traceback says
raisereraises the current exception unchangedthe original traceback
raise NewError(...) from errexplicit chaining; err is stored in __cause__"The above exception was the direct cause of the following exception"
raise NewError(...)implicit chaining; stored in __context__"During handling of the above exception, another exception occurred"
raise NewError(...) from Nonehides the originalonly the new exception

Use a bare raise when you want to log or clean up and then let the error continue. Use from err when you translate a low level error into one that fits your function:

Adding a note

Since Python 3.11, add_note() attaches extra context to an exception without changing its type. Tracebacks print the notes under the message:

Custom exceptions

Your own exception type is a class that inherits from Exception. It can be a single statement; the docstring is its body. Name it with an Error suffix (PEP 8). Classes and objects explains classes in full, but you do not need that unit to write these:

A small hierarchy like this lets callers choose: catch MissingSettingError alone, or every ConfigError at once.

An exception can also carry data. Give it an initialiser that stores the extra values, and pass the message on to the parent class with super().__init__(...). Classes and objects explains both ideas; for now you can copy the shape:

The text passed to super().__init__() is what str(err) and the traceback show. The extra attribute lets the code that catches the error read the value without picking the message apart.

Several errors at once: ExceptionGroup and except*

Sometimes several independent things fail together, for example when you validate every field of a form or run tasks concurrently. Since Python 3.11, ExceptionGroup bundles them, and except* handles each type inside the group:

Unlike except, several except* clauses can run for one group, and each receives a smaller group holding only its matching exceptions. You cannot mix except and except* in one try. You will meet exception groups again with asyncio.TaskGroup in Concurrency.

EAFP and LBYL

There are two styles for code that might fail:

  • LBYL, "look before you leap": check first, then act.
  • EAFP, "easier to ask forgiveness than permission": act, and handle the exception if it comes.

Python code leans towards EAFP. For files it is also more correct: between an if path.exists() check and the open() call, another program can delete the file, so the check proves nothing. LBYL is fine when the check is cheap and failure is common, and often a method such as dict.get removes the need for either.

Context managers and with

A context manager is an object that sets something up when a with block starts and tears it down when the block ends, even if the block raises. You have already met one: with open(...) closes the file for you. Behind the scenes, with calls the object's __enter__ and __exit__ methods.

The file is closed as soon as the block ends, whether it ends normally or with an exception. Files and data formats covers open() in full.

The easiest way to write your own is contextlib.contextmanager. Everything before yield is setup, the yielded value is bound by as, and the finally part is the teardown:

The teardown ran in both cases, and the RuntimeError still reached the outer try. A with block cleans up; it does not hide errors.

contextlib.suppress ignores the exception types you name, for the cases where "it was already gone" is fine:

Tip. For this exact case cache.unlink(missing_ok=True) is shorter. Reach for suppress when no such option exists.

assert is for bugs, not for input

assert condition, message raises AssertionError when the condition is false. It documents an assumption about your own code. Python removes every assert when it runs with the -O option, so never use it to validate data from users, files or networks:

Validate input with an if and a raise ValueError(...), as set_volume does above. Keep assert for invariants and for tests (Testing with pytest).

Warnings and logging

A warning tells the caller about something that is not yet an error, such as a deprecated function. The program keeps running:

Normally a warning goes to stderr, once per code location. stacklevel=2 makes it point at the caller's line, which is the line that needs changing. catch_warnings is used here only to print it on stdout. By default Python shows a DeprecationWarning only when the code that triggers it is in the main script; run python -W default script.py to see all of them, or python -W error script.py to turn warnings into exceptions, which is useful in tests.

When you catch an exception you cannot fix, log it with logging.exception inside the except block. It logs at ERROR level and appends the full traceback:

Logging writes to stderr, so the log record does not appear in the output above. Your terminal also shows (path shortened):

A tour of the standard library covers logging configuration.

Check yourself

  1. When does Python report a SyntaxError, and why can't a try in the same file catch it?
  2. You have except LookupError: above except KeyError:. What goes wrong?
  3. What is the difference between code in the else block and code at the end of the try block?
  4. Why does PEP 8 discourage a bare except:? Why is except Exception: still risky?
  5. What do raise, raise X from err and raise X from None each do inside an except block?
  6. Why should you not validate user input with assert?
  7. What happens to an exception in flight if finally executes return, and what does Python 3.14+ do about it?
  8. Two except* clauses both match parts of an ExceptionGroup. How many of them run?

Suggested answers

  1. While parsing, before any code in the file runs. The try statement itself never starts, so it cannot handle anything.
  2. KeyError is a subclass of LookupError, so the first clause catches every KeyError and the second is never reached. Put specific types first.
  3. Exceptions raised in else are not caught by that statement's except clauses, and else runs only after the try block succeeded. It keeps the guarded region small.
  4. A bare except: also catches KeyboardInterrupt and SystemExit, so the user cannot stop the program. except Exception: still hides programming mistakes such as a NameError from a typo.
  5. raise reraises the current exception unchanged. raise X from err raises X with err stored as its direct cause. raise X from None raises X and hides the original exception from the traceback.
  6. python -O removes every assert, so the check would silently disappear in some deployments.
  7. The exception is discarded and the function returns normally. Since 3.14 the compiler emits a SyntaxWarning for return, break or continue that leaves a finally block.
  8. Both. Each clause receives a group containing only the exceptions it matched.

Exercises

These exercises are optional practice. Pick the ones that help you.

  1. Ask for a number. Write ask_int(prompt, low, high) that calls input(prompt) until the user enters a whole number between low and high inclusive, then returns it. For text that is not a number print '<text>' is not a whole number; for a number out of range print please enter a number from <low> to <high>. With inputs ten, 150, 42 and the range 1 to 100, the function returns 42.
  2. Validation error. Define ValidationError(ValueError) whose initialiser takes field and message, stores field as an attribute, and has the string form "<field>: <message>". Write validate_order(order: dict) -> dict that raises it when quantity is not an int of at least 1 (a bool does not count), or when email lacks an @. Valid orders are returned unchanged.
  3. Retry with backoff. Write retry(func, attempts=4, base_delay=0.1, retry_on=(ConnectionError,), sleep=time.sleep). It calls func() and returns its result. If func raises one of retry_on, it sleeps base_delay * 2 ** (n - 1) seconds after the nth failure and tries again. After the last attempt it reraises the last exception. Reject attempts < 1 with ValueError. Test it with a fake sleep that records the delays, so the tests run instantly. For a function that fails twice and then succeeds, the recorded delays are [0.1, 0.2].
  4. Safe file read. Write read_text_safely(path, default=""). It returns the file's text decoded as UTF-8; returns default if the file does not exist; and raises ValueError("<name> is not valid UTF-8") chained from the UnicodeDecodeError if the bytes are not UTF-8. Other OSErrors propagate unchanged.

Solutions

1. Ask for a number. The answers are fed in automatically here, so each message appears on the same line as the prompt. When you type the answers yourself, each message starts on a new line.

2. Validation error.

Because ValidationError inherits from ValueError, callers that already catch ValueError keep working.

3. Retry with backoff.

Notice the bare raise on the last attempt: the caller sees the real ConnectionError with its original traceback. Errors outside retry_on, such as a TypeError from a bug, are never retried.

4. Safe file read.