Handbooks / Python / Chapter 5

Errors & Exceptions

44 pages · ~77 min✓ Reviewed

Builds on Decorators & Closures. Next up: Files & JSON.

Part 1 · Introduction

Handling Errors in Python

Every program you write will eventually meet something it did not expect: a file that is missing, a number typed as text, a network that drops mid-request. Python reports these moments as exceptions. If nothing deals with an exception, the program stops and prints a traceback. That is useful for you as the author, but a crash is a poor experience for anyone else using your code.

This chapter teaches you to work with exceptions instead of fearing them. You will start by reading a traceback so you can find the cause of a failure quickly, then learn how Python organises its exception types. From there you will use try, except, else and finally to respond to failures, raise your own errors when your code is given bad input, and define custom exception classes that describe problems in your own domain.

By the end you will also be able to chain exceptions so the original cause is never lost, clean up resources reliably with context managers, choose between the EAFP and LBYL styles, and record failures with logging rather than print. The last section collects the mistakes that bite most often, plus a cheatsheet you can keep beside you while coding.

Before you start

You need Python 3.11 or newer and a way to run short scripts, either a terminal or any editor. You should be comfortable with functions, if statements, loops, and basic classes. Everything in this chapter uses only the standard library, so there is nothing to install.

Part 2 · Reading a Traceback

What a Traceback Is Telling You

When a Python program hits an error that nothing handles, it stops and prints a traceback. It looks like noise at first, but it is a precise report: the chain of function calls that was active when things went wrong, followed by what went wrong. Once you know the layout, you can read any traceback in a few seconds.

The calls are listed oldest first. The program starts at the top, in <module> (your script's top level), and each frame below is a function that the frame above it called. That is why the header says Traceback (most recent call last). This phrase means the same thing as "oldest call first": the oldest call sits at the top and the most recent call sits at the bottom, right above the final line. Here is a small script, app.py, that fails, followed by its traceback.

text
Traceback (most recent call last):
  File "app.py", line 9, in <module>
    print(greet(user))
  File "app.py", line 5, in greet
    return "Hi " + get_name(user)
  File "app.py", line 2, in get_name
    return d['name']
KeyError: 'name'

Oldest call at the top, newest call and the error at the bottom

Every frame is two lines. The first line tells you where, the second shows the exact source line that was running when the next call was made (or when the error happened). The very last line, with no indentation, is the exception itself: its type, a colon, then its message.

Part of the tracebackExample from aboveWhat it tells you
File and line numberFile "app.py", line 5Which file and which line was executing
Function namein greetWhich function that line lives in (<module> means top level)
Source linereturn "Hi " + get_name(user)The code that was running, so you rarely need to open the file just to see it
Last lineKeyError: 'name'The exception type, then its message

Because the report ends with the error, you read it bottom-up: start at the last line to learn what happened, then move upward through the frames to learn how the program got there.

A Worked Example: KeyError

Take the last line of the traceback above: KeyError: 'name'. It comes from the expression d['name']. It means the dictionary d has no key called 'name'. It does not mean the dictionary is broken or corrupt. The dictionary is fine; it simply does not contain what the code asked for. The message holds the missing key itself, so you can see at once which lookup failed.

The next example rebuilds the same three-function chain and catches the error so we can inspect it. The user dictionary has a typo, nme, so the lookup fails. The program prints the exception, then walks the frames stored in the traceback to show their order.

python
import traceback

def get_name(d):
    return d['name']

def greet(user):
    return 'Hi ' + get_name(user)

user = {'nme': 'Asha'}
try:
    greet(user)
except KeyError as e:
    print(f"{type(e).__name__}: {e}")
    frames = traceback.extract_tb(e.__traceback__)
    print(' -> '.join(f.name for f in frames))
output
KeyError: 'name'
<module> -> greet -> get_name

The frame list reads <module> -> greet -> get_name: oldest call first, newest last, exactly as in the printed traceback. The fix is not in get_name, which did its job. The bug is the typo in the data that greet was given.

The error line names the symptom

KeyError: 'name' tells you what Python could not do (find that key). To learn why it was missing, look at the code and data that fed the failing line.

Finding the Real Cause

The bottom frame shows where the error was raised, which is not always where the mistake was made. Often the real cause is a frame or two higher up, in your own code, where a wrong value was created or passed along. The code at the bottom only discovered the problem.

This matters most when the bottom frames belong to a library. In the traceback below, the error is raised deep inside the standard json module, yet the line to inspect is in app.py: the call on line 12 that handed an empty string to the parser.

text
Traceback (most recent call last):
  File "app.py", line 12, in <module>
    settings = load_settings(text)
  File "app.py", line 7, in load_settings
    return json.loads(text)
  File "/usr/lib/python3.11/json/__init__.py", line 346, in loads
    return _default_decoder.decode(s)
  File "/usr/lib/python3.11/json/decoder.py", line 337, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

The error is raised inside the library, but the cause is in app.py

Libraries are heavily tested, so a failure inside one almost always means it received something it could not accept. Rule of thumb: start at the bottom-most frame in your own file. Scan upward from the last line, skip every frame whose path points into a library, and stop at the first one that is in a file you wrote. Here that is app.py, line 7, json.loads(text). From there, ask what text contained, and follow it up to line 12.

Where to start debugging
Common mistake: reading only part of it

Many beginners glance at the top line, Traceback (most recent call last):, which says nothing, or paste only the last line when asking for help. The last line alone tells a helper the symptom but not the path. Copy the whole traceback, every frame, so the bottom-most frame in your own file is visible.

Syntax Errors and Chained Tracebacks

A SyntaxError is different from the errors above because it happens before your program runs. Python first compiles the whole file. If it finds invalid grammar, such as a missing bracket or a stray =, it stops right there. No line of your code has executed, so there are no frames for your functions, only the file, line and a caret pointing at the problem.

python
source = "print('before')\nx = = 1\n"
try:
    compile(source, 'demo.py', 'exec')
except SyntaxError as e:
    print(f"{type(e).__name__}: {e.msg}")
    print('nothing ran')
output
SyntaxError: invalid syntax
nothing ran

Notice that print('before') is a perfectly valid first line, yet it never ran, because the second line stopped the compile. When you see a SyntaxError, look at the reported line and the line just above it, since an unclosed bracket on one line is often reported on the next.

The other special case is the chained traceback. If an error happens while Python is already handling another one, it prints both blocks, joined by the sentence During handling of the above exception, another exception occurred. The first block is the original problem; the second is the new exception raised in response, and it is the one that finally stopped the program.

text
Traceback (most recent call last):
  File "app.py", line 3, in load
    int('abc')
ValueError: invalid literal for int() with base 10: 'abc'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "app.py", line 5, in load
    raise RuntimeError('config broken')
RuntimeError: config broken

Read chained tracebacks bottom-up too, but do not stop at the last block. The final exception says what the program reported; the block above it often holds the original cause. Python keeps that earlier exception on the new one as __context__, which this example shows.

python
def load():
    try:
        int('abc')
    except ValueError:
        raise RuntimeError('config broken')

try:
    load()
except RuntimeError as e:
    print(type(e).__name__, e)
    print(type(e.__context__).__name__, e.__context__)
output
RuntimeError config broken
ValueError invalid literal for int() with base 10: 'abc'
What you seeWhat it meansWhere to look
SyntaxError, no frames for your functionsPython could not compile the file, so nothing ranThe reported line and the one above it
One block, ends with an exceptionA single error stopped the programThe bottom-most frame in your own file
Two blocks joined by "During handling..."A second error occurred while handling the firstBoth blocks; the first holds the original cause
Habit to build

Read the last line first, then climb to the bottom-most frame in your own file, and check the blocks above if the traceback is chained.

Part 3 · Exception Hierarchy and Common Types

The Family Tree of Exceptions

Every exception in Python is a class, and those classes form a family tree. At the very top sits BaseException, the root that every raised object must descend from. Almost everything you will ever handle lives one level below it, under Exception. Two other children of the root sit beside it on purpose: KeyboardInterrupt (the user pressed Ctrl+C) and SystemExit (raised by sys.exit()). They are not errors in your code. They are requests to stop the program.

Where the common exceptions live

Because KeyboardInterrupt and SystemExit are outside Exception, writing except Exception: handles your program's errors without trapping a Ctrl+C. That separation is the main reason to prefer except Exception over a bare except:.

The built-ins you will meet daily

Seven exceptions account for most of the errors beginners see. The table lists what triggers each one and its direct parent. Notice that KeyError and IndexError share the parent LookupError, since both mean that something you looked up was not there. FileNotFoundError is a more specific kind of OSError, the parent for failures talking to the operating system.

ExceptionTypical triggerParent
ValueErrorint('abc')Exception
TypeError1 + 'a'Exception
KeyErrord['missing']LookupError
IndexErroritems[99]LookupError
AttributeErrorNone.upper()Exception
ZeroDivisionError1 / 0ArithmeticError
FileNotFoundErroropen('nope.txt')OSError

The helper below runs a function, catches any Exception, and prints the class name with the message. We will reuse attempt for the rest of the section.

python
def attempt(label, func):
    try:
        func()
    except Exception as exc:
        print(f"{label}: {type(exc).__name__}: {exc}")

d = {"a": 1}
items = [1, 2, 3]
attempt("d['b']", lambda: d["b"])
attempt("items[5]", lambda: items[5])
attempt("None.upper()", lambda: None.upper())
attempt("1 / 0", lambda: 1 / 0)
attempt("open", lambda: open("nope.txt"))
output
d['b']: KeyError: 'b'
items[5]: IndexError: list index out of range
None.upper(): AttributeError: 'NoneType' object has no attribute 'upper'
1 / 0: ZeroDivisionError: division by zero
open: FileNotFoundError: [Errno 2] No such file or directory: 'nope.txt'

ValueError vs TypeError, and Why Order Matters

These two are confused constantly. A TypeError means the value is the wrong kind of thing for the operation: you cannot add an int to a str. A ValueError means the type is acceptable but this particular value is not: int() accepts strings, just not the string 'abc'.

ValueErrorTypeError
MeaningRight type, bad valueWrong type for the operation
Exampleint('abc')1 + 'a'
Fix byValidating or cleaning the valueConverting or choosing the right type
python
import math

attempt("int('abc')", lambda: int("abc"))
attempt("sqrt(-1)", lambda: math.sqrt(-1))
attempt("1 + 'a'", lambda: 1 + "a")
output
int('abc'): ValueError: invalid literal for int() with base 10: 'abc'
sqrt(-1): ValueError: math domain error
1 + 'a': TypeError: unsupported operand type(s) for +: 'int' and 'str'

Catching a parent catches every child

An except clause matches the named class and all of its subclasses. So except LookupError catches KeyError and IndexError too. Python checks the clauses from top to bottom and runs the first one that matches. If a broad parent comes before a narrow child, the child's clause can never run. Put the most specific exceptions first and the general ones last.

python
def lookup(data, key):
    try:
        return data[key]
    except LookupError:
        return "lookup failed"
    except KeyError:
        return "no such key"

print(lookup({}, "x"))

Wrong order: the KeyError clause is unreachable.

output
lookup failed

Swapping the clauses gives each failure its own message, and LookupError still serves as the safety net for anything else in that family.

python
def lookup(data, key):
    try:
        return data[key]
    except KeyError:
        return "no such key"
    except LookupError:
        return "no such position"

print(lookup({"a": 1}, "b"))
print(lookup([1], 4))

Right order: specific first, parent last.

output
no such key
no such position
Common mistake: parent before child

Placing except Exception: or except LookupError: above a more specific clause silently hides the specific one. Nothing warns you, so read your clauses from top to bottom and ask whether an earlier one already covers a later one.

Import Errors, Inspecting Ancestors, and Control-Flow Signals

Two import failures are related by inheritance. ImportError is the general one, raised when an import statement cannot do its job. ModuleNotFoundError is a subclass raised in the narrower case where the module itself cannot be found. Importing a name that does not exist inside a module that does exist raises plain ImportError. Because of the subclass relationship, except ImportError catches both.

python
try:
    import not_a_real_module
except ImportError as exc:
    print(type(exc).__name__, "|", exc.name)

try:
    from math import not_a_name
except ImportError as exc:
    print(type(exc).__name__)

print(issubclass(ModuleNotFoundError, ImportError))
output
ModuleNotFoundError | not_a_real_module
ImportError
True

Reading any class's ancestors with mro

You never need to memorise the tree. Every class has an __mro__ attribute, a tuple of the class followed by each ancestor in lookup order. Reading it from left to right tells you exactly which except clauses would catch that exception.

python
for cls in (KeyError, FileNotFoundError, ModuleNotFoundError):
    names = [c.__name__ for c in cls.__mro__]
    print(" -> ".join(names))
output
KeyError -> LookupError -> Exception -> BaseException -> object
FileNotFoundError -> OSError -> Exception -> BaseException -> object
ModuleNotFoundError -> ImportError -> Exception -> BaseException -> object

Signals that are not bugs

Some exceptions exist so that Python can steer control flow. StopIteration is how an iterator says it has run out of items. Every for loop catches it for you and ends quietly. GeneratorExit is thrown into a generator when it is closed, giving it a chance to clean up. Seeing these in your own code is normal, and they are rarely something to log as a failure. GeneratorExit derives directly from BaseException, so except Exception will not swallow it.

python
it = iter([1])
print(next(it))
try:
    next(it)
except StopIteration:
    print("exhausted")

def gen():
    try:
        yield 1
        yield 2
    except GeneratorExit:
        print("closing")
        raise

g = gen()
print(next(g))
g.close()
output
1
exhausted
1
closing
What to remember

Everything descends from BaseException, and you normally handle Exception and below. KeyError and IndexError share LookupError, FileNotFoundError is an OSError, and ModuleNotFoundError is an ImportError. Put narrow except clauses before broad ones, and use __mro__ whenever you are unsure who a class's parents are.

Part 4 · try and except Basics

The shape of try and except

Some lines of code can fail for reasons you cannot control: the user types letters where you wanted a number, a file is missing, a network drops. Python reports such a failure by raising an exception. A try statement lets you say, "attempt this, and if it fails in a particular way, do that instead of crashing."

The syntax has two parts. Under try: you put the risky code. Under except ErrorType: you put the handler, the code that runs only if that kind of error happened. Both parts are indented blocks, and the handler names the exception type it is willing to deal with.

python
def to_int(text):
    try:
        number = int(text)
        print("converted", number)
    except ValueError:
        print("not a number:", text)
    print("done with", repr(text))

to_int("42")
to_int("abc")

The same function, once with good input and once with bad input

output
converted 42
done with '42'
not a number: abc
done with 'abc'

Compare the two calls. With "42" nothing went wrong, so the except block was skipped entirely. With "abc", int(text) raised a ValueError and Python jumped straight to the handler. The print("converted", number) line sat after the failing line, so it never ran. The line after the whole try statement runs in both cases, because the problem was handled and the function carried on.

What Python does with a try statement
No exceptionException raised
Rest of the try bodyRuns to the endSkipped from the failing line onward
except blockSkippedRuns
Code after the try statementRunsRuns, because the error was handled
Remember

The moment a line in the try body raises, the lines below it in that body are abandoned. Never put code in the body that depends on the failing line unless you are happy for it to be skipped.

Looking at the error and retrying

Binding the exception with as

Often you want to know what went wrong, not only that something did. Write except ValueError as e: and Python stores the exception object in the name e for the duration of the handler. Printing str(e) gives the plain message, which suits users. Printing repr(e) gives the type name plus the message, which suits logs and debugging.

python
try:
    int("abc")
except ValueError as e:
    print(str(e))
    print(repr(e))
output
invalid literal for int() with base 10: 'abc'
ValueError("invalid literal for int() with base 10: 'abc'")
CallShowsGood for
str(e)Only the messageText a user may read
repr(e)Exception type and messageLogs and debugging

The name e disappears afterwards

Python deletes e the moment the except block ends. This avoids keeping the error and everything it references alive longer than needed. The consequence is that code after the try statement cannot use e. If you need the error, or its message, later, copy it into another name while still inside the handler.

python
saved = None
try:
    int("abc")
except ValueError as e:
    saved = e
    message = str(e)

try:
    print(e)
except NameError as err:
    print("NameError:", err)

print(message)
print(type(saved).__name__)
output
NameError: name 'e' is not defined
invalid literal for int() with base 10: 'abc'
ValueError
Common mistake

Using e after the except block ends. It raises NameError, even though the error was handled just above. Copy it, as in saved = e, or keep only what you need, such as message = str(e).

Ask again until the input is valid

A classic use of try is a loop that keeps asking until the user gives something usable. Wrap int(...) in a while True loop. If it succeeds, the line after it runs and a break leaves the loop. If it raises, the break is skipped, the handler prints a hint, and the loop goes round again. To keep the example runnable here, a stand-in for input() replays a fixed list of replies and echoes them as a person would see them.

python
answers = iter(["abc", "forty", "4.5", "42"])

def fake_input(prompt):
    reply = next(answers)
    print(prompt + reply)
    return reply

while True:
    try:
        age = int(fake_input("Age: "))
        break
    except ValueError:
        print("Please type a whole number.")

print("Age is", age)

With the real thing, replace fake_input with input

output
Age: abc
Please type a whole number.
Age: forty
Please type a whole number.
Age: 4.5
Please type a whole number.
Age: 42
Age is 42

Notice that "4.5" is also rejected: int() refuses a string containing a decimal point. The loop only ends when int() returns without raising, which is exactly the condition you want.

What gets caught, and how much to wrap

Only the listed types are caught

An except clause is not a blanket safety net. It catches the exception types it names, plus their subclasses, and nothing else. Any other exception passes straight through the handler and propagates up the call stack: Python looks in the caller of the current function, then that function's caller, and so on, for a try that does name it.

python
def parse(text):
    return int(text)

def load(text):
    try:
        return parse(text)
    except KeyError:
        return -1

try:
    print(load("abc"))
except ValueError as e:
    print("caught further up:", e)

load only handles KeyError, so the ValueError travels on to the outer try

output
caught further up: invalid literal for int() with base 10: 'abc'
Where the ValueError travels
  1. 1parseint(text) raises ValueError
  2. 2loadhandler lists only KeyError, so it passes
  3. 3Top levelexcept ValueError catches it

Keep the try body small

Put only the line or two that can actually fail inside try. If the body is large, a matching exception could come from any of its lines, and your handler cannot tell which one. A narrow body also stops you from hiding unrelated bugs behind a handler that was written for something else.

python
raw = {"width": "10", "height": "tall"}

try:
    width = int(raw["width"])
    height = int(raw["height"])
except ValueError:
    print("a size was bad - but which one?")

try:
    height = int(raw["height"])
except ValueError:
    print("height is bad:", raw["height"])
output
a size was bad - but which one?
height is bad: tall
Common mistake

Wrapping a whole function body in one big try. The handler fires, you know something failed, but not where. Wrap just the risky call, and let the rest of the code run outside it.

When nothing catches it

If an exception travels all the way up the call stack and no try claims it, the program ends on the spot. Python prints a traceback showing the chain of calls that led to the error, and then the exception type and message. Take a small script app.py that prints start and then runs print(parse("abc")), where parse just returns int(text). It produces this on the terminal:

output
start
Traceback (most recent call last):
  File "app.py", line 5, in <module>
    print(parse("abc"))
          ^^^^^^^^^^^^
  File "app.py", line 2, in parse
    return int(text)
           ^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'abc'

Read it from the bottom: the last line names the error, and the lines above show where it started and how the program got there. Anything after the failing line never runs, which is why uncaught exceptions are so visible, and why a good handler is a deliberate choice rather than a reflex.

SituationResult
Error type is listed in exceptHandler runs, program continues
Error type is not listedException propagates to the caller
No caller handles itProgram ends and the traceback is printed
Rule of thumb

Catch an exception only where you know what to do about it. If you do not, let it propagate: a loud traceback is better than a silent wrong answer.

Part 5 · Catching Specific Exceptions

Clauses are checked in order

A single try block can be followed by several except clauses, each ready for a different kind of failure. When an exception is raised, Python walks down the clauses from top to bottom and compares the exception with each one. The first clause that matches wins. Its body runs, and every clause below it is skipped, even if one of them would also have matched.

What Python does when the try block raises
  1. 1Exception raisedinside the try block
  2. 2Check clause 1does the exception match its type?
  3. 3Check clause 2only reached if clause 1 did not match
  4. 4First match runsall later clauses are skipped
  5. 5No match at allthe exception keeps travelling up

Here one function can fail in two different ways. Passing text that is not a number raises ValueError, while passing None raises TypeError. Each gets its own handler, and only the one that matches runs.

python
def convert(value):
    try:
        return int(value)
    except ValueError:
        return "not a number"
    except TypeError:
        return "wrong type"

print(convert("42"))
print(convert("abc"))
print(convert(None))

Each failure lands in the clause built for it

output
42
not a number
wrong type

Subclasses go before their parents

Because matching is top to bottom, the order of related types matters. An except clause for a parent class also matches every subclass of that parent. FileNotFoundError is a subclass of OSError, so a clause for OSError already catches it. If the parent comes first, the child clause below it can never be reached. Python does not warn you about this. The dead clause just sits there and never runs.

python
try:
    open("no_such_file.txt")
except OSError:
    print("OSError handler ran")
except FileNotFoundError:
    print("FileNotFoundError handler ran")

Wrong order: the specific handler is unreachable

output
OSError handler ran

The fix is to put the narrow type first and the wide one after it. Then the specific case gets its special treatment, and anything else in the same family falls through to the general handler.

Common mistake: parent before child

If a broad class such as OSError or Exception appears above a narrower one, the narrower clause is dead code. Read your clauses from the top and ask whether an earlier one already swallows the later one.

Tuples, bare except and the widest nets

Several types, one handler

Sometimes two unrelated exceptions deserve exactly the same response. Instead of copying the handler, list the types in a tuple: except (ValueError, TypeError) as e:. The clause matches if the exception is an instance of any type in the tuple, and e holds the exception that was raised.

python
def to_number(value):
    try:
        return float(value)
    except (ValueError, TypeError) as e:
        print("bad input:", type(e).__name__)
        return None

to_number("x")
to_number(None)

One clause, two exception types

output
bad input: ValueError
bad input: TypeError
Common mistake: forgetting the parentheses

Writing except ValueError, TypeError: without parentheses is a SyntaxError before Python 3.14. Python 3.14 (PEP 758) allows the bare comma form when there is no as, but the parentheses are still required once you write as e. Older versions and older readers expect the parentheses, so always write them.

Why a bare except is dangerous

Python also lets you write except: with nothing after it. That clause matches every exception, including KeyboardInterrupt (what Ctrl+C raises) and SystemExit (what sys.exit() raises). These two are not errors in your logic. They are requests to stop the program. A bare except: inside a loop swallows them, and Ctrl+C appears to do nothing. The demo below raises KeyboardInterrupt by hand so you can see which clause catches it.

python
try:
    raise KeyboardInterrupt
except Exception:
    print("caught by except Exception")
except:
    print("only the bare except caught it")

Ctrl+C is not an Exception, so only the bare clause sees it

output
only the bare except caught it

except Exception is the usual compromise when you really do need a broad net. KeyboardInterrupt and SystemExit inherit from BaseException directly, not from Exception, so this clause lets them pass and Ctrl+C keeps working. It is still broad, though. It catches nearly every ordinary error, which makes it easy to hide real bugs.

ClauseWhat it catchesCtrl+C and sys.exit()Verdict
except:Everything, including BaseException subclassesSwallowed, so the program cannot be stoppedAvoid
except Exception:Nearly every ordinary errorPass through and still workLast-resort safety net, and log what you catch
except ValueError:Only that type and its subclassesPass through and still workBest default

The table runs from widest at the top to narrowest at the bottom. Prefer the narrowest clause that describes the failure you actually know how to handle.

Separate handlers and never staying silent

Different failures, different advice

Opening a file shows why specific clauses pay off. A missing file and a file you are not allowed to read are different problems, and the user needs different guidance for each. These two types are siblings, so their order does not matter here.

python
def read_text(path):
    try:
        with open(path, encoding="utf-8") as f:
            return f.read()
    except FileNotFoundError:
        print(f"{path} does not exist, check the name")
    except PermissionError:
        print(f"{path} exists but you may not read it")
    return None

read_text("settings.cfg")

Each handler gives advice that fits its cause

output
settings.cfg does not exist, check the name

If the file existed but was locked down, the second clause would run instead. Any other problem, such as a bug elsewhere in your code, is not caught and reaches you with a full traceback, which is what you want.

Catching an error and doing nothing

The most tempting handler is pass. It makes the error message vanish, but the problem does not. In this function the string "30" cannot be added to a number. Because the handler is silent, the total is quietly wrong and nothing tells you why.

python
def total(prices):
    s = 0
    for p in prices:
        try:
            s += p
        except Exception:
            pass
    return s

print(total([10, 20, "30", 5]))

The answer should be 65, but no error ever shows

output
35

If you truly must continue after a failure, at least record it. The logging module can write the exception message to a log, so the problem leaves a trail. Later sections cover logging in depth. For now, notice that the handler below is narrow and keeps the details.

python
import logging, sys
logging.basicConfig(stream=sys.stdout, format="%(levelname)s: %(message)s")

def parse_age(text):
    try:
        return int(text)
    except ValueError as e:
        logging.warning("could not parse age %r: %s", text, e)
        return None

print(parse_age("abc"))

The failure is recorded, then handled

output
WARNING: could not parse age 'abc': invalid literal for int() with base 10: 'abc'
None
Common mistake: except Exception: pass

A broad clause with an empty body is the classic way to hide bugs. Catch the specific type you expect, and log or re-raise anything you do not fully handle.

Several errors at once: ExceptionGroup and except*

Normal try blocks assume one error at a time. Some programs, such as ones running several tasks at the same time, can fail in several places before anyone gets to handle the first failure. Python 3.11 added ExceptionGroup, a single exception that wraps a list of other exceptions, so they can travel together. It also added the except* clause for taking such a group apart.

The rules differ from ordinary except. A clause such as except* ValueError as eg: picks out all the ValueError members of the group. The name eg is itself a new ExceptionGroup that contains only those members, and its .exceptions list holds them. Because every except* clause looks at the group, several clauses can run, so it is not first match wins.

python
try:
    raise ExceptionGroup("batch failed", [
        ValueError("bad id"),
        TypeError("bad type"),
        ValueError("bad date"),
    ])
except* ValueError as eg:
    print("ValueError group:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
    print("TypeError group:", [str(e) for e in eg.exceptions])

Both clauses run, each with its own share of the errors

output
ValueError group: ['bad id', 'bad date']
TypeError group: ['bad type']
exceptexcept*
HandlesOne exception at a timeAn ExceptionGroup, split by type
Clauses runOnly the first matchEvery clause that finds members
The as name holdsThe exception itselfAn ExceptionGroup of the matching members
Python versionAll versions3.11 and newer
Remember

Catch the narrowest type you can handle, put subclasses before parents, group types with a parenthesised tuple, never use a bare except:, and never leave a handler silent.

Part 6 · else and finally

else: the success-only clause

A try statement can do more than catch errors. After its except clauses you may add an else clause, and Python runs it only if the try body finished without raising anything. If an exception was raised, whether or not an except caught it, the else block is skipped.

Which clause runs after try?

The clauses always appear in one fixed order: try, then the except clauses, then else, then finally. else must come after every except, and it is only allowed when at least one except is present.

Order of clauses
  1. 1trycode that might fail
  2. 2excepthandle a failure
  3. 3elseonly on success
  4. 4finallyalways runs

Here parse_age prints a message from else only when int() succeeded. With bad input the except handles it and else never runs.

python
def parse_age(text):
    try:
        age = int(text)
    except ValueError:
        print('not a number')
    else:
        print('parsed', age)

parse_age('42')
parse_age('abc')
output
parsed 42
not a number

Why not just put that code at the end of try?

You could write print('parsed', age) as the last line of the try body, and it would seem to work. The problem is that every line inside try is covered by the except clauses below it. If the follow-up code raises the same kind of error, the handler would catch it and report it as if the risky call had failed. Code in else is outside the protection of those except clauses, so its own errors are not caught by them and show up honestly.

In the next example half only guards the conversion. The division lives in else, so a zero is not mistaken for bad input: the ZeroDivisionError escapes to the caller, who can decide what to do.

python
def half(text):
    try:
        n = int(text)
    except ValueError:
        print('bad input')
    else:
        print(10 / n)

half('4')
half('x')
try:
    half('0')
except ZeroDivisionError:
    print('ZeroDivisionError escaped')
output
2.5
bad input
ZeroDivisionError escaped
Common mistake

Stuffing everything into try makes the handler cover far more than the one risky call. Keep try as small as possible and move code that depends on its success into else.

finally: cleanup that always happens

A finally clause runs no matter how the try statement is left. It runs after a clean finish, after an exception that an except handled, and even while an unhandled exception is still on its way up to the caller. That guarantee is what makes it the right place for cleanup. Typical jobs are closing a file you opened by hand, releasing a lock taken with acquire(), closing a network or database connection, and removing a temporary file or directory.

The function below goes down all three paths. Notice that finally prints every time, and that for the unhandled ZeroDivisionError it prints before the exception reaches the except in the caller.

python
def run(text):
    try:
        n = int(text)
        print('value', 10 // n)
    except ValueError:
        print('handled')
    finally:
        print('finally')

run('5')
run('x')
try:
    run('0')
except ZeroDivisionError:
    print('propagated')
output
value 2
finally
handled
finally
finally
propagated

finally and return

finally also runs when the try body leaves early with return. Python first works out the value to return, then runs the finally block, and only then hands the value to the caller. So any printing or cleanup in finally happens before the caller sees the result.

python
def early():
    try:
        return 'from try'
    finally:
        print('cleanup runs first')

print(early())
output
cleanup runs first
from try

finally and break or continue

The same guarantee holds inside loops. When the try body uses continue to skip to the next iteration or break to leave the loop, finally still runs for that iteration before control moves on.

python
for i in range(3):
    try:
        if i == 1:
            continue
        if i == 2:
            break
        print('body', i)
    finally:
        print('finally', i)
output
body 0
finally 0
finally 1
finally 2

Never return from finally

A return placed inside finally replaces whatever was going to happen. It overrides an earlier return, and worse, it silently discards an exception that was propagating, because the function now ends normally. The error vanishes without a trace.

python
def swallow():
    try:
        raise RuntimeError('lost')
    finally:
        return 'finally wins'

print(swallow())
output
finally wins
Common mistake

Putting return (or break) in a finally block hides real failures. Keep finally for cleanup only, and let return values and errors flow from try, except or else.

Putting it together

The classic use of all three clauses is reading a file: opening it is the risky step, parsing is the work that depends on a successful open, and closing must happen however things turn out. In read_config below, the file is opened in try, parsed in else, and closed in finally. The f = None line makes sure finally can tell whether the open ever succeeded.

Remember the ordering from early(): the return json.load(f) in else computes its value first, then finally closes the file, then the value is delivered. That is why closed is printed before the dictionary is. A parse error in else is not caught by except FileNotFoundError, but finally still closes the file while the error propagates.

python
import json, os, shutil, tempfile

def read_config(path):
    f = None
    try:
        f = open(path)
    except FileNotFoundError:
        print('no such file')
        return {}
    else:
        return json.load(f)
    finally:
        if f is not None:
            f.close()
            print('closed')

folder = tempfile.mkdtemp()
good = os.path.join(folder, 'good.json')
bad = os.path.join(folder, 'bad.json')
with open(good, 'w') as w:
    w.write('{"debug": true}')
with open(bad, 'w') as w:
    w.write('{oops')

print(read_config(good))
print(read_config(os.path.join(folder, 'nope.json')))
try:
    read_config(bad)
except json.JSONDecodeError:
    print('bad json propagated')
shutil.rmtree(folder)

open in try, parse in else, close in finally

output
closed
{'debug': True}
no such file
{}
closed
bad json propagated

Code after the block versus finally

It is tempting to think a line written right after the whole try statement does the same job as finally. It does not. Code after the block runs only when nothing is left propagating, whereas finally runs regardless. In this example there is no except, so a bad value propagates: the line after the block is skipped, but finally still prints.

python
def after_try(text):
    try:
        n = int(text)
    finally:
        print('finally')
    print('after block', n)

after_try('7')
try:
    after_try('x')
except ValueError:
    print('propagated')
output
finally
after block 7
finally
propagated
Code after the try blockfinally clause
Runs after a clean finishYesYes
Runs after a handled errorYes, the program carries onYes
Runs while an error propagatesNo, it is skippedYes
Runs after return, break or continue in tryNoYes
Best used forNormal follow-up workCleanup that must not be missed
Remember

Use else for work that depends on the try body succeeding, so its errors are not mistaken for the risky call failing. Use finally for cleanup that must always happen, and never return from it.

Part 7 · Raising Exceptions

Raising your own exceptions

So far exceptions have come from Python itself, such as a failed int() call or a missing key. Your own functions can also signal that something is wrong. The raise statement does this. It stops the current function immediately and hands an exception to whoever called it, who can catch it with try and except.

The usual form is raise followed by a call to an exception class. The call creates an exception instance, and raise throws it. The message you pass is stored on the instance, and except ... as e gives it back to you.

python
def set_age(age):
    if age <= 0:
        raise ValueError('age must be positive')
    return age

try:
    set_age(-3)
except ValueError as e:
    print(type(e).__name__, e)

ValueError('...') builds the object, raise throws it

output
ValueError age must be positive
What happens at a raise
  1. 1BuildValueError('...') makes an instance
  2. 2Throwraise stops the function right here
  3. 3UnwindPython leaves each calling function in turn
  4. 4Catchthe first matching except takes over

You can also write raise followed by the class name with no parentheses. Python then calls the class with no arguments and raises the instance it gets back. So raise ValueError and raise ValueError() do the same thing. The only difference is that the exception carries no message.

python
try:
    raise ValueError
except ValueError as e:
    print(repr(e))
    print(e.args)

No parentheses: the class is called for you

output
ValueError()
()
Prefer the message

The no-parentheses form is fine when the type says everything. In most other cases, pass a message. Someone reading a traceback at 2 a.m. will thank you.

Failing fast with a clear message

The best place to raise is at the very top of a function, before it does any real work. This is called failing fast. If the input is bad, stop right there. Otherwise the bad value travels deeper into your program and causes a confusing error far from its real cause.

A good message names the bad value and says what was expected. A message like 'invalid input' tells the reader nothing. A message like 'percent must be between 0 and 100, got 150' tells them what to fix.

The next function checks the type first and the range second. It uses the exception type that matches each kind of problem.

python
def make_discount(percent):
    if not isinstance(percent, (int, float)):
        raise TypeError(f'percent must be a number, got {type(percent).__name__}')
    if not 0 <= percent <= 100:
        raise ValueError(f'percent must be between 0 and 100, got {percent!r}')
    return percent / 100

for value in (25, 150, 'ten'):
    try:
        print(make_discount(value))
    except (TypeError, ValueError) as e:
        print(type(e).__name__ + ':', e)

Validate first, compute last

output
0.25
ValueError: percent must be between 0 and 100, got 150
TypeError: percent must be a number, got str

Which type should you raise? Python's built-in exceptions already cover most situations, and callers know them. Pick the one that describes the actual problem.

SituationRaiseExample
The right type, but the value is unacceptableValueErrorraise ValueError('age must be positive')
The wrong type was passed inTypeErrorraise TypeError('name must be a str')
A base-class method that subclasses must overrideNotImplementedErrorraise NotImplementedError('implement area()')
Picking a built-in exception

Re-raising and assert

Sometimes you want to catch an exception, do something small such as print a note or release a resource, and then let the error keep travelling. A bare raise, with nothing after it, does this. It is only valid inside an except block. It re-raises the exception currently being handled, with its original traceback intact.

You could also write raise e, where e is the name from except ... as e. The exception still propagates, but Python adds an extra traceback entry for the line holding raise e. The traceback gets longer and slightly misleading. The code below counts the frames in each traceback to show the difference.

python
def parse(text):
    return int(text)

def with_bare(text):
    try:
        return parse(text)
    except ValueError:
        raise

def with_name(text):
    try:
        return parse(text)
    except ValueError as e:
        raise e

def frames(func):
    try:
        func('abc')
    except ValueError as e:
        tb = e.__traceback__
        names = []
        while tb:
            names.append(tb.tb_frame.f_code.co_name)
            tb = tb.tb_next
        return names

print(frames(with_bare))
print(frames(with_name))

Each name is one frame in the traceback

output
['frames', 'with_bare', 'parse']
['frames', 'with_name', 'with_name', 'parse']
raise ebare raise
Does the error propagate?YesYes
TracebackGets an extra entry for the raise e lineStays exactly as it was
Where it worksAnywhere you hold the exceptionOnly inside an except block
VerdictWorks, but adds noisePreferred for passing an error on

The assert statement looks like another way to raise. It is meant for something different. assert condition, message raises AssertionError when the condition is false. It is a developer check, a way to state something that must be true if your own code is correct. When Python runs with the -O flag, every assert is removed. A check that guards user input would then disappear.

python
def average(nums):
    assert len(nums) > 0, 'nums must not be empty'
    return sum(nums) / len(nums)

try:
    average([])
except AssertionError as e:
    print('AssertionError:', e)

Fine as a developer check. Not for validating user input

output
AssertionError: nums must not be empty
Never validate input with assert

Under python -O the assert lines are skipped, and bad data passes straight through. For anything a user, a file or another system can send you, use an if and raise ValueError (or TypeError) instead.

Two mistakes that hide problems

The last two mistakes both make errors harder to handle. One raises something too vague to catch selectively. The other doesn't raise at all. The program below shows both.

python
def withdraw(balance, amount):
    if amount > balance:
        raise Exception('error')
    return balance - amount

try:
    withdraw(10, 50)
except ValueError:
    print('handled as a value problem')
except Exception as exc:
    print('caught only by the catch-all:', exc)

def parse_port(text):
    if not text.isdigit():
        return None
    return int(text)

port = parse_port('80a')
try:
    print(port + 1)
except TypeError as exc:
    print('forgotten check:', exc)

A vague exception, then a forgotten None

output
caught only by the catch-all: error
forgotten check: unsupported operand type(s) for +: 'NoneType' and 'int'
Raising a bare Exception('error')

Exception is the parent of nearly everything. A caller who wants to handle this one problem has to catch Exception, which also swallows unrelated bugs. The message 'error' says nothing either. Raise ValueError, TypeError or a more specific class, and say what went wrong.

Returning None or an error code instead of raising

A returned None or -1 is easy to forget to check. The program carries on with a bad value, and the failure shows up later as a confusing TypeError far from the real cause. An exception can't be ignored by accident. If the caller does nothing, the error is still reported.

The fix for the second mistake is one line. Replace return None with raise ValueError(f'not a port number: {text!r}'). The caller can then decide what to do with the problem.

Remember

Build the exception with a clear message, throw it early with raise, and choose the built-in type that fits. Use bare raise to pass an error on. Keep assert for your own sanity checks, and never use it on user input.

Part 8 · Custom Exception Classes

Defining Your Own Exception

Python ships with many built-in exceptions, but they describe general problems such as a bad value or a missing key. They cannot say what went wrong in your program. A custom exception is a class you write so the error carries a meaning of its own, like InsufficientFundsError in a banking module.

The recipe is short. Subclass Exception, and the class body can stay empty, because everything that makes an exception work (raising, catching, printing, the traceback) is inherited. The only line you must write is class InsufficientFundsError(Exception): pass.

Real programs usually have several related errors. The usual pattern is to define one base error for your library or app, such as AppError, and derive each specific error from it. The hierarchy then looks like this:

Exception

built into Python

AppError

your base class

subclass of Exception

InsufficientFundsError

subclass of AppError

AccountClosedError

subclass of AppError

The base class earns its place because of how except matches. A handler for a parent class also catches every child, so a caller can write a single except AppError to handle any error your code raises, without listing each one. They can still catch a specific child first when they want to react to it on its own.

python
class AppError(Exception):
    """Base class for every error this library raises."""

class InsufficientFundsError(AppError):
    pass

class AccountClosedError(AppError):
    pass

def withdraw(balance, amount, closed=False):
    if closed:
        raise AccountClosedError("account is closed")
    if amount > balance:
        raise InsufficientFundsError("not enough money")
    return balance - amount

for args in [(100, 30, False), (50, 80, False), (50, 10, True)]:
    try:
        print("new balance:", withdraw(*args))
    except AppError as e:
        print(type(e).__name__, "-", e)

One except AppError handles both specific errors

output
new balance: 70
InsufficientFundsError - not enough money
AccountClosedError - account is closed

The loop never lists InsufficientFundsError or AccountClosedError. If you add a third error to the library tomorrow, this caller already handles it.

Carrying Data in the Exception

A message string is fine for humans, but code that handles the error often needs the facts behind it. How much was the balance? How much was needed? You can store these as attributes by writing your own __init__. The important step is to call super().__init__(message) inside it, passing the text you want shown.

That call matters because str(e) and the traceback line both read the message from the arguments stored by the parent class. If you skip it and raise with keyword arguments, nothing gets stored, and the exception prints as an empty string:

python
class Bad(Exception):
    def __init__(self, balance, needed):
        self.balance = balance
        self.needed = needed

print(repr(str(Bad(balance=50, needed=80))))

No super().__init__ call, so there is no message

output
''

The fix is to build a readable message from the values and hand it to the parent, then keep the values as attributes. Raising with keywords, raise InsufficientFundsError(balance=50, needed=80), makes the call site self-explanatory, and the handler can read e.needed directly instead of parsing text.

python
class InsufficientFundsError(AppError):
    def __init__(self, balance, needed):
        super().__init__(f"balance {balance} is less than the {needed} needed")
        self.balance = balance
        self.needed = needed

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance=balance, needed=amount)
    return balance - amount

try:
    withdraw(50, 80)
except InsufficientFundsError as e:
    print(e)
    print("short by", e.needed - e.balance)

Reusing AppError from the previous example

output
balance 50 is less than the 80 needed
short by 30
Common mistake: forgetting super().init

If your __init__ sets attributes but never calls super().__init__(message), then print(e) shows nothing useful and logs and tracebacks lose the message. Always pass a message up to the parent.

Naming, Choosing and Staying Safe

Good names make a traceback readable at a glance. End each class name with Error, and describe the problem, not the place it happened. InsufficientFundsError tells the reader what is wrong, while WithdrawModuleError only says where to look. The same name is then used everywhere, from the first example to the handler.

NameVerdictWhy
InsufficientFundsErrorGoodDescribes the problem and ends in Error
AccountClosedErrorGoodThe problem is clear without reading the code
WithdrawErrorWeakNames the location, not what went wrong
InsufficientFundsAvoidMissing the Error suffix, so it does not read as an exception

Next, decide whether you need a custom class at all. Reusing a built-in such as ValueError is fine when nobody needs to treat the failure specially. A custom exception wins when callers must react differently to this particular failure.

Reuse ValueErrorCustom exception
Catchingexcept ValueError also catches unrelated bad valuesexcept InsufficientFundsError catches exactly this problem
Extra dataOnly a message stringAttributes such as balance and needed
Whole libraryNo shared parent to catchOne except AppError covers everything
Best whenCallers treat all bad input the sameCallers respond differently to different failures

The rule of thumb for how many classes to write: create one only when callers will catch it separately. A class for every possible message just clutters the library, so use the following check before adding another.

Do I need a new exception class?

Finally, always subclass Exception (or one of its subclasses), never BaseException. BaseException also sits above KeyboardInterrupt and SystemExit, which a plain except Exception deliberately lets through so that Ctrl+C and sys.exit() still work. A custom error built on BaseException would slip past ordinary handlers and could break that.

Common mistake: one class per message

Creating BadAmountError, NegativeAmountError and ZeroAmountError when every caller handles them the same way adds names to learn and nothing else. Use one error with a clear message until callers genuinely need to tell them apart.

Remember

Subclass Exception, derive from one base such as AppError, name the problem with an Error suffix, store useful values as attributes, call super().__init__(message), and add a class only when callers will catch it on its own.

Part 9 · Exception Chaining with raise from

Why exceptions carry a history

Real programs catch an error, realise it means something bigger, and raise a different exception. If the original is thrown away at that moment, you lose the one clue that says what actually broke. Python solves this by letting an exception remember the exception that led to it. This is called exception chaining, and it comes in two flavours: one you ask for, and one Python does for you.

The explicit form is raise NewError('msg') from original. It stores the original on the new exception's __cause__ attribute and says, in effect, 'I raised this on purpose because of that'. Here a missing key in a settings dictionary is turned into a ConfigError.

python
class ConfigError(Exception):
    pass

settings = {"host": "localhost"}

def get_port():
    try:
        return settings["port"]
    except KeyError as e:
        raise ConfigError("missing setting: port") from e

try:
    get_port()
except ConfigError as err:
    print("caught:", err)
    print("cause:", repr(err.__cause__))
    print("context:", repr(err.__context__))

Explicit chaining with from

output
caught: missing setting: port
cause: KeyError('port')
context: KeyError('port')

Notice that __context__ is filled in too. That brings us to the implicit form. Whenever you raise a new exception while another one is being handled, inside an except block, Python sets __context__ on the new exception automatically. You do not write anything extra. Only __cause__ needs the from keyword.

python
def get_port_implicit():
    try:
        return settings["port"]
    except KeyError:
        raise ConfigError("missing setting: port")

try:
    get_port_implicit()
except ConfigError as err:
    print("cause:", repr(err.__cause__))
    print("context:", repr(err.__context__))

No from: only the context is set

output
cause: None
context: KeyError('port')

The two attributes tell different stories. __cause__ means 'this was raised deliberately because of that'. __context__ only means 'that was being handled at the time', which could be a coincidence or even a bug in your handler. Python prints the difference in the traceback, using a different sentence to join the two.

How it was raisedAttribute setLine printed between the tracebacks
raise New from originalcause (and context)The above exception was the direct cause of the following exception:
raise New inside an except block, no fromcontext onlyDuring handling of the above exception, another exception occurred:
raise New from Noneneither shownNothing: only the new exception is printed
Which message will the traceback show?

Translating errors and choosing when to hide them

The most common reason to chain is translation. Low-level code raises errors like KeyError or OSError that describe how something failed, such as a missing dictionary key or a missing file. The code calling you cares about what that means for your domain: the configuration is broken. You raise a domain error with a useful message and attach the low-level one with from, so the root cause is still there for whoever debugs it.

python
def load_file(path):
    try:
        with open(path) as f:
            return f.read()
    except OSError as e:
        raise ConfigError(f"cannot read config {path}") from e

try:
    load_file("missing.toml")
except ConfigError as err:
    print(err)
    print(type(err.__cause__).__name__)

OSError becomes ConfigError, root cause kept

output
cannot read config missing.toml
FileNotFoundError

Callers now only have to catch ConfigError, yet the FileNotFoundError is one attribute away. To inspect a whole chain in code, follow __cause__ first and fall back to __context__, the same order Python uses when it prints a traceback. A small loop walks it from the newest exception back to the original.

python
def show_chain(exc):
    while exc is not None:
        print(f"{type(exc).__name__}: {exc}")
        exc = exc.__cause__ or exc.__context__

try:
    load_file("missing.toml")
except ConfigError as err:
    show_chain(err)

Walking the chain

output
ConfigError: cannot read config missing.toml
FileNotFoundError: [Errno 2] No such file or directory: 'missing.toml'

Sometimes the original exception is the wrong thing to show. It may be pure noise, or it may carry details you must not leak, such as a secret value inside a message or a lookup key. Writing from None sets the displayed context aside, so the traceback shows only the new exception. The attribute __suppress_context__ becomes True, and __cause__ is None.

python
tokens = {"abc123": "admin"}

def role_for(token):
    try:
        return tokens[token]
    except KeyError:
        raise PermissionError("invalid token") from None

try:
    role_for("guess")
except PermissionError as err:
    print("cause:", err.__cause__)
    print("hidden:", err.__suppress_context__)

from None hides the original in tracebacks

output
cause: None
hidden: True
Common mistake: wrapping without from

Writing raise ConfigError('missing setting') inside an except block still chains, but it prints 'During handling of the above exception, another exception occurred', which makes it look like your handler crashed by accident. Readers get a confusing double traceback and cannot tell whether the second error was intended. Whenever you wrap on purpose, add from e.

Common mistake: from None by habit

from None throws away the best debugging clue you had. Use it only when the original is noise or would leak sensitive details. If you are unsure, keep the chain.

Rule of thumb

Translating a low-level error into a domain error: use from e. Replacing it because the original is noise or sensitive: use from None. Raising in an except block without either one is almost always a slip.

Part 10 · Context Managers for Cleanup

Why with exists

Some resources must be given back no matter what happens: a file has to be closed, a lock released, a transaction finished. If an exception flies out of the middle of your code, a plain close() call placed after it never runs. A context manager solves this by tying the cleanup to a block of code, so the cleanup runs on the way out whether the block finished normally or blew up.

The most familiar one is with open(path) as f:. When the indented block ends, Python closes the file for you. This holds even when an error is raised inside the block. The next example raises on purpose and then checks the file afterwards.

python
import os
import tempfile

path = os.path.join(tempfile.mkdtemp(), "notes.txt")

try:
    with open(path, "w") as f:
        f.write("hello")
        raise ValueError("boom")
except ValueError:
    print("caught")

print(f.closed)

The error escapes the block, but the file is already closed

output
caught
True

Behind the scenes, the with statement follows a fixed protocol. It calls the object's __enter__ method when the block starts, and the value that method returns is what lands after as. When the block ends, it calls __exit__. The key point is that __exit__ is called in both cases: after a clean finish and after an exception.

The guarantee

If __enter__ succeeded, __exit__ runs. Normal end, return, break or an exception all lead to the same cleanup call.

Writing your own with a class

Any class becomes a context manager when it defines __enter__ and __exit__. The __exit__ method receives three arguments describing the exception that ended the block: its type, the exception object, and the traceback. If the block ended cleanly, all three are None. The small class below only reports what it sees, so you can watch both paths.

python
class Tracer:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"enter {self.name}")
        return self

    def __exit__(self, exc_type, exc, tb):
        label = exc_type.__name__ if exc_type else None
        print(f"exit {self.name}, error: {label}")
        return False

with Tracer("ok"):
    print("working")

try:
    with Tracer("bad"):
        raise KeyError("x")
except KeyError:
    print("still raised")
output
enter ok
working
exit ok, error: None
enter bad
exit bad, error: KeyError
still raised

Notice the return False at the end of __exit__. The return value decides what happens to the exception. Returning a false value (or nothing at all, which means None) lets the exception continue upward, which is why still raised was printed. Returning True tells Python the problem has been dealt with, and the exception is suppressed.

What happens after exit runs

Suppression should be narrow. The next manager inspects exc_type and only swallows ZeroDivisionError, so any other problem still reaches the caller.

python
class Quiet:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        print("saw", repr(exc))
        return exc_type is ZeroDivisionError

with Quiet():
    1 / 0
print("after zero division")

try:
    with Quiet():
        int("x")
except ValueError as e:
    print("escaped:", e)
output
saw ZeroDivisionError('division by zero')
after zero division
saw ValueError("invalid literal for int() with base 10: 'x'")
escaped: invalid literal for int() with base 10: 'x'
Common mistake: returning True too eagerly

An __exit__ that always ends with return True hides every bug inside the block, including typos and KeyboardInterrupt. Return True only for the specific exception types you mean to ignore.

Shortcuts: @contextmanager, suppress and several resources

Writing a whole class for a small piece of setup and teardown is heavy. The @contextmanager decorator from contextlib lets you write a manager as a single generator function. Everything before yield is the setup, the yielded value becomes the as target, and everything after it is the cleanup. Because an exception in the block is re-raised at the yield line, the yield needs to sit inside try/finally for the cleanup to be guaranteed.

python
from contextlib import contextmanager

@contextmanager
def announce(name):
    print(f"open {name}")
    try:
        yield name.upper()
    finally:
        print(f"close {name}")

with announce("db") as conn:
    print("using", conn)

try:
    with announce("cache"):
        raise RuntimeError("fail")
except RuntimeError:
    print("handled")
output
open db
using DB
close db
open cache
close cache
handled
Common mistake: no try/finally around yield

If you write yield followed by a bare cleanup line, an exception in the block is raised at the yield and the cleanup line is never reached. The resource stays open exactly when something went wrong.

python
@contextmanager
def broken(name):
    print(f"open {name}")
    yield
    print(f"close {name}")

try:
    with broken("lock"):
        raise RuntimeError("fail")
except RuntimeError:
    print("handled, but close never printed")

Same shape as before, but without try/finally

output
open lock
handled, but close never printed

Two more tools save typing. contextlib.suppress(FileNotFoundError) is a ready-made manager that ignores exactly the exceptions you name, a short form of a try/except/pass. And a single with can open several resources at once by separating them with commas; they are entered left to right and exited in the reverse order, and each one is cleaned up even if a later one fails to open.

python
import os
import tempfile
from contextlib import suppress

folder = tempfile.mkdtemp()
src = os.path.join(folder, "a.txt")
dst = os.path.join(folder, "b.txt")

with suppress(FileNotFoundError):
    os.remove(src)
print("nothing to remove, no error")

with open(src, "w") as f:
    f.write("one\ntwo\n")

with open(src) as a, open(dst, "w") as b:
    for line in a:
        b.write(line.upper())
print(a.closed, b.closed)

with open(dst) as f:
    print(f.read().split())
output
nothing to remove, no error
True True
['ONE', 'TWO']

with versus try/finally, and where you will use it

Everything a context manager does could be written by hand with try/finally. The difference is who has to remember the cleanup. With try/finally, every caller must repeat the cleanup code correctly, and one forgotten finally is a leak. With with, the cleanup lives in one place, the manager, and every caller gets it for free.

try/finallywith statement
Lines per useSetup, try, finally, cleanup callOne line plus the block
Cleanup writtenAt every call siteOnce, inside the manager
Easy to forgetYes, a missing finally leaks the resourceNo, you cannot enter without exiting
Several resourcesNested try/finally blocksOne line: with open(a) as f, open(b) as g:
ReuseCopy and pasteImport the manager

Once you know the pattern, you will spot it across the standard library. The managers below are the ones you will meet most often.

ResourceTypical formWhat the exit does
Fileswith open(path) as f:Closes the file
Lockswith lock: (threading.Lock)Releases the lock
Database transactionswith conn: (sqlite3)Commits on success, rolls back on an exception
Temporary directorieswith tempfile.TemporaryDirectory() as d:Deletes the directory and its contents
Rule of thumb

Whenever a function has a matching pair such as open and close, acquire and release, or begin and commit, look for a context manager first. If none exists, write one with @contextmanager and put the yield inside try/finally.

Part 11 · EAFP vs LBYL

Two ways to be careful

Every operation that can fail gives you a choice. You can check that it will work before you do it, or you can just do it and deal with the failure if it comes. Python programmers have names for both habits, and knowing which one fits a situation is part of writing code that feels natural in the language.

LBYL stands for look before you leap. You test the conditions first with an if, and only go ahead when everything looks safe. EAFP stands for easier to ask forgiveness than permission. You attempt the operation straight away and catch the exception if it fails. The two styles do the same job but put the safety net in different places.

LBYLEAFP
Toolif checks before actingtry / except around the action
MindsetMake sure it will workAssume it works, handle the exception
Failure shows up asA false conditionAn exception
Typical Python feelCommon in C and JavaThe idiomatic Python way

The clearest small example is reading a key from a dictionary. The LBYL version asks in first. The EAFP version reads the key and catches KeyError when it is missing. Both programs below end up in the same place.

python
prices = {"tea": 30, "coffee": 45}
item = "juice"

# LBYL: look first
if item in prices:
    cost = prices[item]
else:
    cost = None
print("LBYL:", cost)

# EAFP: try, then forgive
try:
    cost = prices[item]
except KeyError:
    cost = None
print("EAFP:", cost)
output
LBYL: None
EAFP: None

Python leans towards EAFP for two reasons. It is the idiomatic style, so other Python readers expect it, and the happy path stays uncluttered because the normal flow is just the operation itself. It is also often faster when failures are rare: if the key is almost always there, the EAFP version does one lookup and never pays for a separate check, because a try block that does not raise costs almost nothing.

Why checking first can still fail

LBYL has a deeper weakness than style. A check only tells you what was true at the moment you looked. Between the check and the action, the world can change. This is a race condition, and files are the classic place to meet it.

Suppose you write if os.path.exists(p): and then open(p). Another program, another thread, or the user can delete the file in the gap between the two lines. The check said yes, the action says no, and your code crashes anyway.

LBYL on a file
  1. 1os.path.exists(p)returns True
  2. 2Someone deletes panother process, thread or user
  3. 3open(p)raises FileNotFoundError

EAFP closes that gap because there is no gap: the open itself is the test. If it succeeds you have the file, and if it fails you are told so at the exact moment it matters. The program below fakes the interfering process by deleting the file ourselves right after the check.

python
import os, tempfile

path = os.path.join(tempfile.mkdtemp(), "note.txt")
with open(path, "w") as f:
    f.write("hi")

if os.path.exists(path):          # LBYL says it is safe
    os.remove(path)               # ...but someone deletes it now
    try:
        open(path)
    except FileNotFoundError:
        print("exists() said yes, open() still failed")

try:                              # EAFP: the open is the check
    with open(path) as f:
        text = f.read()
except FileNotFoundError:
    text = ""
print("read gave", repr(text))
output
exists() said yes, open() still failed
read gave ''
A check is a snapshot

Whenever something outside your program can change between the check and the action, the check cannot be trusted. Catching the exception is the only reliable answer.

Choosing between them

Before reaching for either style, ask whether you need one at all. For the common case of a missing dictionary key or attribute, Python gives you a built-in with a default. d.get(key, default) replaces the whole if/try for dictionaries, and getattr(obj, name, default) does the same for attributes. The code is shorter and says exactly what you mean.

python
prices = {"tea": 30, "coffee": 45}
print(prices.get("juice", 0))

class Config:
    timeout = 5

cfg = Config()
print(getattr(cfg, "timeout", 10))
print(getattr(cfg, "debug", False))
output
0
5
False

When you do need a real choice, the deciding factor is how often the failure happens. Raising and catching an exception is slower than a plain if check. If the failure is rare, that cost is paid almost never, and EAFP wins. If misses are frequent, as in a hot loop where half the lookups fail, you pay the exception price over and over, and an if check is the better pick.

Which style fits?

LBYL is also the right tool in two other situations. The first is when the check is cheap and reads clearly, such as if amount > balance: before a payment, where nobody would want an exception to express a normal business rule. The second is when the action cannot be undone. If sending an email, charging a card or deleting a folder might half-succeed before failing, you want to validate everything up front rather than discover the problem after the damage.

SituationPreferWhy
Failure is rareEAFPNo cost on the happy path
Failure is common, hot loopLBYLA plain check beats raising repeatedly
Outside code can change thingsEAFPA check can go stale
Check is cheap and obviousLBYLReads clearly
Action cannot be undoneLBYLValidate before the point of no return
A default value is enoughget / getattrShortest and clearest

The big try block trap

EAFP has one habit that goes wrong more than any other: putting too much code inside the try. An except KeyError does not know which line raised the error. If the block holds ten lines, any of them can raise it, and your handler will quietly swallow errors that have nothing to do with the one you meant to catch.

In the program below, the user admin exists in names. But a helper further down the block has a bug of its own and raises a KeyError. The oversized try catches it and reports the wrong story, so the admin is shown as a guest.

python
names = {"admin": "Root"}

def lookup_role(user):
    return {"root": "all"}[user]   # bug: raises KeyError

# Too much inside the try
try:
    display = names["admin"]
    role = lookup_role("admin")
except KeyError:
    display = "guest"
print(display)
output
guest

The cure is to keep the try around only the single operation whose failure you expect. Here that is the dictionary lookup alone. Everything else goes after the block, where an unexpected error will show up as a normal traceback instead of being hidden.

python
# Narrow try: only the lookup we expect to fail
try:
    display = names["admin"]
except KeyError:
    display = "guest"
print(display)
# lookup_role("admin") would now run out here, and its bug would be visible
output
Root
Common mistake: a huge try block

Wrapping many lines in one try for the sake of EAFP makes except catch unrelated errors and hides real bugs. Put only the one risky operation inside the try, and use else for the code that should run when it succeeds.

Part 12 · Logging Errors

Why logging beats print

When something goes wrong in a running program, you need to know how serious it was, when it happened and where the message went. The standard logging module gives you all three. We'll take them in that order: severity, time, then destination.

Severity comes from levels. Every message you log is tagged with one of five levels, so a routine note and a disaster don't look the same. A level also works as a filter: you choose the lowest level you care about, and anything quieter is dropped.

LevelNumberUse it for
DEBUG10Detail useful only while hunting a bug
INFO20Normal events: started, finished, loaded 40 rows
WARNING30Something odd happened, but the program carried on
ERROR40An operation failed
CRITICAL50The program may not be able to continue

The levels run in that order, from quietest to loudest. If you set the threshold to WARNING, you see WARNING, ERROR and CRITICAL and nothing below. Until you configure anything, the threshold is WARNING, which is why a bare logger.info(...) seems to do nothing.

Time is added by the format. You put %(asctime)s in the format string and every line is stamped with the moment it was logged. With print, you would have to build that yourself.

Destination is decided by handlers. A handler sends the formatted line somewhere: the console, a file, a network service. Your code only says what happened. The configuration decides where it goes.

Put together, this is why logging replaces print for anything you want to keep or switch on and off. The table below compares them.

printlogging
SeverityNone, every line looks the sameFive levels you can filter by
TimestampOnly if you add it by handOne format setting adds it everywhere
DestinationStandard output onlyConsole, files, rotating files, the network
Switching it offDelete or comment out the callsRaise the level, leave the calls in place
TracebacksYou must format them yourselfexc_info=True attaches the full traceback

Getting started with basicConfig

The quickest way to configure logging is logging.basicConfig. You give it the lowest level to show and a format, and it sets up a handler on the root logger. Below, stream=sys.stdout sends the lines to standard output so the example prints them. By default they go to standard error.

python
import logging
import sys

logging.basicConfig(
    level=logging.INFO,
    format='%(levelname)s:%(name)s:%(message)s',
    stream=sys.stdout,
)
logger = logging.getLogger('shop')

logger.debug('cart contents loaded')
logger.info('order started')
logger.warning('stock is low')
logger.error('payment failed')
logger.critical('database is down')
output
INFO:shop:order started
WARNING:shop:stock is low
ERROR:shop:payment failed
CRITICAL:shop:database is down

The DEBUG line is missing because the threshold is INFO. In a real program you would add %(asctime)s to the format so each line carries a timestamp.

Common mistake: calling basicConfig twice

basicConfig does nothing if the root logger already has a handler. A second call with different settings is silently ignored. Configure once at program start. In Python 3.8 and later, force=True replaces the existing handlers if you really need to reconfigure.

Logging the exception and its traceback

Inside an except block, call logging.exception('message'). It logs at ERROR level and appends the full traceback of the exception being handled, so you keep everything you would have seen if the program had crashed. It is meant for except blocks only, because it needs a current exception to describe.

If you want a different level, pass exc_info=True to any logging call. logger.warning('...', exc_info=True) attaches the same traceback at WARNING level. In fact logging.exception(...) is just error(..., exc_info=True).

python
import io
import logging

buffer = io.StringIO()
logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(message)s',
                    stream=buffer, force=True)

def ratio(a, b):
    return a / b

def show(label):
    lines = buffer.getvalue().splitlines()
    print(label, '->', lines[0], '|', lines[-1])
    buffer.seek(0)
    buffer.truncate(0)

try:
    ratio(1, 0)
except ZeroDivisionError:
    logging.exception('ratio failed')
show('exception')

try:
    ratio(1, 0)
except ZeroDivisionError:
    logging.warning('ratio failed, using 0', exc_info=True)
show('exc_info')

The log goes into a buffer so we can print just the first and last line of each record.

output
exception -> ERROR:ratio failed | ZeroDivisionError: division by zero
exc_info -> WARNING:ratio failed, using 0 | ZeroDivisionError: division by zero

In both records the first line is your message and the last line is the exception. Between them sits the traceback that points at the failing line.

Common mistake: logging only the message

logger.error('failed: %s', exc) records the exception text but throws away the traceback, so you can't tell where it came from. print(e) has the same problem. Use logger.exception(...) or exc_info=True unless you are sure the text alone is enough.

Loggers, lazy formatting and not logging twice

In a real project, each module creates its own logger once, at the top of the file: logger = logging.getLogger(__name__). The name is the module's dotted path, such as shop.orders, so every line says which part of the program produced it. Modules only create loggers and log. The handlers are configured once, at program start, in the entry point.

python
# shop/orders.py  (just a logger, no handlers)
import logging
logger = logging.getLogger(__name__)

# main.py  (configure handlers once, at start-up)
import logging
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler('app.log', maxBytes=1_000_000, backupCount=3)
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s'))
logging.getLogger().addHandler(handler)
logging.getLogger().setLevel(logging.INFO)

A sketch of the two files, not run here. The rotating handler starts a new file at about 1 MB and keeps 3 old ones.

When you log a value, pass it as an argument: logger.info('user %s', name). The logger builds the final string only if the message is actually emitted. An f-string is built before the call, whether or not anyone will ever see it. The class below prints a line whenever it is turned into text, which makes the difference visible.

python
import logging
import sys

logging.basicConfig(level=logging.WARNING, format='%(levelname)s:%(message)s',
                    stream=sys.stdout, force=True)
logger = logging.getLogger('demo')

class Noisy:
    def __str__(self):
        print('building string')
        return 'noisy'

print('lazy, below level')
logger.info('value %s', Noisy())
print('f-string, below level')
logger.info(f'value {Noisy()}')
print('lazy, emitted')
logger.warning('value %s', Noisy())
output
lazy, below level
f-string, below level
building string
lazy, emitted
building string
WARNING:value noisy

The lazy INFO call did no work, because the threshold is WARNING. The f-string call did the work and then threw the result away. For cheap values this barely matters, but for large objects or expensive __str__ methods it adds up.

Common mistake: f-strings in log calls

logger.debug(f'rows: {expensive()}') runs expensive() every time, even when DEBUG is switched off. Write logger.debug('rows: %s', value) instead.

The last habit is about where to log. If every layer of the program catches an error, logs it and re-raises it, the same failure shows up three or four times in the log and looks like several separate problems. Pick one place per error. Either handle the error and log it there, or re-raise it and let a higher layer log it. Don't do both at every layer.

Where should this error be logged?
python
import logging
import sys

logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(message)s',
                    stream=sys.stdout, force=True)
logger = logging.getLogger('settings')

def parse_port(text):
    return int(text)

def load_settings(text):
    return {'port': parse_port(text)}

def main():
    try:
        load_settings('abc')
    except ValueError as exc:
        logger.error('could not load settings: %s', exc)
        return 1
    return 0

print('exit code', main())

Only main() logs. The two lower layers let the error pass through.

output
ERROR:could not load settings: invalid literal for int() with base 10: 'abc'
exit code 1

The error is logged once, at the layer that decides what happens next. In this sketch main logs only the message. In a real program you would use logger.exception there to keep the traceback.

Common mistake: secrets in the log

Log files are copied, shipped to other services and kept for months. A password or token written once is effectively public. Log that a login failed for a user ID, never the credentials, and be careful with %s of whole dictionaries that may contain them.

Remember

Use logging instead of print. Make one logger per module and configure handlers once. Pass values as arguments, not f-strings. Use logger.exception to keep tracebacks, log each error once, and keep secrets out of the log.

Part 13 · Common Mistakes and Cheatsheet

Mistakes with try and except

Most error-handling bugs are not syntax slips. They are handlers that work too well: they stop the crash but also stop you from learning that something is wrong. The first four mistakes all come from how an except clause is written or how much code sits inside try.

Mistake 1: the bare except

A bare except: catches everything, including KeyboardInterrupt (Ctrl+C) and SystemExit, which are not ordinary errors at all. It also catches your own typos. In the function below the variable name is misspelled, so a NameError is raised, and the handler quietly turns the bug into a plausible-looking answer.

python
def total(prices):
    try:
        return sum(prcies)   # typo: NameError
    except:
        return 0

print(total([1, 2, 3]))

The correct total is 6, but the bug is hidden

output
0

Catch a specific type such as except ValueError: when you know what can fail. If you really need a catch-all, write except Exception:, which leaves Ctrl+C and exit requests alone, and log what you caught.

Common mistake

A bare except: makes a program that cannot be stopped with Ctrl+C and hides real bugs. Name the exception type, or use except Exception at the very least.

Mistake 2: swallowing errors with pass

An except block containing only pass leaves no trace. The caller gets None, nothing is printed, and the failure surfaces much later somewhere unrelated. Either log the problem and return a sensible fallback, or re-raise so a higher layer decides. The example shows both versions side by side; the logger is pointed at standard output so you can see its line.

python
import json
import logging
import sys

logging.basicConfig(stream=sys.stdout, level=logging.WARNING,
                    format='%(levelname)s %(message)s')
log = logging.getLogger('cfg')

def load_silent(text):
    try:
        return json.loads(text)
    except ValueError:
        pass

def load_logged(text):
    try:
        return json.loads(text)
    except ValueError as exc:
        log.warning('bad config: %s', exc)
        return {}

print(load_silent(''))
print(load_logged(''))

The first call fails without a sound; the second leaves evidence

output
None
WARNING bad config: Expecting value: line 1 column 1 (char 0)
{}

Mistake 3: putting too much code in try

Putting a whole block of work inside try means any line in it can trigger your handler, including lines you never meant to guard. The handler then reports the wrong cause. Keep try to the one line that can fail, and move the rest to else or after the block.

python
def ratio_wide(text):
    try:
        number = int(text)
        return 100 / number
    except (ValueError, ZeroDivisionError):
        return 'not a number'

def ratio_narrow(text):
    try:
        number = int(text)
    except ValueError:
        return 'not a number'
    return 100 / number

print(ratio_wide('0'))
print(ratio_narrow('4'))
print(ratio_narrow('x'))

The wide version calls the string '0' not a number

output
not a number
25.0
not a number

In the narrow version only int(text) is guarded, so a ZeroDivisionError from the division is not mislabeled. It shows up as the real problem it is.

Mistake 4: the wrong order of except clauses

Python checks except clauses from top to bottom and runs the first one that matches. A parent class matches all of its subclasses, so if the parent comes first, the subclass clause below it can never run. Always put the more specific exception before the more general one. FileNotFoundError is a subclass of OSError, so this order is wrong:

python
try:
    open('missing.txt')
except OSError:
    print('OSError caught')
except FileNotFoundError:
    print('never reached')

The specific handler is dead code

output
OSError caught
Common mistake

Listing a parent class before its subclass. The subclass clause is unreachable, and the handler you wrote for that case never runs. Order clauses from most specific to most general.

Mistakes with finally, re-raising and assert

The next three mistakes are about what happens after an error has been caught, or about using the wrong tool to signal one.

Mistake 5: returning in finally

The finally block always runs, even while an exception is travelling up the stack. If it executes return, the pending exception is discarded and the function appears to succeed. Use finally only for cleanup such as closing a resource, never for returning a value.

python
def run():
    try:
        raise ValueError('boom')
    finally:
        return 'done'

print(run())

The ValueError vanishes without a trace

output
done
Common mistake

A return, break or continue inside finally silently swallows whatever exception was in flight. Keep finally to cleanup statements only.

Mistake 6: raise e instead of a bare raise

When you catch an exception only to log it and pass it on, write a bare raise. It re-raises the same exception with its traceback untouched. Writing raise e also works, but it adds an extra traceback entry for the line that re-raised, which makes the trail noisier. The example prints the function names in each traceback so you can see the difference.

python
import traceback

def fail():
    int('x')

def reraise_e():
    try:
        fail()
    except ValueError as e:
        raise e

def reraise_bare():
    try:
        fail()
    except ValueError:
        raise

for f in (reraise_e, reraise_bare):
    try:
        f()
    except ValueError as err:
        frames = traceback.extract_tb(err.__traceback__)
        print(f.__name__, [fr.name for fr in frames])

raise e adds a second reraise_e frame

output
reraise_e ['<module>', 'reraise_e', 'reraise_e', 'fail']
reraise_bare ['<module>', 'reraise_bare', 'fail']

If you want to change the exception type, for example to hide a low-level detail from callers, do not raise a fresh exception inside except without linking it. Use raise ... from exc so the original error is kept as the cause.

python
def parse(text):
    try:
        return int(text)
    except ValueError as exc:
        raise RuntimeError('bad input') from exc

try:
    parse('x')
except RuntimeError as err:
    print(type(err.__cause__).__name__, '-', err.__cause__)

The original error travels along as __cause__

output
ValueError - invalid literal for int() with base 10: 'x'

Mistake 7: using assert to validate user input

An assert states something that must be true if your own code is correct. It is meant for catching programmer errors, and Python removes every assert when it runs with the -O flag. Input that comes from a user, a file or a network is never guaranteed, so checking it with assert means the check can disappear in production. Use an explicit if and raise with a fitting exception type.

python
def set_age_assert(age):
    assert age >= 0, 'age must be non-negative'
    return age

def set_age_raise(age):
    if age < 0:
        raise ValueError('age must be non-negative')
    return age

for f in (set_age_assert, set_age_raise):
    try:
        f(-5)
    except (AssertionError, ValueError) as err:
        print(type(err).__name__ + ':', err)

Same message, but only the second check survives python -O

output
AssertionError: age must be non-negative
ValueError: age must be non-negative
Common mistake

Validating input with assert. Under python -O the check is skipped and bad data flows straight through. Use if ...: raise ValueError(...) instead.

Cheatsheet

Here is the whole chapter on one page: the statements you will use, the order they run in, how to pick between EAFP and LBYL, and how to organise your own exception classes.

Statements and when to use them

ToolWhat it doesTypical use
try / exceptRuns the handler if the matching error is raisedRecover from a specific failure
elseRuns only if try raised nothingCode that depends on the guarded line succeeding
finallyAlways runs, error or notCleanup that must happen
raiseSignals an error, or re-raises the current one when bareReport bad state; pass an error up after logging
raise ... from excRaises a new error and keeps the old one as the causeTranslate a low-level error into your own
withRuns setup and guaranteed cleanup around a blockFiles, locks, connections
logger.exception(msg)Logs at ERROR level with the full tracebackInside an except block, to leave a record

Run order

Order of a full try statement
  1. 1tryruns until something raises
  2. 2exceptonly if a matching error was raised
  3. 3elseonly if nothing was raised
  4. 4finallyalways, last
python
def demo(value):
    try:
        print('try')
        n = int(value)
    except ValueError:
        print('except')
    else:
        print('else', n)
    finally:
        print('finally')

demo('7')
demo('x')

Success path first, then the failing path

output
try
else 7
finally
try
except
finally

EAFP or LBYL

EAFP (easier to ask forgiveness than permission) means just try the operation and handle the exception. LBYL (look before you leap) means check first. Pick EAFP when failures are rare, because the check would cost something on every call. Pick LBYL when the check is cheap and misses are frequent, since raising and catching an exception is slower than an if.

EAFPLBYL
StyleTry it, catch the exceptionTest the condition, then act
Best whenFailures are rareThe check is cheap and misses are common
Exampletry: value = data[key]if key in data:
RiskToo broad a try hides bugsState can change between check and use
Which style?

Custom errors share one base class

Give your library or application a single base exception that extends Exception, and derive every specific error from it. Callers can then catch your base class to handle anything your code raises, or a subclass for one case, without ever needing a bare except.

python
class AppError(Exception):
    pass

class ConfigError(AppError):
    pass

try:
    raise ConfigError('missing key')
except AppError as err:
    print(type(err).__name__, err)

Catching the base class also catches every subclass

output
ConfigError missing key
Remember

Catch the narrowest exception you can, around the smallest piece of code, and never let an error disappear without a log line or a re-raise.

Part 14 · Check yourself

Quiz

Try to answer each question before you open the answer. They ask you to predict what runs and to spot what is wrong, not to repeat definitions.

What does this print, and why?
  • It prints finally.
  • finally always runs, even when the try body has already hit a return.
  • A return inside finally replaces the earlier return value. It would also silently swallow an exception that was on its way out, so avoid it.
def pick():
    try:
        return 'try'
    finally:
        return 'finally'

print(pick())
What does this print? Is there anything wrong with the code?
  • It prints lookup problem.
  • KeyError is a subclass of LookupError, and the first matching clause wins.
  • The except KeyError clause can never run. Put the subclass before its parent.
settings = {}
try:
    settings['port']
except LookupError:
    print('lookup problem')
except KeyError:
    print('missing key')
This code crashes on the last line. What error do you get, and how do you fix it?
  • You get a NameError, because e is deleted when the except block ends.
  • Copy it inside the block, for example err = e, and use err afterwards.
  • The pass also hides the problem. At the very least, log it.
try:
    n = int('abc')
except ValueError as e:
    pass
print('failed with', e)
Both functions translate a KeyError into a ConfigError. How do their tracebacks differ?
  • a prints The above exception was the direct cause of the following exception, and sets __cause__.
  • b prints During handling of the above exception, another exception occurred, and only sets __context__.
  • Both keep the original KeyError. Use from e when you mean to translate an error, so the reader knows it was deliberate.
def a(cfg):
    try:
        return cfg['host']
    except KeyError as e:
        raise ConfigError('missing host') from e

def b(cfg):
    try:
        return cfg['host']
    except KeyError:
        raise ConfigError('missing host')
A traceback has frames in app.py, then service.py, then two frames inside a library, and ends with ValueError: bad value. Where do you start debugging?
  • Read bottom-up: the last line tells you the type and message of the error.
  • Scan up from the bottom until you meet the first frame in one of your own files. That is the lowest frame in your own file, here the one in service.py.
  • The library frames below it show where the error was raised. The usual cause is how your code called the library.

Summary

  • Read a traceback bottom-up: the last line gives the exception type and message, and the lowest frame in your own file (the first one you meet scanning up from the bottom) is where to start debugging.
  • Catch the narrowest exception you can, keep the try body small, and put subclasses before parents; never use a bare except:.
  • Use else for code that depends on success and finally for cleanup, and never return from finally.
  • Raise the built-in type that fits with a clear message, and derive custom errors from one base class that extends Exception.
  • Use raise ... from e to translate errors, bare raise to re-raise, and with to guarantee cleanup.
  • Prefer EAFP when failures are rare and LBYL for cheap checks or frequent misses; log with logger.exception instead of swallowing errors with pass.