Python basics · Lesson 3 of 10 · about 30 minutes

Lists, dictionaries and loops

How to keep many values together in a list, label them with a dictionary, and run the same steps on each one with a loop, so the checker can handle any number of sites.

By the end you'll know

  • How to keep several values in order with a list
  • How to label values with a dictionary
  • How to make a decision with if and else
  • How to repeat the same steps for every item with a for loop
  • How to keep going until something changes with a while loop
  • Why indentation matters in Python

Where your checker is

At the end of Lesson 2, your checker holds one site's facts in three variables, works out whether that site is up, and reports it. To check a second site, you'd need site_2, status_code_2 and so on, and copies of every line that uses them. By the tenth site, the script is a mess.

This lesson turns it into a checker that handles any number of sites, and you only write the checking steps once. You'll keep using the >>> prompt for quick experiments, and change checker.py in two steps along the way.

Lists: values in order

A list is like a playlist on your phone. The playlist has one name, say "Road trip", but it holds many songs, in order. A Python list works the same way: one name, sites, holds every site you want to check. Without a list, you'd need a separate variable for each site: site_1, site_2, site_3, and so on.

A list holds several values in order, between square brackets and separated by commas. Try it at the prompt:

PYTHON
>>> sites = ["example.com", "example.org", "example.net"]
>>> sites[0]
'example.com'
>>> sites[1]
'example.org'
>>> sites[-1]
'example.net'

Each item has a position, called its index, and you ask for an item by putting its index in square brackets. The one thing to remember: Python counts positions from 0, not 1, so the first site is sites[0], and the second is sites[1]. Negative numbers count from the end, so sites[-1] is always the last item.

A few more things you can do with a list:

PYTHON
>>> len(sites)
3
>>> sites.append("example.edu")
>>> sites
['example.com', 'example.org', 'example.net', 'example.edu']
>>> "example.org" in sites
True

len() counts the items. .append() adds one to the end. in asks "is this in the list?", and like every question in Python, it answers with a bool.

Dictionaries: values with labels

A contact card in your phone holds several facts about one person, each with a label: name, mobile, email. You don't remember that the phone number is "the third thing on the card"; you look it up by its label. A site has several facts too, and a dictionary (or dict) gives each one a label. It holds pairs of a key (the label) and a value, between curly brackets:

PYTHON
>>> site = {"name": "example.com", "status_code": 200}
>>> site["name"]
'example.com'
>>> site["status_code"]
200

You look a value up by its key, in square brackets, the same way you looked up a list item by its index. You can change a value, or add a new pair, by storing under a key:

PYTHON
>>> site["status_code"] = 503
>>> site["checked"] = True
>>> site
{'name': 'example.com', 'status_code': 503, 'checked': True}

Asking for a key that isn't there is an error (you'll see it in the break-it section). When a key might be missing, use .get(), which gives back None instead, or a fallback value you choose:

PYTHON
>>> site.get("owner", "unknown")
'unknown'

Lists and dictionaries combine: a list of dictionaries is a list in order, where each item is a dictionary describing one thing. That's exactly what the checker needs, so make the first change now. In checker.py, replace the three fact lines (site, status_code and response_seconds) with this:

PYTHON
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},
]

A long list can be spread over several lines, one item per line, which is easier to read. The comma after the last item is optional; Python allows it, and it makes adding a fourth line easier. Don't run the checker yet: the lines below still talk about one site, and the loop is what fixes them.

Making decisions: if and else

A thermostat makes a decision every few minutes: if the room is colder than the setting, turn the heat on; otherwise, leave it off. The checker makes the same kind of choice for every site. if runs some lines only when a condition is True; else runs other lines when it isn't:

PYTHON
status_code = 503
if status_code < 400:
    print("up")
else:
    print("down")

That prints down. Look at the shape: the if line ends with a colon, and the lines that belong to it are indented (moved in by 4 spaces). That indentation isn't decoration. It's how Python knows which lines are inside the if and which come after it. When you need more than two choices, add elif (short for "else if") between them: elif status_code < 500:.

Loops: the same steps for every item

Think of a teacher marking 30 test papers: the same steps, done to each paper in the pile, until the pile is empty. A for loop does that to every item in a list, whether the list has 3 sites or 300:

PYTHON
for site in sites:
    print(site)

Read it as "for each item in sites, call it site, and run the indented lines". The first time through, site is the first item; the second time, the second; and so on until the list runs out. The name after for is yours to choose; picking the singular of the list's name, site for sites, is a common habit that keeps it readable.

Again, the colon and the indentation mark what's inside the loop. Lines indented under for run once per item. The first line that's back at the left edge runs once, after the loop is done.

Now the second change. In checker.py, replace everything below the list, from is_up = down to the last line, with this:

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

down = 0
for site in sites:
    status_code = site["status_code"]
    response_ms = round(site["response_seconds"] * 1000)
    if status_code >= 200 and status_code < 400:
        print(f"UP    {site['name']}: {status_code} in {response_ms} ms")
    else:
        print(f"DOWN  {site['name']}: {status_code} in {response_ms} ms")
        down = down + 1

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

Here's what's new:

  • len(sites) counts the sites for you. Add a site, and the count updates itself.
  • down = 0 starts a counter before the loop. Inside the else, down = down + 1 adds one each time a site is down. (You'll also see this written down += 1, which means the same thing.)
  • Inside the loop, site is one dictionary, so site["status_code"] looks up that site's code. The working out and the report are the same as in Lesson 2; they just run once per site now.
  • Inside the f-strings, the key is in single quotes, site['name'], because the f-string itself uses double quotes. Using the same quotes for both would end the string early.
  • The last line isn't indented, so it runs once, after the loop has checked every site.

Checkpoint: your checker so far

Your checker.py should now look like this:

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},
]

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

down = 0
for site in sites:
    status_code = site["status_code"]
    response_ms = round(site["response_seconds"] * 1000)
    if status_code >= 200 and status_code < 400:
        print(f"UP    {site['name']}: {status_code} in {response_ms} ms")
    else:
        print(f"DOWN  {site['name']}: {status_code} in {response_ms} ms")
        down = down + 1

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

Run it:

BASH
python3 checker.py
TEXT
Checking sites...
3 sites to check
UP    example.com: 200 in 250 ms
DOWN  example.org: 503 in 1800 ms
UP    example.net: 301 in 400 ms
1 of 3 sites down

Add a fourth site to the list, with any name and status code you like, and run it again. You didn't touch the loop, but it checked the new site too. That's the whole point: the steps are written once, and the list decides how many times they run. Take the fourth site back out before the break-it section, so the line numbers match.

A loop over a list of dictionariesThe list called sites holds three dictionaries at index 0, 1 and 2, each with a name and a status code. The for loop takes one dictionary at a time, runs the same steps on it, then goes back for the next. Each pass prints one line: UP example.com, DOWN example.org, UP example.net.sites (a list)[0]"name": "example.com""status_code": 200[1]"name": "example.org""status_code": 503[2]"name": "example.net""status_code": 301for site in sites:same steps,one item at a timethen the next itemOutputUPexample.comDOWNexample.orgUPexample.netA list keeps items in order. A loop runs the same steps once for each one.
The loop takes the dictionaries out of the list one at a time, in order, and runs the same indented lines on each. Three items in the list means three passes, and three lines of output.

One more loop: while

When you call someone and they don't pick up, you might try again, but you won't call forever: after three tries you give up and send a text. There's no list there, just a condition: keep going while there's no answer and you have tries left. That's a while loop. It checks its condition before every pass, and keeps running the indented lines as long as the condition is True.

You don't need to type this one; the checker gets a real one in Lesson 6. Just read it:

PYTHON
attempts = 0
answered = False
while not answered and attempts < 3:
    attempts = attempts + 1
    print(f"Attempt {attempts}...")
    if attempts == 2:
        answered = True

print(f"Stopped after {attempts} attempts. Answered: {answered}")
TEXT
Attempt 1...
Attempt 2...
Stopped after 2 attempts. Answered: True

Read the while line as "while we haven't had an answer and we've tried fewer than 3 times". The if pretends the site answers on the second try. If it never answered, attempts < 3 would turn false after the third try, and the loop would stop with Answered: False.

Notice that attempts = attempts + 1 is what eventually makes the condition false. A while loop always needs something inside it that moves it towards stopping. Without that line, attempts would stay at 0, and the loop would never end. That's an infinite loop, and if you ever start one by accident, stop it with Ctrl+C. Python prints a traceback ending in KeyboardInterrupt, which just means "you stopped me".

Which one to use? If you have a list and want to do something for each item, use for. If you want to keep going until something changes, use while. Most loops you'll write are for loops.

Break it on purpose

Make each mistake, run the script, read the error from the bottom, then put it back.

An index that isn't there. Add this line to the very end of checker.py:

PYTHON
print(sites[3]["name"])
TEXT
Traceback (most recent call last):
  File "checker.py", line 22, in <module>
    print(sites[3]["name"])
          ~~~~~^^^
IndexError: list index out of range

(The checker's normal output prints first, since the mistake is on the last line.) An IndexError means you asked for a position the list doesn't have. The list has 3 items, but since counting starts at 0, they're at [0], [1] and [2]. There's no [3]. This is the most common list mistake there is, so when you see "out of range", count from zero. Delete the line.

A key that isn't there. On line 13, change site["status_code"] to site["status"]:

TEXT
Checking sites...
3 sites to check
Traceback (most recent call last):
  File "checker.py", line 13, in <module>
    status_code = site["status"]
                  ~~~~^^^^^^^^^^
KeyError: 'status'

A KeyError is the dictionary version: there's no key called status, and the last line names the key Python couldn't find. Notice the first two lines printed, because they come before the loop. Keys have to match exactly, including capitals and underscores. Put status_code back.

Indentation that's off. First, on line 13, remove the 4 spaces in front of status_code = site["status_code"], so it starts at the left edge:

TEXT
  File "checker.py", line 13
    status_code = site["status_code"]
    ^^^^^^^^^^^
IndentationError: expected an indented block after 'for' statement on line 12

Nothing ran at all. Like the SyntaxError in Lesson 1, an IndentationError means Python couldn't make sense of the file's shape: a line ending in a colon has to have at least one indented line after it. Put the spaces back.

Now the sneaky one. On line 19, remove 4 of the 8 spaces in front of down = down + 1, so it lines up with if and else instead of sitting under else:

TEXT
Checking sites...
3 sites to check
UP    example.com: 200 in 250 ms
DOWN  example.org: 503 in 1800 ms
UP    example.net: 301 in 400 ms
3 of 3 sites down

No error at all, and a wrong answer. The line is still inside the loop, but it's no longer inside the else, so it runs for every site, up or down. Python can't catch this one for you, because it's perfectly valid code; it just isn't what you meant. It's a good reason to check a script's output against what you expect, and not only whether it ran. Put the 4 spaces back and confirm you see 1 of 3 sites down.

Where you are now

Your checker now handles any number of sites with the same few lines. But two things will hurt as it grows. The loop does all the work itself, so reusing any of it means copying it. And the data is typed in by hand; real data is messy, and one bad value would crash the whole run. Lesson 4 fixes both, with functions and errors.

Check yourself

  1. With sites = ["a.com", "b.com", "c.com"], what is sites[1]?

  2. When is a dictionary a better fit than a list?

  3. Which loop fits "keep trying until the site answers, up to 3 times"?

  4. The dictionary site has no "owner" key. What does site.get("owner") give back?

  5. You move down = down + 1 out from under else, level with the if. What happens?

  6. site["status"] gives a KeyError. What does that mean?

Key terms

  • List: values in order, in square brackets, like ["a.com", "b.com"].
  • Index: an item's position in a list, counting from 0. [-1] is the last item.
  • Dictionary (dict): key and value pairs, in curly brackets, like {"name": "a.com"}.
  • Key: the label you look a dictionary value up by.
  • if, elif, else: run different lines depending on whether a condition is true.
  • for loop: run the indented lines once for each item in a list.
  • while loop: keep running the indented lines as long as a condition is true.
  • Infinite loop: a while loop whose condition never becomes false. Stop it with Ctrl+C.
  • Indentation: the spaces at the start of a line, which tell Python which lines belong to an if or a loop.
  • Counter: a variable that starts at 0 and goes up by one each time something happens.
  • IndexError: asking a list for a position it doesn't have.
  • KeyError: asking a dictionary for a key it doesn't have.

Go deeper