In the previous lessons, we learned how Python automatically raises exceptions when it encounters problems such as invalid values, incorrect types, division by zero, missing files, or unavailable dictionary keys. We also learned how try, except, else, and finally can be used to control what happens when those exceptions occur.
However, Python programmers do not have to wait for Python to automatically generate an exception. We can also intentionally raise an exception ourselves when a particular condition violates the rules of our program.
This is where the raise statement becomes important.
The basic syntax is:
raise ExceptionType("message")
For example:
raise ValueError("Invalid value")
When Python executes this statement, it raises a ValueError with the supplied message.
The important idea is that raise allows a programmer to say:
“This situation is not acceptable for my program, so I want Python to raise an exception now.”
This is extremely useful for validation, business rules, functions, classes, APIs, and Data Analytics applications.
Consider a function that calculates a student’s grade.
def calculate_grade(marks):
if marks >= 90:
return "A+"
elif marks >= 80:
return "A"
elif marks >= 70:
return "B"
else:
return "F"
There is an important problem with this function.
What happens if someone passes:
150
Marks of 150 should not be accepted if the valid range is 0 to 100.
Similarly, what happens with:
-20
Negative marks are also invalid.
We can enforce the rule using raise:
def calculate_grade(marks):
if marks < 0 or marks > 100:
raise ValueError(
"Marks must be between 0 and 100."
)
if marks >= 90:
return "A+"
elif marks >= 80:
return "A"
elif marks >= 70:
return "B"
else:
return "F"
Now the function explicitly rejects invalid marks.
For example:
calculate_grade(85)
works normally.
But:
calculate_grade(150)
raises:
ValueError
with the message:
Marks must be between 0 and 100.
This makes the function more reliable because it enforces its own input rules.
ValueError is one of the most commonly used exceptions with raise.
It is appropriate when the type of data is acceptable but the actual value is invalid.
For example:
age = -5
if age < 0:
raise ValueError(
"Age cannot be negative."
)
The value is an integer, so its type is acceptable. But -5 is not a valid age for this application.
Therefore, ValueError is appropriate.
Another example:
percentage = 120
if percentage > 100:
raise ValueError(
"Percentage cannot exceed 100."
)
Again, the value has the correct general type, but the value itself violates the application’s rules.
Sometimes the problem is not the value but its type.
Suppose a function expects a number:
def calculate_salary(salary):
if not isinstance(
salary,
(int, float)
):
raise TypeError(
"Salary must be numeric."
)
return salary * 12
Now:
calculate_salary(50000)
works correctly.
But:
calculate_salary("50000")
raises:
TypeError
because the function expects a numeric value but receives a string.
This demonstrates a useful distinction:
Wrong value
↓
ValueError
Wrong type
↓
TypeError
The raise statement becomes particularly useful when combined with try and except.
Consider:
def validate_marks(marks):
if marks < 0 or marks > 100:
raise ValueError(
"Marks must be between 0 and 100."
)
return marks
try:
marks = validate_marks(150)
print(
"Valid marks:",
marks
)
except ValueError as error:
print(
"Validation error:",
error
)
The function detects that 150 is invalid and raises a ValueError.
The calling code catches that exception.
The flow is:
validate_marks(150)
↓
Invalid value
↓
raise ValueError
↓
except ValueError
↓
Handle validation problem
This creates a clean separation between validation and error handling.
One of the most important uses of raise is inside functions.
A function can define its requirements and reject invalid input.
For example:
def calculate_discount(price, discount):
if price < 0:
raise ValueError(
"Price cannot be negative."
)
if discount < 0 or discount > 100:
raise ValueError(
"Discount must be between 0 and 100."
)
return price - (
price * discount / 100
)
Now:
calculate_discount(
1000,
20
)
returns:
800.0
But:
calculate_discount(
1000,
150
)
raises a ValueError.
This is much safer than allowing invalid business data to silently pass through the application.
Not every exception is caused by a technical programming problem.
Sometimes an application has its own rules.
For example, imagine a banking system where an account cannot withdraw more money than its available balance.
def withdraw(balance, amount):
if amount <= 0:
raise ValueError(
"Withdrawal amount must be positive."
)
if amount > balance:
raise ValueError(
"Insufficient balance."
)
return balance - amount
The Python language itself does not consider withdrawing more than the balance to be a programming error. It is a business rule defined by our application.
We can therefore use raise to enforce that rule.
This is an important professional programming concept.
Applications often have rules such as:
raise allows those rules to be enforced directly in the code.
A common pattern is:
if condition_is_invalid:
raise ValueError(
"Explanation of the problem"
)
For example:
temperature = 150
if temperature > 100:
raise ValueError(
"Temperature exceeds the allowed range."
)
Another example:
quantity = -10
if quantity < 0:
raise ValueError(
"Quantity cannot be negative."
)
This pattern is simple but extremely powerful.
Data Analytics applications frequently need to validate data before performing calculations.
Suppose we are calculating a profit margin:
def profit_margin(revenue, profit):
if revenue <= 0:
raise ValueError(
"Revenue must be greater than zero."
)
return (
profit / revenue
) * 100
This prevents invalid calculations.
For example:
profit_margin(
100000,
20000
)
returns:
20.0
But:
profit_margin(
0,
20000
)
raises a ValueError before the calculation is performed.
This is better than allowing the program to reach a division-by-zero situation without a meaningful explanation of the business problem.
Suppose a function requires at least one record.
def calculate_average(values):
if len(values) == 0:
raise ValueError(
"Dataset cannot be empty."
)
return sum(values) / len(values)
Now:
calculate_average(
[10, 20, 30]
)
works normally.
But:
calculate_average([])
raises:
ValueError:
Dataset cannot be empty.
This is a realistic validation rule for analytical applications.
We can also raise an exception when a required file does not satisfy an application’s requirements.
For example:
filename = ""
if filename == "":
raise ValueError(
"A filename is required."
)
Here, the problem is not necessarily that Python cannot open the file. The application itself has determined that an empty filename is not acceptable.
This demonstrates the broader purpose of raise: it allows developers to communicate invalid application states.
A good exception message should explain the problem clearly.
Compare:
raise ValueError("Invalid")
with:
raise ValueError(
"Salary must be greater than zero."
)
The second message provides much more useful information.
Good messages can help developers debug applications and can also make error handling easier at higher levels of the program.
A useful message should generally answer:
For example:
raise ValueError(
"Marks must be between 0 and 100."
)
clearly communicates the expected range.
When Python executes a raise statement, normal execution at that point stops unless the raised exception is handled appropriately.
For example:
print("Start")
raise ValueError(
"Something is wrong."
)
print("End")
The output will include:
Start
Then Python raises the exception.
The statement:
print("End")
is not executed because the exception interrupts normal execution.
This follows the same exception-flow rules we learned in the previous lessons.
We can also raise exceptions while processing multiple records.
For example:
marks = [
80,
95,
105,
70
]
for mark in marks:
if mark < 0 or mark > 100:
raise ValueError(
"Invalid mark detected."
)
print(
"Valid mark:",
mark
)
When Python reaches 105, the exception is raised.
Execution of the loop is interrupted unless the exception is handled.
Later, we can combine raise with try-except to decide whether the entire process should stop or whether the invalid record should be handled and processing should continue.
A common beginner mistake is to simply print an error message when something is invalid.
For example:
if age < 0:
print(
"Invalid age."
)
Printing a message does not actually stop the function from continuing.
For example:
def calculate_age_category(age):
if age < 0:
print(
"Invalid age."
)
if age < 18:
return "Minor"
return "Adult"
The function may continue after printing the message.
Using raise makes the invalid state explicit:
def calculate_age_category(age):
if age < 0:
raise ValueError(
"Age cannot be negative."
)
if age < 18:
return "Minor"
return "Adult"
Now the invalid input cannot silently continue through the function.
This is one of the biggest advantages of raise.
A good design is often to separate validation from the main calculation.
def validate_salary(salary):
if not isinstance(
salary,
(int, float)
):
raise TypeError(
"Salary must be numeric."
)
if salary <= 0:
raise ValueError(
"Salary must be greater than zero."
)
def calculate_annual_salary(salary):
validate_salary(salary)
return salary * 12
Now the main function does not need to repeat the validation logic.
For example:
calculate_annual_salary(
50000
)
works correctly.
But:
calculate_annual_salary(
-5000
)
raises the appropriate validation exception.
This approach becomes especially useful as applications grow larger.
raise statement allows a programmer to intentionally raise an exception.raise ValueError() is commonly used when a value violates a rule.raise TypeError() can be used when an object has an inappropriate type.raise is useful for validation and business rules.raise can be handled using try-except.raise stops normal execution at the point where the exception is raised unless it is handled.raise to validate datasets, numerical values, business metrics, and input data.raise is different from simply printing an error message because it changes program flow.raise to protect the rest of an application from invalid data.In the next part, we will move from Python’s built-in exceptions to custom exceptions. You will learn how to create your own exception classes, inherit from Python’s Exception class, add meaningful messages, and use custom exceptions in real applications.
In the previous part, we learned how the raise statement allows us to intentionally raise built-in exceptions such as ValueError and TypeError. This is useful when our application has validation rules or business conditions that Python cannot automatically detect.
But sometimes built-in exceptions are not specific enough for an application.
For example, imagine a student management system. We might encounter situations such as:
We could raise ValueError for all of these situations, but doing so may make a large application harder to understand.
Instead, Python allows us to create our own exception classes.
These are called custom exceptions or user-defined exceptions.
A custom exception is an exception class created by the programmer for a specific application requirement.
The simplest form is:
class MyException(Exception):
pass
Here:
MyException is our custom exception.Exception is the built-in base exception class.pass means that we are not adding any additional behavior yet.We can then raise it:
raise MyException(
"Something went wrong."
)
This gives our application a specific exception type that can be recognized and handled separately.
Python’s exception system is based on classes and inheritance.
Most application-level custom exceptions should inherit directly or indirectly from Python’s built-in Exception class.
For example:
class InvalidMarksError(Exception):
pass
Now InvalidMarksError is an exception type.
We can use it like this:
raise InvalidMarksError(
"Marks must be between 0 and 100."
)
The advantage is that the name itself describes the problem.
Compare:
raise ValueError(
"Marks must be between 0 and 100."
)
with:
raise InvalidMarksError(
"Marks must be between 0 and 100."
)
The second version communicates the application’s specific meaning much more clearly.
Let’s create a custom exception for invalid marks.
class InvalidMarksError(Exception):
pass
Now create a validation function:
def validate_marks(marks):
if marks < 0 or marks > 100:
raise InvalidMarksError(
"Marks must be between 0 and 100."
)
return marks
We can test it:
validate_marks(85)
This works successfully.
But:
validate_marks(120)
raises:
InvalidMarksError
Now we can catch the custom exception specifically.
try:
marks = validate_marks(120)
except InvalidMarksError as error:
print(
"Validation failed:",
error
)
The flow becomes:
validate_marks()
↓
Invalid marks
↓
raise InvalidMarksError
↓
except InvalidMarksError
↓
Handle the problem
Imagine a large application with hundreds of different validation rules.
If every problem raises only ValueError, the calling code may have difficulty understanding what type of business problem occurred.
For example:
except ValueError:
handle_problem()
Does the error mean:
A custom exception can make the distinction clear.
class InvalidMarksError(Exception):
pass
class InvalidSalaryError(Exception):
pass
class InvalidQuantityError(Exception):
pass
Now the application can handle them separately:
try:
process_data()
except InvalidMarksError:
print("Invalid student marks.")
except InvalidSalaryError:
print("Invalid salary.")
except InvalidQuantityError:
print("Invalid product quantity.")
This can make large applications easier to maintain.
A simple custom exception does not need to define its own __init__() method.
For example:
class InvalidAgeError(Exception):
pass
We can provide a message when raising it:
raise InvalidAgeError(
"Age cannot be negative."
)
The inherited exception behavior allows the message to be stored and displayed.
We can catch it:
try:
raise InvalidAgeError(
"Age cannot be negative."
)
except InvalidAgeError as error:
print(error)
Output:
Age cannot be negative.
Sometimes an application needs to store more information than just a message.
For example, suppose a student’s marks are invalid and we want the exception to remember the actual marks.
class InvalidMarksError(Exception):
def __init__(self, marks):
self.marks = marks
super().__init__(
f"Invalid marks: {marks}"
)
Now:
raise InvalidMarksError(125)
creates an exception containing the invalid value.
We can access it when handling the exception:
try:
raise InvalidMarksError(125)
except InvalidMarksError as error:
print(error)
print(
"Invalid value:",
error.marks
)
This can be useful when the application needs structured information about the problem.
One of the strongest uses of custom exceptions is representing business rules.
Consider a simple banking example.
class InsufficientBalanceError(Exception):
pass
Now create a withdrawal function:
def withdraw(balance, amount):
if amount > balance:
raise InsufficientBalanceError(
"Insufficient balance."
)
return balance - amount
We can use it:
try:
balance = withdraw(
5000,
7000
)
except InsufficientBalanceError as error:
print(error)
The exception type itself communicates what happened:
InsufficientBalanceError
This is more expressive than using a generic exception for every banking rule.
Custom exceptions are also useful in Data Analytics applications.
Suppose a Data Analytics program requires specific columns:
required_columns = [
"student_id",
"name",
"marks"
]
But the incoming dataset contains:
[
"student_id",
"name",
"age"
]
The required marks column is missing.
We could create:
class MissingColumnError(Exception):
pass
Then:
def validate_columns(
columns,
required_columns
):
for column in required_columns:
if column not in columns:
raise MissingColumnError(
f"Required column missing: {column}"
)
Now the validation is explicit.
For example:
columns = [
"student_id",
"name",
"age"
]
required_columns = [
"student_id",
"name",
"marks"
]
try:
validate_columns(
columns,
required_columns
)
except MissingColumnError as error:
print(
"Dataset validation failed:",
error
)
This is a realistic example because analytical pipelines often depend on specific columns being available before calculations can begin.
We can create a more general exception:
class DataValidationError(Exception):
pass
Then use it for multiple validation rules.
def validate_dataset(data):
if not data:
raise DataValidationError(
"Dataset is empty."
)
if "student_id" not in data:
raise DataValidationError(
"student_id column is required."
)
if "marks" not in data:
raise DataValidationError(
"marks column is required."
)
Now the calling program can handle all dataset validation problems in one place:
try:
validate_dataset(data)
except DataValidationError as error:
print(
"Data validation failed:",
error
)
This creates a clean architecture.
Custom exceptions can also be organized using inheritance.
For example:
class DataProcessingError(Exception):
pass
class MissingColumnError(DataProcessingError):
pass
class InvalidDataError(DataProcessingError):
pass
Now we have a hierarchy:
Exception
↓
DataProcessingError
↓
┌───────────────┐
↓ ↓
MissingColumn InvalidData
This provides flexibility.
We can catch a specific exception:
except MissingColumnError:
print(
"A required column is missing."
)
Or catch all data-processing exceptions together:
except DataProcessingError:
print(
"A data processing problem occurred."
)
This is one of the major benefits of inheritance.
Suppose:
class DataProcessingError(Exception):
pass
class MissingColumnError(DataProcessingError):
pass
class InvalidDataError(DataProcessingError):
pass
We can write:
try:
process_dataset()
except MissingColumnError:
print(
"Required column is missing."
)
except InvalidDataError:
print(
"Dataset contains invalid data."
)
except DataProcessingError:
print(
"General data-processing error."
)
The specific exceptions appear before the broader parent exception.
This follows the same principle we learned earlier with Python’s built-in exception hierarchy.
A larger application may have several validation layers.
Raw Data
↓
Type Validation
↓
Value Validation
↓
Business Rule Validation
↓
Data Processing
Custom exceptions can represent failures at different levels.
For example:
class DataValidationError(Exception):
pass
class InvalidTypeError(DataValidationError):
pass
class InvalidValueError(DataValidationError):
pass
class BusinessRuleError(DataValidationError):
pass
This allows an application to distinguish between different categories of problems.
Custom exceptions are useful when the exception represents a meaningful concept specific to your application.
For example, these names are meaningful:
InvalidMarksError
MissingColumnError
InsufficientBalanceError
DuplicateStudentError
InvalidEnrollmentError
DataValidationError
They communicate what went wrong without requiring the reader to inspect the entire codebase.
However, you do not need to create a custom exception for every small validation condition.
For a simple invalid value, the built-in ValueError may be perfectly appropriate.
A useful rule is:
Use a custom exception when the application needs a distinct, recognizable category of failure.
Suppose we are building a student enrollment system.
class DuplicateStudentError(Exception):
pass
Now:
students = {
101: "Rahul",
102: "Priya"
}
def add_student(student_id, name):
if student_id in students:
raise DuplicateStudentError(
f"Student ID {student_id} already exists."
)
students[student_id] = name
We can call:
try:
add_student(
101,
"Amit"
)
except DuplicateStudentError as error:
print(error)
This is much more descriptive than simply raising a generic ValueError.
Imagine a course has a maximum capacity.
class CourseFullError(Exception):
pass
Then:
def enroll_student(
current_students,
capacity
):
if current_students >= capacity:
raise CourseFullError(
"The course is already full."
)
return current_students + 1
The application can handle this condition specifically:
try:
students = enroll_student(
50,
50
)
except CourseFullError as error:
print(error)
Again, the exception name describes the business problem.
Exception.raise statement.ValueError are often sufficient.In the next part, we will take custom exceptions further by building more realistic application and Data Analytics examples, including validation layers, exception hierarchies, structured error information, and best practices for designing custom exceptions.
In the previous part, we learned how to create custom exceptions by inheriting from Python’s Exception class. We created examples such as InvalidMarksError, MissingColumnError, DuplicateStudentError, and CourseFullError.
Now we will use these concepts in more realistic applications. The goal is to understand not only how to create a custom exception, but also when and why to use one.
A good application should be able to distinguish between different categories of problems. This becomes especially important in Data Analytics, where a pipeline may contain data validation, transformation, calculations, reporting, and database operations.
Imagine that we receive student data from an external source.
student = {
"student_id": 101,
"name": "Rahul",
"marks": 85
}
Before processing this record, we may want to check whether all required fields are available.
We can create a general custom exception:
class DataValidationError(Exception):
pass
Now create a validation function:
def validate_student(student):
required_fields = [
"student_id",
"name",
"marks"
]
for field in required_fields:
if field not in student:
raise DataValidationError(
f"Missing required field: {field}"
)
return True
If all fields exist, the function returns True.
But if the marks field is missing:
student = {
"student_id": 101,
"name": "Rahul"
}
the function raises:
DataValidationError
with a meaningful message.
This creates a clear validation layer before the rest of the application processes the data.
As the application becomes larger, we may want more specific exception types.
class DataValidationError(Exception):
pass
class MissingFieldError(DataValidationError):
pass
class InvalidMarksError(DataValidationError):
pass
class InvalidStudentIDError(DataValidationError):
pass
Now the hierarchy looks like:
Exception
↓
DataValidationError
↓
┌───────────────┬──────────────────┐
↓ ↓ ↓
MissingField InvalidMarks InvalidStudentID
This gives us both specific and general handling options.
We can handle a specific problem:
except InvalidMarksError:
print(
"Marks are invalid."
)
Or handle any data validation problem:
except DataValidationError:
print(
"Student data validation failed."
)
This is one of the biggest advantages of building an exception hierarchy.
Let’s create a function specifically for marks.
def validate_marks(marks):
if not isinstance(
marks,
(int, float)
):
raise InvalidMarksError(
"Marks must be numeric."
)
if marks < 0 or marks > 100:
raise InvalidMarksError(
"Marks must be between 0 and 100."
)
return True
Now the function handles two different validation problems with the same custom exception:
For example:
validate_marks(85)
works successfully.
But:
validate_marks(150)
raises InvalidMarksError.
Similarly:
validate_marks("hello")
also raises InvalidMarksError.
We can create another validation rule.
def validate_student_id(student_id):
if not isinstance(
student_id,
int
):
raise InvalidStudentIDError(
"Student ID must be an integer."
)
if student_id <= 0:
raise InvalidStudentIDError(
"Student ID must be positive."
)
return True
This prevents invalid identifiers from entering the system.
Now we can combine the validation functions.
def validate_student(student):
if "student_id" not in student:
raise MissingFieldError(
"student_id is required."
)
if "name" not in student:
raise MissingFieldError(
"name is required."
)
if "marks" not in student:
raise MissingFieldError(
"marks is required."
)
validate_student_id(
student["student_id"]
)
validate_marks(
student["marks"]
)
return True
This creates a simple validation pipeline.
Student Record
↓
Required Fields?
↓
Student ID Valid?
↓
Marks Valid?
↓
Record Accepted
Now the application can handle the exceptions:
try:
validate_student(student)
print(
"Student record is valid."
)
except MissingFieldError as error:
print(
"Missing field:",
error
)
except InvalidMarksError as error:
print(
"Invalid marks:",
error
)
except InvalidStudentIDError as error:
print(
"Invalid student ID:",
error
)
except DataValidationError as error:
print(
"Validation error:",
error
)
The specific exceptions appear before the general DataValidationError handler.
This is important because all three specific exceptions inherit from DataValidationError.
Sometimes an exception should carry structured information.
For example, suppose we want our InvalidMarksError to store the actual marks.
class InvalidMarksError(DataValidationError):
def __init__(self, marks, message):
self.marks = marks
super().__init__(message)
Now we can raise it like this:
raise InvalidMarksError(
150,
"Marks must be between 0 and 100."
)
When catching the exception:
try:
raise InvalidMarksError(
150,
"Marks must be between 0 and 100."
)
except InvalidMarksError as error:
print(error)
print(
"Invalid marks:",
error.marks
)
This can be useful when an application needs to log the exact value that caused the problem.
Let’s imagine a simple analytics pipeline:
Load Data
↓
Validate Data
↓
Clean Data
↓
Calculate Metrics
↓
Generate Report
Each stage may encounter a different type of problem.
We can create a general pipeline exception:
class DataPipelineError(Exception):
pass
Then create specialized exceptions:
class DataLoadError(DataPipelineError):
pass
class DataValidationError(DataPipelineError):
pass
class DataProcessingError(DataPipelineError):
pass
class ReportGenerationError(DataPipelineError):
pass
Now the application can distinguish between stages.
For example:
try:
load_data()
validate_data()
process_data()
generate_report()
except DataLoadError:
print(
"Unable to load the data."
)
except DataValidationError:
print(
"Data validation failed."
)
except DataProcessingError:
print(
"Data processing failed."
)
except ReportGenerationError:
print(
"Report generation failed."
)
This structure becomes increasingly useful as an application grows.
In professional applications, simply displaying an error may not be enough.
The application may also need to record information about the problem.
For example:
try:
validate_student(student)
except DataValidationError as error:
print(
"Validation failed:",
error
)
# logging can be performed here
A logging system can record details such as:
This becomes particularly important when automated data pipelines run without direct human supervision.
Custom exceptions are also useful in applications that provide APIs.
Suppose an API receives:
{
"student_id": 101,
"marks": 150
}
The application can validate the request and raise:
InvalidMarksError
A higher-level API layer can then convert that exception into an appropriate response for the client.
The important architecture is:
API Request
↓
Validation
↓
Custom Exception
↓
Exception Handler
↓
Appropriate Response
This keeps validation logic separate from the presentation or API response logic.
Custom exceptions can also work naturally with Python classes.
Consider a simple bank account:
class InsufficientBalanceError(Exception):
pass
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientBalanceError(
"Insufficient balance."
)
self.balance -= amount
return self.balance
Now:
account = BankAccount(5000)
try:
account.withdraw(7000)
except InsufficientBalanceError as error:
print(error)
The custom exception clearly communicates the business rule violation.
In a larger Python application, exceptions can form a separate layer of the architecture.
For example:
Application
↓
Business Logic
↓
Validation
↓
Custom Exceptions
↓
Exception Handler
This allows different components to have different responsibilities.
The validation function determines that something is wrong.
The custom exception communicates what is wrong.
The higher-level handler decides what the application should do about it.
This separation is an important software-development principle.
Custom exceptions are useful, but they should not be created unnecessarily.
For a simple function like:
def calculate_percentage(value):
if value < 0 or value > 100:
raise ValueError(
"Value must be between 0 and 100."
)
there may be no need for a custom exception.
ValueError clearly communicates that the value is invalid.
Creating a custom class for every single validation rule can make a small program unnecessarily complicated.
Use custom exceptions when the application benefits from having a distinct exception category.
Custom exception names should normally end with:
Error
Examples:
InvalidMarksError
MissingColumnError
DataValidationError
DuplicateStudentError
CourseFullError
InsufficientBalanceError
Good names make the code self-explanatory.
Compare:
class Error1(Exception):
pass
with:
class MissingColumnError(Exception):
pass
The second version immediately tells another developer what the exception represents.
Exception or an appropriate application-specific exception.Error.Create these custom exceptions:
DataValidationError
MissingColumnError
InvalidValueError
Make MissingColumnError and InvalidValueError inherit from DataValidationError.
Then create a function:
validate_data(data)
The function should:
MissingColumnError if the required name field is missing.InvalidValueError if the age is negative.True if the record is valid.Finally, write try-except blocks that handle the specific exceptions.
ValueError and TypeError should be preferred when they already express the problem adequately.In the final part of this lesson, we will build a complete practical project using raise, custom exceptions, validation, and exception handling. We will also cover debugging exercises, common mistakes, interview questions, and a complete revision of the lesson.
In the previous parts, we learned how to use the raise statement, create custom exception classes, build exception hierarchies, and apply custom exceptions to Data Analytics and application-level validation.
Now we will combine all these concepts into one practical project.
We will build a small Student Data Validation System. The system will check whether student records contain valid information before allowing them to enter the processing stage.
This project will demonstrate:
raiseValueErrorTypeErrortry-exceptFirst, we create a general exception for student-data problems.
class StudentDataError(Exception):
pass
Now we create more specific exceptions:
class MissingFieldError(StudentDataError):
pass
class InvalidStudentIDError(StudentDataError):
pass
class InvalidMarksError(StudentDataError):
pass
class DuplicateStudentError(StudentDataError):
pass
Our hierarchy is now:
Exception
↓
StudentDataError
↓
┌───────────────┬──────────────────┬──────────────────┐
↓ ↓ ↓ ↓
MissingField InvalidStudentID InvalidMarks DuplicateStudent
This structure allows us to catch either a specific problem or any student-data problem as a group.
Now we create the main validation function.
def validate_student(student):
required_fields = [
"student_id",
"name",
"marks"
]
for field in required_fields:
if field not in student:
raise MissingFieldError(
f"Missing required field: {field}"
)
student_id = student["student_id"]
if not isinstance(
student_id,
int
):
raise InvalidStudentIDError(
"Student ID must be an integer."
)
if student_id <= 0:
raise InvalidStudentIDError(
"Student ID must be positive."
)
marks = student["marks"]
if not isinstance(
marks,
(int, float)
):
raise InvalidMarksError(
"Marks must be numeric."
)
if marks < 0 or marks > 100:
raise InvalidMarksError(
"Marks must be between 0 and 100."
)
return True
This function performs several checks.
First, it checks whether all required fields exist.
Then it checks the student ID.
Finally, it checks the marks.
If all checks pass, the function returns:
True
If any rule fails, an appropriate custom exception is raised.
Let’s create a valid record:
student = {
"student_id": 101,
"name": "Rahul",
"marks": 85
}
Now:
validate_student(student)
will return:
True
The record passes all validation rules.
Now remove the marks field:
student = {
"student_id": 101,
"name": "Rahul"
}
When we run:
validate_student(student)
the function raises:
MissingFieldError
with the message:
Missing required field: marks
This is much more informative than simply saying that the data is invalid.
Now consider:
student = {
"student_id": -10,
"name": "Rahul",
"marks": 85
}
The student ID is negative.
The function therefore executes:
raise InvalidStudentIDError(
"Student ID must be positive."
)
The validation process stops at that point.
Consider:
student = {
"student_id": 101,
"name": "Rahul",
"marks": 150
}
The marks exceed the allowed range.
The function raises:
InvalidMarksError
with the message:
Marks must be between 0 and 100.
Again, the invalid record does not silently continue into the processing stage.
We can now create a higher-level function that decides what to do with validation failures.
def process_student(student):
try:
validate_student(student)
except MissingFieldError as error:
print(
"Missing field:",
error
)
return False
except InvalidStudentIDError as error:
print(
"Invalid student ID:",
error
)
return False
except InvalidMarksError as error:
print(
"Invalid marks:",
error
)
return False
else:
print(
"Student record is valid."
)
return True
Notice that the validation function does not decide how the application should respond.
It simply identifies the problem and raises an appropriate exception.
The higher-level function decides what to do with that exception.
This separation is a useful programming pattern.
Now let’s create multiple student records.
students = [
{
"student_id": 101,
"name": "Rahul",
"marks": 85
},
{
"student_id": 102,
"name": "Priya",
"marks": 92
},
{
"student_id": 103,
"name": "Amit",
"marks": 150
},
{
"student_id": 104,
"name": "Neha"
},
{
"student_id": -5,
"name": "Ravi",
"marks": 70
}
]
We can process each record:
valid_students = []
for student in students:
if process_student(student):
valid_students.append(student)
The invalid records are reported, while valid records can continue into the next stage of the pipeline.
This is a realistic pattern for data processing:
Raw Records
↓
Validation
↓
┌───────────────┐
↓ ↓
Valid Invalid
↓ ↓
Process Report
↓
Analysis
Now let’s add another business rule.
A student ID should not appear twice.
We already created:
class DuplicateStudentError(StudentDataError):
pass
We can use a set to track IDs:
student_ids = set()
valid_students = []
for student in students:
try:
validate_student(student)
student_id = student["student_id"]
if student_id in student_ids:
raise DuplicateStudentError(
f"Student ID {student_id} already exists."
)
student_ids.add(student_id)
valid_students.append(student)
except StudentDataError as error:
print(
"Student data error:",
error
)
This example demonstrates an important feature of the exception hierarchy.
We can catch all our student-data exceptions with:
except StudentDataError:
because the specific exceptions inherit from StudentDataError.
Suppose the application has:
MissingFieldError
InvalidStudentIDError
InvalidMarksError
DuplicateStudentError
Sometimes we want different responses:
except InvalidMarksError:
send_marks_warning()
except MissingFieldError:
request_missing_data()
But sometimes we simply want to know whether any student-data problem occurred:
except StudentDataError:
reject_student_record()
The hierarchy gives us both options.
Not every problem needs a custom exception.
For example, if a function receives a value of the wrong type, a built-in TypeError may be appropriate.
def calculate_total(price, quantity):
if not isinstance(
price,
(int, float)
):
raise TypeError(
"Price must be numeric."
)
if not isinstance(
quantity,
int
):
raise TypeError(
"Quantity must be an integer."
)
return price * quantity
For application-specific rules, a custom exception may be clearer.
class InvalidQuantityError(Exception):
pass
Then:
if quantity < 0:
raise InvalidQuantityError(
"Quantity cannot be negative."
)
The decision depends on whether the problem is adequately represented by an existing built-in exception.
Let’s apply the same principle to a dataset.
Suppose our analytics pipeline requires:
required_columns = [
"date",
"sales",
"profit"
]
We can create:
class DataPipelineError(Exception):
pass
class MissingColumnError(DataPipelineError):
pass
class InvalidDataError(DataPipelineError):
pass
Now validate the columns:
def validate_columns(
columns,
required_columns
):
for column in required_columns:
if column not in columns:
raise MissingColumnError(
f"Missing column: {column}"
)
We can then validate the values.
def validate_sales(sales):
if sales < 0:
raise InvalidDataError(
"Sales cannot be negative."
)
These functions can be part of a larger analytical pipeline.
Load Dataset
↓
Check Columns
↓
Check Values
↓
Clean Data
↓
Calculate KPIs
↓
Create Dashboard
If the dataset is structurally incorrect, the pipeline can stop early rather than producing misleading analytical results.
Imagine calculating total revenue from a dataset containing invalid sales values.
If the data contains:
10000
20000
-500000
30000
the calculation may technically work, but the result may be meaningless if negative sales are not allowed in the business context.
Validation helps ensure that the analytical calculations are performed on data that satisfies the expected rules.
The principle is:
Validate First
↓
Process Second
↓
Analyze Third
This is an important habit for anyone working with Data Analytics.
1. Creating too many custom exceptions
Not every small condition needs a separate exception class.
For example, using ValueError may be completely appropriate for a simple invalid range.
2. Poor exception names
Avoid names such as:
MyError
Error1
Problem
Prefer descriptive names:
InvalidMarksError
MissingColumnError
DuplicateStudentError
3. Catching the parent exception too early
If you need different responses for child exceptions, handle the specific exceptions first.
For example:
try:
process_student()
except InvalidMarksError:
print("Invalid marks.")
except StudentDataError:
print("Other student data problem.")
If the parent exception were placed first, it could catch the child exception before the specific handler gets a chance.
4. Losing useful information
Exception messages should explain the problem clearly.
For example:
raise MissingColumnError(
"Required column 'profit' is missing."
)
is more useful than:
raise MissingColumnError(
"Invalid data."
)
What exception does this code raise?
class InvalidAgeError(Exception):
pass
def validate_age(age):
if age < 0:
raise InvalidAgeError(
"Age cannot be negative."
)
validate_age(-10)
Answer: InvalidAgeError.
The condition age < 0 is true, so the custom exception is raised.
What happens here?
class DataError(Exception):
pass
class MissingColumnError(DataError):
pass
try:
raise MissingColumnError(
"Column missing."
)
except DataError:
print("Data error")
Answer: The output is:
Data error
Why?
Because MissingColumnError inherits from DataError, the parent exception handler can catch it.
What is wrong with the order here?
try:
raise MissingColumnError(
"Column missing."
)
except DataValidationError:
print("General validation error")
except MissingColumnError:
print("Missing column")
The broader parent exception appears before the more specific child exception.
The specific handler will therefore never be reached for that exception.
A better order is:
except MissingColumnError:
print("Missing column")
except DataValidationError:
print("General validation error")
1. What is the purpose of the raise statement?
It allows a programmer to intentionally raise an exception when a condition violates the expected behavior of the program.
2. What is a custom exception?
A custom exception is a programmer-defined exception class used to represent an application-specific problem.
3. From which class should custom exceptions normally inherit?
They normally inherit from Python’s built-in Exception class or from an appropriate application-specific exception that ultimately inherits from it.
4. Why use custom exceptions?
They make application-specific errors easier to identify, handle, and communicate.
5. Can a custom exception have additional attributes?
Yes. A custom exception can define its own __init__() method and store additional information.
6. What is an exception hierarchy?
It is a structure in which related exception classes inherit from common parent classes, allowing them to be handled individually or as a group.
7. Should every error have a custom exception?
No. Built-in exceptions such as ValueError and TypeError are often sufficient.
8. Why are custom exceptions useful in Data Analytics?
They can represent problems such as missing columns, invalid records, failed validation, and data-pipeline failures.
9. Why should specific exceptions be handled before general exceptions?
Because a broader parent exception can catch the child exception first, preventing the specific handler from executing.
10. What is the difference between print and raise?
print() only displays information, while raise creates an exception and changes the program’s control flow.
| Concept | Purpose |
|---|---|
raise |
Intentionally raise an exception |
ValueError |
Invalid value |
TypeError |
Invalid or inappropriate type |
| Custom Exception | Represent application-specific problems |
| Inheritance | Build related exception types |
try |
Execute potentially risky code |
except |
Handle an exception |
| Exception hierarchy | Handle specific or related exceptions |
The most important pattern from this lesson is:
class CustomError(Exception):
pass
def validate(data):
if invalid_condition:
raise CustomError(
"Meaningful error message."
)
try:
validate(data)
except CustomError as error:
print(error)
This pattern allows a function to detect and communicate a specific problem while allowing another part of the application to decide how that problem should be handled.
In Data Analytics, the same concept can be applied to data validation:
Raw Data
↓
Validation
↓
raise appropriate exception
↓
Exception Handler
↓
Valid Data → Analysis
Invalid Data → Report / Fix / Skip
Good exception design improves the reliability and readability of Python applications. Instead of allowing invalid data or invalid application states to silently move through the system, we can identify them early and respond appropriately.
After completing this lesson, you should be able to:
raise to intentionally generate exceptions.try-except.✅ LESSON 3 COMPLETED — Python raise and Custom Exceptions.
The next lesson should be the final important lesson of this Exception Handling chapter: Python Exception Handling Best Practices, Debugging and Real-World Project.
In this final part of the Python Exception Handling chapter, we will bring together everything we have learned so far: try, except, else, finally, raise, and custom exceptions.
The goal is not simply to memorize syntax. A good Python programmer should know when to handle an exception, when to raise one, which exception to catch, and how to keep the program understandable.
Good exception handling should make a program more reliable without hiding genuine problems.
A basic structure is:
try:
risky_operation()
except ValueError as error:
print("Invalid value:", error)
else:
print("Operation successful.")
finally:
print("Processing completed.")
Each section has a specific responsibility.
try — contains code that may raise an exception.except — handles a specific exception.else — runs when the try block succeeds.finally — performs finalization or cleanup.One of the most important rules is to catch the exception you actually expect.
Prefer:
try:
age = int(
input("Enter age: ")
)
except ValueError:
print(
"Please enter a valid number."
)
rather than unnecessarily using:
try:
age = int(
input("Enter age: ")
)
except:
print("Something went wrong.")
The specific version tells us exactly what type of problem we are expecting.
If an unexpected exception occurs, it is less likely to be hidden accidentally.
A bare except catches almost every exception that reaches it.
try:
process_data()
except:
print("Error")
This may make debugging difficult because important problems can disappear behind a generic message.
A better approach is:
try:
process_data()
except ValueError:
print("Invalid data.")
except FileNotFoundError:
print("File not found.")
Now the program distinguishes between different problems.
A common mistake is putting a large amount of unrelated code inside one try block.
For example:
try:
data = load_data()
clean_data(data)
calculate_metrics(data)
generate_report(data)
send_email()
except Exception:
print("Something failed.")
If something fails, it may be difficult to determine which operation caused the problem.
When practical, keep the protected operation focused:
try:
data = load_data()
except FileNotFoundError:
print("Data file not found.")
Then handle other operations separately where appropriate.
Error messages should explain what happened.
Compare:
raise ValueError("Invalid")
with:
raise ValueError(
"Marks must be between 0 and 100."
)
The second message is much more useful.
A good message can tell us:
Avoid code such as:
try:
process_data()
except:
pass
This tells Python to ignore the problem completely.
Although there can be specialized situations where deliberately ignoring an exception is appropriate, it should never be the default approach.
Silently ignoring exceptions can lead to incorrect results that are much harder to discover later.
If an operation requires cleanup, finally can be used.
resource = None
try:
resource = open_resource()
process(resource)
except SomeException:
print("Processing failed.")
finally:
if resource is not None:
resource.close()
The cleanup logic is separated from the error-handling logic.
For files, Python also provides the with statement, which is generally preferred for automatic resource management. We will study that more deeply in File Handling.
When your application has a rule that must be enforced, use raise.
def validate_age(age):
if age < 0:
raise ValueError(
"Age cannot be negative."
)
return age
This prevents invalid information from silently entering the rest of the program.
If an application has a meaningful category of error, a custom exception can make the code clearer.
class MissingColumnError(Exception):
pass
Then:
if "sales" not in columns:
raise MissingColumnError(
"Required sales column is missing."
)
This is especially useful in larger applications and Data Analytics pipelines.
Exception handling should not replace normal program logic unnecessarily.
For example, if you simply need to check whether a number is positive:
if number > 0:
print("Positive")
there is no reason to raise and catch an exception for this normal condition.
Exceptions are most useful when something violates an expected contract or an operation cannot proceed normally.
When catching an exception, you can store it in a variable:
try:
number = int("hello")
except ValueError as error:
print(
"Conversion failed:",
error
)
This gives us access to the original exception message.
In professional applications, this information can also be recorded through logging systems.
Let’s now combine the complete chapter into a small project.
Our system will:
First, define the custom exceptions:
class StudentDataError(Exception):
pass
class MissingFieldError(StudentDataError):
pass
class InvalidMarksError(StudentDataError):
pass
class DuplicateStudentError(StudentDataError):
pass
Now create the validation function:
def validate_student(student):
required_fields = [
"student_id",
"name",
"marks"
]
for field in required_fields:
if field not in student:
raise MissingFieldError(
f"Missing field: {field}"
)
marks = student["marks"]
if not isinstance(
marks,
(int, float)
):
raise InvalidMarksError(
"Marks must be numeric."
)
if marks < 0 or marks > 100:
raise InvalidMarksError(
"Marks must be between 0 and 100."
)
Now create sample data:
students = [
{
"student_id": 101,
"name": "Rahul",
"marks": 85
},
{
"student_id": 102,
"name": "Priya",
"marks": 92
},
{
"student_id": 103,
"name": "Amit",
"marks": 120
},
{
"student_id": 104,
"name": "Neha"
},
{
"student_id": 105,
"name": "Ravi",
"marks": 76
}
]
Now process the records:
student_ids = set()
valid_students = []
invalid_students = []
for student in students:
try:
validate_student(student)
student_id = student["student_id"]
if student_id in student_ids:
raise DuplicateStudentError(
f"Duplicate ID: {student_id}"
)
student_ids.add(student_id)
except StudentDataError as error:
print(
"Invalid record:",
error
)
invalid_students.append(student)
else:
valid_students.append(student)
print(
"Valid record:",
student["name"]
)
finally:
print(
"Record processing completed."
)
At the end, we have two groups:
valid_students
invalid_students
This is a basic example of defensive data processing.
Once validation is complete, we can safely calculate statistics on the valid records.
if valid_students:
total_marks = sum(
student["marks"]
for student in valid_students
)
average_marks = (
total_marks /
len(valid_students)
)
print(
"Average marks:",
average_marks
)
Notice the sequence:
Raw Data
↓
Validation
↓
Invalid Data Separated
↓
Valid Data
↓
Analysis
This is a valuable pattern in Data Analytics.
It is better to validate data before calculating important KPIs, averages, totals, or reports.
What will happen?
try:
value = int("abc")
except ValueError:
print("Invalid value")
else:
print("Success")
finally:
print("Finished")
Answer:
Invalid value
Finished
The else block does not run because the conversion failed.
What is the problem with this code?
try:
number = 10 / 0
except Exception:
print("Error")
except ZeroDivisionError:
print("Division error")
The problem is the order of the exception handlers.
ZeroDivisionError is a subclass of Exception.
The general handler appears first and can catch the error before the specific handler gets a chance.
The better order is:
except ZeroDivisionError:
print("Division error")
except Exception:
print("Other error")
What does this code demonstrate?
class InvalidAgeError(Exception):
pass
def validate_age(age):
if age < 0:
raise InvalidAgeError(
"Age cannot be negative."
)
return age
try:
validate_age(-5)
except InvalidAgeError as error:
print(error)
It demonstrates a custom exception being created, raised, and handled.
Which block executes when there is no exception?
try:
result = 10 / 2
except ZeroDivisionError:
print("Error")
else:
print("Success")
finally:
print("Finished")
Both else and finally execute.
The output is:
Success
Finished
Which block executes when an exception occurs?
try:
result = 10 / 0
except ZeroDivisionError:
print("Error")
else:
print("Success")
finally:
print("Finished")
The output is:
Error
Finished
The else block does not execute.
Create a custom exception:
class InvalidSalesError(Exception):
pass
Then create:
def validate_sales(sales):
The function should:
InvalidSalesError when the data is invalid.Test it with:
10000
-500
"abc"
25000
Create:
class DatasetError(Exception):
pass
class MissingColumnError(DatasetError):
pass
Then write a function that checks whether a dataset contains:
date
sales
profit
If any required column is missing, raise MissingColumnError.
Finally, use try-except to handle the problem.
What does raise do in Python?
It intentionally raises an exception.
What is a custom exception?
A programmer-defined exception class representing an application-specific problem.
Why inherit custom exceptions from Exception?
Because Exception is part of Python’s exception hierarchy and provides the standard exception behavior.
What is the difference between ValueError and TypeError?
ValueError generally indicates an inappropriate value, while TypeError generally indicates an inappropriate type or operation for the given type.
What is the purpose of finally?
It provides a place for finalization or cleanup code.
When does else execute?
When the try block completes without raising an exception.
Can multiple except blocks be used?
Yes.
Can custom exceptions inherit from another custom exception?
Yes. This allows developers to build exception hierarchies.
Why should specific exceptions be handled before general exceptions?
Because a general parent exception can catch the specific exception first.
| Concept | Meaning | Example |
|---|---|---|
try |
Run potentially risky code | try: operation() |
except |
Handle an exception | except ValueError: |
else |
Run when try succeeds | else: |
finally |
Finalization / cleanup | finally: |
raise |
Intentionally raise exception | raise ValueError() |
| Custom Exception | Application-specific error | class DataError(Exception) |
| Exception Hierarchy | Organize related errors | Child → Parent |
Think of Python exception handling as a controlled decision system:
TRY
↓
Does something fail?
↙ ↘
YES NO
↓ ↓
EXCEPT ELSE
↓ ↓
Handle error Success code
↘ ↙
FINALLY
↓
Finalization
And when the programmer wants to create an error deliberately:
Invalid condition
↓
raise
↓
Exception
↓
except
↓
Handle problem
For application-specific problems:
class MyError(Exception):
pass
and:
raise MyError(
"Application-specific problem."
)
After completing this chapter, you should be comfortable with:
try and except.except blocks.else.finally.raise.Exception handling is not about making errors disappear. It is about making programs predictable and resilient when something goes wrong.
For Data Analysts and Python developers, this becomes particularly important when working with real-world datasets, files, APIs, databases, and automated data-processing pipelines.
Instead of allowing one unexpected value to break an entire workflow, good exception handling lets us identify the problem, handle it appropriately, and continue when it is safe to do so.
✅ LESSON 4 COMPLETED — Python Exception Handling Best Practices, Debugging & Real-World Project.
✅ PYTHON EXCEPTION HANDLING CHAPTER COMPLETED.