Python Try Except and Context Managers: Error Handling Guide
Catch specific exceptions, keep the original cause with raise from, and let context managers handle cleanup. A practical guide to Python error handling.
A payment job ran every night for three weeks and reported success each time. It had processed nothing. A handler written as except Exception: pass swallowed a KeyError caused by a renamed field, so every record failed silently. The fix took five minutes, and finding it took three weeks.
A Python try except statement runs a block of code and, if that code raises an exception, hands control to a matching handler. An exception is an object that signals an error and carries a message and a traceback. A context manager is an object used with the with statement that runs setup and cleanup code around a block.
This guide uses Python 3.10 and only the standard library. Run python --version to confirm your interpreter. You should be comfortable writing functions and know that a function without a return hands back None.
My position: most error handling code should not exist. Every except clause is a claim that you know what went wrong and what to do about it. When that claim is false, the handler does more damage than the error.
How Python try except works
The full statement has four clauses. Only try and one of except or finally are required.
try:
risky() runs first
except ValueError:
handle() runs only if the try block raised ValueError
else:
on_success() runs only if the try block raised nothing
finally:
cleanup() always runs, on success, failure, or return
| What happened in try | except | else | finally | Afterwards |
|---|---|---|---|---|
| No exception | Skipped | Runs | Runs | Execution continues |
| A matching exception | Runs | Skipped | Runs | Execution continues |
| A non-matching exception | Skipped | Skipped | Runs | Exception propagates to the caller |
Here is a small example that uses every clause.
def parse_port(text: str) -> int | None:
try:
port = int(text)
except ValueError:
print(f'not a number: {text!r}')
return None
else:
print(f'parsed {port}')
return port
finally:
print('done')
parse_port('8080')
parse_port('http')
parsed 8080
done
not a number: 'http'
done
The else clause looks optional, yet it earns its place. Code inside try is protected by the handler, and code in else is not. Consequently, keeping the try block to the one line that can fail prevents the handler from catching an unrelated error by accident.
Catch specific exceptions, not everything
Exceptions form a class hierarchy, and an except clause catches the named class and all its subclasses. The top of the tree matters most.
BaseException
SystemExit raised by sys.exit()
KeyboardInterrupt raised by Ctrl+C
Exception the base for ordinary errors
ArithmeticError
ZeroDivisionError
LookupError
KeyError
IndexError
OSError
FileNotFoundError
PermissionError
ValueError
TypeError
A bare except: catches BaseException, including KeyboardInterrupt and SystemExit. The result is a program that ignores Ctrl+C.
# WRONG: hides every error, including typos and Ctrl+C
def read_age(record: dict) -> int:
try:
return int(record['agee'])
except:
return 0
# RIGHT: handles the one failure you expect, and lets bugs surface
def read_age(record: dict) -> int:
try:
return int(record['age'])
except ValueError:
return 0
In the wrong version, the misspelled key 'agee' raises KeyError on every call, and the function returns 0 forever. In the right version, the same typo crashes on the first run with a traceback that points at the line. A crash during development is cheap, and wrong data in production is not.
To handle several types the same way, use a tuple: except (KeyError, IndexError):. To handle them differently, write several except clauses, ordered from most specific to most general, because Python uses the first match.
Ask forgiveness, not permission
Python code usually attempts the operation and handles failure, a style known as EAFP: “easier to ask forgiveness than permission”. The alternative, checking first, is called LBYL: “look before you leap”.
from pathlib import Path
path = Path('settings.json')
# LBYL: the file can disappear between the check and the read
if path.exists():
text = path.read_text(encoding='utf-8')
# EAFP: one step, no gap
try:
text = path.read_text(encoding='utf-8')
except FileNotFoundError:
text = '{}'
The check-first version has a race condition: another process can delete the file after exists() returns True. In practice, EAFP is the safer choice for anything outside your process, such as files, networks, and databases.
Raise, re-raise, and chain with raise from
You signal an error with raise. Inside a handler, you have three options, and each one tells a different story in the traceback.
| Statement | Effect | Use it when |
|---|---|---|
raise |
Re-raises the current exception unchanged | You logged or cleaned up, and the caller must still see the error |
raise NewError(...) from error |
Raises a new exception and records the original as its cause | You translate a low-level error into one your callers understand |
raise NewError(...) from None |
Raises a new exception and hides the original | The original is noise or would leak internal details |
Chaining matters because it preserves evidence. With from error, the traceback prints both exceptions, joined by this line:
The above exception was the direct cause of the following exception:
If you raise a new exception inside a handler without from, Python still links them, but with a different message: During handling of the above exception, another exception occurred. That wording suggests your handler itself failed. Therefore, write from error whenever the translation is deliberate.
Define custom exceptions
A custom exception is a class that inherits from Exception. It gives callers something precise to catch. If classes are unfamiliar, read about writing a regular class first.
class ConfigError(Exception):
"""Raised when the configuration cannot be used."""
class MissingKeyError(ConfigError):
"""Raised when a required key is absent."""
Give each library or application one base exception and derive the rest from it. Callers can then catch the base to handle anything from your code, or a subclass to handle one case. Keep the hierarchy shallow: three or four classes serve most projects.
Context managers: cleanup you cannot forget
Resources such as files, locks, and database connections must be released, even when an error interrupts your code. You could write try and finally every time. A context manager packages that pattern so you cannot forget it.
# WRONG: if write() raises, the file is never closed
handle = open('report.txt', 'w', encoding='utf-8')
handle.write(build_report())
handle.close()
# RIGHT: the file closes when the block ends, however it ends
with open('report.txt', 'w', encoding='utf-8') as handle:
handle.write(build_report())
The with statement calls the object’s __enter__ method at the start and its __exit__ method at the end. Python calls __exit__ on normal completion, on an exception, and on return.
Write your own with contextlib
The simplest way to build a context manager is the @contextmanager decorator. You write a generator function with a single yield. Code before the yield is setup, and code after it is cleanup.
import os
from contextlib import contextmanager
from pathlib import Path
@contextmanager
def working_directory(path: Path):
previous = Path.cwd()
os.chdir(path)
try:
yield
finally:
os.chdir(previous)
The try and finally around yield are essential. Without them, an exception inside the with block skips the cleanup, which defeats the purpose.
The contextlib module includes other ready-made helpers. suppress replaces a try block whose handler is only pass, and it states the intent openly:
from contextlib import suppress
from pathlib import Path
with suppress(FileNotFoundError):
Path('cache.tmp').unlink()
For a variable number of resources, contextlib.ExitStack lets you register context managers in a loop and closes them all in reverse order.
A complete example: load a config file
This script combines a custom exception, raise ... from, a context manager, and a single handler at the top level. Save it as load_config.py.
import json
import sys
import time
from contextlib import contextmanager
from pathlib import Path
class ConfigError(Exception):
"""Raised when the configuration file cannot be used."""
@contextmanager
def timed(label: str):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f'{label} took {elapsed:.4f}s')
def load_config(path: Path) -> dict:
try:
text = path.read_text(encoding='utf-8')
except FileNotFoundError as error:
raise ConfigError(f'config file not found: {path}') from error
try:
config = json.loads(text)
except json.JSONDecodeError as error:
raise ConfigError(f'invalid JSON in {path}: {error}') from error
if 'port' not in config:
raise ConfigError(f"missing required key 'port' in {path}")
return config
def main() -> int:
path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('config.json')
try:
with timed('load'):
config = load_config(path)
except ConfigError as error:
print(f'error: {error}', file=sys.stderr)
return 1
print(f"listening on port {config['port']}")
return 0
if __name__ == '__main__':
raise SystemExit(main())
Run it in a folder without a config file:
python load_config.py
load took 0.0001s
error: config file not found: config.json
Now create config.json containing {"port": 8080} and run it again:
load took 0.0002s
listening on port 8080
Notice the structure. load_config translates two low-level errors into one domain error, and it lets everything else propagate. Only main decides what the user sees and which exit code the process returns. The timer prints in both runs, because its cleanup sits in finally.
The myth: try blocks are slow
A common belief says that exceptions are expensive, so you should check conditions first. The truth has two halves. Entering a try block costs almost nothing. Raising and catching an exception costs noticeably more than an if.
You can measure both halves. These are my own illustrative tests, and your numbers will differ. The first pair looks up a key that exists:
python -m timeit -s "d = {'a': 1}" "try:" " v = d['a']" "except KeyError:" " v = 0"
python -m timeit -s "d = {'a': 1}" "v = d['a'] if 'a' in d else 0"
The second pair looks up a key that is missing:
python -m timeit -s "d = {'a': 1}" "try:" " v = d['b']" "except KeyError:" " v = 0"
python -m timeit -s "d = {'a': 1}" "v = d['b'] if 'b' in d else 0"
On my machine, the try version wins when the key exists, because it does one lookup instead of two. It loses by a wide margin when the key is missing, because building and unwinding an exception is real work.
The rule that follows: use try when failure is rare, and a check (or dict.get) when “missing” is a normal, frequent outcome. Either way, the difference is a fraction of a microsecond, so correctness and clarity should decide first.
Where to catch: the three legitimate reasons
Here is the heuristic I use in code review. A handler is justified only if it does one of three things.
- Recover. You can produce a correct result anyway: retry, use a default, or skip one record.
- Translate. You convert a low-level error into one that means something to your caller, with
raise ... from. - Record and re-raise. You add context to a log, then use a bare
raiseso the error continues.
If a handler does none of these, delete it. An exception that travels up to a single top-level handler produces one clear log entry. The same exception caught and logged at four levels produces four entries and four chances to lose the traceback.
The finally trap
One non-obvious behavior deserves a warning. A return inside finally silently discards any exception in flight.
def risky() -> str:
try:
raise ValueError('lost forever')
finally:
return 'ok'
print(risky()) # prints ok, and the ValueError vanishes
The function returns normally, and no traceback appears. Never use return, break, or continue inside a finally block. If you need different return values and None handling, put the return after the whole statement.
How real systems handle errors
- One boundary handler per entry point. A web framework catches unhandled exceptions per request, logs the traceback, and returns a 500 response. A command-line tool does the same in
main()and returns a non-zero exit code. - Domain exceptions at layer edges. A data access layer converts driver errors into its own exception types. Callers never import the database driver just to catch its errors.
- Per-item isolation in batch jobs. A loop over 10,000 records catches the expected failure for one record, logs it with the record ID, counts it, and continues. Afterwards, the job fails loudly if the error rate crosses a threshold.
- Context managers for every resource. Files, locks, transactions, and temporary directories all use
with. Code review treats a manualclose()call as a defect. - Logging with the traceback. Inside a handler,
logging.exception('message')records the full traceback. A plainprint(error)records one line and discards the rest.
We once hit a bug when a retry wrapper caught Exception around a call that sent emails. A TypeError from a bad template argument was retried five times with backoff, for every message in the queue. The queue backed up for hours, and the real error appeared only at debug log level. Narrowing the handler to connection and timeout errors made the template bug fail on the first message.
Handling an error: a decision framework
When a line of code can fail, ask these questions in order.
- Is the failure a bug in your code? For
TypeError,AttributeError, orNameError, do not catch it. Fix the code. - Can this function recover correctly right here? If so, catch the specific exception and recover.
- Does the caller need a clearer error? Catch the low-level exception and raise a domain exception with
from error. - Must something be released either way? Use a
withstatement, orfinallyif no context manager exists. - None of the above? Write no handler, and let the exception reach the boundary.
When NOT to catch an exception
- When it signals a programming error. Catching
TypeErrororAttributeErrorto “be safe” turns a visible crash into silent wrong behavior. - When you have nothing useful to do. A handler that only prints and continues leaves the program in an unknown state. Later code then fails somewhere unrelated.
- When absence is a normal answer. For a lookup that often misses,
dict.get(key)or a function that returnsNoneis clearer and cheaper than raising and catching on every call.
Common mistakes
- Writing a bare except. It catches
KeyboardInterruptandSystemExit, so the program cannot be stopped cleanly. Useexcept Exceptionat most, and only at a boundary. - Swallowing with pass.
except Exception: passerases the only evidence of a failure. Jobs report success while doing nothing. - Wrapping too much code in one try. A twenty-line block can raise the same exception type from five places. The handler then treats four unexpected failures like the one you planned for.
- Raising a new exception without from. Writing
raise ConfigError(str(error))flattens the original to a string. You lose its type and its traceback location. - Returning inside finally. The return value replaces any exception in flight. Errors disappear without a trace.
- Closing resources by hand. A
close()call after the work is skipped when the work raises. File handles and connections leak until the process hits its limit.
Key takeaways
- Catch the most specific exception that you can actually handle.
- Keep the
tryblock to the line that can fail, and put follow-up code inelse. - Translate low-level errors with
raise NewError(...) from errorto keep the cause. - Use
withfor every file, lock, and connection. - Build simple context managers with
@contextmanager, and wrap theyieldintryandfinally. - A handler must recover, translate, or record and re-raise. Otherwise, remove it.
- Handle unexpected errors once, at the boundary of the program.
FAQ
How does try except work in Python?
Python runs the try block. If it raises an exception that matches an except clause, that handler runs. The optional else runs when nothing was raised, and finally always runs.
What is the difference between except and finally?
except runs only when a matching exception occurs, and it handles the error. finally runs every time, with or without an error, and it is meant for cleanup.
Why is a bare except bad in Python?
A bare except: catches everything, including KeyboardInterrupt and SystemExit. It hides bugs such as typos and makes the program hard to stop.
What does raise from do in Python?
raise NewError() from original raises a new exception and stores the original as its cause. The traceback then shows both, so you keep the root cause while giving callers a clearer error.
What is a context manager in Python?
It is an object used with the with statement that runs setup code on entry and cleanup code on exit. The cleanup runs even if the block raises an exception.
Catch less, clean up always
Good error handling is mostly restraint. Let unexpected failures crash loudly at one boundary, translate the few errors your callers care about, and hand cleanup to with. The code gets shorter, and the failures get easier to find.
Rule of thumb: if you cannot say what the handler fixes, the handler is the bug.
