◐ ai-teacher

🐍 Python for Data Engineers · Chapter 6

Idiomatic Python for pipelines

The Python that passes code review is a small dialect. Learn the dozen idioms that make up 90% of pipeline code.

Study this lesson free in the app →

The basics

Idiomatic Python is the small set of built-in patterns experienced engineers reach for by reflex — comprehensions, enumerate, zip, dict.get, defaultdict, Counter, dataclasses, pathlib.

The problem it solves: the same transform can be 40 lines of counters, index arithmetic and nested ifs, or 12 lines that read like a sentence. The long version is not just ugly; it hides bugs (off-by-one, KeyError three stages later, a list used where a set was needed) and it fails code review.

The mental model: a cook's knife roll. Each idiom is a tool with one job. You do not invent a new way to group by key; you pick up defaultdict(list). Choosing the right container is half the work: dict for lookup, set for membership, list for order, Counter for frequency.

Words you must know 5

Comprehension
[expr for x in xs if cond], a loop that builds a list, dict or set in one line
Generator expression
the same with ( ), which streams instead of building a list
Dataclass
a typed record class; frozen=True makes it immutable and hashable
defaultdict / Counter
dicts that group and count without existence checks
Mutable default
def f(x=[]), the bug where one list is shared across every call

Explained

Two engineers write the same transform. One writes 40 lines of loops and index arithmetic. The other writes 12 lines that read like a sentence.

Both work. Only one gets merged without three rounds of comments — and only one is still readable when it breaks at 3 AM.

The kitchen, again

A cook who has worked one kitchen for ten years does not think about where the knives are. Their hands know.

Idioms are that. enumerate instead of a counter, a comprehension instead of an append loop, a dataclass instead of a bare dict — none of them are clever. They are the things you stop having to think about, so the thinking is left for the actual problem.

Loops → comprehensions

# the version everyone writes first
fares = []
for e in events:
    if e["event_type"] == "ride_completed":
        fares.append(e["fare"])

# the version that gets merged
fares = [e["fare"] for e in events if e["event_type"] == "ride_completed"]

# dict comprehension — index events by id
by_id = {e["event_id"]: e for e in events}

# set comprehension — distinct drivers, deduped for free
drivers = {e["driver_id"] for e in events}

Rule of thumb: one condition and one transform belongs in a comprehension. Two conditions and a nested loop belongs in a function with a name.

The six built-ins you will use daily

# enumerate — index without a counter variable
for line_no, line in enumerate(file, start=1):
    ...

# zip — walk two sequences together
for city, revenue in zip(cities, revenues):
    ...

# any / all — short-circuit validation
if any(e["fare"] < 0 for e in events):
    raise ValueError("negative fare in batch")

# sorted with a key — sort by a field, newest first
latest = sorted(events, key=lambda e: e["event_time"], reverse=True)

# max with a key and a default — safe on an empty batch
newest = max(events, key=lambda e: e["event_time"], default=None)

# sum with a generator — no intermediate list
total = sum(e["fare"] for e in events if e["event_type"] == "ride_completed")

Dicts: the four methods that remove most bugs

from collections import defaultdict, Counter

# .get with a default — never a KeyError
fare = event.get("fare", 0)

# defaultdict — group without checking whether the key exists
by_city = defaultdict(list)
for e in events:
    by_city[e["city"]].append(e)

# Counter — frequency in one line, with .most_common()
top_reasons = Counter(r.reason for r in rejections).most_common(5)

# dict unpacking — merge, right side wins
enriched = {**event, "ingested_at": now, "source": "kafka"}

defaultdict(list) and Counter between them replace an enormous amount of hand-written pipeline code. Counter(...).most_common(5) is also the fastest way to answer "what are the top 5 failure reasons?" in an interview.

Dataclasses: stop passing dicts around

from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal

@dataclass(frozen=True, slots=True)
class RideEvent:
    event_id: str
    ride_id: str
    event_type: str
    event_time: datetime
    fare: Decimal | None = None
    tags: list[str] = field(default_factory=list)   # never a bare []

    @property
    def is_billable(self) -> bool:
        return self.event_type == "ride_completed" and self.fare is not None

Why it beats a dict:

Type hints earn their keep at boundaries

Python does not enforce them at runtime — mypy or your editor does. That is still worth it, because the hint is a contract a reader can trust.

from collections.abc import Iterable, Iterator

def parse_events(lines: Iterable[str]) -> Iterator[RideEvent]:
    ...

def daily_revenue(events: Iterable[RideEvent]) -> dict[str, Decimal]:
    ...

Two rules that keep typing useful instead of exhausting:

  1. Type the function signatures, not every local variable. The signature is the contract; locals are obvious from context
  2. Accept the widest type, return the narrowest. Take Iterable, return list or Iterator — so callers can pass a generator, a list or a file

Paths, files and f-strings

from pathlib import Path

# pathlib beats os.path string-joining
raw = Path("data/raw") / "rides" / f"dt={run_date}" / "events.jsonl"
raw.parent.mkdir(parents=True, exist_ok=True)

for path in Path("data/raw").rglob("*.jsonl"):
    ...

# context managers — the file closes even if the loop raises
with raw.open() as fh:
    for line in fh:                       # streams, does not load the file
        ...

# f-strings, including the = debug form and number formatting
print(f"{len(events)=}")                 # len(events)=1043
print(f"loaded {count:,} rows in {secs:.2f}s")   # loaded 1,043,201 rows in 3.14s

Words to know

ComprehensionBuilding a list/dict/set inline from an iterable, with optional filtering.
DataclassA decorator generating __init__, __repr__ and __eq__ from typed class attributes.
frozen=TrueMakes a dataclass immutable and hashable — right for events and other facts.
slots=TrueStores attributes in a fixed layout instead of a per-instance dict, cutting memory.
defaultdictA dict that creates a default value on first access to a missing key.
CounterA dict subclass for counting, with .most_common().
Context managerThe with protocol — guarantees cleanup even when an exception is raised.
Type hintAn annotation describing expected types; checked by tools, not at runtime.
Mutable default argumentA default like [] or {} that is created once and shared across all calls.

In two minutes

Test yourself

1. Which container should back a duplicate check inside a loop over 1M events?

  1. list
  2. set
  3. tuple
  4. string
Show the answer

set — Membership in a set is O(1); in a list it is O(n), making the loop quadratic. This is the most common real-world Python performance bug.

2. What does def enrich(event, tags=[]) do wrong?

  1. Nothing — it is fine
  2. The default list is created once and shared across every call
  3. It cannot accept keyword arguments
  4. It is slower than using None
Show the answer

The default list is created once and shared across every call — Defaults are evaluated once at definition time. Mutating that list leaks state between calls. Use None, or field(default_factory=list) in a dataclass.

3. Why prefer a frozen dataclass over a dict for an event?

  1. It is faster to serialise
  2. Typos become AttributeError immediately, and the class documents the schema
  3. Dicts cannot hold nested data
  4. It avoids importing json
Show the answer

Typos become AttributeError immediately, and the class documents the schema — A misspelled attribute fails at the point of the mistake instead of surfacing as a missing key three stages downstream. Immutability also matches what an event is: a fact.

4. You need the 5 most frequent rejection reasons. Fastest idiomatic route?

  1. Sort a list of tuples manually
  2. Counter(r.reason for r in rejections).most_common(5)
  3. A defaultdict(int) then sorted()
  4. pandas value_counts()
Show the answer

Counter(r.reason for r in rejections).most_common(5) — Counter is built for exactly this. The defaultdict version works but is three lines; pandas is a heavy dependency for a one-line question.

5. event = {"city": "BLR", "fare": 100}. What is {**event, "city": "Bengaluru"}["city"]?

  1. "Bengaluru"
  2. "BLR"
  3. A KeyError for the duplicate key
  4. A list of both values
Show the answer

"Bengaluru" — Dict unpacking merges left to right and the right side wins on a clash. That is why enrichment puts overrides last: {**event, "ingested_at": now}.

6. events has ids e1, e2, e1 with fares 100, 200, 150. After by_id = {e["id"]: e for e in events}, what are len(by_id) and by_id["e1"]["fare"]?

  1. 3 and 100
  2. 2 and 100
  3. 2 and 150
  4. It raises on the duplicate key
Show the answer

2 and 150 — A dict comprehension silently overwrites: the last e1 wins. Handy as a 'keep latest' dedupe when the input is sorted by time — dangerous when it is not, because which record survives depends on arrival order.

There are 9 questions, 3 puzzles and 6 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →