Exceptions in Python — theory with the faculty admin office: try, except, else, finally, raise and assert (and the comma grade that knocked the script over)

The first day I helped out with a script for the faculty admin office (turn the grades from a form into numbers and work out the average), I ran it full of confidence and it stopped at the third grade. Someone had typed 7,5 with a decimal comma, the Spanish way, and Python shows no mercy:

# The admin office script: turn the grades from the form into numbers
grades_txt = ['8', '6.5', '7,5', '9']   # someone typed a decimal comma, Spanish style
grades = []
for text in grades_txt:
    grades.append(float(text))          # with '7,5' Python cannot go on
    print('Grade stored:', grades[-1])
print('Average:', sum(grades) / len(grades))

Output:

Grade stored: 8.0
Grade stored: 6.5
Traceback (most recent call last):
  File "hook.py", line 5, in <module>
    grades.append(float(text))         # with '7,5' Python cannot go on
                  ~~~~~^^^^^^
ValueError: could not convert string to float: '7,5'

Two grades stored, a Traceback and no average at all. The rest of the list was never processed. That red message is an exception: Python is telling you it has found something it cannot resolve and, since nobody told it what to do, it stops the program. In FP1 you learned to avoid errors by checking first with if. In FP2 you take the next step: catching errors when they happen, deciding what to do with them and raising them yourself when you spot something wrong.

Today we cover the whole topic with the faculty admin office as the thread: credits that arrive as text, records that don’t exist, deadlines that pass. If you come from the last FP1 topic, Text files in Python, you have already met FileNotFoundError: now you will know what to do with it. When you finish, the exceptions practice is waiting with four complete programs.

What is an exception?

An exception is an object Python creates when an operation cannot be completed: turning 'NP' (a no-show) into a number, dividing by zero, asking for position 5 of a 3-item list. As soon as it is created, the program stops running line by line and the exception starts to climb through the functions that were running, looking for someone to catch it.

Look at this program: report calls average, average calls to_number and to_number does the float() that fails.

def to_number(text):
    return float(text)                # the exception is born here


def average(list_txt):
    numbers = [to_number(t) for t in list_txt]
    return sum(numbers) / len(numbers)


def report(student, list_txt):
    print('Calculating the average for', student)
    print('Average:', average(list_txt))


report('Laura', ['7', '8.5', 'NP'])   # NP = no-show: not a number

Output:

Calculating the average for Laura
Traceback (most recent call last):
  File "stack.py", line 15, in <module>
    report('Laura', ['7', '8.5', 'NP'])   # NP = no-show: not a number
    ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "stack.py", line 12, in report
    print('Average:', average(list_txt))
                      ~~~~~~~^^^^^^^^^^
  File "stack.py", line 6, in average
    numbers = [to_number(t) for t in list_txt]
               ~~~~~~~~~^^^
  File "stack.py", line 2, in to_number
    return float(text)                # the exception is born here
ValueError: could not convert string to float: 'NP'

The Traceback looks like a wall of text, but it is easy to read once you know the trick: start with the last line. ValueError: could not convert string to float: 'NP' tells you what happened. The line above tells you where: to_number, line 2. Everything above that is the path that led there, from the outermost call (<module>, the main program) to the innermost. The ~~~~ and ^^^^ marks point at the exact piece of each line that was running.

Call stack of the programFour stacked frames: module calls informe, informe calls media, media calls a_numero, where float fails on NP; the exception climbs to the top.Where the exception is born and how it climbs<module>report('Laura', […])line 15reportaverage(list_txt)line 12averageto_number(t)line 6to_numberfloat(text)line 2each function calls the next one (downwards)ValueError: … 'NP'the exception climbsfunction by functionif nobody catches it,the program stopsand Python printsthe TracebackThe Traceback lists the frames top to bottom: the last line says what happened; the one above, where.
The exception is born in the bottom frame (float) and climbs the stack until someone catches it or the program stops.

Look at the drawing: the call goes down from function to function and the exception travels the other way. Since none of the four has a try, it climbs all the way to the top and the program stops. “Calculating the average for Laura” was printed (it happened before the error), but “Average:” never was.

A Traceback is not your enemy: it is the best error report you will ever get. Before searching online, read the last line and the one above. Most of the time you already know what to fix.

Types of exceptions in Python

Each problem has its own exception class. These are the ones you will see most in FP2. To show them all without the program stopping at the first one, each line goes inside its own try (explained in the next section; for now look only at the name and the message):

# Seven lines, seven different exceptions. Each goes in its own try so the program carries on.
grades = {'Laura': 8.5}
cases = ["int('NP')", "10 / 0", "['FP1', 'FP2'][2]", "grades['Pablo']",
         "'Mark: ' + 7", "open('marks_2027.txt')", "grades.upper()"]
for code in cases:
    try:
        eval(code)                     # runs the text as if it were code
    except Exception as err:           # Exception: the base of them all (more later)
        print(f'{code:<23} {type(err).__name__}: {err}')

Output:

int('NP')               ValueError: invalid literal for int() with base 10: 'NP'
10 / 0                  ZeroDivisionError: division by zero
['FP1', 'FP2'][2]       IndexError: list index out of range
grades['Pablo']         KeyError: 'Pablo'
'Mark: ' + 7            TypeError: can only concatenate str (not "int") to str
open('marks_2027.txt')  FileNotFoundError: [Errno 2] No such file or directory: 'marks_2027.txt'
grades.upper()          AttributeError: 'dict' object has no attribute 'upper'

Each one tells a different story:

ExceptionWhen it is raisedAdmin office example
ValueErrorThe type is fine, the value is notint('NP'): it is a str, but not a number
ZeroDivisionErrorYou divide (or use % or //) by zeroPrice per credit with 0 credits
IndexErrorA position that does not exist in a list or stringThe third subject in a list of two
KeyErrorA key that does not exist in a dictionaryPablo’s record, which is not in grades
TypeErrorAn operation between incompatible types'Mark: ' + 7 (text plus number)
FileNotFoundErrorThe file does not existThe 2027 records, not created yet
AttributeErrorThe object has no such method or attributeA dictionary has no upper()

Mixing up ValueError and TypeError. int('NP') is a ValueError because you can pass text to int() (int('7') works); what fails is that particular text. 'Mark: ' + 7 is a TypeError because there is no way to add a str and an int, whatever the number.

Exceptions come in families

Exceptions are not loose: they form a tree, and each one “is a child” of a more general one. You can see anyone’s chain with __mro__:

# Whose 'child' is each exception? __mro__ lists the chain up to the base
for exc_class in [ZeroDivisionError, KeyError, IndexError, FileNotFoundError, ValueError, KeyboardInterrupt]:
    chain = [c.__name__ for c in exc_class.__mro__ if c is not object]
    print(' -> '.join(chain))

# In practice: an except for the parent catches all its children
try:
    ['FP1', 'FP2'][2]
except LookupError as err:             # LookupError = IndexError + KeyError
    print('Caught as LookupError:', type(err).__name__)

Output:

ZeroDivisionError -> ArithmeticError -> Exception -> BaseException
KeyError -> LookupError -> Exception -> BaseException
IndexError -> LookupError -> Exception -> BaseException
FileNotFoundError -> OSError -> Exception -> BaseException
ValueError -> Exception -> BaseException
KeyboardInterrupt -> BaseException
Caught as LookupError: IndexError
Exception hierarchyTree: BaseException at the top; below, Exception and KeyboardInterrupt; under Exception hang ArithmeticError, LookupError, ValueError, TypeError, OSError and our own ErrorMatricula.Exception families (the ones you use most in FP2)BaseExceptionExceptionKeyboardInterruptCtrl+C: except Exceptiondoesn't catch it (except: does)ArithmeticErrorLookupErrorValueErrorTypeErrorOSErrorZeroDivisionErrorIndexErrorKeyErrorFileNotFoundErrorPermissionErrorEnrolmentErrorCreditsErrorDeadlineErroryours: class X(Exception)An except for a box catches that box and every box hanging from it.
except LookupError catches IndexError and KeyError; except Exception, almost everything except Ctrl+C.

This has a very practical consequence: an except for the parent catches all its children. except LookupError catches both IndexError and KeyError, and except Exception catches almost everything. Almost: KeyboardInterrupt (what Ctrl+C raises) hangs straight from BaseException, so that an except Exception does not stop you from killing a program that has frozen. The green boxes are custom exceptions, which we create at the end of the article.

try/except vs if/else — when to use each

The question that comes up most in FP2: if I can check with an if, why do I want a try? There are two styles and both are valid:

StyleIdeaWhen it fits
Check first (if)“Look before you leap”The check is easy and cheap: if credits != 0: before dividing
Try and catch (try)“Easier to ask forgiveness than permission” (EAFP)You don’t know if it will fail until you try: converting what someone typed, opening a file, looking something up in a dictionary

Checking with an if whether '7,5' is a valid number is a mess (a sign? a dot? spaces? '1e3'?). But float('7,5') already knows how: you try it and, if it fails, you catch it. With files it gets worse: even if you check that a file exists, someone can delete it between your if and your open(). That is why Python code uses the EAFP style so much.

Ask yourself “who knows better whether this is valid, me with an if or the operation itself?”. If it is the operation (int(), open(), d[key]), use try.

The try/except structure

try:
    # code that may raise an exception
except SomeException:
    # what to do if THAT type happens

The rule that takes longest to sink in: as soon as one line in the try raises an exception, the rest of the try is skipped and Python jumps straight to the matching except. Afterwards, the program carries on below the whole block. You can see it with the admin office working out the price per credit of a 735 € enrolment:

def price_per_credit(amount, credits_txt):
    print('Received:', repr(credits_txt))
    try:
        credits = int(credits_txt)            # (1) may raise ValueError
        print('  (1) converted:', credits)
        price = amount / credits              # (2) may raise ZeroDivisionError
        print('  (2) price per credit:', price)
    except ValueError:
        print('  (3) credits must be a whole number')
    except ZeroDivisionError:
        print('  (4) zero credits: nothing to enrol in')
    print('  moving on to the next enrolment')


for item in ['30', 'thirty', '0']:
    price_per_credit(735.0, item)

Output:

Received: '30'
  (1) converted: 30
  (2) price per credit: 24.5
  moving on to the next enrolment
Received: 'thirty'
  (3) credits must be a whole number
  moving on to the next enrolment
Received: '0'
  (1) converted: 0
  (4) zero credits: nothing to enrol in
  moving on to the next enrolment

Go through the three calls calmly:

  • With '30', everything works: (1) and (2) are printed, and 735 / 30 = 24.5 € per credit.
  • With 'thirty', int() fails on the first line of the try. Neither (1) nor (2) is printed: the jump to (3) is immediate.
  • With '0', int('0') works, so (1) is printed; the division fails and jumps to (4).

In all three cases “moving on to the next enrolment” is printed: the program did not break. That is the difference from the hook at the start.

Specific except — catching concrete exceptions

In the example above there is one except for each problem we expect, with its own message. That is the recommended way: if you know what failed, you can tell the user something useful (“credits must be a whole number”) instead of a “something went wrong” that helps nobody.

Catching several exceptions in one block

If several errors are handled the same way, group them in a tuple:

for item in ['thirty', '0']:
    try:
        price = 735.0 / int(item)
    except (ValueError, ZeroDivisionError) as err:   # two types, the same handling
        print(f'{item!r}: invalid value ({type(err).__name__})')

Output:

'thirty': invalid value (ValueError)
'0': invalid value (ZeroDivisionError)

The parentheses are compulsory. except ValueError, ZeroDivisionError: (no parentheses) has been a syntax error since Python 3, even if you see it in old code online.

Generic except — the difference that confuses most

Here is the first big trap of the topic: the order of the except clauses matters. Python tries them top to bottom and enters the first one that matches. If you put the parent before the child, the child never runs:

def read_credits(text):
    try:
        return 735.0 / int(text)
    except Exception:                  # the parent of almost all of them goes FIRST...
        print('Something went wrong with', repr(text))
    except ValueError:                 # ...so this is never reached
        print('Not a number:', repr(text))


read_credits('thirty')

Output:

Something went wrong with 'thirty'

'thirty' causes a ValueError, but a ValueError also is an Exception (it is its child), so the first except catches it and the second is dead. Python does not warn you: the program works, just with the wrong message. The rule: from the most specific to the most general.

The second trap is even worse: a bare except: (or except Exception:) added “just in case”. Look what it does with a simple typo:

def average(grades):
    try:
        return sum(grades) / len(grade)  # typo: grade instead of grades
    except:                              # 'just in case'... and it hides the typo
        return 0.0


print('Laura's average:', average([8, 7, 9]))

Output:

Laura's average: 0.0

An average of 0.0 with an 8, a 7 and a 9? The typo len(grade) raises a NameError, the except: catches it without asking and returns 0.0 as if nothing had happened. If you catch only what you expect (an empty list, ZeroDivisionError), the typo comes to light at once:

def average(grades):
    try:
        return sum(grades) / len(grade)  # the same typo
    except ZeroDivisionError:            # only what we expect: empty list
        return 0.0


print('Laura's average:', average([8, 7, 9]))

Output:

Traceback (most recent call last):
  File "hidden_fixed.py", line 8, in <module>
    print('Laura's average:', average([8, 7, 9]))
                              ~~~~~~~^^^^^^^^^^^
  File "hidden_fixed.py", line 3, in average
    return sum(grades) / len(grade)  # the same typo
                             ^^^^^
NameError: name 'grade' is not defined. Did you mean: 'grades'?

It even suggests the fix: Did you mean: 'grades'?. An error Python shows you on the first run is worth far more than one that hides and gives you false averages for weeks.

Using except: or except Exception: so that “the red error doesn’t show up”. You don’t fix the problem: you hide it. On top of that, a bare except: also catches Ctrl+C and can stop you from ending the program. Use it only at the end of a chain of specific except clauses, and always show what happened.

Accessing exception information

With as you name the exception object and can read what it carries:

try:
    int('7,5')
except ValueError as err:              # err is the exception object
    print('Message :', err)            # str(err): the text
    print('Type    :', type(err).__name__)
    print('args    :', err.args)       # tuple with what was passed when it was created

Output:

Message : invalid literal for int() with base 10: '7,5'
Type    : ValueError
args    : ("invalid literal for int() with base 10: '7,5'",)

print(err) gives the message (the same as the last line of the Traceback), type(err).__name__ the name of the type and err.args the tuple with what was passed when it was created. The first one is the most common: print('Error:', err).

The additional blocks — else and finally

A try can have two more blocks:

  • else: runs only if the try finished without an exception. It is the place for whatever depends on everything having gone well, but does not need protecting.
  • finally: runs always, with or without an exception, caught or not. It is the place for tidying up: closing a file, releasing something, closing the counter.
def charge(amount, credits_txt):
    print(f'--- enrolment with {credits_txt!r} credits')
    try:
        price = amount / int(credits_txt)
    except ValueError:
        print('except: not a number')
    except ZeroDivisionError:
        print('except: zero credits')
    else:
        print(f'else: {price:.2f} € per credit')   # only if try finished without an exception
    finally:
        print('finally: closing the counter')      # always, whatever happens


for c in ['30', 'NP', '0']:
    charge(735.0, c)

Output:

--- enrolment with '30' credits
else: 24.50 € per credit
finally: closing the counter
--- enrolment with 'NP' credits
except: not a number
finally: closing the counter
--- enrolment with '0' credits
except: zero credits
finally: closing the counter
Paths through try, except, else and finallyFive blocks by three inputs: with 30, try, else and finally run; with NP, try jumps to except ValueError and finally; with 0, to except ZeroDivisionError and finally.Which blocks run? charge(735.0, …)input'30''NP''0'try:price = amount / int(…)except ValueError:print('not a number')except ZeroDivisionError:print('zero credits')else:print(f'{price:.2f} €…')finally:print('closing the counter')runsstarts and jumps (exception)does not run
finally is the only row ticked in all three columns; else only runs when try finishes without an exception.

The table sums up the three paths. Why not put the price print at the end of the try and forget about else? Because everything inside the try is “protected”: if that line raised a ValueError for some other reason, you would mistake it for the credits one. else leaves inside the try only what can really fail.

And now the most surprising thing about finally: it runs even if there is a return inside the try and even if nobody catches the exception:

def serve():
    try:
        print('try: serving Laura')
        return 'Laura served'
    finally:
        print('finally: screen off (before returning)')


print(serve())


def find_record():
    try:
        print('try: looking for Pablo's record')
        records = {'Laura': 8.5}
        return records['Pablo']          # KeyError that nobody catches
    finally:
        print('finally: runs even though nobody catches the KeyError')


find_record()

Output:

try: serving Laura
finally: screen off (before returning)
Laura served
try: looking for Pablo's record
finally: runs even though nobody catches the KeyError
Traceback (most recent call last):
  File "finally_return.py", line 21, in <module>
    find_record()
    ~~~~~~~~~~~^^
  File "finally_return.py", line 16, in find_record
    return records['Pablo']          # KeyError that nobody catches
           ~~~~~~~^^^^^^^^^
KeyError: 'Pablo'

In serve(), the return already has 'Laura served' ready, but before leaving the function Python runs the finally: that is why “screen off” comes out before the result. In find_record() there is no except at all: the finally runs anyway and then the KeyError keeps climbing and stops the program.

Thinking finally “fixes” the error. It does not catch it: it just runs and lets the exception carry on. To handle the error you need an except.

raise — throwing your own exceptions

What does raise do in Python? It throws an exception yourself. So far it was Python raising them when something could not be done. But some situations are perfectly valid for Python and not for your program: an 11 is a lovely number, but it is not a grade (grades go from 0 to 10 in Spain). With raise you turn that rule of yours into an exception like any other:

def validate_grade(grade):
    if not 0 <= grade <= 10:
        raise ValueError(f'grade fuera de rango: {grade}')   # we raise it ourselves
    return grade


for n in [7.5, 11, -1]:
    try:
        print('Stored:', validate_grade(n))
    except ValueError as err:
        print('Rejected:', err)

Output:

Stored: 7.5
Rejected: grade fuera de rango: 11
Rejected: grade fuera de rango: -1

You write raise followed by the exception created with its message: raise ValueError('…'). Whoever calls validate_grade decides what to do (here, print “Rejected”). And if nobody catches it, the same happens as with Python’s own:

def validate_grade(grade):
    if not 0 <= grade <= 10:
        raise ValueError(f'grade fuera de rango: {grade}')
    return grade


validate_grade(12)                       # nobody catches it: the program stops

Output:

Traceback (most recent call last):
  File "raise_alone.py", line 7, in <module>
    validate_grade(12)                       # nobody catches it: the program stops
    ~~~~~~~~~~~~~~^^^^
  File "raise_alone.py", line 3, in validate_grade
    raise ValueError(f'grade fuera de rango: {grade}')
ValueError: grade fuera de rango: 12

That is the beauty of raise: the function that detects the problem does not need to know how to solve it. validate_grade does not know whether you are in a form (and should ask again) or in a script processing a thousand records (and should log it). It just warns; the caller decides.

Pick the type that best describes the problem. A value outside what is allowed: ValueError. The wrong type (text where you expected a number): TypeError. And write messages that say which value failed: f'grade out of range: {grade}' saves a lot of debugging time.

raise without arguments — propagating exceptions

Inside an except, a bare raise, with nothing after it, raises the same exception again. It is useful for doing something (warning, counting attempts) and still letting the problem climb:

# What the person would type (on your computer, replace read_line() with input()):
TYPED = iter(['a', 'b', 'c'])


def read_line(prompt):
    text = next(TYPED)     # the next 'typed' value
    print(prompt + text)   # we show it as it would look in the terminal
    return text


def read_credits():
    attempts = 0
    while True:
        try:
            return int(read_line('Credits: '))
        except ValueError:
            attempts += 1
            print('  Enter a whole number')
            if attempts == 3:
                print('  Third failure: passing the problem up')
                raise                  # re-raises the SAME exception


try:
    read_credits()
except ValueError as err:
    print('Enrolment cancelled:', err)

Output:

Credits: a
  Enter a whole number
Credits: b
  Enter a whole number
Credits: c
  Enter a whole number
  Third failure: passing the problem up
Enrolment cancelled: invalid literal for int() with base 10: 'c'

The first two failures are handled inside the function: a warning and another try. On the third, raise re-raises the ValueError from int('c') exactly as it was, with its original message, and the outer try catches it and cancels the enrolment. Outside an except there is no “active” exception to re-raise: a bare raise gives RuntimeError (you will see it in the exercises).

The programs in this topic don’t use input(): what the person would type goes in a list (TYPED) and read_line() prints it as it would appear in the terminal. That way every output you see is real. On your computer, replace read_line(...) with input(...) and it will work the same.

Custom exceptions

When your errors have names of their own (“past the deadline”, “credits not allowed”), the clearest thing is to create your own exception classes. All you need is a class that inherits from Exception (inheritance is the topic after next; for now just copy the class Name(Exception): form):

class EnrolmentError(Exception):
    """Base of all enrolment errors."""


class CreditsError(EnrolmentError):
    """Credits outside what the rules allow (in this example, 24 to 78)."""


class DeadlineError(EnrolmentError):
    """The request arrives after the deadline."""


def enrol(name, credits, day):
    if day > 15:
        raise DeadlineError(f'{name}: the deadline was day 15 (it arrived on day {day})')
    if not 24 <= credits <= 78:
        raise CreditsError(f'{name}: {credits} credits (se permiten de 24 a 78)')
    return f'{name}: enrolled in {credits} credits'


requests = [('Laura', 60, 3), ('Pablo', 90, 7), ('Ana', 60, 20)]
for s in requests:
    try:
        print(enrol(*s))
    except CreditsError as err:        # the specific child, first
        print('Credits ->', err)
    except EnrolmentError as err:      # any other enrolment error
        print(type(err).__name__, '->', err)

Output:

Laura: enrolled in 60 credits
Credits -> Pablo: 90 credits (se permiten de 24 a 78)
DeadlineError -> Ana: the deadline was day 15 (it arrived on day 20)

Three important things:

  1. The family: CreditsError and DeadlineError hang from EnrolmentError (green boxes in the hierarchy diagram). That is why the second except EnrolmentError catches Ana’s DeadlineError without naming it.
  2. The order: the child’s except (CreditsError) goes before the parent’s, as we saw above.
  3. The docstring is enough as the class body; no pass needed. By convention, names end in Error, like Python’s.

The credit rules (24 to 78) and the deadline (day 15) are made up for the example: they are not the ULPGC ones.

Sometimes you want to translate a Python error into one of yours without losing the original. That is what raise … from err is for:

class CreditsError(Exception):
    pass


def read_credits(text):
    try:
        return int(text)
    except ValueError as err:
        raise CreditsError(f'"{text}" are not credits') from err   # translates the error


read_credits('sixty')

Output:

Traceback (most recent call last):
  File "from_err.py", line 7, in read_credits
    return int(text)
ValueError: invalid literal for int() with base 10: 'sixty'

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

Traceback (most recent call last):
  File "from_err.py", line 12, in <module>
    read_credits('sixty')
    ~~~~~~~~~~~~^^^^^^^^^
  File "from_err.py", line 9, in read_credits
    raise CreditsError(f'"{text}" are not credits') from err   # translates the error
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
CreditsError: "sixty" are not credits

The Traceback shows both: the original ValueError and, after “The above exception was the direct cause of the following exception”, your CreditsError. Anyone reading it knows which of your rules was broken and what caused it underneath.

Assertions — assert

An assertion is a check that, if the program is written correctly, should always hold. If it doesn’t, it raises AssertionError with the message you give:

def average(grades):
    assert len(grades) > 0, 'average() needs at least one grade'
    return sum(grades) / len(grades)


print(average([8, 6.5, 9]))
print(average([]))                       # the assertion fails

Output:

7.833333333333333
Traceback (most recent call last):
  File "assertion.py", line 7, in <module>
    print(average([]))                       # the assertion fails
          ~~~~~~~^^^^
  File "assertion.py", line 2, in average
    assert len(grades) > 0, 'average() needs at least one grade'
           ^^^^^^^^^^^^^^^
AssertionError: average() needs at least one grade

The form is assert condition, 'message' (the message is optional). It is roughly the same as if not condition: raise AssertionError('message'). Check: (8 + 6.5 + 9) / 3 = 23.5 / 3 = 7.8333…, the first line of the output.

But one detail changes everything: assertions can be switched off. If you run Python with the -O (optimise) option, they disappear:

import subprocess, sys

# The same code, run with plain python3 and with python3 -O (optimised)
code = """def average(n):
    assert len(n) > 0, 'no grades'
    return sum(n) / len(n)
print(average([]))"""
for options in ([], ['-O']):
    r = subprocess.run([sys.executable, *options, '-c', code], capture_output=True, text=True)
    print('python3', *options, '->', r.stderr.strip().splitlines()[-1])

Output:

python3 -> AssertionError: no grades
python3 -O -> ZeroDivisionError: division by zero

With -O the assertion does not exist and the program reaches the division: ZeroDivisionError. That is why an assert must never be the only thing protecting your program from data that comes from outside.

When to use assert vs raise

Use…For…Example
raiseThings that can happen even if your code is right: data from a person, files, the networkAn 11 typed into the form
assertThings that should never happen if your code is right: checking your own assumptions while programmingaverage() called with an empty list because of a bug of yours

Validating user input with assert. It works in your tests, but a single -O makes the validation vanish. For user input, if + raise.

Common mistakes with exceptions

A huge try around half the program. If 30 lines can raise ValueError for different reasons, the except doesn’t know which one it was. Put in the try only the lines that can fail.

except ValueError, TypeError: without parentheses. Since Python 3 it is a syntax error: except (ValueError, TypeError):.

raise ValueError when you meant to say something. It works without a message, but the Traceback will only say ValueError. Always write what failed and with which value.

Using finally to return a value. A return inside finally swallows any exception that was climbing. In finally, only tidy up.

Watch it step by step in Python Tutor

Python Tutor runs your code line by line and draws the variables and function calls. Choose Python 3, paste this program and step through with Next:

def credit_price(amount, credits):
    if credits == 0:
        raise ZeroDivisionError('zero credits')
    return amount / credits


def charge(credits):
    try:
        price = credit_price(735.0, credits)
    except ZeroDivisionError as err:
        print('Not charged:', err)
    else:
        print('Price per credit:', price)
    finally:
        print('Counter free')


charge(30)
charge(0)

Output:

Price per credit: 24.5
Counter free
Not charged: zero credits
Counter free

What to watch:

  1. In the first call, charge(30) creates its frame and inside it credit_price appears with credits = 30: two stacked frames, as in the call stack diagram.
  2. On the way back, price is 24.5 and the arrow jumps to else; then to finally.
  3. In charge(0), watch the raise step: the credit_price frame disappears without returning anything and the arrow lands in the except of charge.
  4. The variable price never gets created in the second call: the assignment never finished. That is why the price print goes in else.
  5. In both calls print('Counter free') runs: finally never misses.

Quick summary

I want to…I write…Detail
Protect code that may failtry: … except Type:The rest of the try after the error is skipped
Handle two types the same wayexcept (ValueError, ZeroDivisionError):With parentheses
Read the messageexcept ValueError as err: print(err)type(err).__name__, err.args
Run code only if all went wellelse:After the except clauses
Run code alwaysfinally:Even with return or uncaught
Raise an error of mineraise ValueError('message')With a message that says the value
Re-raise the one I am handlingraise (inside an except)Outside an except: RuntimeError
Translate an errorraise MyError('…') from errBoth appear in the Traceback
My own typeclass MyError(Exception):With families: class Child(MyError):
Check my assumptionsassert condition, 'message'Switched off by -O: never for validating data

Three ideas to take away:

  1. A Traceback is read bottom up: the last line says what happened and the one above, where.
  2. Catch the specific and in order, from child to parent. A generic “just in case” except hides real errors.
  3. The function that detects the problem raises (raise); the one that knows what to do catches (except). finally always tidies up.

Now, let’s put it into practice: in the Python exceptions practice we build an enrolment form that rejects bad data, an unbreakable calculator, a grade reader and a fractions class with its own exceptions. If you study Java too, the same topic is in exceptions in Java (there it is try/catch and throw). And once you master it, the next FP2 topic is Python classes, where custom exceptions will make full sense.

Next step: practicePython exceptions practice — four programs that don’t break: an enrolment form, a calculator, a grade reader and fractions (and the “12,5” that refused to be a number)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *