Python's exception system is one of the most useful features in the language and one of the most frequently misused. The basic try-except block is straightforward, but production-grade error handling involves a set of patterns that most developers learn only after watching something break in a way that was avoidable.
This is a reference collection of error handling code snippets, organized from the basics through edge cases that come up regularly in data pipelines, web applications, and automation scripts. The patterns apply whether you are writing a short script or maintaining a large service.
Basic Try-Except
The minimal pattern: attempt an operation, catch a specific exception, handle it.
try:
result = int(user_input)
except ValueError:
print("Expected a number, got:", user_input)
result = 0
Always catch specific exception types rather than the bare except: clause. A bare except catches everything including SystemExit, KeyboardInterrupt, and GeneratorExit, which are meant to propagate, not be swallowed by application code. Catching Exception is safer than bare except but still too broad for most cases.
# Avoid this -- silent failures are the hardest bugs to diagnose
try:
do_complex_operation()
except Exception:
pass
If you genuinely need to catch all exceptions before re-raising, use except Exception with explicit logging (covered below).

Photo by ThisIsEngineering on Pexels
Catching Multiple Exception Types
Two patterns for handling multiple exception types with different behavior per type:
try:
data = fetch_remote_data(url, timeout=10)
except ConnectionError:
log.error("Network unavailable, aborting")
return None
except TimeoutError:
log.warning("Request timed out after 10s, retrying")
data = fetch_remote_data(url, timeout=60)
When two exception types should produce the same outcome, group them in a tuple:
try:
record = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError) as err:
log.error("Could not parse record: %s", err)
return None
The as err binding is useful when you need to log the specific message or pass it to a downstream exception.
The Full Try-Except-Else-Finally Pattern
Python's else clause runs only when no exception was raised. finally runs always, including when an exception occurs. Together they produce explicit resource management without ambiguity:
file = None
try:
file = open(path, "r")
content = file.read()
except FileNotFoundError:
log.error("File not found: %s", path)
raise
except PermissionError:
log.error("No read permission: %s", path)
raise
else:
# Only runs when open and read both succeeded
process(content)
log.info("Processed %s", path)
finally:
# Always runs -- cleanup goes here
if file:
file.close()
The else block prevents embedding success-path logic inside the try block where it might accidentally be covered by an except handler. If process(content) raises a ValueError, it will not be caught by FileNotFoundError or PermissionError. That is usually the correct behavior.
In Python 3, with statements are preferred for context managers like files. The explicit finally pattern remains the right choice when you need conditional cleanup or multiple resource types that do not support the context manager protocol.
Exception Chaining
When you catch one exception and raise a different one, use raise X from Y to preserve the original cause:
try:
raw = database.query(sql)
except DatabaseConnectionError as err:
raise DataProcessingError("Failed to load source data") from err
The from err clause sets __cause__ on the raised exception. When the traceback prints, Python shows both the original database error and the new DataProcessingError, separated by "The above exception was the direct cause of the following exception." The root cause is preserved in the traceback rather than replaced, which makes production diagnostics much faster.
The Python language documentation covers the full exception hierarchy and chaining semantics. Python Enhancement Proposals on peps.python.org -- specifically PEP 3134 -- describe the design intent behind __cause__ and __context__.
To suppress the original exception context when you explicitly do not want it in the traceback:
try:
sensitive_operation()
except InternalError:
raise PublicFacingError("Operation failed") from None
from None clears __context__. Reserve this for cases where the original exception contains information that should not be exposed to callers.

Photo by Diana ✨ on Pexels
Custom Exception Classes
Define custom exceptions when callers need to distinguish between failure modes or when you want to attach structured data to the exception itself:
class DataProcessingError(Exception):
def __init__(self, message, record_id=None, original_data=None):
super().__init__(message)
self.record_id = record_id
self.original_data = original_data
class RetryableError(DataProcessingError):
"""Caller should retry this operation."""
pass
class FatalError(DataProcessingError):
"""No retry will succeed. Escalate immediately."""
pass
A hierarchy rooted at a custom base class lets callers catch all domain errors with except DataProcessingError, or handle specific failure types:
try:
process(record)
except RetryableError as err:
queue.retry(err.record_id)
except FatalError as err:
alerts.send(f"Fatal failure on record {err.record_id}: {err}")
At 137Foundry, custom exception hierarchies are part of the standard service layer for data pipelines and web application backends. The web development and data integration work we deliver both rely on structured exceptions to make failure modes explicit for downstream consumers. An exception that carries the record ID and original payload is far more actionable in a production alert than a plain string message.
"In every production Python application we build, error handling is the first thing we audit. A well-designed exception hierarchy makes the difference between a system that fails silently for days and one that surfaces problems in seconds. Custom exception classes with structured data attached are worth the few lines of setup." - Dennis Traina, founder of 137Foundry
Logging Exceptions in Production
The most common logging mistake is catching an exception, logging a message, and discarding the traceback:
# This loses the traceback -- the message alone is rarely enough
try:
process(record)
except Exception as err:
log.error("Processing failed: %s", err)
Use log.exception() to capture the full traceback automatically:
import logging
log = logging.getLogger(__name__)
try:
process(record)
except Exception:
log.exception("Failed to process record %s", record.id)
# Equivalent to log.error("...", exc_info=True)
# Captures the full traceback from the current exception context
log.exception() logs at ERROR level and attaches the current exception traceback. You do not need as err because exc_info=True captures the context automatically.
When you need to log and then re-raise:
try:
process(record)
except Exception:
log.exception("Processing error on record %s, re-raising", record.id)
raise # Bare raise preserves the original traceback
For production error monitoring, platforms like Sentry integrate with Python's standard logging and capture additional context such as environment, user ID, and request breadcrumbs. They work alongside standard logging rather than replacing it.

Photo by panumas nikhomkhai on Pexels
Testing Exception Handling with pytest
Use pytest.raises as a context manager to assert that specific exceptions are raised:
import pytest
def test_raises_on_negative_input():
with pytest.raises(ValueError, match="must be positive"):
parse_amount(-10)
def test_custom_exception_carries_record_id():
with pytest.raises(DataProcessingError) as exc_info:
process_record(bad_record)
assert exc_info.value.record_id == bad_record.id
def test_retryable_error_on_timeout(mocker):
mocker.patch("myapp.fetch", side_effect=TimeoutError)
with pytest.raises(RetryableError):
load_external_data()
The match parameter takes a regex and asserts it against the exception message. exc_info.value gives you the exception instance so you can assert on custom attributes like record_id or original_data.
Testing that an exception is not raised:
def test_valid_input_does_not_raise():
result = parse_amount(100)
assert result == 100.0
The pytest documentation covers pytest.warns for warnings and pytest.deprecated_call for deprecation assertions.
Re-Raising Without Losing the Traceback
Two patterns for re-raising that preserve different amounts of context:
# Re-raise the same exception type, traceback origin intact
try:
dangerous_call()
except SpecificError:
log.exception("Failed during setup phase")
raise # NOT raise err -- that would reset the traceback
# Re-raise as a different type with chaining
try:
dangerous_call()
except SpecificError as err:
raise WrapperError("Setup phase failed") from err
Using bare raise inside an except block re-raises the current exception with its original traceback intact. Using raise err re-raises the same exception but resets the traceback to the current line, making it harder to trace the original call site in logs. Use bare raise.
This distinction matters at API boundaries. Internal exceptions should be wrapped with chaining so callers get a clean interface-level exception while the root cause is preserved in logs. The 137Foundry services architecture applies this consistently across REST API handlers and background job processors.
Silencing Expected Exceptions Cleanly
For the cases where an exception is genuinely expected and means "do nothing," use contextlib.suppress instead of an empty except block:
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove(temp_file)
suppress is explicit about intent: this specific exception is expected and the correct response is to continue. It is more readable than try: ... except FileNotFoundError: pass and harder to accidentally expand when someone edits the block later.
A Note on Structured Logging
Most of the patterns above assume that your log.exception() calls reach a log aggregation system where you can search and correlate them. For small scripts, print statements or file-based logging may be sufficient. For production services, structured logging -- where log entries are JSON objects with queryable fields rather than plain strings -- makes a significant difference in diagnosability.
Libraries like structlog integrate with Python's standard logging module and add key-value context to every log entry. When an exception occurs, structured log entries carry the record ID, user ID, request ID, and any other context you have bound, rather than forcing you to grep through text. The Python documentation covers the standard logging module in depth; structured logging builds on top of it.
Error handling is not about making code more complex. Each pattern above addresses a specific failure mode: resource cleanup under failure, debugging efficiency in production, domain-level failure modeling, and test coverage of failure paths. The investment pays off the first time something breaks in a running service and the traceback tells you exactly what happened rather than leaving you with a silent failure and no breadcrumb.
For related code reference collections, see the earlier entries in this series on 137Foundry covering JavaScript array methods, CSS layout patterns, and TypeScript utility types.