Home › Session 08
Control flow · Session 08

Exceptions, Tracebacks & Context Managers

A traceback isn't Python shouting at you. It's a receipt: the list of desks an exception cleared on its way up the stack, looking for someone to deal with it. Once you can see that walk, try, finally and with stop being syntax to memorise and become three ways of standing in its path.

~45 minutes
6 interactive demos
Builds on Sessions 02, 04 and 05
Learning goal

An exception is an object that climbs the stack.

raise builds an ordinary instance, then clears one frame after another until a frame with a matching except says stop. Every feature in this session (the traceback, finally, with, chaining) is a detail of that climb: what it records, what it runs on the way, and who gets to intercept it.

exception traceback handler __enter__ / __exit__ __cause__
Recommended path
1 Let one escape. Demo 1 raises with no handler anywhere. Watch the desks go and the __traceback__ row grow.
2 Then catch it. Demo 2 stops the climb one desk up, and shows why err vanishes before the function returns.
3 Guarantee the cleanup. Demos 3 to 5 are the same try/finally idea three times over, ending with with.
Interactive lab

A frame drawn in red is being torn down

Two things are new in the memory panel. An exception shows up on the heap as an instance with a few extra rows. __traceback__ is the list of frames it has passed through, outermost first. And a frame the exception is leaving turns red and dashed. It's being cleared just as it would be on a return, except that no value goes back.

Tip: at every step, ask which frame the exception is in, and whether that frame has a try around the line it's on.
1 Choose a demo
›
2 Step with → or Play
›
3 Watch the frames go
›
4 Read the model below
Call stack: red and dashed means the exception is clearing that frame
Exception objects, files and context managers
→ next ← back Space play R reset
python
1 / 1
Memory
stack heap
1

Nothing has run yet

Press Play or use Next to watch an exception climb the stack.

What raise actually does

In plain words Picture an office where every call opens a desk on top of the last. Someone at the top desk hits a problem they can't solve, writes it on a card, clears their desk and hands the card down. The next person either deals with it or clears their own desk and passes it on. If it reaches the front door unanswered, the card is pinned to the wall (that's the traceback) and the office closes for the day.

raise X first evaluates X, which is normally a constructor call, so an instance of an exception class appears on the heap. The interpreter then attaches a traceback entry for the current frame and looks for a try block covering the current line. If there's none, the frame is popped, locals and all, just as on return, and the search continues in the caller at the line that made the call, which gets a traceback entry of its own. Only a matching except stops the walk. If nothing matches before the module level, the traceback is printed to stderr and the process exits with status 1.

An ordinary instance

ValueError("bad data") is a Session 04 constructor call: a __class__ link, an args tuple, and room for a traceback.

Frames are popped

Each frame without a handler is cleared and adds one entry to __traceback__. The locals die with the frame. The exception object doesn't.

A handler, or the wall

The first frame whose except matches keeps its desk and runs the handler. Otherwise the traceback is printed, the exit status is 1, and nothing else runs.

Read
Read a traceback from the bottom up
The last line is the error and its message. The entry directly above it is where it was raised. Everything above that is the chain of calls that led there, outermost first, which is why the heading says most recent call last. The printed text is just the __traceback__ row from the memory panel, one frame per entry.
Try this in a REPL

try: 1 / 0 then except ZeroDivisionError as e: tb = e.__traceback__. Now inspect it: tb.tb_frame.f_code.co_name and tb.tb_lineno tell you where, and tb.tb_next walks toward the raise site. It's the same chain the panel draws, and the same one traceback.print_exc() prints.

Catching

try, except, else, finally

In plain words A try block is a desk with a note on it: "if a card arrives while I'm on one of these lines, check it against this list". The list is the except clauses, read top to bottom. The first one that fits handles it, and the rest are ignored.

The match is isinstance(exc, ExceptionClass), so a clause catches the named class and every subclass of it. except LookupError catches a KeyError, and except Exception catches nearly everything you'll ever raise yourself. else runs only if the try body finished without raising, and finally runs no matter what. The next section is about that.

try The body runs. If it finishes cleanly, skip every except. always
except Only if the body raised: clauses are tested in order, and the first isinstance match runs. If nothing matches, the frame is cleared and the climb continues. first match
else Only if the body did not raise. It keeps the protected code small, so a bug in the else block isn't swallowed by the except above it. no error
finally Runs last on every path: after a clean body, after a handler, after an unmatched exception, and even on the way out through a return. always
Key
as err is a normal name that gets deleted for you
At the end of an except ... as err block Python executes del err. Reading err afterwards gives NameError. The reason is a cycle: the exception references its traceback, the traceback references the frames it walked through, and the frame you're in would reference err. That loop would keep every local in every one of those frames alive until the cyclic collector got round to it. If you need the exception later, bind it to another name inside the block.

The family tree decides what a clause catches

Exceptions are classes, and except matches by inheritance. A handful of parents cover most of what you'll meet:

You raise or meet Its parents So this clause catches it
KeyError, IndexError LookupError → Exception except LookupError
ZeroDivisionError, OverflowError ArithmeticError → Exception except ArithmeticError
FileNotFoundError, PermissionError OSError → Exception except OSError
ValueError, TypeError, StopIteration Exception except Exception
KeyboardInterrupt, SystemExit BaseException only a bare except:, and almost never on purpose
Care
A bare except: catches Ctrl-C
KeyboardInterrupt and SystemExit deliberately sit outside Exception, so that except Exception: lets the user quit and lets sys.exit() work. A bare except: catches them too, which is how a script ends up impossible to interrupt. Name the class you mean. If you really do mean "anything my code could raise", write except Exception.
Try this in a REPL

KeyError.__mro__ prints the whole chain up to object. Then try: {}["x"] with except LookupError as e: print(type(e).__name__). It prints KeyError, because the clause matched a subclass. Finally, after any except ... as e block, type e: NameError.

The way out

finally runs before the frame is left, whatever the reason

In plain words "Lock the door on your way out" applies whether you leave because you finished, because you were called away, or because you're running from a fire. finally is that rule. Demo 3 shows the version people find hardest to believe: a return inside the try still runs the finally block first.

When control leaves a try that has a finally, whether by falling off the end, by return, break or continue, or by an exception, the interpreter parks the pending result (the return value, or the exception in flight), runs the finally block, and then resumes whatever was pending. A return inside finally replaces the pending result, which throws away an exception that was on its way out.

What it guarantees

The block runs on every exit

Files get closed, locks get released, temporary state gets restored, even when the line that failed is one you didn't think could fail.

What it doesn't

It doesn't cancel the exception

After the block, the parked exception carries on climbing. The only thing that stops it is a return, break or continue inside finally, and Python 3.14 warns about all three (PEP 765) because they almost always hide a bug.

python
def risky(): try: raise ValueError("lost") finally: return "fine" # the ValueError is thrown away, nobody will ever see it print(risky()) # fine
Try this in a REPL

Define def f(): try: return "from try" finally: print("cleanup") on separate lines and call it. cleanup prints before the value comes back. Then change the finally to return "from finally" and see which string wins.

Context managers

with is try/finally with the cleanup moved into the object

In plain words Instead of every caller having to remember to lock the door, the door locks itself. An object that knows how to set itself up and tear itself down is a context manager, and with is the two-line way of saying "set up, do this, tear down, no matter what".

with EXPR as name: evaluates EXPR to an object, looks up __enter__ and __exit__ on its class, calls __enter__() and binds its return value to name, runs the body, and then calls __exit__(exc_type, exc, tb), with three Nones if the body finished, or with the exception and its traceback if it didn't. If __exit__ returns a true value the exception is suppressed. Otherwise it continues as if the with had never been there.

What you write

with open(path) as f: then a body that uses f. Two lines, no cleanup in sight. Demo 4 is the success path; demo 5 is the body raising.

What runs (roughly)

mgr = open(path); f = type(mgr).__enter__(mgr); a try around the body; on an exception, __exit__(type, exc, tb) and re-raise unless it returned true; on success, __exit__(None, None, None).

python
# with EXPR as name: BODY is close to this: mgr = EXPR exit_ = type(mgr).__exit__ name = type(mgr).__enter__(mgr) try: BODY except BaseException as e: if not exit_(mgr, type(e), e, e.__traceback__): raise # __exit__ said "not handled": keep climbing else: exit_(mgr, None, None, None)

Managers you already use

Expression as gives you __exit__ does
open(path) the file object itself closes it, which is why with open(...) is the idiom rather than f = open(...)
threading.Lock() True, so nobody writes as releases the lock, which is why Session 07 said to always take it with with
tempfile.TemporaryDirectory() the path, as a string deletes the whole directory
sqlite3.connect(...) the connection commits on success, rolls back on an exception, and does not close the connection
contextlib.suppress(FileNotFoundError) None returns True for the named classes, swallowing them
Key
Session 05's generator, cut at the yield
@contextlib.contextmanager turns a generator function into a context manager: everything before the yield is __enter__, the yielded value is what as binds, and everything after it is __exit__. If the body raises, the exception is thrown into the paused generator at the yield, so wrap the yield in try/finally if the cleanup must happen either way. It's the same suspended frame as in Session 05, with a different caller pulling the strings.
Try this in a REPL

with 42: pass gives TypeError: 'int' object does not support the context manager protocol, raised before the body ever runs. Then with open("x.txt", "w") as f: pass followed by f.closed: the name survived the block and reports True.

Chaining

One error causes another

In plain words Sometimes the person who receives the card can't fix the problem, but can describe it better. "The file is corrupt" is more useful to the caller than "invalid literal for int()". Python lets you raise the better card while keeping the original stapled to it, and the traceback shows both.

Any exception raised while another is being handled gets __context__ set to the one being handled, automatically. raise New from old also sets __cause__ and __suppress_context__ = True. The printer uses those attributes to choose its sentence, and from None hides the first exception from the output while leaving it reachable through __context__.

Inside an except block Attributes on the new exception The traceback says
raise RuntimeError("bad input") __context__ → the original During handling of the above exception, another exception occurred:
raise RuntimeError("bad input") from err __cause__ and __context__ → the original The above exception was the direct cause of the following exception:
raise RuntimeError("bad input") from None __context__ → the original, but suppressed only the new exception is printed
raise (bare) the same object, unchanged the original traceback, continued from here
Care
raise, not raise err, to re-raise
Both continue the same exception, but raise err adds a fresh traceback entry for the line it is on, so the same function appears twice in the printed traceback, once for the original failure and once for the re-raise. A bare raise continues the traceback where it was.
What you should be able to draw

A stack of frames with one drawn red, an exception instance on the heap whose __traceback__ lists every frame it has cleared, a handler frame that keeps its desk, and, for chaining, a second exception whose __cause__ arrow points back at the first. If you can draw that, every traceback you meet is a picture you have already seen.

Common mistakes

Six ways this goes wrong

Catching too much

except Exception: pass turns every bug in the block (a typo, a None where a list should be, a missing key) into silence. Catch the class you expect, at the place you can actually recover. If you only want to log and move on, log the traceback (logging.exception(...)) so the bug still leaves a trail.

A bare except:

It catches KeyboardInterrupt and SystemExit as well, so Ctrl-C stops working and sys.exit() gets swallowed. The widest net that still lets the program be stopped is except Exception.

Using err after the block

except ValueError as err: then print(err) two lines later gives NameError: name 'err' is not defined. Python deleted it to break the cycle between the frame and the traceback. Bind it to another name inside the block if you need it afterwards.

return inside finally

It overrides the pending return value and discards any exception in flight. The failure isn't an error you see. It's an error you never see. Python 3.14 emits a SyntaxWarning for it. Put the return after the try statement instead.

Opening a file without with

data = open(path).read() works on CPython because the file object is freed, and therefore closed, the moment its reference count hits zero. Keep the object alive by accident, or run under PyPy, and the handle stays open. with open(path) as f: closes it on a line you can point at.

Several classes without the parentheses

except ValueError, TypeError: is a SyntaxError in every Python up to 3.13 (multiple exception types must be parenthesized). Write the tuple: except (ValueError, TypeError) as err:. Python 3.14 finally allows the bare form when there's no as, but the tuple works everywhere.

Check
Two questions catch nearly all of it
Which frame should deal with this? That decides where the try goes, and it's usually further up than the line that fails. What must happen on the way out, even if it fails? That's a finally, or better, a with.
Check your understanding

Can you predict the memory?

Every question comes back to the same two: which frame is the exception in, and what runs on the way out?

1. What is the first thing raise ValueError("bad data") does?
The expression after raise is evaluated first, like any other expression. Only once the instance exists does the interpreter attach a traceback entry and start looking for a handler, one frame at a time.
2. An exception reaches the module level and no try covers it. What happens?
An exception that runs out of frames ends the program. The traceback you see is the __traceback__ chain, one entry per frame it cleared, outermost first.
3. {}["x"] raises a KeyError inside a try whose only clause is except LookupError:. Is it caught?
Exceptions are classes with a family tree. A clause catches the class it names and every subclass. That's also why except Exception catches nearly everything, and why a bare except: catches too much.
4. After an except ValueError as err: block ends, you write print(err). What happens?
The exception holds the traceback, the traceback holds the frames, and this frame would hold err. To stop that loop keeping every local alive, the name is unbound for you. Copy it to another name if you need it.
5. Inside a try that has a finally, the body executes return f.read(). In what order do things happen?
finally runs on every way out of the try, and return is one of them. The return value is evaluated first, held, and delivered after the cleanup, unless the finally block returns something of its own.
6. The body of a with block raises. The manager's __exit__ runs and returns False. What happens next?
__exit__ receives the exception and votes: a true return value swallows it, a false one (including the None a bare function returns) lets it carry on. Either way the cleanup ran, and that's the point of with.
7. Inside an except ValueError as err: block you write raise RuntimeError("bad input") from err. What does the printed traceback show?
from err sets __cause__ on the new exception, which keeps the old one alive and tells the printer how the two are related. Without from, __context__ is still set and the sentence becomes During handling of the above exception, another exception occurred.