◀ Course contents Part 3 · Module 3-04

Errors & Exceptions

Failing on purpose, in a way somebody can act on

Up to now an error has ended the program. That is the right default — a program that stops is easier to fix than one that carries on with a wrong number. This module is about the cases where you can do better than stopping, and about the far more common mistake: catching an error, hiding it, and leaving whoever comes next with a symptom and no cause.

Ready?

1

Two Kinds of Wrong

A SyntaxError means Python could not read the code. Nothing ran, and no amount of handling will help — the file has to be fixed.

An exception is different. The code was fine; something that happened while it was running was not. A file that is missing, a number that will not parse, a key that is not there. Those you can sometimes do something about.

try:
    quantity = int(answer)
except ValueError:
    quantity = 0

Python runs the try block. If nothing goes wrong the except is skipped entirely. If a ValueError is raised, the rest of the try is abandoned and the except runs instead.

The important word is abandoned. Everything after the failing line inside the try is skipped, so a try block wrapped around fifty lines tells you almost nothing about where things stopped. Keep the block small — ideally the one line that can fail.

An exception is not a bug

int("abc") raising ValueError is int working correctly. It was asked to do something impossible and said so, immediately and precisely. The bug would have been quietly returning 0.

Quick check

A try block has five lines and the second raises. How many of the remaining three run?

2

Catch the One You Can Actually Handle

except: on its own catches everything — including the typo in your own code, the interrupt from the person trying to stop the program, and the MemoryError nobody can do anything about.

try:
    total = int(row["quantity"]) * price
except:                 # never do this
    total = 0

If row is spelled rows, that is a NameError, and this code turns it into a total of zero. The program keeps going, the report is wrong, and there is nothing in the log at all.

Name the exception you know how to deal with. Anything else should be allowed to reach someone who can fix it:

except ValueError:              # one
except (ValueError, KeyError):  # either
except ValueError as error:     # and keep the object

Several except blocks can follow one try, and the first matching one wins — so the specific ones go above the general ones, exactly like an elif chain.

Hides your typos

except Exception:

Better than a bare except:, still far too wide for most code.

Says what you expected

except ValueError:

Handles the case you thought about; everything else still surfaces.

Quick check

Why is a bare except: worse than no error handling at all?

3

else, finally, and the Exception Object

A full try statement has four parts, and the last two are under-used:

try:
    value = int(answer)
except ValueError as error:
    print("could not read it:", error)
else:
    print("worked:", value)      # only when nothing was raised
finally:
    print("this always runs")    # raised or not

as error gives you the exception object. str(error) is the message and type(error).__name__ is the class name — between them, a log line that says what actually happened rather than "something went wrong".

else holds the code that should run only when the try succeeded. Putting it inside the try would work, and would also mean an exception raised by that code gets caught by an except written for something else entirely.

finally runs whatever happens — exception or not, return or not. It is for cleanup that must happen: closing a file, releasing a lock, putting a connection back. It runs even when the exception is on its way up to crash the program.

Quick check

A function returns from inside a try that has a finally. Does the finally run?

4

Raising Your Own

raise reports a problem your own code has found. The rule of thumb is to do it at the boundary — where data arrives — so that everything below can trust what it is holding:

def parse_quantity(text):
    quantity = int(text)          # raises ValueError on rubbish
    if quantity < 0:
        raise ValueError(f"quantity must not be negative: {quantity}")
    return quantity

Pick a class that means something. ValueError for a value that is the right type and wrong; TypeError for the wrong type entirely; KeyError for a missing key. And put the offending value in the message — "invalid quantity" sends someone hunting, "quantity must not be negative: -3" does not.

Sometimes you catch an exception only to add context and send it on. A bare raise re-raises the one you are handling, with its original traceback intact:

try:
    value = parse_quantity(cell)
except ValueError as error:
    print(f"row {n}: {error}")
    raise

The question worth asking

Before writing an except, ask what you will do in it. If the answer is "carry on with a default", be sure that default is genuinely correct and not just convenient. If the answer is "log it and continue", make sure someone reads the log. If there is no good answer, the right handling is none: let it stop.

A crash costs one debugging session. A swallowed exception costs a quarter of wrong numbers and nobody knowing why.

Quick check

Which message is worth writing?

0 of 9 completed

Real Python runs right here in your browser — nothing to install, nothing sent to a server. The interpreter downloads once the first time you press Run, then stays cached.

01

To do

int("abc") raises a ValueError. That is int working correctly — it was asked for something impossible and said so.

try:
    quantity = int(answer)
except ValueError:
    quantity = 0

If the try block finishes, the except is skipped entirely. If it raises, the rest of the block is abandoned and the except runs instead.

Your task: convert each answer to a whole number, using 0 for the ones that will not parse. Print one line per answer, then the total:

12
0
7
0
19
your_code.py
Python
Hint

Wrap only the int() call in the try. quantity = int(answer) inside, quantity = 0 in the except. Keep the print and the total outside the try — neither of them can fail.

Output

      
    02

    To do

    This code has a spelling mistake in it — quantitiy — and it prints a report full of zeros without a word of complaint. The bare except: catches the NameError along with everything else.

    That is the case against catching broadly: without the handler this would have stopped on line one and told you exactly what was wrong.

    Your task: narrow the except to ValueError, then fix the NameError it exposes. The output should be:

    36
    0
    21
    57

    Each line is the quantity times three, with 0 for anything unparseable.

    your_code.py
    Python
    Hint

    Change `except:` to `except ValueError:` and run it. The NameError that appears names the misspelled variable, and the fix is one character.

    Output
    
          
      03

      To do

      as error hands you the exception object. str(error) is the message Python wrote, and type(error).__name__ is the class name.

      Between them you can log a line that names the problem, which is the difference between a report somebody can act on and one that says "something went wrong".

      Your task: for each answer, print either the number or a line naming the exception and its message:

      12
      ValueError: invalid literal for int() with base 10: 'twelve'
      7
      your_code.py
      Python
      Hint

      except ValueError as error: then print(f"{type(error).__name__}: {error}"). The f-string calls str() on the exception for you.

      Output
      
            
        04

        To do

        Several except blocks can follow one try, and the first matching one wins. That lets each kind of failure get the response it deserves rather than one shared fallback.

        Your task: rate(hits, attempts) should hand back the percentage. A non-numeric input is a ValueError and should give -1; zero attempts is a ZeroDivisionError and should give 0.0, because nobody attempting anything is a real answer rather than bad data.

        75.0
        0.0
        -1
        your_code.py
        Python
        Hint

        Convert both with int() and return int(hits) / int(attempts) * 100 inside the try. Then two excepts: ValueError returning -1 and ZeroDivisionError returning 0.0.

        Output
        
              
          05

          To do

          else runs only when the try raised nothing. finally runs either way — exception or not, return or not.

          else matters because code that belongs after a successful attempt should not sit inside the try, where an exception it raises would be caught by a handler written for something else.

          finally is for cleanup that must happen: closing a file, releasing a lock. It runs even while an exception is on its way up to crash the program.

          Your task: for each answer, print the number on success or the failure on error, then done either way:

          ok 12
          done
          failed twelve
          done
          your_code.py
          Python
          Hint

          Four blocks: try, except ValueError, else, finally. The success line is print("ok", value) in the else — not in the try, where a print failing would be caught by the wrong handler.

          Output
          
                
            06

            To do

            raise reports a problem your own code has found. Doing it where data arrives means everything below can trust what it is holding.

            if quantity < 0:
                raise ValueError(f"quantity must not be negative: {quantity}")

            Put the offending value in the message. "invalid quantity" sends someone hunting; "quantity must not be negative: -3" does not.

            Your task: write parse_quantity(text). It returns a whole number, and raises ValueError with a message containing the offending value when the number is negative. Rubbish text already raises ValueError from int(), so that case needs nothing.

            12
            quantity must not be negative: -3
            your_code.py
            Python
            Hint

            quantity = int(text), then `if quantity < 0: raise ValueError(f"quantity must not be negative: {quantity}")`, then return quantity.

            Output
            
                  
              07

              To do

              This function catches every failure and returns 0. The import runs, the report is produced, and three of the numbers in it are wrong with nothing anywhere to say so.

              Handling an error means doing something about it. Returning a plausible value and saying nothing is not handling — it is hiding.

              Your task: keep the program running, but make each failure visible. Print a line naming the bad value, count them, and leave that row out of the total rather than counting it as zero:

              skipped: twelve
              skipped:
              Total: 19
              Skipped: 2

              The blank value prints as an empty string after the colon and a space.

              your_code.py
              Python
              Hint

              Have to_quantity return None when it cannot parse, and narrow the except to ValueError. Then in the loop: if the result is None, print the skipped line and add one to skipped; otherwise add it to the total.

              Output
              
                    
                08

                To do

                Two ways to deal with something that might not be there:

                # check first
                if "region" in config:
                    region = config["region"]
                else:
                    region = "eu"

                # or try it
                try:
                    region = config["region"]
                except KeyError:
                    region = "eu"

                Python leans towards the second — it reads as one attempt rather than two lookups, and it cannot go stale between the check and the use. For a dictionary specifically, .get() says the same thing shorter, which is why it exists.

                Your task: write setting(config, key, fallback) using try/except KeyError, then print three calls:

                9000
                eu
                30
                your_code.py
                Python
                Hint

                return config[key] inside the try, return fallback in the except KeyError. Square brackets are what raise — .get() would never give you a KeyError to catch.

                Output
                
                      
                  09

                  To do

                  Sometimes you catch an exception only to say something the deeper code could not know — which row, which file, which user — and then let it continue on its way.

                  A bare raise inside an except re-raises the exception being handled, with its original traceback intact. Writing raise error would work and would lose part of that.

                  except ValueError as error:
                      print(f"row {n}: {error}")
                      raise

                  Your task: load(rows) should print which row failed and then let the exception through. The caller catches it, so the program finishes:

                  row 3 failed: invalid literal for int() with base 10: 'x'
                  stopped after 2 good rows
                  your_code.py
                  Python
                  Hint

                  Wrap the int(row) in a try. In `except ValueError as error:` print f"row {number} failed: {error}", then a bare `raise` on its own line.

                  Output
                  
                        

                    The import validator

                    To do

                    A supplier's export has to be loaded, and some of it is wrong. The job is not to make the bad rows disappear — it is to load every good one, and report every bad one precisely enough that somebody can fix the file.

                    Each row should be name,quantity,price. A row can be wrong in four ways, and each needs its own message.

                    Write parse_row(row): it returns (name, quantity, price) with the numbers converted, and raises ValueError with a useful message when the row is not usable.

                    • Not exactly three fields — expected 3 fields, got 2
                    • A quantity or price that will not parse — let int()'s own ValueError through unchanged
                    • A negative quantity — quantity must not be negative: -4
                    • A price of zero or less — price must be positive: 0

                    Then load the file. Keep going after a bad row. Collect the good ones into loaded as tuples, count the bad ones in rejected, and print one line per bad row saying which it was and why.

                    Print exactly six lines:

                    row 3: expected 3 fields, got 2
                    row 4: invalid literal for int() with base 10: 'x'
                    row 5: quantity must not be negative: -4
                    row 6: price must be positive: 0
                    Loaded: 2
                    Value: 15630

                    Value is quantity times price, summed over the loaded rows only.

                    your_code.py
                    Python
                    Hint

                    In parse_row: split on the comma, and if the number of fields is not 3, raise ValueError(f"expected 3 fields, got {len(fields)}"). Then int() both numbers — leave that call unwrapped so its own ValueError travels. Then check the quantity and the price and raise your own for each. In the loop, use enumerate(..., start=1) for the row number, wrap the parse_row call in try/except ValueError as error, print f"row {number}: {error}" and count it; on success append the tuple. The value is a sum over the loaded tuples.

                    Output
                    
                          

                      Notification