raise and assert in Python — When, Why and How to Use Them Right
raise like a referee blowing a whistle mid-game — something went wrong, the game must stop, and everyone needs to know why. assert is more like a pre-flight checklist a pilot runs before takeoff — if anything on the list is wrong, you don't even leave the ground. Both exist to catch problems, but at completely different moments and for completely different audiences.raise like a referee blowing a whistle mid-game — something went wrong, the game must stop, and everyone needs to know why. assert is more like a pre-flight checklist a pilot runs before takeoff — if anything on the list is wrong, you don't even leave the ground. Both exist to catch problems, but at completely different moments and for completely different audiences.Every program you write makes assumptions — that a user's age is positive, that a file actually exists, that a payment amount isn't zero. When those assumptions break, your program shouldn't silently limp along producing garbage output. It should fail loudly, clearly, and in a way that points directly at the problem. That's exactly what raise and assert are built for, and knowing the difference between them is what separates defensive code from fragile code.
The real problem isn't that Python will crash when things go wrong — it's that without raise and assert, it often crashes in the wrong place, showing you a confusing error three function calls away from where the actual bug lives. These two tools let you put guardrails exactly where your assumptions live, so when something breaks, the error message is a signpost, not a riddle.
By the end of this article you'll know how to raise built-in and custom exceptions with meaningful messages, how to use assert as a development-time safety net, why you should never use assert to validate user input, and how to chain exceptions to preserve debugging context. You'll be writing code that fails helpfully instead of mysteriously.
raise — Telling Python Something Has Gone Wrong
raise is how you deliberately trigger an exception. You're not waiting for Python to stumble — you're the one blowing the whistle because you've already detected the problem.
The basic form is raise ExceptionType("your message"). The exception type tells Python (and the developer reading the traceback) what kind of problem occurred. The message tells them what the specific problem was. Both matter — a ValueError with the message "age must be a positive integer, got -3" is infinitely more useful than a generic crash.
Python has a rich built-in exception hierarchy. Choosing the right type isn't just cosmetic — it lets callers catch specific exceptions without catching everything. Use ValueError when a value is the wrong kind. Use TypeError when the wrong type was passed. Use RuntimeError when something goes wrong that doesn't fit a neater category. Using the right exception type is a form of documentation.
You can also raise inside an except block to re-raise after logging, or to wrap a low-level exception in a higher-level one that makes more sense to the caller.
def register_user(username: str, age: int) -> dict: """ Registers a new user. Raises informative errors if the input is invalid. """ # Check for the wrong TYPE first — age should be an integer, not a string if not isinstance(age, int): raise TypeError( f"age must be an integer, got {type(age).__name__} instead" ) # Check for the wrong VALUE — technically an int, but makes no real-world sense if age < 0 or age > 130: raise ValueError( f"age must be between 0 and 130, got {age}" ) # Check username isn't empty — a blank name would corrupt our database records if not username or not username.strip(): raise ValueError("username cannot be empty or whitespace") # All checks passed — safe to build the user record return {"username": username.strip(), "age": age, "status": "registered"} # --- Demo: what happens with valid and invalid inputs --- try: result = register_user("alice", 28) print(f"Success: {result}") except (TypeError, ValueError) as error: print(f"Registration failed: {error}") try: result = register_user("bob", -5) # Nonsensical age print(f"Success: {result}") except (TypeError, ValueError) as error: print(f"Registration failed: {error}") try: result = register_user(" ", 25) # Blank username print(f"Success: {result}") except (TypeError, ValueError) as error: print(f"Registration failed: {error}") try: result = register_user("carol", "twenty") # Wrong type entirely print(f"Success: {result}") except (TypeError, ValueError) as error: print(f"Registration failed: {error}")
Registration failed: age must be between 0 and 130, got -5
Registration failed: username cannot be empty or whitespace
Registration failed: age must be an integer, got str instead
Custom Exceptions — Making raise Even More Powerful
Built-in exceptions are great, but they're generic. When you're building a library, an API client, or any system with its own domain logic, callers need to distinguish your errors from Python's built-in ones.
Creating a custom exception is just one line: subclass Exception (or a more specific built-in). That's it. You've now given your package its own exception namespace. Callers can except PaymentError and know they're handling your domain problem, not some random ValueError from an unrelated library.
The real power is in the hierarchy. A payment system might have a base PaymentError, with InsufficientFundsError and CardDeclinedError as subclasses. Callers who care about both can catch PaymentError. Callers who need to handle each case differently can catch the subclass. This is the same design Python itself uses — you can catch OSError for any file system problem, or FileNotFoundError specifically.
Add raise ... from original_error (exception chaining) when you're wrapping a low-level exception. This preserves the original traceback so debugging context is never lost — a sign of professional-grade code.
# --- Custom Exception Hierarchy --- class PaymentError(Exception): """Base exception for all payment-related failures in this module.""" pass class InsufficientFundsError(PaymentError): """Raised when an account doesn't have enough balance to cover a charge.""" def __init__(self, account_id: str, required: float, available: float): self.account_id = account_id self.required = required self.available = available # Store structured data AND give a clear human-readable message super().__init__( f"Account {account_id} needs ${required:.2f} but only has ${available:.2f}" ) class CardDeclinedError(PaymentError): """Raised when the payment network explicitly rejects the card.""" def __init__(self, card_last_four: str, reason: str): super().__init__(f"Card ending in {card_last_four} was declined: {reason}") # --- The function that uses them --- def process_payment(account_id: str, amount: float, card_last_four: str) -> str: # Simulate fetching the account balance from a database mock_balances = {"ACC001": 120.00, "ACC002": 30.00} if account_id not in mock_balances: # Wrap a lookup failure in a domain-meaningful exception try: raise KeyError(account_id) except KeyError as lookup_error: # 'raise ... from' chains the original error — traceback is preserved raise PaymentError( f"Account {account_id} not found" ) from lookup_error balance = mock_balances[account_id] if balance < amount: raise InsufficientFundsError(account_id, amount, balance) # Simulate a random network decline for demo purposes if card_last_four == "0000": raise CardDeclinedError(card_last_four, "do-not-honour flag from issuer") return f"Payment of ${amount:.2f} approved for account {account_id}" # --- Callers can now be as specific or broad as they need --- for account, amount, card in [ ("ACC001", 50.00, "1234"), # Should succeed ("ACC002", 50.00, "5678"), # Insufficient funds ("ACC001", 50.00, "0000"), # Card declined ("ACC999", 50.00, "1234"), # Account not found ]: try: message = process_payment(account, amount, card) print(f"OK: {message}") except InsufficientFundsError as e: # We can access structured data, not just the string message print(f"FUNDS ERROR — shortfall: ${e.required - e.available:.2f}") except CardDeclinedError as e: print(f"CARD ERROR: {e}") except PaymentError as e: # Catches anything else in the PaymentError family print(f"PAYMENT ERROR: {e}")
FUNDS ERROR — shortfall: $20.00
CARD ERROR: Card ending in 0000 was declined: do-not-honour flag from issuer
PAYMENT ERROR: Account ACC999 not found
assert — Catching Bugs During Development, Not Validating User Input
assert is a development-time sanity check. The syntax is assert condition, "message if it fails". If the condition is True, nothing happens. If it's False, Python raises an AssertionError with your message and the program stops.
The key word is development-time. assert statements are completely disabled when Python runs with the -O (optimize) flag — meaning they silently disappear in many production deployments. This is not a bug; it's by design. assert is for catching logic errors that should never happen if your code is correct, not for handling bad user input or external data.
Think of assert as a note to your future self (or your teammates): "At this point in the code, I am certain this condition is true. If it isn't, my assumptions about this function are completely wrong, and I want to know immediately."
Good places for assert: after a complex algorithm to verify its output shape, inside a function to document a pre-condition that callers must satisfy, in test helpers. Bad places: anywhere input comes from users, files, APIs, or databases. For those, always use raise.
def calculate_weighted_average(values: list, weights: list) -> float: """ Calculates a weighted average. Both lists must be the same length and weights must sum to 1.0. These are developer contracts, not user-facing validation — so we use assert, not raise. """ # ASSERT: This is a programmer contract. If lengths differ, the calling # code has a bug. We want to catch this instantly during development. assert len(values) == len(weights), ( f"values and weights must be the same length: " f"got {len(values)} values and {len(weights)} weights" ) # ASSERT: Weights that don't sum to ~1.0 mean our math will silently produce # wrong results. Catching this beats debugging mysterious output later. total_weight = sum(weights) assert abs(total_weight - 1.0) < 1e-9, ( f"weights must sum to 1.0, got {total_weight}" ) # The actual calculation — safe to proceed because our contracts hold weighted_sum = sum(v * w for v, w in zip(values, weights)) # POST-CONDITION assert: the result must be within the range of input values # If this fails, our algorithm itself is broken — not the inputs assert min(values) <= weighted_sum <= max(values), ( f"result {weighted_sum} is outside the range of input values — " "this is a logic bug in this function" ) return weighted_sum # --- Valid usage --- exam_scores = [95, 72, 88] # Midterm 20%, Final 50%, Assignment 30% score_weights = [0.20, 0.50, 0.30] final_grade = calculate_weighted_average(exam_scores, score_weights) print(f"Weighted grade: {final_grade:.2f}") # --- This reveals a programmer error immediately --- try: bad_result = calculate_weighted_average( [95, 72, 88], [0.20, 0.50] # Forgot the third weight — a bug in the calling code ) except AssertionError as error: print(f"AssertionError caught (dev bug, not user error): {error}")
AssertionError caught (dev bug, not user error): values and weights must be the same length: got 3 values and 2 weights
raise vs assert — Choosing the Right Tool for the Job
The confusion between raise and assert is one of the most common intermediate-level mistakes in Python. They look similar — both stop execution when something is wrong — but they exist for completely different reasons and different audiences.
raise is for runtime conditions that can legitimately happen in production and that your code needs to handle gracefully. Bad user input, missing files, network failures, invalid API responses — these aren't bugs, they're expected failure modes. raise communicates the problem to the caller so they can decide what to do next.
assert is for invariants that should never be false if your code is correct. It's a communication tool between developers, not a runtime guard. It says: "I wrote this function assuming X is always true here. If X is ever false, the logic of this program is broken and we need to fix the code, not handle the error."
A useful mental model: raise handles the unexpected-but-possible. assert documents the impossible. If you find yourself writing assert to handle something that a user, file, or network could cause — swap it for raise. If you find yourself catching AssertionError in production code — that's a red flag that you've misused assert.
import json # ============================================================ # raise: for conditions that can happen legitimately at runtime # ============================================================ def load_product_catalog(filepath: str) -> dict: """ Loads product data from a JSON file. File-not-found and bad JSON are EXPECTED failure modes — they happen in production. Use raise. """ try: with open(filepath, "r") as file: catalog = json.load(file) except FileNotFoundError: # Re-raise with a more useful message for the caller raise FileNotFoundError( f"Product catalog not found at '{filepath}'. " "Check the path and ensure the file was deployed." ) except json.JSONDecodeError as parse_error: raise ValueError( f"Catalog file at '{filepath}' contains invalid JSON." ) from parse_error return catalog def apply_discount(product: dict, discount_rate: float) -> dict: """ Applies a discount to a product dict. The discount_rate comes from user input, so invalid values are expected. Use raise. """ if not 0.0 <= discount_rate <= 1.0: raise ValueError( f"discount_rate must be between 0.0 and 1.0, got {discount_rate}" ) discounted_price = product["price"] * (1 - discount_rate) return {**product, "price": round(discounted_price, 2), "discounted": True} # ============================================================ # assert: for internal logic invariants — developer contracts # ============================================================ def split_inventory_into_batches(items: list, batch_size: int) -> list: """ Splits an item list into equal-sized batches for warehouse processing. This is an internal utility. We control all callers. """ # ASSERT: We control every caller of this function. If batch_size <= 0 # arrives here, the calling code has a logic bug — not user error. assert batch_size > 0, f"batch_size must be positive, got {batch_size}" batches = [items[i:i + batch_size] for i in range(0, len(items), batch_size)] # POST-CONDITION: reassembling batches must recreate the original list # If this fails, our batching algorithm has a bug assert sum(len(b) for b in batches) == len(items), ( "Batching produced a different number of items than the input — logic error" ) return batches # --- Demonstration --- # raise in action — invalid user-supplied discount rate product = {"name": "Widget Pro", "price": 99.99} try: result = apply_discount(product, 1.5) # 150% discount — clearly wrong except ValueError as e: print(f"Discount error (user input problem): {e}") # Valid discount discounted = apply_discount(product, 0.20) print(f"After 20% off: ${discounted['price']}") # assert in action — correct internal usage warehouse_items = ["SKU001", "SKU002", "SKU003", "SKU004", "SKU005"] batches = split_inventory_into_batches(warehouse_items, batch_size=2) print(f"Batches: {batches}") # assert catching a developer mistake try: bad_batches = split_inventory_into_batches(warehouse_items, batch_size=0) except AssertionError as e: print(f"AssertionError (developer bug): {e}")
After 20% off: $79.99
Batches: [['SKU001', 'SKU002'], ['SKU003', 'SKU004'], ['SKU005']]
AssertionError (developer bug): batch_size must be positive, got 0
| Feature / Aspect | raise | assert |
|---|---|---|
| Primary purpose | Signal a runtime error to the caller | Document a programmer assumption / invariant |
| Target audience | Callers of your function (including production code) | Fellow developers reading or testing the code |
| Disabled in production? | Never — always active | Yes — silently removed with python -O flag |
| Exception type raised | Any exception you choose (ValueError, TypeError, custom, etc.) | Always AssertionError — you can't choose another type |
| Right use case | Invalid user input, missing files, API failures | Post-conditions, algorithm invariants, pre-conditions you control |
| Catchable in production? | Yes — callers can and should catch specific exceptions | Catching AssertionError in production is a code smell |
| Carries structured data? | Yes — custom exceptions can hold attributes | Only a string message |
| Exception chaining? | Yes — raise X from Y preserves original traceback | Not applicable |
| In test code? | Use raise in the code being tested | Use assert in test assertions (pytest style) |
🎯 Key Takeaways
- Use
raisefor any condition that can legitimately occur at runtime — bad input, missing files, network failures. It's always active, even in optimized production builds. assertis a developer communication tool, not a runtime guard. It documents invariants that should be impossible if your code is correct, and it disappears entirely withpython -O.- Always pick the most specific built-in exception type (
ValueError,TypeError,FileNotFoundError) or build a custom exception hierarchy — genericExceptionforces callers to catch too broadly. - Use
raise NewError('...') from original_errorwhenever you wrap a low-level exception — exception chaining preserves the full traceback chain so debugging never starts from scratch.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using assert to validate user input —
assert user_age > 0looks like a guard but vanishes entirely when Python runs with the-Oflag, silently allowing negative ages through. Fix: replace all input validation withif not condition: raise ValueError("..."). Reserve assert only for internal programmer contracts. - ✕Mistake 2: Raising the wrong exception type — raising
Exception('username is empty')instead ofValueError('username is empty')forces callers to catch the overly broadExceptionclass, which swallows every possible error including bugs. Fix: always pick the most specific built-in exception that fits (ValueErrorfor bad values,TypeErrorfor wrong types,FileNotFoundErrorfor missing files), or create a custom exception subclass. - ✕Mistake 3: Losing the original exception when wrapping — writing
except SomeError: raise MyCustomError('something went wrong')discards the original traceback entirely, making the root cause invisible during debugging. Fix: always useraise MyCustomError('...') from original_errorto chain exceptions and preserve the full diagnostic trail.
Interview Questions on This Topic
- QWhat is the practical difference between `raise` and `assert` in Python, and why should you never use `assert` to validate user input?
- QHow does exception chaining work with `raise ... from ...`, and why is it important in real-world applications?
- QIf `assert` statements are disabled with the `-O` flag, what does it mean if you find production code that depends on an `assert` not being skipped — and how would you fix it?
Frequently Asked Questions
Can I catch an AssertionError in Python?
Technically yes — except AssertionError is valid syntax. But catching AssertionError in production code is almost always a design mistake. If an assertion fires, it means your code has a logic bug that needs to be fixed, not silently swallowed. If you need catchable errors, use raise instead of assert.
What happens if I use raise without an argument inside an except block?
A bare raise inside an except block re-raises the current active exception unchanged, including its original traceback. It's useful when you want to log an error but still let it propagate to the caller. Outside an except block, a bare raise causes a RuntimeError: No active exception to re-raise.
Should I create custom exceptions for every project, or is it overkill?
For small scripts, built-in exceptions are usually enough. For any library, package, or application with distinct business domains (payments, authentication, file processing), a base custom exception plus specific subclasses pays off immediately — callers can catch your domain errors separately from Python's built-in ones, and your tracebacks become far more readable.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.