Python Primer·Chapter 6

Control Flow, Comprehensions and Unpacking

Loops and conditions, then the comprehension syntax that replaces most of them. Plus the unpacking tricks that appear everywhere in data code and confuse everyone the first time they see them.

You already know what an if statement does. This chapter covers Python’s particular spelling of the basics, then spends most of its length on comprehensions and unpacking, because those are the two things that make Python code look like Python code.

Conditions

score = 72

if score >= 90:
    grade = 'A'
elif score >= 70:
    grade = 'B'
else:
    grade = 'C'

Indentation defines the block. Four spaces, consistently. Your editor will handle it.

Python lets you chain comparisons in a way most languages do not, and it reads exactly as you would say it aloud:

if 0 <= score <= 100:        # both conditions at once
    ...

Combine conditions with and, or and not:

if tier == 1 and population > 10:
    ...
if not is_deleted:
    ...

Note for later: in pandas and NumPy you must use &, | and ~ instead, with parentheses. That is a different thing that looks similar, and mixing them up produces a confusing error. The word forms work on single values; the symbol forms work element by element on arrays.

The conditional expression

A one-line if-else that produces a value:

label = 'high' if score >= 70 else 'low'

Useful when assigning. Do not nest them; two levels deep and nobody can read it.

Loops

for iterates over anything that can be iterated. You rarely need an index.

for city in ['Delhi', 'Tokyo']:
    print(city)

for i in range(5):            # 0, 1, 2, 3, 4
    print(i)

for i in range(2, 10, 2):     # 2, 4, 6, 8
    print(i)

Two built-ins remove almost all remaining need for index juggling:

cities = ['Delhi', 'Tokyo', 'Sydney']

for i, city in enumerate(cities):          # index and value together
    print(f"{i}: {city}")

for i, city in enumerate(cities, start=1): # numbering for humans
    print(f"{i}. {city}")

pops = [33.8, 37.1, 5.3]
for city, pop in zip(cities, pops):        # two lists in step
    print(f"{city}: {pop}m")

If you catch yourself writing for i in range(len(things)), one of those two is what you actually wanted.

zip stops at the shorter input, silently. When the lists should be the same length, say so:

for city, pop in zip(cities, pops, strict=True):   # raises if lengths differ
    ...

Breaking out

for city in cities:
    if city == 'Sydney':
        break          # stop the loop entirely
    if city == 'Delhi':
        continue       # skip to the next item
    print(city)

while loops until a condition becomes false. In data work you will use them rarely, mostly for paginated API calls where you do not know the number of pages in advance.

Comprehensions

This is the syntax that makes Python code look fluent, and it is worth taking slowly because the shape is unfamiliar at first.

A comprehension builds a list from a loop, in one expression.

# The loop you would otherwise write
squares = []
for n in range(5):
    squares.append(n ** 2)

# The comprehension
squares = [n ** 2 for n in range(5)]

Read it in the order it is written: what to collect, then where it comes from.

flowchart LR
    A["<b>[ n ** 2</b><br/>what to collect"] --> B["<b>for n in range(5)</b><br/>where each value comes from"]
    B --> C["<b>if n % 2 == 0 ]</b><br/>which ones to keep<br/><i>(optional)</i>"]
    style A fill:#eef1fc,stroke:#2141c8

With a filter:

evens = [n for n in range(10) if n % 2 == 0]        # [0, 2, 4, 6, 8]

cities = ['Delhi', 'Tokyo', 'Sydney']
short = [c.lower() for c in cities if len(c) <= 5]  # ['delhi', 'sydney']

The same syntax builds the other collection types:

{c.lower() for c in cities}                    # a set
{c: len(c) for c in cities}                    # a dict
(n ** 2 for n in range(5))                     # a generator, see next chapter

The dictionary form is genuinely useful:

records = [{'id': 1, 'name': 'Asha'}, {'id': 2, 'name': 'Ravi'}]
by_id = {r['id']: r for r in records}          # instant lookup by id
by_id[2]['name']                               # 'Ravi'

When not to use one

Comprehensions are for building a collection from a simple transformation. They stop being readable when:

  • They need more than one filter or a nested loop. Write the loop.
  • The expression is long. If it wraps a line, it is too much.
  • You are not collecting anything. A comprehension whose result you discard is a loop written confusingly. Use a loop.
# Don't. Nobody can read this.
result = [f(x) for sub in data if sub for x in sub if g(x) and h(x)]

# Do. It is longer and it is clear.
result = []
for sub in data:
    if not sub:
        continue
    for x in sub:
        if g(x) and h(x):
            result.append(f(x))

Readability is the point of the language. A comprehension that needs decoding has defeated its own purpose.

Unpacking

Assigning several names at once. This appears everywhere in data code and looks like magic until someone explains it.

point = (10, 20)
x, y = point               # x = 10, y = 20

a, b = 1, 2                # no brackets needed
a, b = b, a                # swap, in one line, with no temporary

The number of names must match the number of values, or you get an error. Unless you use a star:

first, *rest = [1, 2, 3, 4]        # first = 1, rest = [2, 3, 4]
*most, last = [1, 2, 3, 4]         # most = [1, 2, 3], last = 4
first, *middle, last = [1, 2, 3, 4]  # middle = [2, 3]

The starred name always collects a list, even when it collects nothing.

Use _ by convention for values you do not need:

name, _, tier = ('Delhi', 33.8, 1)     # ignoring the population

Unpacking in loops

This is where it earns its keep:

pairs = [('Delhi', 33.8), ('Sydney', 5.3)]

for city, pop in pairs:                 # unpacked automatically
    print(f"{city}: {pop}")

for i, (city, pop) in enumerate(pairs): # nested unpacking
    print(f"{i}: {city}")

for city, pop in population.items():    # the dictionary pattern
    ...

Unpacking into function calls

Two stars, two meanings, and they mirror each other.

def describe(city, population, tier):
    return f"{city}: {population}m, tier {tier}"

args = ['Delhi', 33.8, 1]
describe(*args)                         # spread a list into positional args

kwargs = {'city': 'Sydney', 'population': 5.3, 'tier': 2}
describe(**kwargs)                      # spread a dict into keyword args

The same syntax merges collections, which is the modern way to combine dictionaries:

defaults = {'colour': 'blue', 'size': 10}
overrides = {'size': 12}
settings = {**defaults, **overrides}    # {'colour': 'blue', 'size': 12}

combined = [*list_a, *list_b]           # concatenate lists

Later values win, which is exactly what you want for configuration.

Structural Pattern Matching

Python has a match statement for branching on the shape of data. You will not need it often, but it is genuinely clearer than a chain of if statements when handling varied API responses:

match response:
    case {'status': 'ok', 'data': data}:
        process(data)
    case {'status': 'error', 'message': msg}:
        log(msg)
    case _:
        raise ValueError(f"unexpected shape: {response}")

It matches on structure, not just value, and it binds names as it goes. Worth knowing it exists.

What Carries Forward

enumerate and zip remove almost all index juggling. If you write range(len(x)), one of them is what you meant.

Comprehensions are for simple transformations. One loop, optionally one filter. Beyond that, write the loop.

Unpacking is everywhere. for key, value in d.items() is unpacking. So is a, b = b, a. So is **kwargs.

{**a, **b} merges dictionaries with the second winning, which is the pattern for layering configuration over defaults.

Next: functions, including the default-argument trap that has caught every Python programmer exactly once.