Error Handling Best Practices
Good error handling is a discipline, not an afterthought: catch what you can actually handle, never hide a failure silently, and always clean up resources regardless of what went wrong.
Python gives you powerful exception-handling tools, but tools can be misused. This topic collects the practical rules that separate error handling that makes a codebase more reliable from error handling that quietly makes bugs harder to find: catching specific exceptions, never swallowing errors silently, preserving causes when translating exceptions, and using finally/context managers for guaranteed cleanup.
Catch Specific Exceptions, Not Everything
Catch the narrowest exception type that you can actually recover from. A broad except Exception: (or worse, bare except:) catches bugs you didn't anticipate — a typo causing an AttributeError, a logic error causing an IndexError — right alongside the specific failure you meant to handle, hiding real problems as if they were expected.
If fetch_and_parse() has a bug that raises AttributeError, the broad version silently returns {} and hides the bug; the specific version lets that AttributeError propagate so it gets noticed and fixed.
# Too broad — hides unrelated bugs
try:
data = fetch_and_parse(url)
except Exception:
data = {}
# Specific — only handles the failure you actually anticipated
try:
data = fetch_and_parse(url)
except (ConnectionError, TimeoutError):
data = {}Avoid Bare except:
A bare except: (no exception type at all) catches everything, including KeyboardInterrupt and SystemExit, which are BaseException subclasses deliberately excluded from except Exception:. Catching them by accident can make a program impossible to interrupt with Ctrl-C or prevent sys.exit() from actually exiting.
except Exception: still excludes BaseException-only subclasses like KeyboardInterrupt and SystemExit, so Ctrl-C and sys.exit() keep working as expected.
# Dangerous: catches KeyboardInterrupt and SystemExit too
try:
run_forever()
except:
pass
# Correct: only catches ordinary application exceptions
try:
run_forever()
except Exception:
log.exception("unexpected error")Don't Silently Swallow Errors
An empty except: pass (or one that only does something invisible) is one of the most damaging anti-patterns in error handling — it makes failures disappear without a trace, so the program appears to work while quietly doing the wrong thing, and there is no log, no message, nothing to debug from later.
If you truly intend to ignore a specific, expected failure, do so explicitly and narrowly (contextlib.suppress is designed for exactly this) — never as a catch-all that could be masking anything.
# Silent failure — the bug vanishes without a trace
try:
save_to_database(record)
except Exception:
pass
# At minimum, log what happened
try:
save_to_database(record)
except DatabaseError:
log.exception("failed to save record %r", record.id)
raise # or handle it meaningfully, but never silently discardPreserve Causes When Translating Exceptions
When you catch a low-level exception and raise a higher-level, more meaningful one, always chain with raise NewError(...) from original_exc (see the Raising Exceptions topic). Losing the original exception makes debugging production failures dramatically harder, because the traceback no longer shows what actually went wrong underneath.
The caller sees a clear, domain-specific DatabaseUnavailableError, but the underlying OSError (e.g. connection refused, DNS failure) remains visible in the chained traceback.
try:
conn = connect_to_db(dsn)
except OSError as exc:
raise DatabaseUnavailableError("cannot reach database") from excCleanup with finally and Context Managers
Resources (files, locks, network connections, database transactions) must be released whether or not an error occurred. finally runs regardless of whether the try block succeeded, raised, or even returned/broke out early; a with statement using a context manager does the same thing more concisely for anything that supports the protocol, and is generally preferred.
Both guarantee lock.release() runs even if do_work() raises; the with form is shorter and less error-prone (impossible to forget the release call), so prefer it whenever the resource supports the protocol.
See the dedicated Context Managers and contextlib topics for the full protocol and standard-library helpers like ExitStack for managing multiple resources at once.
# finally — explicit, works for any cleanup
lock.acquire()
try:
do_work()
finally:
lock.release()
# with — preferred when a context manager is available
with lock:
do_work()Use Exceptions for Exceptional Conditions
Exceptions are for conditions that are genuinely unexpected or that interrupt the normal flow — a missing file, invalid input, a failed network call. They are not a general substitute for ordinary control flow like loop termination or expected 'not found' results in a hot path. Overusing exceptions for routine logic makes code harder to follow and, in CPython, can carry real performance overhead when triggered very frequently.
dict.get() already expresses 'return None if missing' directly — reaching for try/except here adds ceremony without benefit. Exceptions are the right tool when failure is genuinely unusual, not for routine lookups with a built-in expected-absence path.
That said, Python's iterator protocol itself relies on StopIteration as an expected, routine control-flow signal — 'exceptional' doesn't mean 'rare in the language's own machinery', it means 'not the normal path for the specific code you're writing'.
# Overusing exceptions for ordinary logic
def get_or_none(d: dict, key):
try:
return d[key]
except KeyError:
return None
# Simpler and clearer for this specific case
def get_or_none(d: dict, key):
return d.get(key)Handle Exceptions at the Right Level
Catch an exception where you can actually do something meaningful about it — retry, fall back to a default, present a useful message — not just anywhere convenient. Catching too early (deep inside a helper function) often means you don't yet have enough context to decide how to respond; letting it propagate to a caller that does have that context is usually better.
fetch_user() doesn't catch anything — it has no meaningful recovery action. display_profile() does, because it can decide what the user actually sees.
def fetch_user(user_id: int) -> dict:
# Let network errors propagate -- this function has no way to recover
return api_client.get(f"/users/{user_id}")
def display_profile(user_id: int) -> None:
try:
user = fetch_user(user_id)
except ConnectionError:
show_message("Could not load profile, please check your connection.")
return
render(user)Logging vs Re-raising vs Handling
Three distinct responses to a caught exception should not be confused: (1) fully handle it and continue normally, (2) log it for observability and re-raise (or raise a translated exception) because the caller still needs to know, (3) log it and suppress it because it's truly recoverable and nothing further needs to happen. Pick deliberately — don't default to whichever is easiest to type.
Each branch documents, in code, exactly what category of response was chosen and why — future readers don't have to guess whether swallowing an error was intentional.
try:
result = risky_operation()
except RecoverableError:
result = fallback_value() # (1) handled, continue
except CriticalError as exc:
log.error("critical failure", exc_info=exc)
raise # (2) log + propagate
except KnownBenignError:
log.debug("benign, ignoring") # (3) log + suppress deliberatelyKey Takeaways
Catch the narrowest exception type you can actually recover from — broad except clauses hide unrelated bugs.
Never use bare except:; it also catches KeyboardInterrupt and SystemExit, which almost never should be swallowed.
Never silently discard an exception (except: pass) — at minimum log it, or suppress it explicitly and narrowly with contextlib.suppress.
Chain translated exceptions with raise ... from ... so the original cause remains visible for debugging.
Use finally or, preferably, a with-statement context manager to guarantee cleanup regardless of success or failure.
Reserve exceptions for genuinely exceptional conditions, and handle them at the level of the call stack that has enough context to respond meaningfully.
try: do_thing() except: pass try: do_thing() except SpecificError: log.exception("do_thing failed")
A bare except: pass hides every possible failure (including KeyboardInterrupt/SystemExit) with no record whatsoever — always name the exception type and at least log what happened.
try: connect() except OSError: raise AppError("connection failed") # no 'from', context lost try: connect() except OSError as exc: raise AppError("connection failed") from exc
Raising a new exception without chaining discards easy access to the real underlying cause; always chain with from when translating exceptions.
lock.acquire() do_work() # if this raises, lock is never released lock.release() with lock: do_work()
Cleanup written after the risky call only runs on success; use finally or a context manager so the lock releases even when do_work() raises.
Why is except Exception: generally better than bare except:?
Bare except: also catches BaseException-only subclasses like KeyboardInterrupt and SystemExit, which almost never should be intercepted; except Exception: excludes them while still catching ordinary application errors.
What's wrong with try: risky() except: pass?
It silently discards every possible failure with no record — bugs and real errors disappear without a trace, making the program appear to work while quietly failing, and there's nothing to debug from afterward.
Why should exception translation use raise NewError(...) from original instead of just raise NewError(...)?
Using from explicitly preserves and documents the original exception as the cause in the traceback, which is essential for debugging; omitting it (especially combined with from None) can obscure or discard the real root cause.
Why is with lock: do_work() preferred over manual acquire/release with try/finally?
It's equivalent in guaranteeing release even if do_work() raises, but shorter and impossible to get wrong (you can't forget the release call or put it in the wrong place), since the context manager protocol handles it automatically.
Give an example of using exceptions for something that is NOT genuinely exceptional, and a better alternative.
Using try/except KeyError to implement 'get with default' from a dict is overuse; dict.get(key, default) expresses the same logic directly without exception-handling ceremony, since a missing key isn't an exceptional condition there.
Where in a call stack should an exception typically be caught?
At the level that has enough context to respond meaningfully — retry, show a fallback, present a useful message — not necessarily where it's most convenient to write a try block; a low-level helper function often has no way to recover and should let it propagate.
What are the three distinct responses to a caught exception, and why does it matter to choose deliberately?
(1) handle it and continue, (2) log and re-raise/translate because the caller still needs to know, (3) log and deliberately suppress because it's truly benign. Choosing deliberately (and making the choice visible in code) avoids accidentally swallowing errors that should have propagated.
This works and prints 0 — what's the practical risk of writing it this way regardless?
The bare except: also silently catches KeyboardInterrupt, SystemExit, MemoryError, and any unrelated bug in surrounding code moved into this block later; it should be except ValueError: to catch only the specific, anticipated failure from int().
Rewrite this function to catch only the specific exception, log it, and re-raise a domain-specific exception with the cause preserved: def load(path):\n try:\n return json.load(open(path))\n except Exception:\n return None import json import logging log = logging.getLogger(__name__) class ConfigLoadError(Exception): pass def load(path: str) -> dict: try: with open(path) as f: return json.load(f) except (OSError, json.JSONDecodeError) as exc: log.exception("failed to load config from %s", path) raise ConfigLoadError(f"could not load config: {path}") from exc
Catches only the specific expected failures (file I/O, JSON parsing), uses a context manager for the file, logs the failure, and raises a chained domain-specific exception instead of silently returning None on any error.
Write a function using finally to guarantee a temporary resource counter is decremented even if the work raises.
active_count = 0 def do_tracked_work(fn): global active_count active_count += 1 try: return fn() finally: active_count -= 1 finally guarantees active_count is decremented whether fn() succeeds, raises, or returns early, keeping the counter accurate under every outcome.
try:
x = int("abc")
except:
x = 0
print(x)