Raising Exceptions - When and How to Signal Errors
Reading time: ~17 minutes | Level: Foundation → Engineering
Here is a function most developers write when they are new to Python:
def get_user(user_id):
if user_id <= 0:
return None # "Signal" the error by returning None
return db.fetch(user_id)
user = get_user(-5)
user["name"] # AttributeError: 'NoneType' object has no attribute 'name'
The error happens three lines after the actual mistake. The traceback points at user["name"], not at get_user(-5). The developer spends ten minutes debugging the wrong location.
Now look at the same function written correctly:
def get_user(user_id):
if not isinstance(user_id, int):
raise TypeError(f"user_id must be int, got {type(user_id).__name__!r}")
if user_id <= 0:
raise ValueError(f"user_id must be positive, got {user_id}")
return db.fetch(user_id)
get_user(-5)
# ValueError: user_id must be positive, got -5
# Traceback points directly at get_user(-5) - the mistake
The error fires immediately, at the exact location of the problem, with a message that tells you exactly what was wrong. That is the difference between a function that is easy to debug and one that produces mysterious failures far from their source.
Knowing when and how to raise exceptions is one of the most important skills in production Python.
What You Will Learn
- The three forms of
raise: new instance, pre-created instance, and bare re-raise - Exception chaining with
raise X from Y- preserving the original cause - Suppressing the chain with
raise X from None - When to raise and when to return a sentinel value instead
- Writing exception messages that include the bad value, what was expected, and context
- The guard clause pattern: validate inputs at the start of a function
- Raising in library code vs application code: different audiences, different standards
ExceptionGroup(Python 3.11+) for signaling multiple simultaneous errors- Real-world API boundary validation patterns
Prerequisites
- Understanding of Python exception objects and the hierarchy (see: Exceptions Explained and Exception Hierarchy)
- Python functions and type annotations basics
- Basic class concepts for the custom exception examples
The Three Forms of raise
| Form | Syntax | When to Use |
|---|---|---|
| Form 1 | raise ExceptionType(message) | Creates a new instance and raises it in one step - most common form |
| Form 2 | raise exception_instance | Raises a pre-created instance; useful when building the exception separately |
| Form 3 | raise (bare) | Re-raises the current exception - only valid inside an except block |
Form 1: Create and Raise in One Step
def parse_port(port_str):
try:
port = int(port_str)
except ValueError:
raise ValueError(f"Port must be a number, got: {port_str!r}")
if not (1 <= port <= 65535):
raise ValueError(f"Port must be between 1 and 65535, got: {port}")
return port
parse_port("abc") # ValueError: Port must be a number, got: 'abc'
parse_port("99999") # ValueError: Port must be between 1 and 65535, got: 99999
parse_port("8080") # 8080
Form 2: Pre-create the Instance
def validate_matrix(matrix):
"""Validate a 2D list representing a matrix."""
if not matrix:
exc = ValueError("Matrix cannot be empty")
raise exc # Equivalent to raise ValueError("Matrix cannot be empty")
row_length = len(matrix[0])
for i, row in enumerate(matrix):
if len(row) != row_length:
# Build the exception with rich context before raising
exc = ValueError(
f"Matrix has inconsistent row lengths: "
f"row 0 has {row_length} columns, "
f"row {i} has {len(row)} columns"
)
raise exc
Pre-creating is useful when building the exception message requires complex logic that would be awkward inline.
Form 3: Bare raise - Re-raising
The bare raise statement re-raises the currently active exception. It is only valid inside an except block:
import logging
logger = logging.getLogger(__name__)
def process_payment(amount, currency):
try:
result = payment_gateway.charge(amount, currency)
return result
except ConnectionError as e:
# Log the error with context, then let it propagate
logger.error(
"Payment gateway connection failed: amount=%s currency=%s error=%s",
amount, currency, e
)
raise # Re-raises the original ConnectionError unchanged
except ValueError as e:
logger.error("Invalid payment parameters: %s", e)
raise # Re-raises the original ValueError
The bare raise preserves the original traceback - the exception looks exactly as if it was never caught. This is the right way to log and re-raise.
:::warning Do Not raise e When You Mean raise
There is a subtle difference:
try:
risky()
except Exception as e:
raise e # Creates a NEW traceback starting here - loses original location!
try:
risky()
except Exception:
raise # Preserves the original traceback - correct
raise e creates a new traceback pointing to the raise e line. Bare raise preserves the traceback from the original raise. Always use bare raise when re-raising.
:::
Part 2 - Exception Chaining
Exception chaining lets you raise a new exception while preserving the original cause. This is essential in production code where you translate low-level errors into domain-specific ones.
Explicit Chaining: raise X from Y
class DatabaseError(Exception):
"""Domain-specific database error."""
pass
def get_user(user_id):
try:
return db.fetch(f"SELECT * FROM users WHERE id = {user_id}")
except ConnectionError as e:
# Translate the low-level error into a domain error
# but preserve the original cause
raise DatabaseError(
f"Cannot fetch user {user_id}: database unavailable"
) from e
When the caller sees the traceback:
ConnectionError: connection refused at localhost:5432
The above exception was the direct cause of the following exception:
DatabaseError: Cannot fetch user 42: database unavailable
The from e sets:
new_exception.__cause__ = e(the original exception)new_exception.__suppress_context__ = True(show "direct cause" message)
The caller sees both: the high-level domain error and the root technical cause. This is critical for debugging in production.
When to Chain
# Library code: always chain when wrapping a lower-level exception
class ConfigError(Exception):
pass
def load_config(path):
try:
with open(path) as f:
import json
return json.load(f)
except FileNotFoundError as e:
raise ConfigError(f"Config file not found: {path!r}") from e
except json.JSONDecodeError as e:
raise ConfigError(
f"Config file contains invalid JSON at line {e.lineno}: {e.msg}"
) from e
# Now callers can handle either:
try:
config = load_config("/etc/app/config.json")
except ConfigError as e:
print(f"Configuration error: {e}")
# The original FileNotFoundError or JSONDecodeError is in e.__cause__
Suppressing the Chain: raise X from None
Sometimes the original exception is an implementation detail that would confuse callers:
class UserNotFoundError(Exception):
pass
class UserRepository:
def find(self, user_id):
# Internal implementation uses a dict
try:
return self._store[user_id]
except KeyError:
# The caller should not see "KeyError: 42" - that's internal
# They should see our domain-specific error
raise UserNotFoundError(f"User {user_id} does not exist") from None
Without from None:
KeyError: 42
During handling of the above exception, another exception occurred:
UserNotFoundError: User 42 does not exist
With from None:
UserNotFoundError: User 42 does not exist
The from None sets __suppress_context__ = True, hiding the internal KeyError. The implementation detail does not leak.
:::tip When to Use from None
Use from None when:
- The original exception is a pure implementation detail (KeyError from an internal dict)
- Showing it would reveal internals that callers should not depend on
- The new exception's message already contains all the useful information
Do NOT use from None when:
- The original exception has useful context (e.g., which file failed to open, which line had a syntax error)
- You are wrapping a third-party library's error and callers might need the root cause :::
Part 3 - When to Raise and When Not To
The decision to raise vs return is one of the most important design decisions in Python.
Raise When: The Function Cannot Fulfill Its Contract
# Raise when the inputs make it impossible to produce a valid result
def calculate_bmi(weight_kg, height_m):
"""Calculate Body Mass Index.
Raises:
TypeError: If arguments are not numeric.
ValueError: If arguments are out of valid range.
"""
if not isinstance(weight_kg, (int, float)):
raise TypeError(f"weight_kg must be numeric, got {type(weight_kg).__name__!r}")
if not isinstance(height_m, (int, float)):
raise TypeError(f"height_m must be numeric, got {type(height_m).__name__!r}")
if weight_kg <= 0:
raise ValueError(f"weight_kg must be positive, got {weight_kg}")
if height_m <= 0:
raise ValueError(f"height_m must be positive, got {height_m}")
if height_m > 3.0:
raise ValueError(f"height_m implausibly large ({height_m}m). Expected meters, not centimeters?")
return weight_kg / (height_m ** 2)
Do Not Raise When: "Not Found" Is a Normal Outcome
# Do NOT raise when "not found" is an expected, normal outcome
def find_user_by_email(email, users):
"""Return the user with the given email, or None if not found."""
for user in users:
if user["email"] == email:
return user
return None # Normal - user might not exist
# Compare: DO raise when the caller REQUIRES the user to exist
def get_user_by_email_or_raise(email, users):
"""Return the user with the given email.
Raises:
ValueError: If no user with the given email exists.
"""
for user in users:
if user["email"] == email:
return user
raise ValueError(f"No user found with email: {email!r}")
Provide both variants when both behaviors are useful. The naming convention get_X_or_raise vs find_X signals the behavior clearly.
The Sentinel Pattern
When you return None to signal "not found," be explicit about it in the type signature:
from typing import Optional
def find_config_value(key: str, config: dict) -> Optional[str]:
"""Look up a config value, returning None if not present."""
return config.get(key) # Returns None if missing - documented and expected
# Caller must check:
value = find_config_value("api_key", config)
if value is None:
raise RuntimeError("API key not configured")
Do Not Raise for Flow Control
# Bad: using exceptions for normal iteration (slow + confusing)
def sum_list_bad(items):
total = 0
i = 0
while True:
try:
total += items[i]
i += 1
except IndexError:
break
return total
# Good: use the language's iteration tools
def sum_list_good(items):
return sum(items) # or: total = 0; for x in items: total += x
Part 4 - Writing Good Exception Messages
A good exception message tells the reader three things:
- What went wrong (the bad value or condition)
- What was expected (the valid range or type)
- Context (where it came from, if not obvious from the traceback)
A good exception message contains three ingredients:
| Ingredient | Purpose | Example |
|---|---|---|
| The actual bad value | Use !r to show both type and value | got {price!r} |
| What the valid range or type is | Tell the caller what to pass instead | must be a positive number |
| A hint for common mistakes | Optional but valuable | To indicate 'free', use price=0. |
Bad: raise ValueError("invalid input") - What was invalid? What would be valid?
Good: raise ValueError(f"price must be a positive number, got {price!r}. To indicate 'free', use price=0.")
Examples: Bad vs Good Messages
# Bad - no context
raise ValueError("invalid port")
# Good - includes the bad value and valid range
raise ValueError(f"port must be between 1 and 65535, got {port!r}")
# Bad - vague
raise TypeError("wrong type")
# Good - specifies what type was expected and what was received
raise TypeError(
f"model must be a scikit-learn estimator with fit() and predict(), "
f"got {type(model).__name__!r}"
)
# Bad - no hint for the common mistake
raise ValueError("invalid height")
# Good - includes a hint for the most common mistake
raise ValueError(
f"height_m must be in meters and positive, got {height_m!r}. "
f"Did you accidentally pass centimeters? (e.g., 170 instead of 1.70)"
)
# Bad - generic message loses the specific key
raise KeyError("config error")
# Good - use the actual key so the caller can fix it
raise KeyError(
f"Required config key {key!r} is missing. "
f"Available keys: {sorted(config.keys())}"
)
Using !r in Messages
The !r format spec calls repr() on the value. This shows both the type and the value, and handles strings that might be empty or contain special characters:
name = ""
raise ValueError(f"name cannot be empty, got {name!r}")
# ValueError: name cannot be empty, got ''
name = " "
raise ValueError(f"name cannot be empty or whitespace, got {name!r}")
# ValueError: name cannot be empty or whitespace, got ' '
value = None
raise TypeError(f"expected str, got {value!r}")
# TypeError: expected str, got None
Without !r, name = "" would show got (nothing after "got"), making the message confusing.
Part 5 - The Guard Clause Pattern
Guard clauses validate inputs at the very start of a function, before any real work begins. This is the "fail fast" principle: detect invalid state as early as possible.
# Without guard clauses - error happens deep in execution
def train_model(data, labels, epochs, learning_rate):
# ...many lines of setup...
for epoch in range(epochs):
for x, y in zip(data, labels):
prediction = forward_pass(x)
loss = compute_loss(prediction, y)
# Error: learning_rate is 0, update is always 0, model never learns
update_weights(loss, learning_rate)
return model # Returns a useless untrained model, no error raised
# With guard clauses - fail at the boundary, before any work
def train_model(data, labels, epochs, learning_rate):
# Guard: types
if not hasattr(data, '__iter__'):
raise TypeError(f"data must be iterable, got {type(data).__name__!r}")
if not hasattr(labels, '__iter__'):
raise TypeError(f"labels must be iterable, got {type(labels).__name__!r}")
if not isinstance(epochs, int):
raise TypeError(f"epochs must be int, got {type(epochs).__name__!r}")
if not isinstance(learning_rate, (int, float)):
raise TypeError(
f"learning_rate must be numeric, got {type(learning_rate).__name__!r}"
)
# Guard: values
if epochs <= 0:
raise ValueError(f"epochs must be positive, got {epochs}")
if learning_rate <= 0:
raise ValueError(f"learning_rate must be positive, got {learning_rate}")
if learning_rate >= 1.0:
raise ValueError(
f"learning_rate should be < 1.0 (typical: 0.001–0.1), got {learning_rate}. "
f"Values >= 1.0 cause training instability."
)
data = list(data)
labels = list(labels)
if len(data) != len(labels):
raise ValueError(
f"data and labels must have the same length: "
f"len(data)={len(data)}, len(labels)={len(labels)}"
)
if not data:
raise ValueError("data cannot be empty")
# All guards passed - now do the real work
# ...
The guard clause pattern:
- Uses
if condition: raise- nottry/except- because these are precondition checks - Raises at the function boundary - not deep inside nested logic
- Puts all guards at the top, in a block, before any setup or computation
- Uses
TypeErrorfor wrong types,ValueErrorfor wrong values
Part 6 - Raising in Library Code vs Application Code
The audience changes how you should phrase error messages and which exceptions you raise.
Library Code (published package, shared module)
The audience is other developers. The messages must be precise, technical, and unambiguous:
# Library exception message style: precise and developer-facing
class sklearn_style_validator:
def fit(self, X, y=None):
# Library code validates thoroughly at the API boundary
if not hasattr(X, '__len__') or not hasattr(X, '__getitem__'):
raise TypeError(
f"Expected array-like for X, got {type(X).__name__!r}. "
f"X must support len() and indexing."
)
if len(X) == 0:
raise ValueError(
"X is empty. Cannot fit on zero samples."
)
if y is not None and len(X) != len(y):
raise ValueError(
f"X and y have inconsistent numbers of samples: "
f"X has {len(X)} samples, y has {len(y)} samples."
)
Best practices for library code:
- Document every exception your functions can raise in docstrings (
Raises:section) - Raise specific built-in exceptions that follow the Python convention (
ValueError,TypeError) - For complex libraries, define a custom exception hierarchy (covered in the next topic)
- Include the bad value and expected value in every message - developers will see these in tracebacks
Application Code (service, script, web handler)
The audience is the system you are building - logs, monitoring, and error responses:
# Application exception message style: operational context included
def process_payment_request(request_data):
# Application code focuses on operational clarity
user_id = request_data.get("user_id")
amount = request_data.get("amount")
currency = request_data.get("currency", "USD")
if user_id is None:
raise ValueError(
f"Payment request missing required field 'user_id'. "
f"Request keys received: {list(request_data.keys())}"
)
if not isinstance(amount, (int, float)) or amount <= 0:
raise ValueError(
f"Payment amount must be a positive number, got {amount!r}. "
f"user_id={user_id}"
)
# Include correlation data (user_id, request_id) so logs are searchable
Part 7 - ExceptionGroup (Python 3.11+)
Python 3.11 introduced ExceptionGroup for signaling multiple simultaneous errors - for example, when validating a form with multiple fields, or when running parallel operations where several tasks fail.
# Python 3.11+ only
def validate_user_form(data: dict) -> dict:
"""Validate all fields and report all errors at once."""
errors = []
if not data.get("username"):
errors.append(ValueError("username is required"))
elif len(data["username"]) < 3:
errors.append(ValueError(
f"username must be at least 3 characters, got {len(data['username'])!r}"
))
if not data.get("email") or "@" not in data.get("email", ""):
errors.append(ValueError(f"email is invalid: {data.get('email')!r}"))
password = data.get("password", "")
if len(password) < 8:
errors.append(ValueError("password must be at least 8 characters"))
if not any(c.isupper() for c in password):
errors.append(ValueError("password must contain at least one uppercase letter"))
if errors:
raise ExceptionGroup("User form validation failed", errors)
return data
# Handling ExceptionGroup with except* syntax (Python 3.11+)
try:
validate_user_form({
"username": "ab",
"email": "not_an_email",
"password": "weak",
})
except* ValueError as eg:
print(f"Validation errors ({len(eg.exceptions)}):")
for exc in eg.exceptions:
print(f" - {exc}")
Output:
Validation errors (4):
- username must be at least 3 characters, got 2
- email is invalid: 'not_an_email'
- password must be at least 8 characters
- password must contain at least one uppercase letter
The except* syntax is new in Python 3.11 and specifically designed for ExceptionGroup. It handles matching exceptions while allowing non-matching ones to propagate.
:::note ExceptionGroup Use Cases
ExceptionGroup is most valuable when you want to report all errors to the caller at once rather than failing on the first one. Common scenarios: form validation, batch data validation, parallel task execution, test runner failures.
:::
Part 8 - Real-World Validation Patterns
Pattern 1: API Route Guard Clauses (FastAPI)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, validator
app = FastAPI()
class TrainingRequest(BaseModel):
model_name: str
epochs: int
learning_rate: float
dataset_id: str
@validator("epochs")
def validate_epochs(cls, v):
if v <= 0:
raise ValueError(f"epochs must be positive, got {v}")
if v > 1000:
raise ValueError(
f"epochs={v} is very large. Maximum allowed is 1000. "
f"Use early stopping for longer training."
)
return v
@validator("learning_rate")
def validate_learning_rate(cls, v):
if v <= 0:
raise ValueError(f"learning_rate must be positive, got {v}")
if v >= 1.0:
raise ValueError(
f"learning_rate={v} is dangerously high. "
f"Typical range: 1e-5 to 0.1."
)
return v
@app.post("/api/train")
async def start_training(request: TrainingRequest):
# Pydantic has already validated - these guards catch business logic errors
dataset = await dataset_service.get(request.dataset_id)
if dataset is None:
raise HTTPException(
status_code=404,
detail=f"Dataset {request.dataset_id!r} not found. "
f"Create a dataset first at POST /api/datasets."
)
if dataset.sample_count < 100:
raise HTTPException(
status_code=422,
detail=f"Dataset {request.dataset_id!r} has only {dataset.sample_count} samples. "
f"Minimum required: 100. Add more training data."
)
return await training_service.start(request)
Pattern 2: Data Pipeline Validation
import pandas as pd
from typing import List
def validate_training_dataframe(
df: pd.DataFrame,
required_columns: List[str],
target_column: str,
) -> pd.DataFrame:
"""Validate a DataFrame for ML training.
Raises:
TypeError: If df is not a DataFrame.
ValueError: If required columns are missing, target has wrong type,
or data has quality issues.
"""
if not isinstance(df, pd.DataFrame):
raise TypeError(
f"Expected pd.DataFrame, got {type(df).__name__!r}. "
f"Did you forget to call pd.read_csv() or pd.DataFrame()?"
)
if df.empty:
raise ValueError("DataFrame is empty - cannot train on zero samples")
# Check required columns
missing = [col for col in required_columns if col not in df.columns]
if missing:
raise ValueError(
f"DataFrame is missing required columns: {missing}. "
f"Columns present: {sorted(df.columns.tolist())}"
)
# Check target column
if target_column not in df.columns:
raise ValueError(
f"Target column {target_column!r} not found in DataFrame. "
f"Available columns: {sorted(df.columns.tolist())}"
)
# Check for all-null target
if df[target_column].isna().all():
raise ValueError(
f"Target column {target_column!r} is entirely null - "
f"no training labels available"
)
# Warn (but don't raise) on partial nulls
null_frac = df[target_column].isna().mean()
if null_frac > 0.1:
import warnings
warnings.warn(
f"Target column {target_column!r} has {null_frac:.1%} null values. "
f"These rows will be dropped during training.",
UserWarning,
stacklevel=2
)
return df.dropna(subset=[target_column])
Interview Questions
Q1: What is the difference between bare raise and raise e inside an except block?
Answer: Bare raise re-raises the currently active exception with its original traceback intact - the traceback still points to where the exception was originally raised. raise e creates a new traceback starting at the raise e line, losing the original location. For example: if the exception was originally raised on line 50 of database.py, bare raise preserves that information. raise e shows the traceback pointing to the re-raise line in your handler, losing the original context. Always use bare raise when you want to re-raise without modification.
Q2: When should you use raise X from Y vs raise X from None?
Answer: Use raise X from Y when the original exception Y is useful context that callers need to understand and debug the problem - for example, when wrapping a FileNotFoundError in a ConfigurationError, the file path in FileNotFoundError helps diagnosis. Use raise X from None when the original exception is an implementation detail that should not be exposed - for example, when your internal implementation uses a dict and raises KeyError, but callers should only see your domain-specific UserNotFoundError. from None sets __suppress_context__ = True, hiding the original exception from the displayed traceback.
Q3: What makes a good exception message? Give an example of a bad one and a good one for the same situation.
Answer: A good exception message contains: (1) the actual bad value (use !r to show type and value), (2) what the valid range or type is, and (3) optional context or a hint for the common mistake.
Bad: raise ValueError("invalid learning rate") - tells the developer nothing. They have to find the call site and inspect the variable.
Good: raise ValueError(f"learning_rate must be positive and less than 1.0, got {learning_rate!r}. Typical range: 1e-5 to 0.1. Values above 1.0 cause training divergence.") - includes the bad value, the valid range, and a hint about the symptom of the mistake.
Q4: When should you raise an exception vs return None to signal failure?
Answer: Raise an exception when the function cannot fulfill its contract - when the inputs make it impossible to produce a valid result, or when a required resource is missing and the caller must be notified. Return None (or a sentinel value) when "not found" or "no result" is a normal, expected outcome - for example, a search function that may legitimately find nothing. The key question is: does the caller need to handle the "not found" case as a normal flow? If yes, return None. Does the caller need to stop and fix something? Raise. Never use None to hide errors - callers then get cryptic AttributeError: 'NoneType' object... errors far from the original mistake.
Q5: What is the guard clause pattern, and why is it preferred?
Answer: Guard clauses are a set of validation checks at the very top of a function that validate all inputs before any real work begins. Each guard raises an appropriate exception if a condition is not met. The pattern implements "fail fast" - errors are detected at the function boundary, with a traceback pointing directly to the caller that passed bad arguments. Without guard clauses, errors surface deep inside nested logic with confusing tracebacks. Guards also serve as executable documentation of a function's preconditions, making the function's contract explicit in code.
Q6: What is ExceptionGroup (Python 3.11+) and when would you use it?
Answer: ExceptionGroup is a special exception class introduced in Python 3.11 that holds a list of other exceptions, allowing a function to signal multiple errors simultaneously rather than stopping at the first one. You use it when: validating a form and wanting to report all field errors at once (rather than returning after the first bad field), running parallel tasks and collecting multiple failures, or validating a batch of data records. The companion except* syntax (Python 3.11+) handles ExceptionGroup by matching on the exception types within the group. Before Python 3.11, the common alternative was to collect errors in a list and raise with raise ValueError(f"Validation failed: {errors}").
Practice Challenges
Beginner - Raise the Right Exception
For each scenario, write the correct raise statement with a well-formed message:
- A function
set_volume(level)receiveslevel = -10(must be 0–100) - A function
process(data)receivesdata = None(must be a list) - A function
connect(host, port)receivesport = "8080"(must be int) - A function
read_csv(path)callsopen(path)and getsPermissionError- wrap it in a domain error
Solution
# 1. Wrong value - ValueError
def set_volume(level):
if not isinstance(level, (int, float)):
raise TypeError(f"level must be numeric, got {type(level).__name__!r}")
if not (0 <= level <= 100):
raise ValueError(
f"volume level must be between 0 and 100, got {level}. "
f"Use 0 for silence and 100 for maximum volume."
)
# ... set the volume
# Test
try:
set_volume(-10)
except ValueError as e:
print(f"ValueError: {e}")
# 2. Wrong type - TypeError
def process(data):
if not isinstance(data, list):
raise TypeError(
f"data must be a list, got {type(data).__name__!r}. "
f"If you have a single item, wrap it: process([item])"
)
return [x * 2 for x in data]
try:
process(None)
except TypeError as e:
print(f"TypeError: {e}")
# 3. Wrong type for port - TypeError
def connect(host, port):
if not isinstance(host, str):
raise TypeError(f"host must be str, got {type(host).__name__!r}")
if not isinstance(port, int):
raise TypeError(
f"port must be int, got {type(port).__name__!r}. "
f"If port is a string, convert it first: connect(host, int(port_str))"
)
if not (1 <= port <= 65535):
raise ValueError(f"port must be between 1 and 65535, got {port}")
try:
connect("localhost", "8080")
except TypeError as e:
print(f"TypeError: {e}")
# 4. Wrap PermissionError with chaining
class DataError(Exception):
"""Domain error for data pipeline failures."""
pass
def read_csv(path):
try:
with open(path) as f:
return f.readlines()
except FileNotFoundError as e:
raise DataError(f"CSV file not found: {path!r}") from e
except PermissionError as e:
raise DataError(
f"Cannot read CSV file - permission denied: {path!r}. "
f"Check file permissions with: ls -la {path!r}"
) from e
try:
read_csv("/root/secret.csv")
except DataError as e:
print(f"DataError: {e}")
print(f"Caused by: {type(e.__cause__).__name__}: {e.__cause__}")
Output:
ValueError: volume level must be between 0 and 100, got -10. Use 0 for silence and 100 for maximum volume.
TypeError: data must be a list, got 'NoneType'. If you have a single item, wrap it: process([item])
TypeError: port must be int, got 'str'. If port is a string, convert it first: connect(host, int(port_str))
DataError: Cannot read CSV file - permission denied: '/root/secret.csv'. Check file permissions with: ls -la '/root/secret.csv'
Caused by: PermissionError: [Errno 13] Permission denied: '/root/secret.csv'
Intermediate - Guard Clause Refactor
The following function works but has no input validation. The errors it produces when given bad inputs are confusing and hard to debug. Refactor it to include complete guard clauses:
def compute_weighted_average(values, weights):
"""Compute the weighted average of values."""
total = sum(v * w for v, w in zip(values, weights))
return total / sum(weights)
Bad inputs to handle:
valuesis not iterableweightsis not iterable- Either is empty
- Lengths differ
- A weight is negative
- All weights sum to zero (division by zero)
- A value or weight is not numeric
Solution
def compute_weighted_average(values, weights) -> float:
"""Compute the weighted average of values.
Args:
values: Iterable of numeric values.
weights: Iterable of numeric weights, one per value.
All weights must be non-negative and at least one must be positive.
Returns:
The weighted average as a float.
Raises:
TypeError: If values or weights are not iterable, or contain non-numeric items.
ValueError: If the sequences are empty, have different lengths,
contain negative weights, or all weights sum to zero.
"""
# Guard: iterability
if not hasattr(values, '__iter__'):
raise TypeError(
f"values must be iterable, got {type(values).__name__!r}"
)
if not hasattr(weights, '__iter__'):
raise TypeError(
f"weights must be iterable, got {type(weights).__name__!r}"
)
# Materialize to lists so we can check length and iterate multiple times
try:
values = list(values)
except Exception as e:
raise TypeError(f"Could not iterate over values: {e}") from e
try:
weights = list(weights)
except Exception as e:
raise TypeError(f"Could not iterate over weights: {e}") from e
# Guard: non-empty
if not values:
raise ValueError("values cannot be empty")
if not weights:
raise ValueError("weights cannot be empty")
# Guard: same length
if len(values) != len(weights):
raise ValueError(
f"values and weights must have the same length: "
f"len(values)={len(values)}, len(weights)={len(weights)}"
)
# Guard: numeric types and non-negative weights
for i, v in enumerate(values):
if not isinstance(v, (int, float)):
raise TypeError(
f"values[{i}] must be numeric, got {type(v).__name__!r}: {v!r}"
)
for i, w in enumerate(weights):
if not isinstance(w, (int, float)):
raise TypeError(
f"weights[{i}] must be numeric, got {type(w).__name__!r}: {w!r}"
)
if w < 0:
raise ValueError(
f"weights[{i}] must be non-negative, got {w}. "
f"All weights must be >= 0."
)
# Guard: weights sum > 0
total_weight = sum(weights)
if total_weight == 0:
raise ValueError(
"All weights are zero - cannot compute weighted average. "
"At least one weight must be positive."
)
# Computation - all guards passed
weighted_sum = sum(v * w for v, w in zip(values, weights))
return weighted_sum / total_weight
# Tests
def test_guard(description, fn, expected_exc=None):
try:
result = fn()
if expected_exc:
print(f"FAIL {description}: expected {expected_exc.__name__}, got {result}")
else:
print(f"PASS {description}: result={result:.4f}")
except Exception as e:
if expected_exc and isinstance(e, expected_exc):
print(f"PASS {description}: {type(e).__name__}: {e}")
else:
expected = expected_exc.__name__ if expected_exc else "no exception"
print(f"FAIL {description}: expected {expected}, got {type(e).__name__}: {e}")
test_guard("normal case",
lambda: compute_weighted_average([1, 2, 3], [0.5, 0.3, 0.2]))
test_guard("non-iterable values",
lambda: compute_weighted_average(42, [1, 2]),
expected_exc=TypeError)
test_guard("empty values",
lambda: compute_weighted_average([], []),
expected_exc=ValueError)
test_guard("mismatched lengths",
lambda: compute_weighted_average([1, 2, 3], [0.5, 0.5]),
expected_exc=ValueError)
test_guard("negative weight",
lambda: compute_weighted_average([1, 2], [0.5, -0.5]),
expected_exc=ValueError)
test_guard("all zero weights",
lambda: compute_weighted_average([1, 2, 3], [0, 0, 0]),
expected_exc=ValueError)
test_guard("non-numeric value",
lambda: compute_weighted_average([1, "two", 3], [1, 1, 1]),
expected_exc=TypeError)
Output:
PASS normal case: result=1.7000
PASS non-iterable values: TypeError: values must be iterable, got 'int'
PASS empty values: ValueError: values cannot be empty
PASS mismatched lengths: ValueError: values and weights must have the same length: len(values)=3, len(weights)=2
PASS negative weight: ValueError: weights[1] must be non-negative, got -0.5. All weights must be >= 0.
PASS all zero weights: ValueError: All weights are zero - cannot compute weighted average. At least one weight must be positive.
PASS non-numeric value: TypeError: values[1] must be numeric, got 'str': 'two'
Advanced - Exception-Raising Context Manager and Decorator
Design a raises context manager / decorator that:
- As a context manager: asserts that a specific exception is raised inside the block (useful for testing)
- As a decorator: wraps a function so that any exception it raises is translated to a new exception type, preserving the chain
- Provides a useful error message when the expected exception is not raised
- Works with both specific exception types and exception tuples
Solution
import functools
import inspect
from contextlib import contextmanager
from typing import Type, Union, Tuple
class ExceptionNotRaised(AssertionError):
"""Raised when a raises() context manager exits without the expected exception."""
pass
class raises:
"""Assert that a specific exception is raised, or translate exceptions.
Usage as context manager (for testing):
with raises(ValueError):
int("not a number") # passes - ValueError was raised
with raises(ValueError, match="invalid literal"):
int("not a number") # passes - message matches
Usage as decorator (for translation):
@raises(DatabaseError, translates=True)
def fetch_user(user_id):
return db.get(user_id) # ConnectionError → DatabaseError
Args:
exc_type: The exception type (or tuple of types) expected / to translate.
match: Optional substring that must appear in the exception message.
translates: If True, act as a translation decorator rather than assertion.
message: Message for the translated exception (decorator mode).
"""
def __init__(
self,
exc_type: Union[Type[Exception], Tuple],
match: str = None,
translates: bool = False,
message: str = None,
):
if isinstance(exc_type, tuple):
self.exc_types = exc_type
else:
self.exc_types = (exc_type,)
self.match = match
self.translates = translates
self.message = message
self.exception = None # Set after context exits
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
# No exception raised - fail the assertion
type_names = " or ".join(t.__name__ for t in self.exc_types)
raise ExceptionNotRaised(
f"Expected {type_names} to be raised, but no exception occurred"
)
if not issubclass(exc_type, self.exc_types):
# Wrong exception type - let it propagate
return False
# Correct exception raised
self.exception = exc_val
if self.match is not None:
msg = str(exc_val)
if self.match not in msg:
raise AssertionError(
f"Expected exception message to contain {self.match!r}, "
f"but got: {msg!r}"
)
# Suppress the exception (it was expected)
return True
def __call__(self, func):
"""Use as a decorator for exception translation."""
if self.translates:
return self._make_translator(func)
else:
return self._make_asserter(func)
def _make_translator(self, func):
"""Translate caught exceptions to a new type."""
target_type = self.exc_types[0] # Translation target
exc_types = self.exc_types
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except exc_types as e:
msg = self.message or f"{func.__name__} failed: {e}"
raise target_type(msg) from e
return wrapper
def _make_asserter(self, func):
"""Assert that the decorated function raises the expected exception."""
exc_types = self.exc_types
match = self.match
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except exc_types as e:
if match is not None and match not in str(e):
raise AssertionError(
f"Expected message to contain {match!r}, got {str(e)!r}"
)
return e # Return the exception for inspection
else:
type_names = " or ".join(t.__name__ for t in exc_types)
raise ExceptionNotRaised(
f"Expected {type_names} from {func.__name__}(), "
f"but no exception was raised"
)
return wrapper
# --- Demo: Context Manager Mode (testing) ---
print("=== Context Manager Mode ===")
# Should pass - ValueError is raised
with raises(ValueError):
int("not a number")
print("Test 1 passed: ValueError was raised as expected")
# Should pass - message matches
with raises(ValueError, match="invalid literal"):
int("not a number")
print("Test 2 passed: ValueError with matching message")
# Should fail - no exception raised
try:
with raises(ValueError):
x = 1 + 1 # No exception
except ExceptionNotRaised as e:
print(f"Test 3 correctly caught: {e}")
# Should fail - wrong exception type
try:
with raises(TypeError):
int("not a number") # Raises ValueError, not TypeError
except ValueError:
print("Test 4 correctly let ValueError propagate (wrong type)")
# --- Demo: Decorator Mode (translation) ---
print("\n=== Decorator Mode (translation) ===")
class ServiceError(Exception):
"""Domain-level service error."""
pass
@raises(ServiceError, translates=True, message=None)
def risky_fetch(url):
"""Fetch from URL - ConnectionError is translated to ServiceError."""
raise ConnectionError(f"Could not connect to {url}")
try:
risky_fetch("https://api.example.com/data")
except ServiceError as e:
print(f"ServiceError: {e}")
print(f"Caused by: {type(e.__cause__).__name__}: {e.__cause__}")
Output:
=== Context Manager Mode ===
Test 1 passed: ValueError was raised as expected
Test 2 passed: ValueError with matching message
Test 3 correctly caught: Expected ValueError to be raised, but no exception occurred
Test 4 correctly let ValueError propagate (wrong type)
=== Decorator Mode (translation) ===
ServiceError: risky_fetch failed: Could not connect to https://api.example.com/data
Caused by: ConnectionError: Could not connect to https://api.example.com/data
Quick Reference
| Form | Syntax | When to Use |
|---|---|---|
| Create and raise | raise ValueError("message") | Most common form |
| Raise pre-created | exc = ValueError("msg"); raise exc | When building message requires complex logic |
| Re-raise | raise (bare, inside except) | Log and re-raise; preserve original traceback |
| Explicit chain | raise New("msg") from original | Translate low-level to domain error |
| Suppress chain | raise New("msg") from None | Hide implementation detail |
| Re-raise with new type | raise New("msg") from e | Preferred over raise New("msg") in except |
| Situation | Exception to Raise |
|---|---|
| Wrong argument type | TypeError |
| Right type, bad value | ValueError |
| Missing key in mapping | KeyError |
| File/directory not found | FileNotFoundError |
| Permission denied | PermissionError |
| Network connection failed | ConnectionRefusedError / ConnectionError |
| Abstract method not implemented | NotImplementedError |
| State is internally inconsistent | RuntimeError |
| Programmer mistake caught by assertion | AssertionError |
| Anything else domain-specific | Custom exception (next topic) |
Key Takeaways
- The three forms of
raise:raise ExcType(msg)(create and raise),raise instance(raise pre-created), and bareraise(re-raise current exception with original traceback) - Always use bare
raiseto re-raise -raise ecreates a new traceback and loses the original location raise X from Ypreserves the original cause in__cause__and is essential for debuggable production code;raise X from Nonecleanly hides implementation details- Raise exceptions when a function cannot fulfill its contract; return
Noneor a sentinel when "not found" is a normal expected outcome - Good exception messages include the bad value (with
!r), the valid range or type, and a hint for the common mistake - Guard clauses - validate all inputs at the top of a function before any real work - implement "fail fast" and produce tracebacks that point directly at the calling mistake
- Library code and application code have different audiences: library exceptions should be precise and technical; application exceptions should include operational context (user IDs, request IDs)
ExceptionGroup(Python 3.11+) allows signaling multiple simultaneous errors and is ideal for form validation and batch processing
