Skip to main content
Published / updated

Errors and Exceptions

Before you start

You need: functions (Article 04).

Time: about 45 minutes, plus the practice.

Learning objective

Read a traceback to find a fault, handle exceptions where you can act on them, and define exceptions a caller can use.

Topics

  • Error categories
  • Reading a traceback
  • try, except, else, finally
  • Catching the right exception
  • Raising and chaining
  • Custom exceptions
  • with and context managers
  • Logging
  • Assertions

Error categories

CategoryWhenExample
SyntaxBefore anything runsMissing colon, bad indentation
RuntimeDuring executionValueError, KeyError, ZeroDivisionError
LogicalNever — the code runs and is wrongWrong grade, wrong total

Syntax errors are free — Python refuses to start. Runtime errors are loud. Logical errors are the expensive ones, because nothing tells you: the absent student marked "Fail" runs perfectly and prints a wrong result sheet.

Exceptions cover the second category. Tests and careful reading cover the third.

Reading a traceback

Traceback (most recent call last):
File "main.py", line 42, in <module>
report = build_report(students)
File "reports.py", line 18, in build_report
percentage = calculate_percentage(student.marks, student.max_marks)
File "calculations.py", line 7, in calculate_percentage
return (marks_obtained / max_marks) * 100
ZeroDivisionError: division by zero

Read it bottom-up.

PartMeaning
Last lineThe exception type and message — the fault
The frame above itWhere it was raised — calculations.py, line 7
Frames above thatHow execution got there
First frameWhere the call chain started

"Most recent call last" means the bottom frame is the innermost. That is the opposite of C# and Java stack traces, and it catches people coming from those languages.

The exception type tells you the category; the message tells you the specifics; the bottom frame tells you where to put a breakpoint.

The common exceptions

ExceptionCause
ValueErrorRight type, wrong value — int("abc")
TypeErrorWrong type — "5" + 5
KeyErrorMissing dictionary key
IndexErrorIndex out of range
AttributeErrorNo such attribute — often on None
FileNotFoundErrorMissing file
ZeroDivisionErrorDivision by zero
ImportErrorModule not found
StopIterationIterator exhausted

AttributeError: 'NoneType' object has no attribute 'x' is the most frequent runtime error in Python. It means something returned None — a dict.get miss, a function with no return on one path, a database query finding nothing.

try and except

try:
marks = int(raw_input_value)
except ValueError:
print("Please enter a whole number.")
try:
student = students[roll_number]
percentage = calculate_percentage(student.marks, student.max_marks)
except KeyError:
print(f"No student with roll number {roll_number}.")
except ZeroDivisionError:
print("The exam has no maximum marks recorded.")
except (ValueError, TypeError) as error:
print(f"Invalid data: {error}")

Except blocks are tested in order. A broad type listed first makes the specific ones unreachable:

try:
...
except Exception: # catches everything
...
except ValueError: # never runs
...

else and finally

try:
connection = open_connection()
except ConnectionError as error:
logger.error("Could not connect: %s", error)
raise
else:
process(connection) # only when no exception occurred
finally:
connection.close() # always

else runs when the try block succeeded. It keeps the try block small — only the line that can fail belongs there:

# Wrong — a KeyError from process() is caught by the ValueError handler's neighbours
try:
marks = int(raw)
process(marks)
except ValueError:
...

# Right
try:
marks = int(raw)
except ValueError:
...
else:
process(marks)

finally runs on success, on exception, and on return. It is for cleanup — though with is usually better.

Catching the right exception

# Wrong — hides every bug, including typos
try:
process_students()
except:
pass

A bare except: catches KeyboardInterrupt and SystemExit too, so the program cannot be stopped with Ctrl+C.

# Still wrong — silently swallows real failures
try:
process_students()
except Exception:
pass

An empty except block is the most damaging pattern here. The application appears to work while producing wrong results, and when it is eventually investigated there is nothing in the logs.

# Right — specific, and it acts
try:
marks = int(raw)
except ValueError:
logger.warning("Invalid marks value %r for %s", raw, roll_number)
marks = None

Catch what you can act on. If the handler cannot do anything useful, let the exception travel up to a level that can — usually one place that logs it and shows the user a message.

Catching broadly at the top level is different and correct:

def main() -> int:
try:
run()
return 0
except KeyboardInterrupt:
print("\nCancelled.")
return 130
except Exception:
logger.exception("Unhandled error")
print("Something went wrong. See the log for details.")
return 1

logger.exception records the full traceback. logger.error(str(e)) records only the message and loses the location — a difference you notice at 2 a.m.

Raising

def calculate_percentage(marks_obtained: int, max_marks: int) -> float:
if max_marks <= 0:
raise ValueError(f"max_marks must be positive, got {max_marks}")

return (marks_obtained / max_marks) * 100

Include the offending value in the message. "Invalid input" costs an hour; "max_marks must be positive, got 0" costs nothing.

try:
student = repository.find(roll_number)
except DatabaseError as error:
raise StudentLoadError(f"Could not load {roll_number}") from error

raise ... from error chains the exceptions, so the traceback shows both:

DatabaseError: connection timed out

The above exception was the direct cause of the following exception:

StudentLoadError: Could not load NCA-2024-0012

Without from, the original cause is lost and you are debugging the wrapper.

try:
save(student)
except DatabaseError:
logger.exception("Save failed for %s", student.roll_number)
raise # re-raise, preserving the original traceback

A bare raise re-raises with the original traceback. raise error restarts it from this line, so the traceback points at your handler rather than the failing code.

Custom exceptions

class SchoolError(Exception):
"""Base class for every error this application raises."""


class StudentNotFoundError(SchoolError):
def __init__(self, roll_number: str) -> None:
super().__init__(f"No student with roll number {roll_number}")
self.roll_number = roll_number


class DuplicateRollNumberError(SchoolError):
def __init__(self, roll_number: str) -> None:
super().__init__(f"Roll number {roll_number} is already in use")
self.roll_number = roll_number


class PaymentExceedsBalanceError(SchoolError):
def __init__(self, amount: Decimal, outstanding: Decimal) -> None:
super().__init__(f"Payment {amount} exceeds the outstanding balance {outstanding}")
self.amount = amount
self.outstanding = outstanding

A common base class lets a caller catch everything from your module in one clause:

try:
enrol_student(request)
except SchoolError as error:
return {"error": str(error)}, 400

Carry structured data on the exception, not only in the message. error.roll_number lets a caller act; parsing the message string does not.

Inherit from Exception, never BaseException — that is reserved for KeyboardInterrupt and SystemExit.

with and context managers

# Manual — leaks the handle if an exception occurs
file = open("students.csv")
data = file.read()
file.close()

# Correct
with open("students.csv", encoding="utf-8") as file:
data = file.read()

with closes the resource even when an exception is raised. For files that means no leaked handle; for connections it means one returned to the pool.

with open("input.csv", encoding="utf-8") as source, \
open("output.csv", "w", encoding="utf-8") as target:
target.write(source.read())

Writing your own:

from contextlib import contextmanager

@contextmanager
def database_transaction(connection):
transaction = connection.begin()

try:
yield transaction
except Exception:
transaction.rollback()
raise
else:
transaction.commit()
with database_transaction(connection) as transaction:
insert_payment(transaction, payment)
update_balance(transaction, payment)

The code before yield is setup, after it is teardown. This is the same transaction pattern as the other tracks, expressed as a context manager — and the caller cannot forget to commit or roll back.

from contextlib import suppress

with suppress(FileNotFoundError):
os.remove(temporary_path)

suppress is an explicit, readable "ignore this specific exception" — better than try/except/pass, because the intent is stated.

Logging

import logging

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s"
)

logger = logging.getLogger(__name__)

logger.debug("Searching students for %s", term)
logger.info("Payment %s of %s recorded for %s", payment_id, amount, roll_number)
logger.warning("Duplicate roll number %s rejected", roll_number)
logger.error("Could not save student %s", roll_number)
logger.exception("Unhandled failure") # includes the traceback

Use %s placeholders, not f-strings, in log calls. With placeholders the formatting only happens if the level is enabled — so a logger.debug in a hot loop costs nothing in production. An f-string formats every time.

logger = logging.getLogger(__name__) names the logger after the module, so output shows where each line came from and levels can be configured per module.

logger.exception inside an except block records the full traceback. logger.error(str(error)) records one line and loses the location.

Never log a password, a token or a connection string. Logs are copied to aggregation services, retained for months, and readable by more people than the database.

print is for a program's output. Logging is for diagnostics, and it can be filtered, redirected and levelled.

Assertions

def calculate_percentage(marks_obtained: int, max_marks: int) -> float:
assert max_marks > 0, "max_marks must be positive"
return (marks_obtained / max_marks) * 100

Assertions are removed when Python runs with -O. Anything relying on them for validation disappears in an optimised run:

python -O main.py # every assert is stripped

Never use assert to validate input. Use it for internal invariants — conditions you believe can never be false, documenting an assumption for the next reader.

# Wrong — this is input validation
assert amount > 0, "amount must be positive"

# Right
if amount <= 0:
raise ValueError(f"amount must be positive, got {amount}")

Validating input

def parse_marks(raw: str, max_marks: int) -> int | None:
"""Return the parsed marks, or None when the input is blank.

Raises:
ValueError: if the value is not a whole number in range.
"""
stripped = raw.strip()

if not stripped:
return None

try:
marks = int(stripped)
except ValueError as error:
raise ValueError(f"Marks must be a whole number, got {raw!r}") from error

if not 0 <= marks <= max_marks:
raise ValueError(f"Marks must be between 0 and {max_marks}, got {marks}")

return marks

{raw!r} uses repr, so " 87 " prints with its quotes and whitespace visible — which is exactly what you need when debugging an input problem.

Returning None for blank and raising for invalid distinguishes "not supplied" from "wrong", which the caller usually needs to treat differently.

EAFP versus LBYL

# LBYL — look before you leap
if roll_number in students:
student = students[roll_number]

# EAFP — easier to ask forgiveness than permission
try:
student = students[roll_number]
except KeyError:
student = None

EAFP is the Python convention. It avoids the race between the check and the use, and it is faster when the exception is rare.

For a dictionary, students.get(roll_number) is better than either.

Errors you will hit

MessageCauseFix
ValueError: invalid literal for int() with base 10int() on non-numeric textValidate, or catch it
ZeroDivisionErrorDivided by zeroGuard the denominator
AttributeError: 'NoneType' object has no attribute 'x'Something returned NoneCheck before using
Errors vanish and the program continuesBare except:Catch specific exceptions
The traceback points at the wrong placeRe-raised with a new exceptionraise ... from err

A bare except: catches everything, including KeyboardInterrupt. Catch the exception you can actually handle.

Common mistakes

  • Reading a traceback top-down
  • Bare except:
  • except Exception: pass
  • A broad handler listed before specific ones
  • Catching an exception you cannot act on
  • raise error instead of bare raise, resetting the traceback
  • No from error when wrapping, losing the cause
  • Exception messages with no offending value
  • Inheriting from BaseException
  • Manual close() instead of with
  • logger.error(str(e)) instead of logger.exception
  • f-strings in log calls
  • Logging secrets
  • assert for input validation
  • Too much inside a try block

Practice

The course exercise is fix type and indentation errors, extended to exceptions.

  1. Trigger a ZeroDivisionError three calls deep. Read the traceback and name the fault and its location.
  2. Trigger AttributeError: 'NoneType' object has no attribute. Trace back to what returned None.
  3. Write except Exception: pass around a failing call. Confirm the program appears to work, then add logging and see what was hidden.
  4. Write a bare except: and try to stop the program with Ctrl+C.
  5. List except Exception before except ValueError. Confirm the specific handler never runs.
  6. Put two statements in a try block and confirm the wrong one is caught. Move one to else.
  7. Wrap a DatabaseError in a custom exception without from. Compare the traceback with and without.
  8. Use raise error instead of bare raise in a handler. Compare the tracebacks.
  9. Define a SchoolError hierarchy with three subclasses carrying structured data. Catch them by the base class.
  10. Open a file without with, raise inside, and confirm the handle leaks. Then use with.
  11. Write a @contextmanager transaction helper. Force a failure and confirm the rollback.
  12. Log with an f-string and with %s placeholders. Set the level to WARNING and measure both in a loop of 100,000.
  13. Use logger.error(str(e)) and logger.exception and compare the output.
  14. Write validation with assert, then run with python -O and confirm it is gone.
  15. Rewrite it with raise ValueError.
  16. Use !r in an error message for a value with leading whitespace.

Exercises 3, 8 and 14 correspond to three real production problems.

You can now

  • Read a traceback bottom-up to find the fault
  • Catch specific exceptions, never bare except:
  • Raise exceptions with useful messages
  • Use finally and context managers for cleanup
  • Preserve the original error with raise ... from

Review questions

  1. In which direction do you read a Python traceback, and why?
  2. Why is except Exception: pass more damaging than an unhandled error?
  3. What does raise ... from error preserve?
  4. Why must assert never validate input?

Next: Files and JSON