All Advanced 5 60 min read 10 question quiz 22 exam marks

Exceptions, Logging and Defensive Code

Handle errors on purpose. The full try, except, else and finally statement, custom exception hierarchies, raising from another error, assertions and design by contract, the logging module with levels and handlers, and a method for reading a traceback and testing the failure paths.

The try statement in full

You have used try and except to stop a program crashing. The full statement has four parts, and each has a job:

try:        the code that might fail
except:     what to do if it does
else:       what to do if it did NOT fail
finally:    what must happen either way
Python
def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("cannot divide by zero")
        return None
    except TypeError as problem:
        print("bad types:", problem)
        return None
    else:
        print("division worked")
        return result
    finally:
        print("done with", repr(a), repr(b))

print(divide(10, 2))
print(divide(1, 0))
print(divide("a", 2))

Watch the order of the output. else runs only when there was no exception, so the success code goes there, which keeps try as short as possible and stops it hiding unrelated errors. finally runs always, even if the function returns or an error is not caught, which is why it is where clean-up goes.

Python
def f():
    try:
        return "try"
    finally:
        print("finally runs before the function returns")

print(f())

Exceptions form a family tree

Every exception is a class, and they inherit from each other. Catching a parent catches all its children:

Python
for kind in (ZeroDivisionError, KeyError, FileNotFoundError):
    parents = " <- ".join(c.__name__ for c in kind.__mro__[1:-1])
    print(kind.__name__, "<-", parents)

except LookupError catches both KeyError and IndexError. except Exception catches nearly everything. That is why order matters: put the specific handlers first, because Python uses the first one that fits.

Python
try:
    {}["x"]
except LookupError:
    print("LookupError caught first")
except KeyError:
    print("never reached: a KeyError is also a LookupError")
Never write a bare except

except: on its own catches everything, including the keyboard interrupt that lets you stop a runaway program, and hides real bugs. Catch the specific errors you can deal with, and let the rest crash loudly where you can see them.

Raising, re-raising and chaining

raise throws an exception of your own. Inside an except block, a bare raise re-throws the one being handled, which is useful for logging an error and then letting it carry on. raise ... from ... throws a new exception while keeping the original as its cause, so nothing is lost:

Python
def load_age(text):
    try:
        return int(text)
    except ValueError as problem:
        raise ValueError("age must be a whole number, got " + repr(text)) from problem

try:
    load_age("abc")
except ValueError as error:
    print(error)
    print("caused by:", repr(error.__cause__))

The new message is meaningful to whoever called load_age, and the technical reason is still attached.

The flow of a try statement: try, then except or else, then finallyA flowchart. The try block runs the risky code. A decision asks whether an error was raised. If yes, the first matching except block runs to handle it. If no, the else block runs, which holds the code that should only run on success. Both paths then arrive at the finally block, which always runs, for clean up, even after a return or an error that was not caught. After that the program continues, or an error that was not caught carries on upwards.try, except, else and finallytry: run the risky codeerror raised?yesexcept: handle itthe first matching one runsnoelse: success codeonly if nothing went wrongfinally: ALWAYS runsclean up, even after a return or an uncaught errorthen the program continues, or an uncaught error carries on upwards1234
  1. try holds the code that might fail, kept as short as possible.
  2. except handles a named error. Put the specific handlers before the general ones.
  3. else runs only when the try block raised nothing, so success code goes there.
  4. finally always runs, so it is where clean up belongs.
Reading a try statement is following this one flow chart.

Custom exceptions and where to catch them

The built-in exceptions describe Python's problems: ValueError, KeyError. Your program has its own: an overdrawn account, an unknown product, a game that is over. A custom exception is a class that inherits from Exception, and it names a problem in your program's own words.

Python
class BankError(Exception):
    '''Everything that can go wrong in the bank.'''

class InsufficientFunds(BankError):
    def __init__(self, balance, wanted):
        super().__init__("balance " + str(balance) + " is less than " + str(wanted))
        self.balance = balance                 # keep the facts, for whoever catches it
        self.wanted = wanted

class AccountClosed(BankError):
    pass

class Account:
    def __init__(self, balance):
        self.balance = balance
        self.open = True

    def withdraw(self, amount):
        if not self.open:
            raise AccountClosed("account is closed")
        if amount > self.balance:
            raise InsufficientFunds(self.balance, amount)
        self.balance -= amount
        return self.balance

account = Account(50)
for amount in (20, 100):
    try:
        print("left:", account.withdraw(amount))
    except InsufficientFunds as problem:
        print("short by", problem.wanted - problem.balance, "|", problem)
    except BankError as problem:               # anything else the bank can raise
        print("bank problem:", problem)

account.open = False
try:
    account.withdraw(1)
except BankError as problem:
    print(type(problem).__name__, "-", problem)

A hierarchy with one base class (BankError) lets a caller choose how precisely to catch. Handle InsufficientFunds to offer a loan. Handle BankError to show a generic apology. And an exception object can carry data, so the handler does not have to parse a message.

Where should an exception be caught?

Catch an exception at the level that knows what to do about it. The function that discovers the problem usually does not: withdraw cannot know whether to show a message, ask for another amount or cancel an order. So it raises, the exception travels up the call stack through the callers that cannot deal with it, and the first one that can, catches it.

Ask forgiveness, not permission

There are two styles for code that might fail:

StyleLooks likePython's preference
LBYL: look before you leapif key in config: then config[key]for cheap, clear checks
EAFP: easier to ask forgiveness than permissiontry: config[key] except KeyError:the usual Python style

EAFP avoids a gap in time. If you check that a file exists and then open it, the file might be deleted in between. Opening it and handling FileNotFoundError has no gap.

Assertions and defensive design

An assertion states something that must be true. If it is false, the program stops at once with an AssertionError:

Python
def average(marks):
    assert len(marks) > 0, "average needs at least one mark"
    return sum(marks) / len(marks)

print(average([7, 9]))
try:
    average([])
except AssertionError as problem:
    print("assertion failed:", problem)

An assertion is not input validation

Exceptions and assertions look alike but answer different questions:

ExceptionAssertion
Forthings that can go wrong at run time, such as bad input, a missing file, a network failurethings that can only be false if the programmer made a mistake
Examplethe user typed abc for an agethis list should never be empty at this point
Can be switched off?noyes

Python can remove assertions when it runs with the -O option, to save time. A program that relies on an assert to reject bad input would then accept it. See it happen:

Python
source = "assert False, 'boom'\nprint('the assertion was skipped')"

try:
    exec(compile(source, "<demo>", "exec"))
except AssertionError as problem:
    print("normal run:", problem)

exec(compile(source, "<demo>", "exec", optimize=1))       # what python -O does

So check user input with if and raise, and use assert for your own sanity checks while developing.

Design by contract

A useful way to think about a function is as a contract:

  • a precondition: what must be true when it is called (the caller's duty)
  • a postcondition: what will be true when it returns (the function's duty)
  • an invariant: something that stays true throughout, such as "the balance is never negative"
Python
def integer_sqrt(n):
    assert n >= 0, "precondition: n must not be negative"
    result = 0
    while (result + 1) * (result + 1) <= n:
        result += 1
    assert result * result <= n < (result + 1) * (result + 1), "postcondition failed"
    return result

print(integer_sqrt(50), integer_sqrt(64))

Defensive programming

Defensive programming assumes things will go wrong, and arranges for them to fail safely and visibly:

  • validate at the boundary, where data enters the program (input, files, the network), and then trust it inside
  • fail fast: stop at the first sign of trouble instead of carrying bad data further
  • use safe defaults, and never silently swallow an error
  • do not trust the caller, or the client, or the file
  • write the tests for the failure paths as well as for the normal ones
Fail loudly, not silently

A crash with a clear message is far cheaper to fix than a wrong answer that nobody notices. The worst code is except: pass.

Logging

Debugging with print is quick, and it has problems. The output cannot be switched off without editing the code, it all looks the same, it mixes with the real output, and it goes nowhere once the program is running on a server. The logging module is the professional answer.

Each message has a level, saying how serious it is. A logger has a threshold and ignores anything below it:

LevelNumberUse
DEBUG10detail useful only while developing
INFO20normal events: an order was accepted
WARNING30something unexpected, but the program carries on
ERROR40something failed
CRITICAL50the program cannot continue

There are three parts to set up. A logger receives your messages, a handler decides where they go (the screen, a file, the network), and a formatter decides how they look.

Python
import logging
import sys

class Short(logging.Formatter):                       # keeps this demo's output short
    def formatException(self, exc_info):
        return "  (a traceback would follow: " + exc_info[0].__name__ + ")"

def make_logger(name, level):
    logger = logging.getLogger(name)
    logger.setLevel(level)
    logger.propagate = False
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(Short("%(levelname)-8s %(name)s: %(message)s"))
    logger.handlers = [handler]
    return logger

log = make_logger("orders", logging.DEBUG)
log.debug("checking stock")
log.info("order %d accepted", 17)
log.warning("stock for %s is low", "pens")
log.error("payment failed for order %d", 17)

try:
    1 / 0
except ZeroDivisionError:
    log.exception("unexpected error")                  # logs at ERROR, with the traceback

quiet = make_logger("quiet", logging.WARNING)
quiet.info("this is below the threshold, so it is hidden")
quiet.warning("this is shown")

Notice log.info("order %d accepted", 17): the values are passed separately from the message, so Python only builds the string if the message is actually going to be used.

log.exception is meant for use inside an except block, and records the full traceback, which a print of the error message does not.

Logging to a file

On your own computer, a real program logs to a file, so that you can read what happened after it has run. A rotating handler starts a new file when the old one is full, so that logs never fill the disk:

Python (on your computer)
import logging
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler("app.log", maxBytes=100000, backupCount=3)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))

log = logging.getLogger("shop")
log.setLevel(logging.INFO)
log.addHandler(handler)

log.info("server started")
log.warning("disk space is low")
What not to log

Never write passwords, keys, card numbers or personal details into a log. Logs get copied, emailed and kept for years, and are a favourite target. Data protection law applies to log files too.

Logger names

A common pattern is logging.getLogger(__name__) at the top of each module. Each module then logs under its own name, and you can turn up the detail for just one of them while debugging, leaving the rest quiet.

Five logging levels with a threshold, and the logger, handler and formatter that deal with each messageLeft, the five levels of the logging module from most to least serious: CRITICAL, 50, the program cannot continue. ERROR, 40, something failed. WARNING, 30, something unexpected but the program carries on. INFO, 20, normal events. DEBUG, 10, detail for developers. A red dashed line labelled logger level equals WARNING sits between WARNING and INFO: the three levels above it are shown, and INFO and DEBUG, drawn with dashed outlines, are dropped. Right, three parts: the logger receives messages and checks the level, the handler decides where they go, such as the screen, a file or the network, and the formatter decides how each line looks.five levels, and a thresholdCRITICAL 50cannot continueERROR 40something failedWARNING 30unexpected, carries onINFO 20droppedDEBUG 10droppedlogger level = WARNING1three partsloggerreceives messages, checks levelhandlerwhere: screen, file, networkformatterhow each line looks2
  1. Every message has a level. The logger ignores anything below its threshold, so turning the level up or down needs no change to the code that logs.
  2. A logger receives the message, a handler sends it somewhere, and a formatter sets out how it looks.
Unlike print, logging can be filtered by seriousness and sent to a file or a server, which is what makes it fit for a running program.

Reading tracebacks and debugging professionally

When an exception is not caught, Python prints a traceback: the chain of calls that led to it, oldest first, ending with the error itself:

Traceback (most recent call last):
  File "shop.py", line 22, in <module>
    total = checkout(basket)
  File "shop.py", line 14, in checkout
    return sum(price_of(item) for item in basket)
  File "shop.py", line 9, in price_of
    return PRICES[item]
KeyError: 'pencil'

Read it from the bottom. The last line says what happened: a KeyError for 'pencil'. The line above says where: price_of, line 9. The frames above that explain how the program got there. The bug is at the bottom, but the cause might be anywhere above it. Perhaps checkout was given an item that was never in the price list.

A program can examine a traceback too:

Python
import traceback

def level3():
    return 1 / 0

def level2():
    return level3()

def level1():
    return level2()

try:
    level1()
except ZeroDivisionError as error:
    frames = traceback.extract_tb(error.__traceback__)
    print([frame.name for frame in frames])
    print(type(error).__name__, "-", error)

A method for debugging

  1. Reproduce it. If you cannot make it fail, you cannot know you have fixed it.
  2. Isolate it. Cut the program down to the smallest version that still fails. Half the bugs are found at this stage.
  3. Read the evidence: the traceback, the logs, the values of the variables.
  4. Form a hypothesis, a specific guess about the cause, and test one thing at a time.
  5. Fix the cause, not the symptom.
  6. Write a test that fails without the fix and passes with it, so the bug cannot come back.

For step 3 the debugger is better than scattered prints. Call breakpoint() where you want to stop, and Python opens an interactive prompt there:

Python (on your computer)
def total(prices):
    result = 0
    for price in prices:
        breakpoint()          # the program pauses here
        result += price
    return result

total([3, 4, 5])

At the (Pdb) prompt: p price prints a variable, n runs the next line, s steps into a call, c continues, l lists the code around you and q quits.

Test the failure paths

The tests that matter most are often the ones for what should go wrong. assertRaises checks that the right exception is raised:

Python
import io
import unittest

class InsufficientFunds(Exception):
    pass

class AccountClosed(Exception):
    pass

class Account:
    def __init__(self, balance):
        self.balance = balance
        self.open = True

    def withdraw(self, amount):
        if not self.open:
            raise AccountClosed("account is closed")
        if amount > self.balance:
            raise InsufficientFunds("not enough money")
        self.balance -= amount
        return self.balance

class TestAccount(unittest.TestCase):
    def test_too_much(self):
        with self.assertRaises(InsufficientFunds):
            Account(50).withdraw(100)

    def test_normal(self):
        self.assertEqual(Account(50).withdraw(20), 30)

    def test_closed(self):
        account = Account(50)
        account.open = False
        with self.assertRaises(AccountClosed):
            account.withdraw(1)

suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestAccount)
result = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
print("tests run:", result.testsRun, "| failures:", len(result.failures), "| errors:", len(result.errors))
Name the type, then the cause

An error question gives marks for naming the type of error (syntax, runtime or logic), and for saying what caused it. A traceback gives you both, if you read it from the last line.

Key terms you must be able to define

Exception
An object that represents an error or unusual event, which interrupts normal flow until it is caught.
try / except / else / finally
The statement that runs code, handles errors, runs code only on success, and always runs clean-up code.
Custom exception
A class that inherits from Exception and names a problem specific to a program.
Exception chaining
Raising a new exception from inside a handler while keeping the original as its cause, with raise ... from ...
Assertion
A statement that a condition must be true, which stops the program with an AssertionError if it is false.
Precondition and postcondition
What must be true when a subprogram is called, and what it promises will be true when it returns.
Invariant
A condition that stays true throughout the execution of a part of a program.
Defensive programming
Writing code that expects errors and unreliable input, and handles them safely.
Logging
Recording messages with a level and a destination as a program runs, so that its behaviour can be reviewed.
Traceback
The list of calls that were active when an exception occurred, printed with the error.

Flashcards

Say the definition out loud before you flip the card. Retrieving it from memory is what builds recall, reading it again is not.

DefineException
AnswerAn object that represents an error or unusual event, which interrupts normal flow until it is caught.
Definetry / except / else / finally
AnswerThe statement that runs code, handles errors, runs code only on success, and always runs clean-up code.
DefineCustom exception
AnswerA class that inherits from Exception and names a problem specific to a program.
DefineException chaining
AnswerRaising a new exception from inside a handler while keeping the original as its cause, with raise ... from ...
DefineAssertion
AnswerA statement that a condition must be true, which stops the program with an AssertionError if it is false.
DefinePrecondition and postcondition
AnswerWhat must be true when a subprogram is called, and what it promises will be true when it returns.
DefineInvariant
AnswerA condition that stays true throughout the execution of a part of a program.
DefineDefensive programming
AnswerWriting code that expects errors and unreliable input, and handles them safely.

Reach the top grade on this topic

Error handling questions reward knowing which tool is for which job.

  • Say that else runs only when no exception was raised and finally always runs
  • Put the specific except clauses before the general one, and say why
  • Distinguish exceptions (bad input and run-time failures) from assertions (programmer mistakes, which can be switched off)
  • For logging, name the level and say what the handler and formatter do
  • For a traceback, say you read it from the bottom: the error first, then where

Mistakes that cost marks here

Avoid these
  • Writing a bare except: or except Exception: pass, which hides the bugs you most need to see.
  • Using assert to validate input from the user, when assertions can be turned off.
  • Putting too much code in the try block, so that an unrelated error is caught by accident.
  • Printing a message instead of raising, so the caller carries on as if nothing went wrong.
  • Logging passwords or personal data.
Fun fact

The most famous bug is a moth. In 1947 operators of the Harvard Mark II computer found one trapped in a relay and taped it into the logbook, with the note 'first actual case of bug being found'. The joke worked because engineers had been calling faults bugs for at least seventy years, and Edison used the word in the 1870s.

Knowledge check

0 of 10 answered
1When does the else block of a try statement run?
2When does a finally block run?
3Why should except KeyError be placed before except LookupError?
4What does raise ValueError('bad') from original do?
5Why create a custom exception class, such as InsufficientFunds?
6Which is the correct use of an assertion?
7What does 'fail fast' mean?
8Which logging level is for something unexpected where the program can carry on?
9In which direction should a Python traceback be read to find the error first?
10Why is logging usually better than print for recording what a program does?
Score 0 / 10

Exam-style questions

Five written questions in the style of the real paper. Write a full answer, then let the marker check it against the mark scheme.

22 marks
1Describe the purpose of the try, except, else and finally parts of a Python try statement.4 marks
Describe

Mark scheme0 / 4
  • Ltry contains the code that might cause an error (raise an exception)
  • Lexcept contains the code that handles a particular error if one occurs
  • Lelse runs only if no exception occurred in the try block
  • Lfinally always runs, whether or not an exception occurred, and is used for clean-up
Model answer

The try block contains the code that might cause an error. The except block contains the code that handles a particular error if one occurs. The else block runs only if no exception occurred in the try block. The finally block always runs, whether or not an exception occurred, and is used for clean-up such as closing a file.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
2The subprogram divide is called three times, as shown. State the output of the program.4 marks
State
Python
def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("cannot divide by zero")
        return None
    except TypeError as problem:
        print("bad types:", problem)
        return None
    else:
        print("division worked")
        return result
    finally:
        print("done with", repr(a), repr(b))

print(divide(10, 2))
print(divide(1, 0))
print(divide("a", 2))

Mark scheme0 / 4
  • LThe first call outputs division worked, then done with 10 2, then 5.0
  • LThe second call outputs cannot divide by zero, then done with 1 0, then None
  • LThe finally block runs in each case, after the try or except code but before the value is returned
  • LThe third call outputs bad types, with the message about the operand types, then done with 'a' 2 and None
Model answer

For divide(10, 2) the output is division worked, then done with 10 2, and then 5.0 is printed. For divide(1, 0) the output is cannot divide by zero, then done with 1 0, and then None is printed. For divide('a', 2) the output is bad types: unsupported operand type(s) for /: 'str' and 'int', then done with 'a' 2, and then None is printed. The finally block runs in every case, after the try or except code and just before the value is returned.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
3Explain the difference between an exception and an assertion, and state when each should be used.4 marks
Explain

Mark scheme0 / 4
  • LAn exception is raised when something goes wrong while the program is running, such as invalid input or a missing file
  • LExceptions should be used for problems that can occur even when the program is correct, and can be handled with try and except
  • LAn assertion checks that a condition that should always be true is true, and stops the program if it is not
  • LAssertions are for finding programmer mistakes and can be switched off, so must not be used to validate input
Model answer

An exception is raised when something goes wrong while the program is running, such as invalid input or a missing file, and it can be caught and handled using try and except. Exceptions should be used for problems that can happen even when the program is correct. An assertion checks that a condition that should always be true really is, and stops the program if it is not. Assertions are for finding mistakes made by the programmer, and because they can be switched off they must not be used to validate input.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
4Write a custom exception class called InvalidAgeError, and a subprogram check_age that takes an age and raises an InvalidAgeError with a suitable message if the age is less than 0 or greater than 130. Otherwise the subprogram should return the age.4 marks
Write

Mark scheme0 / 4
  • LA class InvalidAgeError that inherits from Exception
  • LA subprogram check_age with a parameter
  • LTests whether the age is less than 0 or greater than 130
  • LRaises InvalidAgeError with a message, otherwise returns the age
Model answer
Python
class InvalidAgeError(Exception):
    pass

def check_age(age):
    if age < 0 or age > 130:
        raise InvalidAgeError("age must be between 0 and 130, not " + str(age))
    return age

print(check_age(15))
try:
    check_age(200)
except InvalidAgeError as problem:
    print(problem)

The new class inherits from Exception. check_age tests whether the age is less than 0 or greater than 130 and raises the new exception with a message if so, and otherwise returns the age.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
5A programmer records what a program is doing using print statements. Discuss whether the programmer should use the logging module instead.6 marks
Discuss

Mark scheme0 / 6
  • LLogging messages have levels (debug, info, warning, error), so the amount of detail can be changed without editing the code
  • LPrint statements have to be found and removed or commented out when they are no longer wanted
  • LLogging can send messages to a file or a server, so they can be reviewed after the program has run
  • LLog messages can carry formatting such as the time, level and module, which helps to find the cause of a problem
  • LDisadvantage: logging takes more effort to set up than a print statement, and logs can grow large or contain sensitive data
  • LA reasoned conclusion: logging is better for anything beyond a small, short lived program
Model answer

The logging module gives each message a level such as debug, info, warning or error, so the programmer can change how much detail is recorded without editing the code, whereas print statements have to be found and removed when they are no longer wanted. Logging can send messages to a file or a server, so they can be reviewed after the program has run, and each message can be formatted to include the time, the level and the module, which helps in finding the cause of a problem. On the other hand it takes more effort to set up than a print statement, and log files can become large or contain sensitive data if the programmer is not careful. For anything more than a small, short lived program the programmer should therefore use logging.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
Machine mark 0 / 22