IntermediatePython · 45 min · 19 challenges

Collections: Intermediate

It's late November at Spotify, and the Wrapped pipeline has to turn a year of raw play events into each listener's top 5 tracks. Play counts arrive as text, tracks with 0 plays must be dropped, track names sit in a separate list from their counts, and 2 songs tied at 310 plays need a fixed order. Whether the card a listener shares is right comes down to 4 built-ins.

Using sorted() with key

Daily Life
Interviews

sorted() takes any iterable, compares its items with <, and returns a new list in ascending order. The input is left as it was, so other code that still reads it sees the original order.

durations = [42, 7, 19, 7, 63]
ordered = sorted(durations)
print(ordered)
print(durations)
>>>Output
[7, 7, 19, 42, 63]
[42, 7, 19, 7, 63]

Strings compare character by character using each character's code, and the uppercase letters A to Z all have lower codes than the lowercase letters a to z. A mixed-case list of table names therefore comes back in an order nobody would pick by hand. The key argument fixes that:

tables = ["orders", "Users", "events", "Accounts"]
print(sorted(tables))
print(sorted(tables, key=str.lower))
>>>Output
['Accounts', 'Users', 'events', 'orders']
['Accounts', 'events', 'orders', 'Users']

What the key function does

key takes a function. sorted() calls it once on each item, sorts by the values it returns, and hands back the original items in that order. With key=str.lower, the comparisons run on 'users' and 'accounts', yet the output still reads 'Users' and 'Accounts'. The key decides the order and never changes what is in the list.

Any function that takes 1 argument can be a key, built-ins included. With key=len, table names line up by character count. events and orders both have 6 characters, and events stays ahead because it came first in the input:

tables = ["events", "orders", "Users", "Accounts", "dim"]
print(sorted(tables, key=len))
>>>Output
['dim', 'Users', 'events', 'orders', 'Accounts']

Pipeline records are usually dicts, and 2 dicts cannot be compared with <, so you write a small function that returns the field to sort on. This program prints a line each time its key function runs. Compare the order of those calls with the order of the final output.

1def by_minutes(run):
2 print("key for", run["job"])
3 return run["minutes"]
4
5
6runs = [
7 {"job": "load_orders", "minutes": 42},
8 {"job": "dedupe_users", "minutes": 7},
9 {"job": "build_daily", "minutes": 19},
10]
11ordered = sorted(runs, key=by_minutes)
12for run in ordered:
13 print(run["job"], run["minutes"])
runs
[
]
012
run
:
:
run
:
:
run
:
:
ordered
[
]
012
output0 lines
defines by_minutes
1/23

The key function runs once per run, in input order, before any run moves.

All 3 key calls happen first, in input order, before any item moves; the runs then come out by their returned minutes. The argument is key=by_minutes, with no parentheses, which hands sorted() the function itself. Writing key=by_minutes() would call it on the spot with no run to read. The same slip with a built-in, key=len(), fails before any sorting with TypeError: len() takes exactly one argument (0 given).

Sorting on 2 fields with a tuple key

Tuples compare the way lists do: first items first, and the second items only when the first ones tie. A key function that returns a tuple therefore sorts by the first field and breaks ties with the next. Here the runs group by team, and within a team the shortest run leads.
runs = [
{"job": "load_orders", "team": "sales", "minutes": 42},
{"job": "dedupe_users", "team": "growth", "minutes": 7},
{"job": "build_daily", "team": "sales", "minutes": 19},
{"job": "sync_crm", "team": "growth", "minutes": 7},
]
def team_then_minutes(run):
return (run["team"], run["minutes"])
for run in sorted(runs, key=team_then_minutes):
print(run["team"], run["minutes"], run["job"])
>>>Output
growth 7 dedupe_users
growth 7 sync_crm
sales 19 build_daily
sales 42 load_orders

Both growth runs have the key ('growth', 7). With the keys fully tied, dedupe_users stays ahead of sync_crm because it came first in the input. Python's sort is stable: items with equal keys keep their original relative order. That guarantee is what makes a sorted report come out the same on every run over the same data.

Missing values break the comparison

Real columns have gaps, and Python has no rule for comparing None with a number. Decide what this program prints before you check the answer.

Multiple Choice

A latency column has a missing reading. What does this program print?

latencies = [120, None, 85]
print(sorted(latencies))

The fix is a key that never puts None and a number into the same comparison. Return a tuple whose first item marks the value as present (0) or missing (1). Every missing value then sorts after every real one, and the second items are only compared between values of the same kind.

def nulls_last(value):
if value is None:
return (1, 0)
return (0, value)
latencies = [120, None, 85]
print(sorted(latencies, key=nulls_last))
>>>Output
[85, 120, None]
TIP
Give each key function a name that states its rule, the way by_minutes and nulls_last do, and define it next to the data it reads. The call sorted(runs, key=by_minutes) then reads like a description of the result.

Using sorted() in Reverse

Daily Life
Interviews

Ranking flips the question: which items come first when bigger is better. reverse=True makes sorted() put the largest key first, and slicing the result with [:n] keeps the top N. Here are 5 tracks with a year of play counts, 2 of them tied at 310.

plays = {
"Paper Moon": 310,
"Low Tide": 95,
"Night Drive": 310,
"Static": 180,
"Afterglow": 42,
}
def by_count(pair):
return pair[1]
ranked = sorted(plays.items(), key=by_count, reverse=True)
print(ranked[:3])
>>>Output
[('Paper Moon', 310), ('Night Drive', 310), ('Static', 180)]

Each item of plays.items() is a (name, count) pair, so by_count returns pair[1]. Paper Moon and Night Drive tie at 310, and Paper Moon stays ahead because it was inserted first: reverse=True keeps equal keys in input order. Sorting ascending and then reversing with [::-1] gives a different answer:

sorted(..., reverse=True)
ranktrackplays
1Paper Moon310
2Night Drive310
3Static180
4Low Tide95
5Afterglow42
sorted(...)[::-1]
ranktrackplays
1Night Drive310
2Paper Moon310
3Static180
4Low Tide95
5Afterglow42

The ascending sort keeps Paper Moon ahead of Night Drive, and [::-1] then flips the entire list, ties included. If one report uses reverse=True and another uses [::-1], the same data shows 2 different number-one tracks. Pick reverse=True for descending order with ties in input order.

Passing the dict itself to sorted() sorts its keys and returns them alone, alphabetically. To rank the names by their counts, use the dict's get method as the key: sorted() calls plays.get on each name and orders the names by the counts it returns.

plays = {
"Paper Moon": 310,
"Low Tide": 95,
"Night Drive": 310,
"Static": 180,
"Afterglow": 42,
}
print(sorted(plays))
print(sorted(plays, key=plays.get, reverse=True)[:3])
>>>Output
['Afterglow', 'Low Tide', 'Night Drive', 'Paper Moon', 'Static']
['Paper Moon', 'Night Drive', 'Static']

key=plays.get carries no parentheses for the same reason key=by_minutes carried none: sorted() needs the method itself, ready to call on each name. Use this form when the report lists names only, and plays.items() when each line needs its count beside the name.

Descending on one field, ascending on another

reverse=True flips every part of a tuple key together. A common ranking rule wants plays descending but names ascending, so tied tracks land alphabetically. For a numeric field, negate it inside the key: the most plays becomes the smallest key, and the sort itself stays ascending.

plays = {
"Paper Moon": 310,
"Low Tide": 95,
"Night Drive": 310,
"Static": 180,
"Afterglow": 42,
}
def rank_key(pair):
name, count = pair
return (-count, name)
for name, count in sorted(plays.items(), key=rank_key):
print(count, name)
>>>Output
310 Night Drive
310 Paper Moon
180 Static
95 Low Tide
42 Afterglow
TIP
Text cannot be negated. When the field that needs the opposite direction is a string, sort twice: first by the tiebreaker, then by the main field. The second sort is stable, so among its ties it keeps the order the first sort produced.

Say runs should be listed fastest first, and runs tied on minutes should list their names from Z to A. Names cannot be negated, so the first pass sorts by name with reverse=True and the second pass sorts by minutes:

runs = [
("sync_crm", 7),
("build_daily", 19),
("dedupe_users", 7),
("load_orders", 42),
]
def by_name(pair):
return pair[0]
def by_minutes(pair):
return pair[1]
by_name_desc = sorted(runs, key=by_name, reverse=True)
final = sorted(by_name_desc, key=by_minutes)
for job, minutes in final:
print(minutes, job)
>>>Output
7 sync_crm
7 dedupe_users
19 build_daily
42 load_orders

After the first pass, sync_crm sits ahead of dedupe_users. The second pass sees them tied at 7 minutes and, being stable, leaves them in that order. With any number of fields the rule holds: sort by the least important field first and the most important field last, each pass with its own reverse.

The top 1 with max() and min()

When N is 1, sorting the whole list does work you then throw away. max() and min() accept the same key argument and return the single best item. When several items share the best key, they return the first one they meet.

plays = {
"Paper Moon": 310,
"Low Tide": 95,
"Night Drive": 310,
"Static": 180,
"Afterglow": 42,
}
def by_count(pair):
return pair[1]
print(max(plays.items(), key=by_count))
print(min(plays.items(), key=by_count))
>>>Output
('Paper Moon', 310)
('Afterglow', 42)

Small inputs behave differently across these tools. A slice past the end of a list returns what exists, so ranked[:5] for a listener who played only 3 tracks all year returns those 3 with no error. max() on an empty input raises ValueError, so check that a list has items before asking for its best one.

Multiple Choice

Both lines rank the same scores. What do they print?

scores = [("ana", 9), ("bo", 7), ("cy", 9)]
def by_score(pair):
return pair[1]
print(sorted(scores, key=by_score, reverse=True)[0][0])
print(sorted(scores, key=by_score)[::-1][0][0])

Using map() for Transforms

Daily Life
Interviews

map(function, iterable) calls the function on each item and produces the results in the same order. Raw extracts often deliver numbers as text, and map(int, raw) converts a whole column in a single expression. Predict both printed lines before you look:

raw = ["42", "7", "19"]
minutes = map(int, raw)
print(list(minutes))
print(list(minutes))
>>>Output
[42, 7, 19]
[]

map() returns a map object, which produces each value only when something asks for it, and produces it once. list() asks for every value, so the second list() call finds nothing left and returns []. Store the result of list() in a variable whenever you need the values twice.

TIP
sum(), max() and min() read a map object directly, so sum(map(int, raw)) totals a text column with no list built in between.
Multiple Choice

A job converts row counts, totals them, then checks how many it got. What does this print?

raw = ["1200", "0", "98000"]
counts = map(int, raw)
total = sum(counts)
print(total, len(list(counts)))

The function runs when the values are read

Creating the map object converts nothing. Step through this program and watch when each converting line prints relative to the loop.

1def to_minutes(text):
2 print("converting", text)
3 return int(text)
4
5
6raw = ["42", "7", "19"]
7total = 0
8print("loop starts, nothing converted yet")
9for value in map(to_minutes, raw):
10 total += value
11 print("running total", total)
12print("done:", total)
total=
value=
raw
[
]
012
output0 lines
defines to_minutes
1/25

Each value is converted at the moment the loop asks for it.

If map() converted eagerly, all 3 converting lines would print before the first running total. Instead each text value is converted only when the loop asks for it, with the running total updated in between. That timing also decides where an error surfaces: a bad value fails on the line that reads the map, which can be far from the line that built it.

raw = ["42", "n/a", "19"]
minutes = map(int, raw)
print("map created")
print(list(minutes))

Run it and map created prints first, because building the map converted nothing. The failure comes on line 4, when list() reads the values and int('n/a') runs: the traceback names line 4 and ends with ValueError: invalid literal for int() with base 10: 'n/a'. Line 2, where map() was written, never appears in it. When a step fails inside a list() or sum() call, trace the map object back to the data it was built from.

Transforming records with your own function

Any function that takes 1 item and returns 1 value works with map(). For records, the function usually builds a new dict from the old one, so the source list stays intact for any other step that reads it.

def clean(user):
return {"id": user["id"], "email": user["email"].lower()}
users = [
{"id": 1, "email": "Ana@Site.com"},
{"id": 2, "email": "BO@site.COM"},
]
for user in map(clean, users):
print(user)
>>>Output
{'id': 1, 'email': 'ana@site.com'}
{'id': 2, 'email': 'bo@site.com'}

A map object is iterable like a list, so one map can feed another. str.strip and str.upper are methods named through their type, and they plug in the same way str.lower did as a sort key. Here country codes arrive padded and in mixed case:

raw_codes = [" pt", "BR ", "us"]
codes = list(map(str.upper, map(str.strip, raw_codes)))
print(codes)
>>>Output
['PT', 'BR', 'US']

Neither map does anything until list() reads the outer one. Then each code passes through both steps before the next code starts: ' pt' becomes 'pt', then 'PT'. Keep the list that list() returns, because both map objects are spent after that one read.

Given 2 iterables, map() pairs them up by position and calls the function with 1 item from each. It stops when the shorter one runs out:

def line_total(price, quantity):
return price * quantity
prices = [5, 12, 3]
quantities = [2, 1, 4]
print(list(map(line_total, prices, quantities)))
>>>Output
[10, 12, 12]
✓A for loop with append
  • Needs an empty list set up before the loop that appends to it
  • Has room for several statements and if branches
  • Builds the full list right away
  • Reads well when the transform is written inline
✗map()
  • A single expression around a named function
  • The function takes 1 item per iterable and returns 1 value
  • Produces values only when read, and only once
  • Reads well when you pass a built-in like int or a method like str.lower

Using filter() for Selection

Daily Life
Interviews

filter(function, iterable) runs the function against every item and keeps those for which it returns True. A function used this way is called a predicate: it answers a yes-or-no question about 1 item. Like a map object, the result is a filter object that computes each item as you read it and can be read only once.

runs = [
{"job": "load_orders", "status": "ok", "minutes": 42},
{"job": "dedupe_users", "status": "failed", "minutes": 7},
{"job": "build_daily", "status": "ok", "minutes": 19},
{"job": "sync_crm", "status": "failed", "minutes": 7},
]
def is_failed(run):
return run["status"] == "failed"
for run in filter(is_failed, runs):
print(run["job"], run["minutes"])
>>>Output
dedupe_users 7
sync_crm 7

The 2 dicts printed are the same objects that sit in runs. Nothing in them changed, and they come out in their original order. filter() chooses which items survive and never edits them, which gives you 2 properties to check in any output: it holds at most as many items as the input, and every item in it came from the input.

✓map()
  • Function returns a new value for each item
  • Output has exactly as many items as the input
  • Items come out changed
  • Answers: what does each item become?
✗filter()
  • Function returns True or False for each item
  • Output has as many items as the input, or fewer
  • Items come out unchanged
  • Answers: which items stay?

Predicates that test several conditions

A predicate can combine conditions with and and or, as long as it ends by returning True or False. It receives exactly 1 argument, the item, so a threshold it needs has to be written inside the function or read from a constant defined at module level.

SLOW_MINUTES = 10
def is_slow_success(run):
return run["status"] == "ok" and run["minutes"] > SLOW_MINUTES
runs = [
{"job": "load_orders", "status": "ok", "minutes": 42},
{"job": "dedupe_users", "status": "failed", "minutes": 7},
{"job": "build_daily", "status": "ok", "minutes": 19},
{"job": "sync_crm", "status": "failed", "minutes": 7},
]
slow = list(filter(is_slow_success, runs))
print(len(slow), slow[0]["job"])
>>>Output
2 load_orders

load_orders at 42 minutes and build_daily at 19 pass both tests, so slow holds 2 dicts. The 2 failed runs fall at the status test, whatever their minutes. A threshold kept in a named constant such as SLOW_MINUTES changes in one place when the rule changes.

Filtering a dict

Looping over a dict gives its keys, so a predicate on values needs .items(), where each item is a (key, value) pair. Pass the surviving pairs to dict() to rebuild the mapping. Here only tables that loaded rows stay:

row_counts = {"orders": 1200, "users": 0, "events": 98000, "refunds": 0}
def has_rows(pair):
return pair[1] > 0
print(dict(filter(has_rows, row_counts.items())))
>>>Output
{'orders': 1200, 'events': 98000}

Dropping missing values with a predicate

Earlier, None in a latency column broke sorted(), and a key placed it last. When missing readings should not appear in the result at all, a predicate built on is not None removes them, and sorted() takes the filter object directly:

def is_present(value):
return value is not None
latencies = [120, None, 85, None, 240]
present = sorted(filter(is_present, latencies))
print(present)
print(len(latencies) - len(present), "readings missing")
>>>Output
[85, 120, 240]
2 readings missing
Placing gaps last and dropping them are 2 different report decisions. A chart built from the filtered list looks complete. Print or log how many items the filter removed, as the last line does, so whoever reads the result knows 2 of the 5 readings never reached it.

Chaining filter, map and sorted

Each of these functions accepts the others' output, so the 3 calls can nest in a single expression. Read it from the inside out: the innermost call is the first to touch the raw list.
4 run dicts
runs
keeps 2 dicts
filter(is failed)
dict to job name
map(job name)
new list
sorted(reverse=True)
output
print

Left to right: 4 run dicts enter filter, 2 failed dicts reach map, 2 job names reach sorted, and print shows the ordered list.

runs = [
{"job": "load_orders", "status": "ok", "minutes": 42},
{"job": "dedupe_users", "status": "failed", "minutes": 7},
{"job": "build_daily", "status": "ok", "minutes": 19},
{"job": "sync_crm", "status": "failed", "minutes": 7},
]
def is_failed(run):
return run["status"] == "failed"
def job_name(run):
return run["job"]
print(sorted(map(job_name, filter(is_failed, runs)), reverse=True))
>>>Output
['sync_crm', 'dedupe_users']

Put filter() inside map() when the order is yours to choose. The transform then runs on fewer items, and the predicate sees the full original record with every field it might test, before map() reduces each record to a name.

Multiple Choice

The arguments to filter() are swapped. What happens when this runs?

def is_failed(run):
return run["status"] == "failed"
runs = [{"job": "sync_crm", "status": "failed"}]
kept = filter(runs, is_failed)
print("built")
print(list(kept))

Using zip() for Combining

Daily Life
Interviews

You have walked 2 lists side by side with zip() in a for loop. Called on its own, zip() returns a zip object that yields tuples and can be consumed once. Whatever you pass it to sets the final shape: list() gives pairs and dict() turns those pairs into a mapping.

job_ids = ["load_orders", "dedupe_users", "build_daily"]
minutes = [42, 7, 19]
print(list(zip(job_ids, minutes)))
print(dict(zip(job_ids, minutes)))
>>>Output
[('load_orders', 42), ('dedupe_users', 7), ('build_daily', 19)]
{'load_orders': 42, 'dedupe_users': 7, 'build_daily': 19}

zip() accepts any number of iterables and builds a tuple with 1 item from each, so 3 parallel columns become 3-field rows that a for loop can unpack in a single line:

job_ids = ["load_orders", "dedupe_users", "build_daily"]
minutes = [42, 7, 19]
statuses = ["ok", "failed", "ok"]
for job, mins, status in zip(job_ids, minutes, statuses):
print(job, mins, status)
>>>Output
load_orders 42 ok
dedupe_users 7 failed
build_daily 19 ok

dict(zip(...)) has its own silent failure. When a key repeats, each later pair overwrites the earlier value, the same as assigning to that key twice:

job_ids = ["load_orders", "build_daily", "load_orders"]
minutes = [42, 19, 45]
runtimes = dict(zip(job_ids, minutes))
print(runtimes)
print(len(job_ids), len(runtimes))
>>>Output
{'load_orders': 45, 'build_daily': 19}
3 2

The first load_orders runtime of 42 is gone. Without any error, 3 job entries became 2 dict entries. If the key column can repeat, compare len(job_ids) with the length of the dict you built, as the last line does, before trusting the mapping.

Turning rows into records

Extracts often arrive as a header row plus value rows, the layout of a CSV file or a query result. dict(zip(header, row)) labels each value with its column name, and wrapping that in a function lets map() apply it to every row.

header = ["id", "email", "country"]
rows = [
[1, "ana@site.com", "PT"],
[2, "bo@site.com", "BR"],
]
def to_record(row):
return dict(zip(header, row))
for record in map(to_record, rows):
print(record)
>>>Output
{'id': 1, 'email': 'ana@site.com', 'country': 'PT'}
{'id': 2, 'email': 'bo@site.com', 'country': 'BR'}

The shortest input sets the length

zip() stops as soon as any input is exhausted, and it raises no error about the leftovers. Step through a mismatch where the runtime list lost its last value somewhere upstream.

1job_ids = ["load_orders", "dedupe_users", "build_daily"]
2minutes = [42, 7]
3paired = {}
4for job, mins in zip(job_ids, minutes):
5 paired[job] = mins
6 print("paired", job, mins)
7print(len(job_ids), len(minutes), len(paired))
job=
mins=
job_ids
[
]
012
minutes
[
]
01
paired
:
:
output0 lines
job_ids is a new list that holds 'load_orders', 'dedupe_users', 'build_daily'
1/12

zip stops when minutes runs out, and build_daily is left behind.

build_daily is never paired, and nothing in the output says so. In a pipeline this is silent data loss. When 2 lists are supposed to match, compare their lengths before zipping and stop the step if they differ:

def pair_runtimes(job_ids, minutes):
if len(job_ids) != len(minutes):
print("length mismatch:", len(job_ids), "jobs,", len(minutes), "runtimes")
return None
return dict(zip(job_ids, minutes))
print(pair_runtimes(["load_orders", "dedupe_users", "build_daily"], [42, 7]))
>>>Output
length mismatch: 3 jobs, 2 runtimes
None

Unzipping and sorting parallel lists

The * in a call spreads a list into separate arguments. zip(*pairs) therefore receives each pair as its own argument and groups all the first items together, then all the second items, splitting a list of pairs back into 2 tuples. Combined with sorted(), this orders 2 parallel lists by one of them while keeping every pairing intact.

job_ids = ["load_orders", "dedupe_users", "build_daily"]
minutes = [42, 7, 19]
pairs = sorted(zip(minutes, job_ids))
sorted_minutes, sorted_jobs = zip(*pairs)
print(pairs)
print(sorted_jobs)
print(sorted_minutes)
>>>Output
[(7, 'dedupe_users'), (19, 'build_daily'), (42, 'load_orders')]
('dedupe_users', 'build_daily', 'load_orders')
(7, 19, 42)

Putting minutes first in the zip() call makes each runtime the first item of its tuple, so plain sorted() orders by runtime with no key function, and the job name only matters on a tie. The results come back as tuples; pass them to list() if later code needs lists.

Multiple Choice

A column of counts is 1 value short. What does this program print?

names = ["orders", "users", "events"]
counts = [1200, 0]
pairs = zip(names, counts)
print(len(list(pairs)), len(list(pairs)))

Capstone: a ranking step end to end

The code quiz below puts all 5 ideas into one pipeline step, in the order a real job runs them: label the columns, convert the values, select the rows, rank the survivors. The debug drill after it returns to the tie rule from ranking on its own.

Ties deserve their own check because a wrong tie order raises nothing. Records built from parallel lists keep the order of those lists, so a track's position in the source decides which of 2 tied tracks takes first place under reverse=True. Flip the ties with [::-1] and the top of the report changes while every count stays the same.

❯❯❯PUTTING IT ALL TOGETHER

> Back on Spotify's Wrapped pipeline, an extract delivers a header and rows of track and play count, with every count as text. The step has to produce each listener's top 5 tracks, with the 2 songs tied at 310 plays in a fixed order and tracks with 0 plays left out.

zip() inside a to_record function, applied with map(), turns header-plus-rows into dict records and converts the play counts to integers.
filter() drops the 0-play tracks before ranking, so the sort only compares tracks that can appear on the card.
A key of (-plays, track) ranks by plays descending and breaks the 310-play tie alphabetically, and [:5] keeps the top 5.
Each one-pass object in the chain is read exactly once by the next call, which is why the chain works, and why storing a map object and reading it twice returns nothing the second time.
KEY TAKEAWAYS
sorted() returns a new list; key names a function called once per item, and the items come back unchanged in key order.
A tuple key sorts on several fields, and negating a number flips the direction of that field alone.
reverse=True keeps tied items in input order, while [::-1] flips the ties too; slice with [:n] for the top N.
map() and filter() return one-pass objects that run the function only when read, so errors surface where the values are consumed.
map() changes every item and keeps the count; filter() keeps or drops items without changing them.
zip() stops at the shortest input without a warning, so compare lengths before pairing columns.

Sort by any rule, rank the top N, and transform, select and pair records with sorted, map, filter and zip.

Category
Python
Difficulty
intermediate
Duration
45 minutes
Challenges
19 hands-on challenges

Topics covered: Using sorted() with key, Using sorted() in Reverse, Using map() for Transforms, Using filter() for Selection, Using zip() for Combining

Lesson Sections

  1. Using sorted() with key (concepts: Sorting)

    What the key function does Sorting on 2 fields with a tuple key Tuples compare the way lists do: first items first, and the second items only when the first ones tie. A key function that returns a tuple therefore sorts by the first field and breaks ties with the next. Here the runs group by team, and within a team the shortest run leads. Missing values break the comparison

  2. Using sorted() in Reverse (concepts: Sorting)

    Descending on one field, ascending on another The top 1 with max() and min()

  3. Using map() for Transforms (concepts: Functional Transforms)

    The function runs when the values are read Transforming records with your own function

  4. Using filter() for Selection (concepts: Functional Transforms)

    Predicates that test several conditions Filtering a dict Dropping missing values with a predicate Placing gaps last and dropping them are 2 different report decisions. A chart built from the filtered list looks complete. Print or log how many items the filter removed, as the last line does, so whoever reads the result knows 2 of the 5 readings never reached it. Chaining filter, map and sorted Each of these functions accepts the others' output, so the 3 calls can nest in a single expression. Read

  5. Using zip() for Combining (concepts: Parallel Iteration)

    Turning rows into records The shortest input sets the length Unzipping and sorting parallel lists Capstone: a ranking step end to end The code quiz below puts all 5 ideas into one pipeline step, in the order a real job runs them: label the columns, convert the values, select the rows, rank the survivors. The debug drill after it returns to the tie rule from ranking on its own.