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
withand context managers- Logging
- Assertions
Error categories
| Category | When | Example |
|---|---|---|
| Syntax | Before anything runs | Missing colon, bad indentation |
| Runtime | During execution | ValueError, KeyError, ZeroDivisionError |
| Logical | Never — the code runs and is wrong | Wrong 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.
| Part | Meaning |
|---|---|
| Last line | The exception type and message — the fault |
| The frame above it | Where it was raised — calculations.py, line 7 |
| Frames above that | How execution got there |
| First frame | Where 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
| Exception | Cause |
|---|---|
ValueError | Right type, wrong value — int("abc") |
TypeError | Wrong type — "5" + 5 |
KeyError | Missing dictionary key |
IndexError | Index out of range |
AttributeError | No such attribute — often on None |
FileNotFoundError | Missing file |
ZeroDivisionError | Division by zero |
ImportError | Module not found |
StopIteration | Iterator 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
| Message | Cause | Fix |
|---|---|---|
ValueError: invalid literal for int() with base 10 | int() on non-numeric text | Validate, or catch it |
ZeroDivisionError | Divided by zero | Guard the denominator |
AttributeError: 'NoneType' object has no attribute 'x' | Something returned None | Check before using |
| Errors vanish and the program continues | Bare except: | Catch specific exceptions |
| The traceback points at the wrong place | Re-raised with a new exception | raise ... 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 errorinstead of bareraise, resetting the traceback- No
from errorwhen wrapping, losing the cause - Exception messages with no offending value
- Inheriting from
BaseException - Manual
close()instead ofwith logger.error(str(e))instead oflogger.exception- f-strings in log calls
- Logging secrets
assertfor input validation- Too much inside a
tryblock
Practice
The course exercise is fix type and indentation errors, extended to exceptions.
- Trigger a
ZeroDivisionErrorthree calls deep. Read the traceback and name the fault and its location. - Trigger
AttributeError: 'NoneType' object has no attribute. Trace back to what returnedNone. - Write
except Exception: passaround a failing call. Confirm the program appears to work, then add logging and see what was hidden. - Write a bare
except:and try to stop the program with Ctrl+C. - List
except Exceptionbeforeexcept ValueError. Confirm the specific handler never runs. - Put two statements in a
tryblock and confirm the wrong one is caught. Move one toelse. - Wrap a
DatabaseErrorin a custom exception withoutfrom. Compare the traceback with and without. - Use
raise errorinstead of bareraisein a handler. Compare the tracebacks. - Define a
SchoolErrorhierarchy with three subclasses carrying structured data. Catch them by the base class. - Open a file without
with, raise inside, and confirm the handle leaks. Then usewith. - Write a
@contextmanagertransaction helper. Force a failure and confirm the rollback. - Log with an f-string and with
%splaceholders. Set the level toWARNINGand measure both in a loop of 100,000. - Use
logger.error(str(e))andlogger.exceptionand compare the output. - Write validation with
assert, then run withpython -Oand confirm it is gone. - Rewrite it with
raise ValueError. - Use
!rin 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
finallyand context managers for cleanup - Preserve the original error with
raise ... from
Review questions
- In which direction do you read a Python traceback, and why?
- Why is
except Exception: passmore damaging than an unhandled error? - What does
raise ... from errorpreserve? - Why must
assertnever validate input?
Next: Files and JSON