Please use a larger screen

This presentation is designed for desktop or projector displays.

← Back to curriculum

Python Object Oriented Programming

Exception Handling & Logging

Course 6

try except finally Graceful error handling in Python

to navigate

Course Curriculum

Where we are in the journey

Quiz Time! (Lecture 03)

Can a Python class inherit from multiple parent classes?

A) No, Python only supports single inheritance

B) Yes, Python supports multiple inheritance

C) Only with abstract base classes

D) Only if the parents have no shared methods

Python supports multiple inheritance — a class can inherit from several parents. The MRO (Method Resolution Order) determines which parent method is called first.

Recap: Lecture 03

Inheritance: MRO & Multiple Parents

  • A child class can have multiple parents: class C(A, B)
  • Python uses the MRO (C3 linearization) to resolve conflicts
  • Check the order with ClassName.__mro__
  • Useful for mixins — small classes that add specific behavior
class Flyable:
    def move(self):
        return "Flying"

class Swimmable:
    def move(self):
        return "Swimming"

class Duck(Flyable, Swimmable):
    pass

print(Duck().move())  # "Flying" (MRO)

Quiz Time! (Lecture 04)

What is polymorphism in OOP?

A) Hiding data inside a class

B) Creating multiple constructors

C) Same interface, different behavior

D) Copying objects from one class to another

Polymorphism lets different classes share the same method name but provide their own implementation — "many forms"

Recap: Lecture 04

Polymorphism: Many Forms

  • Different classes can share the same method name
  • Each class provides its own implementation
  • Enables writing code that works with any subclass
  • Key to the Open/Closed Principle
class Cat(Animal):
    def speak(self):
        return "Meow!"

class Dog(Animal):
    def speak(self):
        return "Woof!"

for animal in [Cat(), Dog()]:
    print(animal.speak())

Quiz Time! (Lecture 05)

What happens to composed parts when the owner is destroyed?

A) They move to another owner

B) They continue to exist independently

C) They are destroyed too

D) They become global variables

In composition, parts are owned by the parent — when the owner is destroyed, so are all its composed parts

Recap: Lecture 05

Composition vs Aggregation

Composition

  • Strong has-a relationship
  • Parts created inside the owner
  • Parts die with the owner
  • Example: House & Rooms

Aggregation

  • Weak has-a relationship
  • Parts passed in from outside
  • Parts survive independently
  • Example: Team & Players

Agenda for Today

Exception handling in real-world

A circuit breaker in an electrical system is designed to cut off the flow of electricity when there is an overload or a short circuit, preventing fires and equipment damage.

Similarly, exception handling acts as a circuit breaker for your code, stopping the execution flow in the case of an error but also preventing further damage and allowing for a safe recovery or shutdown.

Circuit breaker

Exception handling in real-world

Imagine exception handling as the emergency services (fire department, paramedics, police) in a city.

Just as these services respond to unexpected situations (fires, medical emergencies, crimes), exception handling in programming allows a system to respond to unexpected errors.

Without emergency services ready to respond, situations could escalate quickly, much like unhandled exceptions can cause a program to crash or behave unpredictably.

Program (the city) 🚨 Exception occurs! except handle it finally clean up

Key Principles

  1. 1.Detection: Identifying that something has gone wrong or is outside the expected operating parameters.
  2. 2.Containment: Preventing the initial problem from causing further damage or system-wide failure.
  3. 3.Alternative Action: Having a pre-defined way to respond to the error, whether it's a fallback mechanism, alerting a user, or attempting to recover.
  4. 4.Logging/Reporting: Recording the error for later analysis and debugging.

What is Exception Handling?

Exception handling is a programming mechanism that enables developers to manage errors and anomalies that arise during the execution of a program.

In essence, it allows a program to continue running or gracefully terminate when it encounters unexpected situations, rather than crashing abruptly.

Program Error! Handle & recover

Purpose of Exception Handling

The purpose of exception handling is not just to prevent software crashes but also to:

It is a fundamental aspect of writing robust, reliable software that can handle the unpredictable nature of real-world operation.

Roses are Red,
Violets are Blue,
Unexpected '{'
on line 32.

Errors

When writing a program, we, more often than not, will encounter errors.

Error caused by not following the proper structure (syntax) of the language is called a syntax error or parsing error.

Syntax errors are detected by the interpreter during the parsing stage, before the execution begins. Examples include missing colons, incorrect indentation, or unmatched parentheses.

Errors need to be fixed in the code; they cannot be handled by exception handling mechanisms because they prevent the program from running correctly in the first place.

Syntax error Example 1

Incorrect version:

if True
    print("This will cause a syntax error because the colon is missing.")

Error Message:

  File "<stdin>", line 1
    if True
         ^
SyntaxError: invalid syntax

Corrected version:

if True:
    print("This will cause a syntax error because the colon is missing.")

Syntax error Example 2

Incorrect version:

def my_function():
print("Hello")  # This line should be indented.

Error Message:

  File "<stdin>", line 2
    print("Hello")
    ^
IndentationError: expected an indented block

Corrected version:

def my_function():
    print("Hello")

Logical Errors: Explanation

These are mistakes in the program's logic that lead to incorrect behavior or results.

Logical errors do not prevent the code from running but can lead to unexpected outcomes.

# Incorrect: overwrites instead of accumulating
total = 10
prices = [2, 5, 8]
for price in prices:
    total = price
print(f"Total: {total}")  # 8, not 25!

Corrected version:

Use += to accumulate instead of = which overwrites.

# Corrected: accumulates properly
total = 10
prices = [2, 5, 8]
for price in prices:
    total += price
print(f"Total: {total}")  # 25

Exceptions?!

Definition:

Exceptions are events that occur during the execution of a program, disrupting its normal flow.

They often arise from errors in the program's logic or unexpected conditions, such as:

Nature of Exceptions:

Unlike syntax errors, which are identified before the program runs, exceptions can happen at any time during execution. They need to be explicitly handled to maintain the stability and reliability of the software.

Exceptions. ZeroDivisionError

Raised when you divide a value or variable with zero

Incorrect version:

print(1 / 0)

Error Message:

ZeroDivisionError: division by zero

Exceptions. NameError

Raised when a variable is not found in the local or global scope.

Incorrect version:

print(undefined_variable)

Error Message:

NameError: name 'undefined_variable' is not defined

Built-in Python Exceptions

  1. AssertionError: assert statement fails
  2. EOFError: input() meets end-of-file
  3. AttributeError: attribute assignment fails
  4. ImportError: importing a module fails
  5. IndexError: sequence index out of range
  6. KeyboardInterrupt: Ctrl+C pressed
  7. RuntimeError: no category fits
  8. NameError: variable not found
  1. MemoryError: out of memory
  2. ValueError: right type, wrong value
  3. ZeroDivisionError: division by zero
  4. SyntaxError: invalid Python syntax
  5. IndentationError: wrong indentation
  6. SystemError: internal interpreter error
  7. TypeError: operation on wrong type

How Exceptions Work in Python?!

After learning about errors and exceptions, we will learn to handle them by using try, except, else, and finally blocks.

So, what do we mean by handling them?

In normal circumstances, these errors will stop the code execution and display the error message.

To create stable systems, we need to anticipate these errors and come up with alternative solutions or warning messages.

try / except: Flow

try: result = 10 / x data = load_file() process(data) Exception raised! except: log_error(e) show_message() use_fallback() Instead of crashing, your code moves on to alternative solutions.

try / except: Simple example

In this example, we will try to print the undefined x variable.

try:
    print(x)

except:
    print("An exception has occurred!")

try / except: Specific error example

try:
    result = 10 / 0

except ZeroDivisionError:
    print("Caught a division by zero error!")

try / except: Multiple except

try:
    x = 1
    result = 10 / x

except ZeroDivisionError:
    print("Caught a division by zero error!")

except:
    print("Something else went wrong")

try / except: Loading the file example

try:
    with open('data.csv') as file:
        read_data = file.read()
except FileNotFoundError as fnf_error:
    print(fnf_error)
    print("Explanation: We cannot load the 'data.csv' file")

Output:

[Errno 2] No such file or directory: 'data.csv'
Explanation: We cannot load the 'data.csv' file

We can print default error messages without breaking the execution.

try / except: Catching multiple in a single block

try:
    choice = input("Enter '1' for ValueError, '2' for TypeError: ")
    if choice == '1':
        int('xyz')
    elif choice == '2':
        sum('123')

except (ValueError, TypeError) as e:
    print(f"Caught an exception: {e}")

try / except / else

try else No exception? Run after try except Exception? Handle it here
try:
    value = int(input("Enter a number: "))

except ValueError:
    print("You must enter a valid integer.")

else:
    print(f"You entered {value}...")
  • try block: code that might fail
  • else runs only if no exception

try / except / finally

try else except finally Always runs, no matter what.

The finally keyword in the try-except block is always executed, irrespective of whether there is an exception or not.

It is quite useful in cleaning up resources and closing the object, especially closing the files.

try:
    # Code that might raise an exception
except ExceptionType:
    # Code that runs if an exception occurs
finally:
    # Cleanup code that runs no matter what

try / except / finally Closing a File

try:
    file = open("example.txt", "r")
    data = file.read()
    print(data)
except FileNotFoundError:
    print("The file was not found.")
finally:
    file.close()
    print("File closed.")

Whether or not the file is found and read, the finally block ensures that the file is attempted to be closed, avoiding a potential resource leak.

try / except / finally Control flow

def example_function():
    try:
        print("Inside try block")
        return "Return from try block"
    except Exception as e:
        print(f"Exception occurred: {e}")
        return "Return from except block"
    else:
        print("Inside else block")
        return "Return from else block"
    finally:
        print("This is the finally block.")

print(example_function())

Output:

Inside try block
This is the finally block.
Return from try block

The finally block is executed even if the try block has a return statement.

try / finally without except

try:
    print("Performing an operation that doesn't specifically need exception handling.")
finally:
    print("This cleanup code runs no matter what.")

This will ensure that no matter what happens in the try block, the cleanup code in the finally block will always run.

You don't always need an except block — sometimes you only care about cleanup, not about catching the exception.

Nested exceptions

def divide(a, b):

    try:
        result = a / b

        try:
            print(f"Opening non-existent file.")
            with open("non_existent_file.txt", "r") as file:
                data = file.read()
        except FileNotFoundError as fnf_error:
            print(f"File error: {fnf_error}")

    except ZeroDivisionError as zde_error:
        print(f"Math error: {zde_error}")

divide(10, 0)
divide(10, 2)

Outer Try-Except Block: Handles division operations. Catches ZeroDivisionError if b is zero.

Inner Try-Except Block: Inside the successful division, attempts to open a non-existent file. Catches FileNotFoundError.

Depending on the inputs, different exceptions can be caught at different levels.

Raising Exceptions in Python

As a Python developer, you have the option to throw an exception if certain conditions are met.

It allows you to interrupt the program based on your requirement.

To throw an exception, we have to use the raise keyword followed by an exception name:

raise ExceptionType("Error message")

Raising exceptions: Example 1

try:
    if age < 0:
        raise ValueError("Age cannot be negative.")
    print(f"Age set to {age}")

except ValueError as e:
    print(e)

This example raises a ValueError if a negative age is passed, which is then caught and handled in the try-except block.

Raising exceptions: Example with function

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

try:
    set_age(-1)
except ValueError as e:
    print(e)

This example raises a ValueError if a negative age is passed, which is then caught and handled in the try-except block.

WHEN (?!) to raise exceptions

Validation: To enforce certain conditions or validate inputs. If a function requires a positive integer, you can raise an exception if a negative value is passed.

📣 Error Reporting: To signal errors in a way that's consistent with Python's error handling model, making your code more intuitive to other Python developers.

🔀 Control Flow: In complex functions, to signal that an error condition has occurred, leading to an early exit from a function or triggering specific error handling logic.

WHY (?!) to raise exceptions

Use exceptions to communicate that something unexpected has happened, to enforce correct usage of functions, and to make your code more robust and maintainable.

Re-raising exceptions

def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Logging: Attempted to divide by zero.")
        raise  # Reraising the exception
    return result

try:
    divide_result = divide(1, 0)
except ZeroDivisionError:
    print("Caught in the outer try-except block.")

Output:

Logging: Attempted to divide by zero.
Caught in the outer try-except block.

The except block catches the exception, prints a logging message, and then uses raise to reraise it.

The reraised exception is propagated upwards, until it reaches the outer try-except block.

In Python, everything is an object,
including exceptions.

This is a fundamental concept in Python's object-oriented programming model.

Understanding Exception object

When an error occurs, Python creates an exception object with useful attributes:

try:
    int("abc")
except ValueError as e:
    print(type(e))
    print(e.args)
    print(str(e))
    print(e.__traceback__)

Output:

<class 'ValueError'>
("invalid literal for int()...",)
invalid literal for int()...
<traceback object at 0x...>

ValueError

.args — tuple of arguments

.__str__() — error message

.__class__ — exception type

.__traceback__ — call stack

Exception Hierarchy

Exception ArithmeticError NameError ValueError OSError RuntimeError ZeroDivisionError FloatingPointError PermissionError FileNotFoundError All exceptions inherit from the Exception base class This is the same inheritance you learned in Lecture 03!

Wait... this IS inheritance! (Lecture 03)

The exception hierarchy you just saw is the same inheritance you learned in Lecture 03:

  • ZeroDivisionError is-a ArithmeticError
  • ArithmeticError is-a Exception
  • Catching ArithmeticError also catches ZeroDivisionError — just like a parent reference can hold a child object
Every time you write except SomeError, you're using inheritance to decide which errors to catch.
try:
    1 / 0
except ArithmeticError:
    print("Catches ZeroDivisionError too!")
    print("Because it inherits from")
    print("ArithmeticError")

Exceptions use all the OOP you've learned

Encapsulation (Lec 02)

Custom exceptions bundle error data inside the object — self.message, self.value

class ValidationError(Exception):
    def __init__(self, msg, val):
        self.message = msg
        self.value = val

Inheritance (Lec 03)

Every custom exception calls super().__init__() — same pattern as any child class

class ValidationError(Exception):
    def __init__(self, msg):
        super().__init__(msg)

Polymorphism (Lec 04)

Multiple except blocks — same structure, different behavior based on exception type

except ValueError:
    print("Bad value")
except TypeError:
    print("Wrong type")
Composition (Lecture 05) also appears — a traceback object is composed of frame objects. Exceptions are OOP all the way down.

Creating custom exceptions

Custom exceptions are defined by subclassing Python's built-in Exception class.

The simplest form:

class MyCustomError(Exception):
    pass

With a constructor:

class ValidationError(Exception):
    def __init__(self, message, value):
        self.message = message
        self.value = value
        super().__init__(message)

Using custom exceptions

class ValidationError(Exception):
    def __init__(self, message, value):
        self.message = message
        self.value = value
        super().__init__(message)
def validate_age(age):
    if age < 0:
        raise ValidationError("Age cannot be negative", age)
    print(f"Valid age: {age}")

try:
    validate_age(-1)
except ValidationError as e:
    print(f"Error: {e.message} - Invalid Value: {e.value}")

Best practices for custom exceptions

🏭

Inheritance

Always inherit from Exception or its subclasses. Avoid BaseException directly.

class MyError(Exception):  # good
    pass

🏷️

Naming

End exception names with "Error" suffix. Makes intent immediately clear.

ValidationError  # good
InvalidInput     # unclear

📄

Documentation

Add docstrings explaining when it's raised and any extra attributes.

class AgeError(Exception):
    """Raised for invalid age"""

Assert statement

The assert statement in Python is a debugging aid that tests a condition as an internal self-check in your program.

It is intended as a debugging aid, not as a mechanism for handling run-time errors.

assert condition, "Optional error message"

Assert statement: Example 1

def calculate_age(birth_year, current_year):
    assert current_year >= birth_year, "Current year must be greater than or equal to birth year"
    return current_year - birth_year

age = calculate_age(1990, 2022)  # This will pass
age = calculate_age(2022, 1990)  # This will raise an AssertionError

Output:

AssertionError: Current year must be greater than or equal to birth year

Assert statement: When to use & Limitations

When to use

  • Debugging: During development, to catch conditions that should never occur
  • Self-checks: To perform sanity checks that verify your program's internal state
  • Documenting Assumptions: To document and assert the assumptions your code makes

Limitations

  • Performance: Assertions can be globally disabled with the -O flag, which removes all assert statements
  • Security: Do not use assert for validating data or security decisions — since it can be disabled, it could introduce vulnerabilities

Logging exceptions

Logging exceptions is a crucial practice in software development, allowing developers to record errors that occur during the execution of a program.

This practice not only aids in debugging by providing a historical record of what went wrong and when, but it also helps in monitoring the health of applications in production.

import logging

try:
    1 / 0
except ZeroDivisionError:
    logging.exception("Exception occurred")

Output: 2023-04-04 12:30:55 - root - ERROR - Exception occurred

Logging Levels

2023-04-01 09:15:30 - root - DEBUG    - This is a debug message
2023-04-02 10:20:45 - root - INFO     - This is an info message
2023-04-03 11:25:50 - root - WARNING  - This is a warning message
2023-04-04 12:30:55 - root - ERROR    - This is an error message
2023-04-05 13:35:00 - root - CRITICAL - This is a critical message

Logging in action

2023-04-06 08:00:00 - webapp - INFO     - Starting the web application.
2023-04-06 08:00:05 - webapp - DEBUG    - Loading configuration from 'config.yaml'.
2023-04-06 08:00:06 - webapp - INFO     - Configuration loaded successfully.
2023-04-06 08:00:10 - webapp - INFO     - Web server is running on http://localhost:5000
2023-04-06 08:00:10 - webapp - DEBUG    - Registered routes: /home, /login, /api/users, /api/orders
2023-04-06 08:05:23 - webapp - INFO     - Received GET request for /home from 192.168.1.42
2023-04-06 08:05:23 - webapp - DEBUG    - Serving page from template 'home.html'. Render time: 12ms.
2023-04-06 08:10:37 - webapp - INFO     - Received POST request for /login from 192.168.1.42
2023-04-06 08:10:38 - webapp - WARNING  - Login attempt with unrecognized username 'admin'.
2023-04-06 08:10:39 - webapp - WARNING  - 3 failed login attempts from 192.168.1.42 in the last 5 minutes.
2023-04-06 08:12:15 - webapp - INFO     - Received GET request for /api/orders from 192.168.1.50
2023-04-06 08:12:15 - webapp - DEBUG    - Querying database: SELECT * FROM orders WHERE status='pending'
2023-04-06 08:15:42 - webapp - ERROR    - Database connection error on query: SELECT * FROM users WHERE username='admin'.
2023-04-06 08:15:42 - webapp - ERROR    - Retry 1/3 failed. Connection refused by db-server:5432.
2023-04-06 08:15:43 - webapp - ERROR    - Retry 2/3 failed. Connection refused by db-server:5432.
2023-04-06 08:15:44 - webapp - ERROR    - Retry 3/3 failed. Giving up.
2023-04-06 08:15:44 - webapp - CRITICAL - Unable to handle request due to database failure. Shutting down web server.
Notice how the log tells a story — startup, config, normal traffic, suspicious login attempts, DB queries, retry failures, and critical shutdown. Each level adds context for debugging.

Setting up logging

Configure logging with basicConfig() at the start of your program:

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    filename="app.log"
)
  • level — minimum severity to capture
  • format — structure of each log line
  • filename — write to file instead of console

📈 Severity levels (low to high):

DEBUG = 10
INFO = 20
WARNING = 30
ERROR = 40
CRITICAL = 50

Named loggers

In real applications, use getLogger(__name__) instead of the root logger. This creates a logger named after your module:

import logging

logger = logging.getLogger(__name__)

def process_order(order_id):
    logger.info(f"Processing order {order_id}")
    try:
        validate(order_id)
    except ValueError as e:
        logger.error(f"Invalid order {order_id}: {e}")
        raise

print() vs logging

print() logging
📈 Severity levels ❌ None ✅ DEBUG, INFO, WARNING, ERROR, CRITICAL
📄 Output to file ❌ Manual redirect ✅ Built-in filename parameter
⏱️ Timestamps ❌ Must add manually ✅ Automatic via format
🚫 Toggle on/off ❌ Delete or comment out ✅ Change level to hide messages
🎓 Production use ❌ Never ✅ Industry standard
Use print() for quick debugging during development. Use logging for everything else.

Best practices for logging

do's

don'ts

Python's philosophy on errors and exceptions

Python's philosophy on errors and exceptions is encapsulated in the mantra:

It's easier to ask for forgiveness
than permission.

This approach, often abbreviated as "EAFP," is deeply ingrained in Python's design,
encouraging the use of try-except blocks for handling potential errors.

Key Takeaways

Next up: SOLID Principles

AI Hackathon — Apr 18, 10AM-7PM at Visma Tech office