Previous | Tutorial index | Next

📚 Tutorial 4: Raising Exceptions with raise

Learning Objectives

1. Introduction: Taking Control of Errors

So far, we have seen how Python automatically raises exceptions when something goes wrong (e.g., dividing by zero, accessing a missing key). However, in many situations, you need to explicitly signal that an error has occurred in your code, even when Python itself doesn't see a problem. This is where the raise statement comes into play.

The raise statement allows you to purposefully throw an exception at any point in your program. This is essential for:

By raising exceptions, you make your code more robust, maintainable, and easier to debug. You are explicitly stating: "This condition is an error, and it should be handled by whoever is calling this code."

2. Key Terms

Before diving into the content, familiarise yourself with these key terms:

Term Definition
raise Statement to purposely throw an exception.
Re‑raising Using raise without arguments inside an except block to re‑throw the current exception.
Exception chaining Using raise ... from to link a new exception to the original cause.
raise ... from None Suppresses the original exception context.
NotImplementedError Common built‑in used to indicate a method must be overridden.

3. Real-World Analogy: The Quality Control Inspector

Imagine you are a quality control inspector at a factory. You receive a product that looks OK on the outside, but you check its specifications and find that its weight is below the required minimum. Python wouldn't automatically "see" this because it doesn't know the business rule. But you, as the inspector, know the rule. So you raise an alarm (throw an exception) to stop the production line and notify the supervisors. This is exactly what raise does: it allows you to signal a problem that the interpreter wouldn't detect on its own.

4. The raise Statement: Syntax and Basic Usage

4.1 Syntax

The raise statement can be used in several ways:

raise ExceptionClass # Raises an instance of ExceptionClass raise ExceptionClass() # Raises an instance (same as above) raise ExceptionClass("message") # Raises with an error message raise # Re-raises the current exception (only inside except block) raise ExceptionClass from cause # Exception chaining

4.2 Raising a Built-in Exception

You can raise any built-in exception class. The most common ones are ValueError, TypeError, RuntimeError, NotImplementedError, and AssertionError (though assert is often used for that).

def set_age(age): if age < 0: raise ValueError("Age cannot be negative.") if age > 150: raise ValueError("Age cannot exceed 150.") print(f"Age set to {age}")

Key Point: You are raising an instance of the exception class. The error message is optional but highly recommended.

4.3 Raising Without an Exception Class

You can raise an exception instance directly:

error = ValueError("Invalid input") raise error

This is equivalent to raise ValueError("Invalid input").

4.4 Raising a Specific Exception with a Custom Message

Always include a clear, descriptive message that explains what went wrong. This helps the caller (and the user) understand the issue.

def withdraw(amount, balance): if amount > balance: raise ValueError(f"Insufficient balance: requested {amount}, available {balance}") return balance - amount

5. Why Raise Exceptions? Benefits and Use Cases

5.1 Input Validation

When a function receives arguments that are invalid (wrong type, out of range, etc.), it should raise an exception rather than silently accepting bad data.

def calculate_square_root(x): if not isinstance(x, (int, float)): raise TypeError("Argument must be a number") if x < 0: raise ValueError("Cannot compute square root of negative number") return x ** 0.5

5.2 Enforcing Business Rules

Business logic often has constraints that are not enforced by the type system. For example, an e-commerce system might have a rule that a discount cannot exceed 50%.

def apply_discount(price, discount_percent): if discount_percent < 0 or discount_percent > 50: raise ValueError("Discount must be between 0% and 50%") return price * (1 - discount_percent / 100)

5.3 Signaling "Not Implemented"

When designing abstract base classes or interfaces, you can raise NotImplementedError to indicate that a method must be overridden by subclasses.

class Animal: def speak(self): raise NotImplementedError("Subclasses must implement speak()")

5.4 Enforcing Preconditions and Postconditions

Defensive programming often uses raise to check that inputs meet expectations and outputs are valid.

def divide(a, b): if b == 0: raise ZeroDivisionError("Denominator cannot be zero") result = a / b # Postcondition: result should be finite if not math.isfinite(result): raise RuntimeError("Result is not finite") return result

5.5 Re-raising After Logging (Covered in Section 6)

Sometimes you catch an exception, log it, and then re-raise it to allow higher-level handlers to deal with it.

6. Re-raising Exceptions: The raise Without Arguments

6.1 Purpose

When you catch an exception but cannot fully handle it, you may want to re-raise the same exception after performing some actions (e.g., logging, cleanup). Using raise without arguments inside an except block re-raises the current exception exactly as it was.

6.2 Syntax

try: risky_operation() except ValueError as e: print(f"Logging: {e}") raise # Re-raise the ValueError

6.3 Example

import logging def process_data(data): try: result = int(data) * 10 except ValueError: logging.error(f"Invalid data: {data}") raise # Re-raise for the caller to handle return result try: process_data("abc") except ValueError: print("Caller: Caught the re-raised ValueError.")

Key Points:

6.4 Difference Between raise and raise e

try: 1 / 0 except ZeroDivisionError as e: raise e # This raises a new instance; the traceback may be truncated raise # This re-raises the original exception with full traceback

Prefer raise without arguments to preserve the original stack trace.

7. Exception Chaining: raise ... from

7.1 Purpose

Sometimes, when you catch one exception, you want to raise a different, more appropriate exception for the caller. Exception chaining allows you to link the new exception to the original cause using the from keyword.

7.2 Syntax

try: risky_operation() except ValueError as e: raise RuntimeError("Operation failed") from e

This creates a chain: the RuntimeError is raised, and its __cause__ attribute points to the original ValueError. The traceback will show both exceptions.

7.3 Example

def read_user_age(filename): try: with open(filename, 'r') as f: age_str = f.read().strip() return int(age_str) except FileNotFoundError as e: raise RuntimeError("User data file missing") from e except ValueError as e: raise RuntimeError("User age is not a valid integer") from e try: age = read_user_age("age.txt") except RuntimeError as e: print(e) # "User age is not a valid integer" print(e.__cause__) # The original ValueError

7.4 Suppressing the Context: from None

If you want to suppress the original exception context and not show it in the traceback, use from None.

try: int("abc") except ValueError: raise RuntimeError("Failed to convert") from None

The traceback will only show the RuntimeError without mentioning the ValueError.

When to use: If the original cause is not relevant to the caller or if it might confuse them.

8. Common Patterns and Best Practices

8.1 Raising with Clear Messages

Always include a message that explains what went wrong and, if possible, what the correct value should be.

def set_password(password): if len(password) < 8: raise ValueError("Password must be at least 8 characters long") # ...

8.2 Checking Types Before Use

You can raise TypeError when a function is called with an argument of the wrong type.

def double_number(x): if not isinstance(x, (int, float)): raise TypeError("Expected a number") return x * 2

8.3 Raising from Validation Functions

Encapsulate validation logic in separate functions that raise exceptions.

def validate_age(age): if not isinstance(age, int): raise TypeError("Age must be an integer") if age < 0 or age > 150: raise ValueError("Age must be between 0 and 150") return age # Usage try: age = validate_age(int(input("Enter age: "))) except (TypeError, ValueError) as e: print(f"Invalid input: {e}")

8.4 Avoid Raising General Exceptions

Do not raise Exception directly; use a more specific built-in exception or create a custom one (see Tutorial 6). Raising Exception makes it difficult for callers to handle it specifically.

# Bad: raise Exception("Something went wrong") # Good: raise ValueError("Invalid data")

8.5 Documenting Raised Exceptions

In function docstrings, list the exceptions that the function may raise (using :raises: in Sphinx/Google style). This helps users of your function.

def divide(a, b): """ Divide a by b. :param a: numerator :param b: denominator :return: division result :raises ZeroDivisionError: if b is zero """ if b == 0: raise ZeroDivisionError("Cannot divide by zero") return a / b

8.6 Reraise with Updated Message

If you catch an exception and want to add more context but keep the original type, you can raise a new exception of the same type with a more informative message.

try: int(user_input) except ValueError as e: raise ValueError(f"Invalid number format: {user_input}") from e

9. Comparing raise vs assert

Feature raise assert
Purpose Signal an error condition at runtime Test assumptions during development
Can be disabled No Yes (with -O flag)
Used for Input validation, business rules, preconditions Debugging, internal invariants
Example raise ValueError("Invalid input") assert x > 0, "x must be positive"
Best practice Always use in production code for validation Use for debugging; not for production validation

Rule of thumb: Use raise for conditions that should always be checked. Use assert for conditions that should only be checked during development to catch bugs.

10. Complete Example: Robust User Input with raise

import math def get_positive_integer(prompt): """ Prompt the user for a positive integer. Raises ValueError if input is not a positive integer. """ try: value = int(input(prompt)) except ValueError as e: raise ValueError("Input must be a valid integer") from e if value <= 0: raise ValueError("Input must be a positive integer") return value def calculate_factorial(n): if n < 0: raise ValueError("Factorial not defined for negative numbers") return math.factorial(n) def main(): try: n = get_positive_integer("Enter a positive integer: ") fact = calculate_factorial(n) print(f"{n}! = {fact}") except ValueError as e: print(f"Error: {e}") if __name__ == "__main__": main()

11. Summary

Feature Description
raise Explicitly signal an error condition.
Re‑raising raise inside except re‑throws current exception.
Exception chaining raise NewError from original preserves the cause.
Suppressing cause raise NewError from None hides the original.
Best practice Use specific built‑ins or custom exceptions, provide clear messages, and document them.
When to use Input validation, business rules, and any condition that must always be enforced.

12. Self-Assessment Quiz

Test your understanding of the concepts covered in this tutorial. Answer each question, then click to reveal the correct answer.

Q1: Which statement is used to purposely throw an exception in Python?

Answer`raise`

Q2: What is the output of the following code?

try: raise ValueError("Oops") except ValueError: print("Caught") raise
Answer(B) It prints "Caught" and then re‑raises the ValueError.

Q3: True or False: You can only raise built‑in exceptions; you cannot raise your own classes.

AnswerFalse. You can raise your own exception classes (see Tutorial 6).

Q4: What is the purpose of raise ... from?

Answer(B) To chain exceptions, linking a new exception to the original cause.

Q5: Which of the following is a good use of raise?

Answer(B) To validate function arguments and enforce business rules.

Q6: What happens if you use raise without any arguments outside of an except block?

Answer(A) It raises a `RuntimeError: No active exception to reraise`.

Q7: In exception chaining, which attribute of the raised exception refers to the original cause?

Answer(B) `__cause__`

Q8: Which is better for enforcing input validation in production code?

Answer(B) `raise` because `assert` can be disabled.

Q9: Given the following code, what will be printed?

try: try: raise ValueError("Inner") except ValueError as e: raise RuntimeError("Outer") from e except RuntimeError as e: print(e.__cause__)
Answer(B) It prints the original `ValueError` instance (with message "Inner").

Q10: Which of the following correctly re‑raises the current exception while preserving the traceback?

Answer(A) `raise` (without arguments).

13. Practical Exercises

Complete the exercises below to reinforce your understanding. Sample solutions are provided after each exercise.

Exercise 1: Raising for Input Validation

Instructions: Write a function create_account(username, password, email) that:

Test the function with valid and invalid inputs.

Sample Answer
def create_account(username, password, email): if not username or len(username) < 3: raise ValueError("Username must be at least 3 characters long") if len(password) < 8: raise ValueError("Password must be at least 8 characters long") if '@' not in email: raise ValueError("Email must contain '@'") return {"username": username, "password": password, "email": email} # Test try: user = create_account("al", "pass", "test") except ValueError as e: print(e)

Exercise 2: Re-raising After Logging

Instructions: Write a function safe_open(filename) that:

In the main program, call safe_open with a non-existent file, catch the re-raised exception, and print "Caught in main."

Sample Answer
def safe_open(filename): try: return open(filename, 'r') except FileNotFoundError: print(f"File not found: {filename}") raise except PermissionError: print(f"Permission denied: {filename}") raise except Exception: print("Unexpected error") raise def main(): try: file = safe_open("non_existent.txt") except FileNotFoundError: print("Caught in main.") if __name__ == "__main__": main()

Exercise 3: Exception Chaining

Instructions: Write a function parse_config(filename) that:

Test it with a missing file and with a file containing invalid JSON (you can create a temporary file for testing).

Sample Answer
import json def parse_config(filename): try: with open(filename, 'r') as f: content = f.read() return json.loads(content) except FileNotFoundError as e: raise RuntimeError("Config file missing") from e except json.JSONDecodeError as e: raise ValueError("Invalid JSON format") from e # Test (requires creating test files)

Exercise 4: Using raise from None

Instructions: Write a function connect_to_database(connection_string) that:

Sample Answer
def connect_to_database(conn_str): try: # Simulate a connection error raise ConnectionError("Cannot connect") except ConnectionError: raise RuntimeError("Database connection failed") from None def main(): try: connect_to_database("test") except RuntimeError as e: print(e) # e.__cause__ is None if __name__ == "__main__": main()

Exercise 5: Documenting Exceptions

Instructions: Write a function parse_date(date_string) that:

Sample Answer
from datetime import datetime def parse_date(date_string): """ Parse a date in YYYY-MM-DD format. Args: date_string (str): The date string. Returns: datetime.date: The parsed date. Raises: ValueError: If format is incorrect, month invalid, or day invalid. """ try: date_obj = datetime.strptime(date_string, "%Y-%m-%d").date() return date_obj except ValueError as e: # Convert to our own ValueError with clearer message raise ValueError(f"Invalid date format or value: {date_string}") from e

14. Homework Questions

Short Answer Questions

1. You are designing a banking application. Write a function transfer(amount, from_account, to_account) that:

Assume accounts are dictionaries with keys 'balance'. Include proper exception handling in the calling code.

Sample Answer
def transfer(amount, from_account, to_account): # Type checks if not isinstance(amount, (int, float)): raise TypeError("Amount must be a number") if not isinstance(from_account, dict) or not isinstance(to_account, dict): raise TypeError("Accounts must be dictionaries") if 'balance' not in from_account or 'balance' not in to_account: raise TypeError("Account dictionaries must have 'balance' key") if amount <= 0: raise ValueError("Amount must be positive") if from_account['balance'] < amount: raise RuntimeError("Insufficient balance") from_account['balance'] -= amount to_account['balance'] += amount return from_account['balance'] # Usage try: transfer(-10, {'balance': 100}, {'balance': 50}) except (ValueError, RuntimeError, TypeError) as e: print(e)

2. Given the following function that reads a number from a file and squares it:

def square_number_from_file(filename): with open(filename, 'r') as f: data = f.read() num = int(data) return num * num

Rewrite this function to:

Sample Answer
def square_number_from_file(filename): try: with open(filename, 'r') as f: data = f.read() except FileNotFoundError as e: raise RuntimeError(f"Could not read number from {filename}") from e try: num = int(data) except ValueError as e: raise ValueError(f"Invalid number format in {filename}") from e return num * num

3. Write a function find_average(numbers) that:

Discuss why you chose assert for the empty list check and raise for the other checks.

Sample Answer
def find_average(numbers): # Debugging assertion (can be disabled) assert len(numbers) > 0, "List must not be empty" if not isinstance(numbers, list): raise TypeError("numbers must be a list") total = 0 for n in numbers: if not isinstance(n, (int, float)): raise ValueError("All elements must be numbers") total += n return total / len(numbers)

Discussion: assert is used for the empty list check because it is a programmer error (the function should never be called with an empty list in correct code). It's a precondition that we assume holds. However, if the list comes from user input, we might want to raise a ValueError instead. The type check and element type checks are for external validation that must always be enforced, so we use raise.

Essay Question

4. Consider the following code:

try: try: x = int("abc") except ValueError as e: raise TypeError("Conversion error") from e except TypeError as e: print(e) print(e.__cause__) print(e.__context__)
Sample Answer
try: try: x = int("abc") except ValueError as e: raise TypeError("Conversion error") from e except TypeError as e: print(e) # "Conversion error" print(e.__cause__) # The original ValueError instance print(e.__context__) # None (since we used 'from', __context__ is not set)

Difference: __cause__ is explicitly set by raise ... from. __context__ is the implicit previous exception that would have been chained if no from was used. With from, __cause__ takes precedence and __context__ is usually set to None.

Research Question

5. Design a validation function validate_user_input(data) that:

Sample Answer
def validate_user_input(data): required_keys = {'name', 'age', 'email'} if not all(key in data for key in required_keys): raise KeyError(f"Missing keys. Required: {required_keys}") name = data['name'] if not isinstance(name, str) or not name.strip(): raise ValueError("Name must be a non-empty string") age = data['age'] if not isinstance(age, int): raise TypeError("Age must be an integer") if age < 0 or age > 120: raise ValueError("Age must be between 0 and 120") email = data['email'] if not isinstance(email, str) or '@' not in email: raise ValueError("Email must contain '@'") return True # Test test_data = [{'name': 'Alice', 'age': 30, 'email': 'alice@example.com'}, {'name': '', 'age': 25, 'email': 'bob@example.com'}, {'name': 'Bob', 'age': 150, 'email': 'bob@example.com'}, {'name': 'Charlie', 'age': 'twenty', 'email': 'charlie@test'}] for data in test_data: try: validate_user_input(data) print("Valid") except (KeyError, ValueError, TypeError) as e: print(f"Invalid: {e}")

This tutorial provides a thorough understanding of raising exceptions with raise. The quizzes, exercises, and homework problems will help you master these concepts and apply them in real-world programming. Happy coding!

Previous | Tutorial index | Next