Exception Handling in Python | Python Course | Dataplexa

Exception Handling in Python

Every program you write will eventually encounter something unexpected — a user types letters where you expected a number, a file does not exist, a network connection drops, a database times out. Without a plan for those moments, your program crashes with an ugly error message and stops completely. Exception handling is Python's built-in system for catching those problems, responding to them gracefully, and keeping your program running.

This lesson covers the full picture — from the basics of try/except to raising your own exceptions, chaining errors, and the production-quality patterns professional developers follow every day.

What Is an Exception

An exception is an event that disrupts the normal flow of a program. When Python encounters an operation it cannot complete — dividing by zero, opening a missing file, converting invalid input — it raises an exception object that carries information about what went wrong and where.

If nothing catches that exception, the program terminates and Python prints a traceback — a chain of messages showing exactly which line failed, what function called it, and what type of error occurred.

Common built-in exception types you will encounter constantly:

  • ValueError — right type, wrong value: int("hello")
  • TypeError — wrong type entirely: "5" + 5
  • ZeroDivisionError — dividing any number by zero
  • FileNotFoundError — opening a file that does not exist
  • IndexError — accessing a list index out of range
  • KeyError — accessing a dictionary key that does not exist
  • AttributeError — calling a method that does not exist on an object
  • NameError — using a variable that was never defined
  • PermissionError — trying to read or write a file without permission
  • OverflowError — a number calculation exceeds the maximum representable value

The try / except Block

The core tool is the try / except block. Code that might fail goes inside try. If an exception is raised, Python jumps immediately to the matching except block instead of crashing. Execution then continues normally after the block.

# Basic try/except — safely convert user input to a number

def get_number(raw):
    try:
        n = int(raw)              # this line might raise ValueError
        print("You entered:", n)
        return n
    except ValueError:
        print("That was not a valid number.")
        return None

get_number("42")      # succeeds
get_number("hello")   # ValueError caught — no crash
get_number("3.14")    # also ValueError — int() won't parse decimals
You entered: 42 That was not a valid number. That was not a valid number.
  • Code inside try runs normally until an exception occurs — then Python immediately jumps to except.
  • If no exception occurs, the except block is skipped entirely.
  • The program continues after the try/except block in both cases — no crash.
  • Only one except block runs per exception — Python stops at the first match.

Catching Multiple Exception Types

A single try block can raise different kinds of errors. Handle each one separately with multiple except clauses, or group them in a tuple when they need the same response.

def safe_divide(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print("Cannot divide by zero.")
        return None
    except TypeError:
        print("Both inputs must be numbers.")
        return None

print(safe_divide(10, 2))     # 5.0
print(safe_divide(10, 0))     # ZeroDivisionError
print(safe_divide("x", 2))    # TypeError

# Group exceptions that need the same response
def parse_data(value, index, data):
    try:
        return int(value) + data[index]
    except (ValueError, IndexError) as e:
        print(f"Data error: {e}")
        return 0

print(parse_data("abc", 0, [10]))    # ValueError
print(parse_data("5",   9, [10]))    # IndexError
5.0 Cannot divide by zero. None Both inputs must be numbers. None Data error: invalid literal for int() with base 10: 'abc' Data error: list index out of range
  • Python checks except clauses top to bottom — always list specific exceptions before broad ones.
  • except (ValueError, TypeError): handles both with the same block — clean when the response is identical.
  • Different errors often need different messages — keep them separate when the user needs specific guidance.

Accessing the Exception Object with as e

Using as e in your except clause binds the exception object to a variable. That object holds the error message and type — essential for logging and for writing informative messages without exposing raw Python tracebacks to users.

items = [10, 20, 30]

try:
    val = items[9]              # IndexError — index 9 does not exist
except IndexError as e:
    print("Error message:", e)
    print("Error type   :", type(e).__name__)

# Using e in a realistic logging function
import datetime

def safe_open(path):
    try:
        with open(path, "r") as f:
            return f.read()
    except FileNotFoundError as e:
        timestamp = datetime.datetime.now().strftime("%H:%M:%S")
        print(f"[{timestamp}] File error: {e}")
        return ""

content = safe_open("missing_file.txt")
Error message: list index out of range Error type : IndexError [14:22:05] File error: [Errno 2] No such file or directory: 'missing_file.txt'
  • str(e) or print(e) shows the human-readable error message Python generated.
  • type(e).__name__ gives the exception class name as a plain string — useful for logging systems.
  • In production, log the full exception but show a friendly message to the user — never expose raw tracebacks in a UI.

else and finally — The Full Structure

Python's try block has two optional extra clauses that give precise control over flow in every outcome:

  • else — runs only if try completed without any exception — keeps success logic separate from error-prone code.
  • finally — runs always, whether an exception occurred or not — the right place for cleanup like closing files, releasing database connections, or stopping timers.
def read_score(val):
    try:
        score = int(val)
    except ValueError:
        print(f"  Invalid: '{val}' is not a number.")
    else:
        # only reaches here if try had no exception
        if 0 <= score <= 100:
            print(f"  Score accepted: {score}")
        else:
            print(f"  Score {score} is out of range (0-100).")
    finally:
        # always runs — success or failure
        print("  --- validation complete ---")

for v in ["85", "abc", "150"]:
    print(f"Testing '{v}':")
    read_score(v)
Testing '85': Score accepted: 85 --- validation complete --- Testing 'abc': Invalid: 'abc' is not a number. --- validation complete --- Testing '150': Score 150 is out of range (0-100). --- validation complete ---
  • else only runs when try completes with zero exceptions — keep the main success logic here, not in try.
  • finally runs even if a return is inside try, and even if an unhandled exception propagates upward.
  • Order is always: tryexcept (if error) → else (if no error) → finally (always).
  • The most common use of finally is closing resources — though with statements (Lesson 37) handle this more elegantly.

Raising Exceptions

You are not limited to catching exceptions Python raises — you can raise your own using the raise keyword. This lets you enforce your own rules and signal errors from inside your functions with clear, meaningful messages.

def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Age must be an integer, got {type(age).__name__}.")
    if age < 0 or age > 120:
        raise ValueError(f"Age {age} is out of valid range (0–120).")
    return age

def register_user(name, age):
    try:
        validated_age = set_age(age)
        print(f"Registered: {name}, age {validated_age}")
    except (TypeError, ValueError) as e:
        print(f"Registration failed: {e}")

register_user("Priya",  28)     # valid
register_user("Kiran",  -5)     # ValueError
register_user("Arjun", "old")   # TypeError
Registered: Priya, age 28 Registration failed: Age -5 is out of valid range (0–120). Registration failed: Age must be an integer, got str.
  • raise ExceptionType("message") is the standard syntax — pick the most specific built-in type that fits.
  • Raised exceptions propagate up the call stack until something catches them — if nothing does, the program terminates.
  • Use raise whenever Python would not naturally raise an error but your application rules say the input is invalid.

Custom Exception Classes

For larger programs, define your own exception types by creating a class that inherits from Exception. Custom exceptions make error handling self-documenting and let callers catch your specific error without confusing it with built-in exceptions.

# Simple custom exception — inherits everything from Exception
class InsufficientFundsError(Exception):
    """Raised when a withdrawal exceeds the account balance."""
    pass

# Custom exception with extra attributes
class ValidationError(Exception):
    """Raised when input fails validation rules."""
    def __init__(self, field, message):
        self.field   = field
        self.message = message
        super().__init__(f"{field}: {message}")

# Using both
def withdraw(balance, amount):
    if amount <= 0:
        raise ValidationError("amount", "Must be greater than zero.")
    if amount > balance:
        raise InsufficientFundsError(
            f"Cannot withdraw ${amount:.2f} — balance is only ${balance:.2f}."
        )
    return balance - amount

for amt in [50, 200, -10]:
    try:
        new_bal = withdraw(100.00, amt)
        print(f"Withdrew ${amt:.2f} — new balance: ${new_bal:.2f}")
    except InsufficientFundsError as e:
        print(f"Transaction failed: {e}")
    except ValidationError as e:
        print(f"Input error ({e.field}): {e.message}")
Withdrew $50.00 — new balance: $50.00 Transaction failed: Cannot withdraw $200.00 — balance is only $100.00. Input error (amount): Must be greater than zero.
  • Custom exceptions inherit from Exceptionpass is enough for a basic one.
  • Override __init__ to add fields like error codes or field names — callers can then access e.field directly.
  • Always call super().__init__(message) so the message appears normally when the exception is printed.
  • Name custom exceptions with the Error suffix by Python convention: InsufficientFundsError, not InsufficientFunds.

Re-raising and Exception Chaining

Sometimes you want to catch an exception, do something with it (like log it), and then let it continue propagating. Use bare raise to re-raise the same exception unchanged. Use raise NewError(...) from original to chain exceptions — preserving the original cause while adding context.

# Re-raise with bare raise — preserves original traceback
def load_config(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError as e:
        print(f"[LOG] Config missing: {e}")
        raise    # re-raises the same FileNotFoundError unchanged

# Exception chaining — raise new error and link to original cause
class ConfigError(Exception):
    pass

def load_settings(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError as e:
        raise ConfigError(f"Cannot load settings from '{path}'.") from e

try:
    load_settings("app_settings.cfg")
except ConfigError as e:
    print(f"Config error: {e}")
    print(f"Caused by   : {e.__cause__}")
[LOG] Config missing: [Errno 2] No such file or directory: 'app_settings.cfg' Config error: Cannot load settings from 'app_settings.cfg'. Caused by : [Errno 2] No such file or directory: 'app_settings.cfg'
  • Bare raise re-raises the current exception with the original traceback intact — essential for middleware and logging layers.
  • raise NewError(...) from original chains exceptions — e.__cause__ holds the original exception for inspection.
  • Chaining is the professional pattern when you want to convert low-level exceptions (like FileNotFoundError) into application-level ones (like ConfigError) without losing the root cause.

Exception Handling in Real Programs

This example simulates a data import pipeline that reads a CSV, validates each row, and collects all errors rather than stopping at the first one — the pattern used in real ETL tools and form validators.

class RowValidationError(Exception):
    def __init__(self, row_num, field, issue):
        self.row_num = row_num
        self.field   = field
        self.issue   = issue
        super().__init__(f"Row {row_num}, {field}: {issue}")

def validate_row(row_num, row):
    """Validates one row of data. Returns cleaned dict or raises."""
    name  = row.get("name", "").strip()
    price = row.get("price", "")
    stock = row.get("stock", "")

    if not name:
        raise RowValidationError(row_num, "name", "cannot be empty")
    try:
        price = float(price)
        if price < 0:
            raise RowValidationError(row_num, "price", "cannot be negative")
    except ValueError:
        raise RowValidationError(row_num, "price", f"'{price}' is not a number")
    try:
        stock = int(stock)
    except ValueError:
        raise RowValidationError(row_num, "stock", f"'{stock}' must be an integer")

    return {"name": name, "price": price, "stock": stock}

# Simulate incoming data with some bad rows
raw_data = [
    {"name": "Laptop",   "price": "1200",  "stock": "15"},
    {"name": "",         "price": "450",   "stock": "30"},     # bad name
    {"name": "Keyboard", "price": "free",  "stock": "100"},    # bad price
    {"name": "Webcam",   "price": "120",   "stock": "many"},   # bad stock
    {"name": "Monitor",  "price": "8500",  "stock": "5"},
]

valid_rows  = []
error_log   = []

for i, row in enumerate(raw_data, start=1):
    try:
        valid_rows.append(validate_row(i, row))
    except RowValidationError as e:
        error_log.append(str(e))

print(f"Valid rows : {len(valid_rows)}")
print(f"Errors     : {len(error_log)}")
print("\nValid data:")
for r in valid_rows:
    print(f"  {r['name']:<12} ${r['price']:.2f}  (stock: {r['stock']})")
print("\nErrors found:")
for err in error_log:
    print(f"  {err}")
Valid rows : 2 Errors : 3 Valid data: Laptop $1200.00 (stock: 15) Monitor $8500.00 (stock: 5) Errors found: Row 2, name: cannot be empty Row 3, price: 'free' is not a number Row 4, stock: 'many' must be an integer
  • Collecting errors rather than stopping at the first one is the standard pattern for data import tools and form validators.
  • The custom RowValidationError carries structured data (row_num, field) — callers can build reports without parsing strings.
  • This separates validation logic from display logic cleanly — each function does one job.

Best Practices

  • Never use bare except: — it silently catches everything including KeyboardInterrupt and SystemExit. Always name the exception type.
  • Keep try blocks small — wrap only the specific line that can fail. Smaller scope means fewer masked bugs.
  • Never suppress exceptions silently — an empty except block hides real problems. At minimum, log the error.
  • Use finally for cleanup — or better, use with statements which handle cleanup automatically.
  • Raise the most specific typeValueError is better than Exception. Custom exceptions are better still.
  • Let unexpected exceptions propagate — only catch exceptions you know how to handle. An unhandled exception that surfaces is easier to debug than one silently swallowed.

Quick Reference Table

Keyword / ConceptPurposeWhen It Runs
tryWraps code that might raise an exceptionAlways — first
except ExcTypeCatches a specific exception typeOnly on matching exception
except (A, B)Catches either of two exception typesOnly on matching exception
except ... as eBinds exception object to variableInside except clause
elseSuccess logic — runs after try with no errorOnly when try had no exception
finallyCleanup — always executesAlways, even if unhandled
raise ExcType("msg")Triggers an exception manuallyWhen you call it explicitly
raise (bare)Re-raises the current exception unchangedInside an except block
raise New from originalException chaining with preserved causeWhen converting exception types
Custom exception classApp-specific error type via class inheritanceWhen raised in your code

Practice

What keyword wraps code that might raise an exception?



What exception type is raised when you call int("hello")?



Which clause runs only when the try block completes with no exception?



Which clause always runs — whether an exception occurred or not?



What built-in class must a custom exception inherit from?



What syntax binds the exception object to the variable e?



Quick Quiz

What happens if an exception is raised in a try block and no matching except clause exists?






Which clause is guaranteed to run regardless of whether an exception occurred?






Which is the correct way to raise a ValueError with a custom message?





What does a bare raise statement (no argument) do inside an except block?





Which is considered best practice in exception handling?






Which syntax chains a new exception while preserving the original cause?





NEXT UP
List Comprehensions in Python
Learn how to build lists in a single line using Python's powerful comprehension syntax — transforming, filtering, and generating data with less code and more clarity.