Python basics · Lesson 4 of 10 · about 25 minutes

Functions and errors

How to package steps into a function you can name and reuse, how to hand back an answer with return, and how to catch an error so one bad value doesn't stop the whole run.

By the end you'll know

  • How to write a function with def, and call it
  • How to pass values in, and hand an answer back with return
  • Why return and print() are not the same thing
  • How to catch an error with try and except, and carry on
  • How to read a traceback that runs through more than one function

Where your checker is

At the end of Lesson 3, your checker loops over a list of sites and reports each one. It works, but two things will hurt as it grows.

First, the loop does everything itself: looks up the status code, works out milliseconds, decides up or down, prints, and counts. When the checker later needs any of those steps somewhere else, like retrying a site or saving results, you'd have to copy them, and every bug would need fixing in several places.

Second, real data is messy. If one site in a list of 300 has a status code that isn't a number, the checker crashes, and the sites after it never get checked.

This lesson fixes both. Functions package steps under a name, so you write them once. try and except let the script deal with a bad value and keep going. You'll change checker.py in two steps again.

Functions: write it once, use it anywhere

A recipe card is written once, and from then on you just say "let's make pancakes" instead of reciting the steps. A function is the same: a set of steps with a name, so the rest of your script can use them by name.

You make a function with def (short for define), a name, brackets, and a colon. The indented lines under it are the function's body:

PYTHON
def to_ms(seconds):
    return round(seconds * 1000)

seconds is a parameter: a name for whatever value is handed in when the function is used, the way a recipe can be made for 2 people or for 6. Writing def doesn't run anything yet. It just teaches Python a new name. The body runs when you call the function, by writing its name with a value in the brackets:

PYTHON
print(to_ms(0.25))
print(to_ms(1.8))
TEXT
250
1800

The value you pass in, 0.25, is called an argument. Inside the function, seconds holds it. You've been calling functions since Lesson 1: print(), len(), round() and int() are all functions that come with Python. Now you can make your own.

A function can take more than one parameter, separated by commas, and indentation marks what's inside, exactly as with if and loops. Variables you create inside a function stay inside it; the rest of the script can't see them.

return: handing an answer back

A calculator shows its answer on the screen, and that's the end of it. A spreadsheet formula puts its answer in a cell, where other formulas can use it. print() is the calculator: it shows a value to a person. return is the spreadsheet cell: it hands the value back to your code, so the next line can use it.

That's why you can write response_ms = to_ms(0.25) and get 250 stored in response_ms. When a function reaches return, it stops and gives that value back to whoever called it.

A function that only prints, and never returns, hands back None, Python's "no value" from Lesson 2. Here's one, with what it shows when it runs:

PYTHON
def greet(name):
    print(f"Hello, {name}")


answer = greet("Kwame")
print(answer)
TEXT
Hello, Kwame
None

The greeting appeared, because print() ran inside the function. But answer is None, because nothing was returned. If you want to use a function's result, it needs a return.

A function can have more than one return. Whichever one Python reaches first ends the function, and the lines after it don't run.

Now the first change to the checker. In checker.py, add these two functions between the list of sites and print("Checking sites..."), with two blank lines around each one (that's just Python's style convention, for readability):

PYTHON
def is_up(status_code):
    return status_code >= 200 and status_code < 400


def to_ms(seconds):
    return round(seconds * 1000)

Then, inside the loop, change two lines so they use them:

PYTHON
    response_ms = to_ms(site["response_seconds"])
    if is_up(status_code):

Run the checker. The output is exactly the same as before, and that's the point: the script does the same job, but each rule now has a name. is_up() answers "is this code a good one?" and returns a bool. to_ms() converts seconds. If the rule for "up" ever changes, there's one place to change it.

Notice the order: the functions are defined above the loop that calls them. Python reads top to bottom, so a function has to be defined before the line that calls it runs. Putting all the defs near the top is the usual habit.

try and except: a backup plan

When a road is closed, a satnav doesn't give up on the journey; it finds a way around and carries on. try and except give your script the same kind of backup plan, so one bad value doesn't stop a check of 300 sites.

You met ValueError in Lesson 2: int("two hundred") can't work, so Python stops. An error like that is called an exception, and you can catch it:

PYTHON
text = "n/a"
try:
    number = int(text)
except ValueError:
    number = 0

print(number)
TEXT
0

Python runs the lines under try. If they work, it skips the except block. If one of them raises a ValueError, Python jumps straight to except ValueError:, runs those lines instead, and carries on below as if nothing happened. No traceback, no crash.

Always name the error you expect, like except ValueError:. You might see except: on its own, which catches every error, but that also hides the mistakes you didn't expect, like a typo in a variable name, and turns them into silent wrong answers. Catch what you know how to handle, and let everything else show up.

Now the second change. Lesson 5 will read the list of sites from a file, and as Lesson 2 showed, data from a file arrives as text. So make the data look like that now. In the list of sites, put quotes around each status code, so 200 becomes "200", and add a fourth site whose status code isn't a number at all:

PYTHON
    {"name": "example.edu", "status_code": "n/a", "response_seconds": 0.0},

Then add a third function, below to_ms(). It checks one site from start to finish, using the other two, and returns a word: "up", "down" or "skipped":

PYTHON
def check_site(site):
    name = site["name"]
    try:
        status_code = int(site["status_code"])
    except ValueError:
        print(f"SKIP  {name}: status code isn't a number")
        return "skipped"

    response_ms = to_ms(site["response_seconds"])
    if is_up(status_code):
        print(f"UP    {name}: {status_code} in {response_ms} ms")
        return "up"
    print(f"DOWN  {name}: {status_code} in {response_ms} ms")
    return "down"

int() turns the text into a number. For "n/a", it raises a ValueError, the except block prints a SKIP line, and return "skipped" ends the function early, so the lines below it never run for that site.

Last, replace everything from down = 0 to the end of the file with a much shorter loop, which calls check_site() and counts the answers:

PYTHON
down = 0
skipped = 0
for site in sites:
    result = check_site(site)
    if result == "down":
        down = down + 1
    elif result == "skipped":
        skipped = skipped + 1

print(f"{down} of {len(sites)} sites down, {skipped} skipped")

Reading the loop now tells you what the script does; the function tells you how.

Checkpoint: your checker so far

Your checker.py should now look like this. If your blank lines differ a little, that's fine, but line numbers in the break-it section assume this layout:

PYTHON
# A website checker, built one piece at a time.
sites = [
    {"name": "example.com", "status_code": "200", "response_seconds": 0.25},
    {"name": "example.org", "status_code": "503", "response_seconds": 1.8},
    {"name": "example.net", "status_code": "301", "response_seconds": 0.4},
    {"name": "example.edu", "status_code": "n/a", "response_seconds": 0.0},
]


def is_up(status_code):
    return status_code >= 200 and status_code < 400


def to_ms(seconds):
    return round(seconds * 1000)


def check_site(site):
    name = site["name"]
    try:
        status_code = int(site["status_code"])
    except ValueError:
        print(f"SKIP  {name}: status code isn't a number")
        return "skipped"

    response_ms = to_ms(site["response_seconds"])
    if is_up(status_code):
        print(f"UP    {name}: {status_code} in {response_ms} ms")
        return "up"
    print(f"DOWN  {name}: {status_code} in {response_ms} ms")
    return "down"


print("Checking sites...")
print(f"{len(sites)} sites to check")

down = 0
skipped = 0
for site in sites:
    result = check_site(site)
    if result == "down":
        down = down + 1
    elif result == "skipped":
        skipped = skipped + 1

print(f"{down} of {len(sites)} sites down, {skipped} skipped")

Run it:

BASH
python3 checker.py
TEXT
Checking sites...
4 sites to check
UP    example.com: 200 in 250 ms
DOWN  example.org: 503 in 1800 ms
UP    example.net: 301 in 400 ms
SKIP  example.edu: status code isn't a number
1 of 4 sites down, 1 skipped

The bad site was reported, the good ones were all checked, and the summary is honest about what was skipped. That's the behaviour you want from any script that handles real data.

Inside check_siteOne site dictionary goes into check_site. The function tries int on the status code. If that works, is_up decides, and the function returns up or down. If int raises a ValueError, the except block runs instead and the function returns skipped. Every path ends with one answer handed back to the loop.siteone dictionarytry:int(status_code)worksis_up()?yes or noyesnoreturn "up"return "down"ValueErrorexcept ValueError:return "skipped"A function takes something in, does one job, and hands one answer back, even when the data is bad.
Every path through check_site ends in a return. A good status code goes to is_up and comes back as up or down; a bad one is caught by except and comes back as skipped, instead of crashing the run.

Break it on purpose

Make each mistake, run the checker, and put it back before the next.

Forget to return. In to_ms(), delete the word return, leaving just round(seconds * 1000):

TEXT
Checking sites...
4 sites to check
UP    example.com: 200 in None ms
DOWN  example.org: 503 in None ms
UP    example.net: 301 in None ms
SKIP  example.edu: status code isn't a number
1 of 4 sites down, 1 skipped

No error, and "None ms". The function still did the maths, then threw the answer away, so it handed back None. Whenever you see None where you expected a value, check the function that produced it for a missing return. Put return back.

Call it without what it needs. On line 26, change to_ms(site["response_seconds"]) to to_ms(), with nothing in the brackets:

TEXT
Checking sites...
4 sites to check
Traceback (most recent call last):
  File "checker.py", line 40, in <module>
    result = check_site(site)
  File "checker.py", line 26, in check_site
    response_ms = to_ms()
TypeError: to_ms() missing 1 required positional argument: 'seconds'

This traceback has two File lines, because the error happened inside a function that was called from somewhere else. Read it from the bottom, as always: the last line says to_ms() was called without its seconds argument. The File line just above it, line 26 in check_site, is where the mistake is. The one above that, line 40, is the call that led there. Each File line is one step in the chain of calls, with the most recent at the bottom. Put the argument back.

Catch the wrong error. On line 22, change except ValueError: to except KeyError::

TEXT
Checking sites...
4 sites to check
UP    example.com: 200 in 250 ms
DOWN  example.org: 503 in 1800 ms
UP    example.net: 301 in 400 ms
Traceback (most recent call last):
  File "checker.py", line 40, in <module>
    result = check_site(site)
  File "checker.py", line 21, in check_site
    status_code = int(site["status_code"])
ValueError: invalid literal for int() with base 10: 'n/a'

The first three sites were checked, then the whole run crashed on the fourth. except KeyError: only catches KeyErrors, and int("n/a") raised a ValueError, so nothing caught it. An except only helps if it names the error that actually happens, which is why the last line of the traceback, the error's name, is the first thing to read. Change it back to ValueError, and run the checker once more to see 1 of 4 sites down, 1 skipped.

Where you are now

Your checker now has a named function for each job, and one bad value no longer stops the run. But the sites are still typed into the script itself, and the results vanish when it finishes. Lesson 5 reads the list of sites from a file, and saves what the checker found.

Check yourself

  1. What happens when Python reads def to_ms(seconds): and its body?

  2. to_ms() uses print() instead of return. What does response_ms = to_ms(0.25) store?

  3. In def to_ms(seconds):, what is seconds?

  4. int("n/a") runs inside a try, with except ValueError: below it. What happens?

  5. Why name the error, as in except ValueError:, instead of catching every error?

  6. A traceback shows two File lines: line 40, then line 26 below it. Where did it fail?

Key terms

  • Function: a named block of steps, made with def, that runs when you call it.
  • Parameter: the name in a function's brackets, like seconds, that holds what's passed in.
  • Argument: the value you pass when calling a function, like 0.25.
  • return: ends a function and hands a value back to the code that called it.
  • Exception: an error raised while a script runs, like ValueError or KeyError.
  • try and except: run some lines, and if they raise the named error, run a backup plan instead of crashing.
  • Call chain: the functions that called each other on the way to an error, shown as one File line each in a traceback.

Go deeper