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.
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.
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 traceback | Example from above | What it tells you |
|---|---|---|
| File and line number | File "app.py", line 5 | Which file and which line was executing |
| Function name | in greet | Which function that line lives in (<module> means top level) |
| Source line | return "Hi " + get_name(user) | The code that was running, so you rarely need to open the file just to see it |
| Last line | KeyError: '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.
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))
KeyError: 'name'
<module> -> greet -> get_nameThe 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.
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.
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.
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.
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')
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.
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.
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__)
RuntimeError config broken ValueError invalid literal for int() with base 10: 'abc'
| What you see | What it means | Where to look |
|---|---|---|
| SyntaxError, no frames for your functions | Python could not compile the file, so nothing ran | The reported line and the one above it |
| One block, ends with an exception | A single error stopped the program | The bottom-most frame in your own file |
| Two blocks joined by "During handling..." | A second error occurred while handling the first | Both blocks; the first holds the original cause |
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.
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.
| Exception | Typical trigger | Parent |
|---|---|---|
| ValueError | int('abc') | Exception |
| TypeError | 1 + 'a' | Exception |
| KeyError | d['missing'] | LookupError |
| IndexError | items[99] | LookupError |
| AttributeError | None.upper() | Exception |
| ZeroDivisionError | 1 / 0 | ArithmeticError |
| FileNotFoundError | open('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.
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"))
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'.
| ValueError | TypeError | |
|---|---|---|
| Meaning | Right type, bad value | Wrong type for the operation |
| Example | int('abc') | 1 + 'a' |
| Fix by | Validating or cleaning the value | Converting or choosing the right type |
import math attempt("int('abc')", lambda: int("abc")) attempt("sqrt(-1)", lambda: math.sqrt(-1)) attempt("1 + 'a'", lambda: 1 + "a")
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.
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.
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.
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.
no such key no such position
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.
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))
ModuleNotFoundError | not_a_real_module
ImportError
TrueReading 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.
for cls in (KeyError, FileNotFoundError, ModuleNotFoundError): names = [c.__name__ for c in cls.__mro__] print(" -> ".join(names))
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.
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()
1 exhausted 1 closing
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.
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
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.
| No exception | Exception raised | |
|---|---|---|
| Rest of the try body | Runs to the end | Skipped from the failing line onward |
| except block | Skipped | Runs |
| Code after the try statement | Runs | Runs, because the error was handled |
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.
try: int("abc") except ValueError as e: print(str(e)) print(repr(e))
invalid literal for int() with base 10: 'abc' ValueError("invalid literal for int() with base 10: 'abc'")
| Call | Shows | Good for |
|---|---|---|
str(e) | Only the message | Text a user may read |
repr(e) | Exception type and message | Logs 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.
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__)
NameError: name 'e' is not defined invalid literal for int() with base 10: 'abc' ValueError
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.
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
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.
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
caught further up: invalid literal for int() with base 10: 'abc'
- 1parseint(text) raises ValueError
- 2loadhandler lists only KeyError, so it passes
- 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.
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"])a size was bad - but which one? height is bad: tall
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:
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.
| Situation | Result |
|---|---|
| Error type is listed in except | Handler runs, program continues |
| Error type is not listed | Exception propagates to the caller |
| No caller handles it | Program ends and the traceback is printed |
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.
- 1Exception raisedinside the try block
- 2Check clause 1does the exception match its type?
- 3Check clause 2only reached if clause 1 did not match
- 4First match runsall later clauses are skipped
- 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.
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
42
not a number
wrong typeSubclasses 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.
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
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.
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.
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
bad input: ValueError bad input: TypeError
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.
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
only the bare except caught itexcept 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.
| Clause | What it catches | Ctrl+C and sys.exit() | Verdict |
|---|---|---|---|
except: | Everything, including BaseException subclasses | Swallowed, so the program cannot be stopped | Avoid |
except Exception: | Nearly every ordinary error | Pass through and still work | Last-resort safety net, and log what you catch |
except ValueError: | Only that type and its subclasses | Pass through and still work | Best 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.
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
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.
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
35If 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.
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
WARNING: could not parse age 'abc': invalid literal for int() with base 10: 'abc' None
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.
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
ValueError group: ['bad id', 'bad date'] TypeError group: ['bad type']
| except | except* | |
|---|---|---|
| Handles | One exception at a time | An ExceptionGroup, split by type |
| Clauses run | Only the first match | Every clause that finds members |
The as name holds | The exception itself | An ExceptionGroup of the matching members |
| Python version | All versions | 3.11 and newer |
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.
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.
- 1trycode that might fail
- 2excepthandle a failure
- 3elseonly on success
- 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.
def parse_age(text): try: age = int(text) except ValueError: print('not a number') else: print('parsed', age) parse_age('42') parse_age('abc')
parsed 42
not a numberWhy 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.
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')
2.5
bad input
ZeroDivisionError escapedStuffing 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.
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')
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.
def early(): try: return 'from try' finally: print('cleanup runs first') print(early())
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.
for i in range(3): try: if i == 1: continue if i == 2: break print('body', i) finally: print('finally', i)
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.
def swallow(): try: raise RuntimeError('lost') finally: return 'finally wins' print(swallow())
finally winsPutting 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.
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
closed
{'debug': True}
no such file
{}
closed
bad json propagatedCode 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.
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')
finally after block 7 finally propagated
| Code after the try block | finally clause | |
|---|---|---|
| Runs after a clean finish | Yes | Yes |
| Runs after a handled error | Yes, the program carries on | Yes |
| Runs while an error propagates | No, it is skipped | Yes |
| Runs after return, break or continue in try | No | Yes |
| Best used for | Normal follow-up work | Cleanup that must not be missed |
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.
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
ValueError age must be positive
- 1BuildValueError('...') makes an instance
- 2Throwraise stops the function right here
- 3UnwindPython leaves each calling function in turn
- 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.
try: raise ValueError except ValueError as e: print(repr(e)) print(e.args)
No parentheses: the class is called for you
ValueError() ()
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.
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
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.
| Situation | Raise | Example |
|---|---|---|
| The right type, but the value is unacceptable | ValueError | raise ValueError('age must be positive') |
| The wrong type was passed in | TypeError | raise TypeError('name must be a str') |
| A base-class method that subclasses must override | NotImplementedError | raise NotImplementedError('implement area()') |
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.
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
['frames', 'with_bare', 'parse'] ['frames', 'with_name', 'with_name', 'parse']
| raise e | bare raise | |
|---|---|---|
| Does the error propagate? | Yes | Yes |
| Traceback | Gets an extra entry for the raise e line | Stays exactly as it was |
| Where it works | Anywhere you hold the exception | Only inside an except block |
| Verdict | Works, but adds noise | Preferred 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.
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
AssertionError: nums must not be empty
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.
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
caught only by the catch-all: error forgotten check: unsupported operand type(s) for +: 'NoneType' and 'int'
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.
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.
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:
built into Python
your base class
subclass of Exception
subclass of AppError
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.
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
new balance: 70
InsufficientFundsError - not enough money
AccountClosedError - account is closedThe 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:
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
''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.
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
balance 50 is less than the 80 needed short by 30
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.
| Name | Verdict | Why |
|---|---|---|
| InsufficientFundsError | Good | Describes the problem and ends in Error |
| AccountClosedError | Good | The problem is clear without reading the code |
| WithdrawError | Weak | Names the location, not what went wrong |
| InsufficientFunds | Avoid | Missing 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 ValueError | Custom exception | |
|---|---|---|
| Catching | except ValueError also catches unrelated bad values | except InsufficientFundsError catches exactly this problem |
| Extra data | Only a message string | Attributes such as balance and needed |
| Whole library | No shared parent to catch | One except AppError covers everything |
| Best when | Callers treat all bad input the same | Callers 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.
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.
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.
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.
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
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.
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
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 raised | Attribute set | Line printed between the tracebacks |
|---|---|---|
| raise New from original | cause (and context) | The above exception was the direct cause of the following exception: |
| raise New inside an except block, no from | context only | During handling of the above exception, another exception occurred: |
| raise New from None | neither shown | Nothing: only the new exception is printed |
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.
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
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.
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
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.
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
cause: None hidden: True
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.
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.
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.
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
caught
TrueBehind 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.
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.
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")
enter ok
working
exit ok, error: None
enter bad
exit bad, error: KeyError
still raisedNotice 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.
Suppression should be narrow. The next manager inspects exc_type and only swallows ZeroDivisionError, so any other problem still reaches the caller.
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)
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'
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.
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")
open db using DB close db open cache close cache handled
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.
@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
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.
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())
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/finally | with statement | |
|---|---|---|
| Lines per use | Setup, try, finally, cleanup call | One line plus the block |
| Cleanup written | At every call site | Once, inside the manager |
| Easy to forget | Yes, a missing finally leaks the resource | No, you cannot enter without exiting |
| Several resources | Nested try/finally blocks | One line: with open(a) as f, open(b) as g: |
| Reuse | Copy and paste | Import 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.
| Resource | Typical form | What the exit does |
|---|---|---|
| Files | with open(path) as f: | Closes the file |
| Locks | with lock: (threading.Lock) | Releases the lock |
| Database transactions | with conn: (sqlite3) | Commits on success, rolls back on an exception |
| Temporary directories | with tempfile.TemporaryDirectory() as d: | Deletes the directory and its contents |
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.
| LBYL | EAFP | |
|---|---|---|
| Tool | if checks before acting | try / except around the action |
| Mindset | Make sure it will work | Assume it works, handle the exception |
| Failure shows up as | A false condition | An exception |
| Typical Python feel | Common in C and Java | The 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.
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)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.
- 1os.path.exists(p)returns True
- 2Someone deletes panother process, thread or user
- 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.
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))
exists() said yes, open() still failed
read gave ''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.
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))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.
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.
| Situation | Prefer | Why |
|---|---|---|
| Failure is rare | EAFP | No cost on the happy path |
| Failure is common, hot loop | LBYL | A plain check beats raising repeatedly |
| Outside code can change things | EAFP | A check can go stale |
| Check is cheap and obvious | LBYL | Reads clearly |
| Action cannot be undone | LBYL | Validate before the point of no return |
| A default value is enough | get / getattr | Shortest 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.
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)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.
# 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
Root
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.
| Level | Number | Use it for |
|---|---|---|
DEBUG | 10 | Detail useful only while hunting a bug |
INFO | 20 | Normal events: started, finished, loaded 40 rows |
WARNING | 30 | Something odd happened, but the program carried on |
ERROR | 40 | An operation failed |
CRITICAL | 50 | The 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.
| logging | ||
|---|---|---|
| Severity | None, every line looks the same | Five levels you can filter by |
| Timestamp | Only if you add it by hand | One format setting adds it everywhere |
| Destination | Standard output only | Console, files, rotating files, the network |
| Switching it off | Delete or comment out the calls | Raise the level, leave the calls in place |
| Tracebacks | You must format them yourself | exc_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.
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')
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.
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).
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.
exception -> ERROR:ratio failed | ZeroDivisionError: division by zero
exc_info -> WARNING:ratio failed, using 0 | ZeroDivisionError: division by zeroIn 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.
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.
# 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.
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())
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.
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.
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.
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.
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.
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.
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
0Catch 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.
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.
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
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.
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
not a number
25.0
not a numberIn 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:
try: open('missing.txt') except OSError: print('OSError caught') except FileNotFoundError: print('never reached')
The specific handler is dead code
OSError caught
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.
def run(): try: raise ValueError('boom') finally: return 'done' print(run())
The ValueError vanishes without a trace
done
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.
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
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.
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__
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.
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
AssertionError: age must be non-negative ValueError: age must be non-negative
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
| Tool | What it does | Typical use |
|---|---|---|
try / except | Runs the handler if the matching error is raised | Recover from a specific failure |
else | Runs only if try raised nothing | Code that depends on the guarded line succeeding |
finally | Always runs, error or not | Cleanup that must happen |
raise | Signals an error, or re-raises the current one when bare | Report bad state; pass an error up after logging |
raise ... from exc | Raises a new error and keeps the old one as the cause | Translate a low-level error into your own |
with | Runs setup and guaranteed cleanup around a block | Files, locks, connections |
logger.exception(msg) | Logs at ERROR level with the full traceback | Inside an except block, to leave a record |
Run order
- 1tryruns until something raises
- 2exceptonly if a matching error was raised
- 3elseonly if nothing was raised
- 4finallyalways, last
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
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.
| EAFP | LBYL | |
|---|---|---|
| Style | Try it, catch the exception | Test the condition, then act |
| Best when | Failures are rare | The check is cheap and misses are common |
| Example | try: value = data[key] | if key in data: |
| Risk | Too broad a try hides bugs | State can change between check and use |
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.
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
ConfigError missing key
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. finallyalways runs, even when thetrybody has already hit areturn.- A
returninsidefinallyreplaces 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. KeyErroris a subclass ofLookupError, and the first matching clause wins.- The
except KeyErrorclause 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, becauseeis deleted when theexceptblock ends. - Copy it inside the block, for example
err = e, and useerrafterwards. - The
passalso 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?
aprintsThe above exception was the direct cause of the following exception, and sets__cause__.bprintsDuring handling of the above exception, another exception occurred, and only sets__context__.- Both keep the original
KeyError. Usefrom ewhen 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
trybody small, and put subclasses before parents; never use a bareexcept:. - Use
elsefor code that depends on success andfinallyfor cleanup, and neverreturnfromfinally. - 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 eto translate errors, bareraiseto re-raise, andwithto guarantee cleanup. - Prefer EAFP when failures are rare and LBYL for cheap checks or frequent misses; log with
logger.exceptioninstead of swallowing errors withpass.