Visual walkthrough — Built-in functions — map, filter, zip, enumerate, sorted, reversed, min, max, sum, any, all
Prerequisites we lean on but will re-build as needed: Lambda functions, Iterators and generators, Truthiness in Python. This is the picture-first companion to the parent topic.
Step 1 — The conveyor belt: what an iterable actually is
WHAT. Before any function, we need the thing they all eat: an iterable. An iterable is anything you can walk through one item at a time — a list [7, 3, 9], a string "abc", a range. Picture a conveyor belt with boxes on it. Each box holds one value.
WHY start here. Every function on this page (map, filter, zip, sorted, sum, …) is defined by what it does to a belt of boxes. If you don't see the belt, the functions look like magic. If you do, they're obvious.
PICTURE. Look at the figure: a white belt carries three amber boxes labelled 7, 3, 9. The arrow shows the direction of travel — left is the start, right is where a hungry function will pull items out.
Step 2 — map: a station that repaints every box
WHAT. Our raw data is scores = [40, 75, 88, 33, 90]. Suppose these are out of 100 but we want them as fractions out of 1.0. We divide each by 100. map(f, xs) puts a worker at a station: as each box rolls past, the worker applies f and puts the transformed box back on the belt.
x— the value inside the box currently at the station.x/100— the repainted value that leaves the station.- The whole thing returns a new belt, same length, every box transformed.
WHY map and not a for-loop? A loop makes you write how to grind through indices. map says what you want: "apply this rule to each." One station, one rule — nothing to get wrong.
WHY lazy? The worker does nothing until someone downstream pulls a box (runs next(...) — see the mechanism box above). list(map(...)) is the "pull everything now" command. Watch the figure: the second belt is drawn faint (not yet computed) until the puller arrives.
PICTURE. Top belt: raw boxes 40, 75, 88, 33, 90. The station (cyan) holds the rule x/100. Bottom belt: 0.40, 0.75, 0.88, 0.33, 0.90.
Step 3 — filter: a station with a trapdoor
WHAT. Now we only care about passing scores (≥ 50, i.e. x >= 0.5 after Step 2). filter(pred, xs) puts a worker who checks a yes/no question on each box. If the answer is truthy, the box continues; if falsy, the trapdoor opens and the box falls away.
pred(x)returnsTrue/False(or anything truthy/falsy).True→ box stays.False→ box drops.
WHY filter and not map? map changes boxes but keeps the count. filter keeps count only for the survivors — it changes the belt's length, never the box contents. These are two different jobs, so two different stations.
Edge case — filter(None, xs). If you pass None instead of a function, filter uses each box's own truthiness. So 0, '', [], None all fall through the trapdoor. We cover this because the parent note flagged it.
PICTURE. Incoming belt 0.40, 0.75, 0.88, 0.33, 0.90. Boxes 0.40 and 0.33 (both < 0.5) fall through a red trapdoor. Surviving belt: 0.75, 0.88, 0.90.
Step 4 — zip and enumerate: running two belts side by side
WHAT. We don't just have scores — we have names too: names = ['Ann','Bo','Cy','Di','Ed']. To keep each score attached to its student, we run both belts through a zip station, which grabs one box from each belt and staples them into a pair.
- Each output box is a tuple
(name, score)— thei-th box of belt A married to thei-th of belt B. zipstops at the shortest belt, so nobody is left holding an empty hand.
enumerate = zip with a counter belt. If you want positions rather than names, enumerate(xs, start=0) staples an auto-counting belt 0, 1, 2, … onto your belt. It is literally zip(counter, xs):
for i, v in enumerate(['a', 'b', 'c'], start=1):
print(i, v) # 1 a / 2 b / 3 cWHY pair before filtering? So that when the trapdoor drops a failing score, it takes the student's name with it — the pair travels as one box. If we filtered scores alone we'd lose track of who passed.
PICTURE. Two parallel belts (names in cyan, scores in amber) feed a stapler station; the output belt carries paired boxes ('Ann',0.40) … ('Ed',0.90). A small inset shows enumerate stapling a 0,1,2 counter belt instead of names.
Step 5 — Chaining stations: the belt is one line
WHAT. Real pipelines chain stations. Pairing (zip) then selecting (filter) compose into:
paired = zip(names, map(lambda x: x / 100, scores))
passing = filter(lambda pair: pair[1] >= 0.5, paired)Read it inside-out (belt flows outward): scores enter map, the scaled belt is zipped with names, the paired belt feeds filter, the survivor belt comes out. Here pair[1] is the score half of each (name, score) box.
WHY inside-out? Because each inner call is the argument to the outer one, so the innermost call is the earliest station on the belt. (If nesting hurts your eyes, List comprehensions write the same pipeline left-to-right — same belt, friendlier syntax.)
WHY still nothing computed? Every station here (map, zip, filter) is lazy. The belt is fully assembled but frozen — no next(...) has been called, so no box has moved. It moves only when a reducer (next step) starts pulling.
PICTURE. A single long belt with three stations in series: x/100, then the zip stapler (names joining in from above), then the trapdoor pair[1] >= 0.5. The output end is capped with a "?" — the puller hasn't arrived yet.
Step 6 — Reducers turn the belt into ONE answer
WHAT. Now a reducer (defined in Step 4) stands at the end and drains the belt. We want two summaries: the average passing score and the top passer. But a belt is single-use — once one reducer drains it, it's empty! So we materialize the survivors into a list first, then reduce as many times as we like.
passing = list(passing) # freeze: [('Bo',0.75),('Cy',0.88),('Ed',0.90)]
scores_only = [p[1] for p in passing]
average = sum(scores_only) / len(scores_only) # 2.53 / 3 = 0.8433...
top = max(passing, key=lambda p: p[1]) # ('Ed', 0.90)sum(scores_only)— accumulator starts at0, adds each box:0 + 0.75 + 0.88 + 0.90 = 2.53.len(scores_only)— how many survived =3.average = 2.53 / 3 = 0.8433…max(passing, key=…)— compares by the score halfp[1]; the biggest is('Ed', 0.90). Swap forminto get the weakest passer,('Bo', 0.75).
WHY materialize into a list? Because we drain the belt several times (sum, len, max). A lazy belt is empty after the first drain. list(...) freezes the boxes into a reusable shelf. (See the parent's "reusing a consumed iterator" mistake.)
WHY sum starts at 0? The accumulator needs a starting value. 0 is the identity of + (adding it changes nothing), so it's the safe seed. This is also why sum(['a','b']) explodes: it tries 0 + 'a' — a number plus a string.
PICTURE. The survivor belt of pairs feeds two hoppers: an amber "SUM" hopper whose dial climbs 0 → 0.75 → 1.63 → 2.53 (with count = 3 beside it → avg = 0.843), and a cyan "MAX" hopper that keeps only the tallest box, ('Ed', 0.90).
Step 7 — Degenerate belts: what if nothing survives?
WHAT. Suppose every score fails: scores = [10, 20, 30]. After scaling and filter(pair[1] >= 0.5), zero boxes survive. Now the reducers behave differently on an empty belt:
Reducer on [] |
Result | Why |
|---|---|---|
sum([]) |
0 |
the identity seed |
len([]) |
0 |
nothing to count |
any([]) |
False |
OR's identity — nobody is True |
all([]) |
True |
AND's identity — no counter-example (vacuous truth) |
min([]) / max([]) |
ValueError |
there is no extreme element of nothing |
So average = sum / len → 0 / 0 → ZeroDivisionError, and max(passing) on an empty belt → ValueError. Both must be guarded.
WHY show this? Because a pipeline that works on a full belt can crash on an empty one — the case beginners never test. You guard both:
average = sum(scores_only) / len(scores_only) if passing else 0.0
top = max(passing, key=lambda p: p[1]) if passing else NoneHere if passing uses the belt's own truthiness: a non-empty list is truthy, an empty list [] is falsy.
The any/all view. "Did anyone pass?" → any(p[1] >= 0.5 for p in belt); on empty → False. "Did everyone pass?" → all(...); on empty → True, vacuously. Note the asymmetry with min/max: any/all have safe defaults on empty input, but min/max raise (unless you pass default=).
PICTURE. Left: an empty belt reaching the SUM hopper — dial stuck at 0, counter at 0, red "÷ 0 ✗" warning. Middle: a MAX hopper with an empty belt showing "ValueError ✗". Right: two gauges — any([]) = False, all([]) = True.
Step 8 — Ordering the belt: sorted and reversed
WHAT. Sometimes we don't reduce — we reorder. sorted(xs, key=…, reverse=…) reads the whole belt, then lays down a brand-new belt with boxes in order. It never touches the original.
gives [('Ed',0.90), ('Cy',0.88), ('Bo',0.75)].
key— an optional worker that says what to compare by (here the scorep[1]). No key → compare boxes directly.reverse=True— flip the final order.- Ties keep their original order — Python's sort is stable.
WHY a new belt (not in-place)? So your original data survives. .sort() would mutate the belt in place and return None; sorted hands you a fresh belt and leaves the source alone. Contrast: reversed(seq) is the lazy back-to-front walk of the SAME belt — no new belt, and it needs a sequence with known length (list/tuple/range/str).
PICTURE. Top: original belt (untouched, marked "original"). Bottom: a separate new belt in descending score order produced by sorted(key=…, reverse=True). A dashed arrow shows read-then-rebuild; the original is highlighted as unchanged.
The one-picture summary
The final figure compresses the whole line: two raw belts (names, scores) → map (scale) → zip (staple) → filter (trapdoor) → list (freeze) → the reducers (sum/len → average, max → top passer) and, off to the side, sorted/reversed re-laying a fresh ordered belt. Read the figure as the story of one box: a raw score is scaled, married to its name, checked at the trapdoor, frozen onto the shelf, and finally counted into the average or crowned as the max. The lazy stations (map, zip, filter) never move a box until a reducer pulls; the reducers are the only stations that end the belt.
Here is the corrected one-liner (no keywords-as-names, all variables defined):
passing = list(filter(lambda p: p[1] >= 0.5,
zip(names, map(lambda x: x / 100, scores))))
average = sum(p[1] for p in passing) / len(passing) if passing else 0.0Recall Feynman retelling — say it to a friend
Imagine conveyor belts with boxes of numbers.
- map is a painter at a station: every box that rolls by gets the same rule applied (÷100). Same number of boxes, new colours. It can even run two belts at once and add them box-for-box.
- zip is a stapler: it takes a box from the names belt and a box from the scores belt and staples them into one pair-box, so a score never gets separated from its student. enumerate is the same stapler but the second belt is an automatic
0, 1, 2, …counter. - filter is a station with a trapdoor: does the pair pass the question (score ≥ 0.5)? it continues. Fails? the whole pair drops. Values untouched, just fewer of them.
- The painter, stapler, and trapdoor are lazy — they do literally nothing until someone at the far end says "next box, please" (that's
next(...), whichfor,list, andsumall say internally). Assembling the belt is free; running it costs the pull. - A reducer stands at the end and drains the belt into one answer: sum into a dial that starts at 0, len counts, max/min keep the tallest/shortest box, any/all boil down to one True/False. Because a belt is single-use, you freeze survivors into a
listif you need to drain them more than once. - If no box survives, the belt is empty:
sumgives 0,lengives 0, and 0÷0 crashes;max/mincrash too (there's no extreme of nothing). Butany([])is False andall([])is True. Guard the empty case. - sorted reads the belt and lays down a fresh ordered belt, leaving the original alone; reversed just walks the same belt backwards.
Recall
Why does len(map(f, xs)) raise an error? ::: map returns a lazy iterator with no __len__; you must len(list(map(f, xs))) after materializing.
In map(f, xs), does the number of boxes change? ::: No — map preserves length; one box in, one box out.
What does zip(a, b) produce and where does it stop? ::: An iterator of tuples (a[i], b[i]); it stops at the shortest input.
How is enumerate related to zip? ::: enumerate(xs) ≈ zip(counter, xs) — it staples an auto-counting index onto each item.
In filter(pred, xs), do the surviving box values change? ::: No — filter only keeps or drops; values are untouched.
Why wrap survivors in list(...) before reducing several times? ::: The lazy belt is single-use; the first reducer drains it, so later reducers would see an empty belt. list freezes reusable boxes.
What is average of passing scores [0.75, 0.88, 0.90]? ::: 2.53 / 3 = 0.8433…
What does max(passing, key=lambda p: p[1]) return here? ::: ('Ed', 0.90) — the pair with the largest score.
On an empty belt, what do sum, any, all, and max do? ::: sum([])=0, any([])=False, all([])=True, but max([]) raises ValueError.
What does sorted do to the original belt? ::: Nothing — it returns a new ordered list and leaves the source unchanged.