Step 03 of 13

Idiomatic Python

Write Python the way experienced Python developers do: unpacking, comprehensions, zip and enumerate, truthiness, EAFP, match and the standard library's helpers.

17 min 10 sections

You can write Python that works but reads like another language translated word for word: index counters everywhere, manual flag variables, nested ifs. Experienced Python developers call the alternative Pythonic code: it uses the language's features so that the code says what it means, with less room for bugs. This lesson collects the idioms you'll see in every good Python codebase, each shown next to the clumsy version it replaces.

Unpacking

Python can assign several names at once from any sequence:

python
point = (3, 4)
x, y = point

first, *rest = [10, 20, 30, 40]     # first = 10, rest = [20, 30, 40]
head, *_, last = "a,b,c,d".split(",")   # head = 'a', last = 'd'

a, b = b, a                          # swap without a temporary variable

It shines in loops over pairs and functions that return several values:

python
def min_max(values: list[float]) -> tuple[float, float]:
    return min(values), max(values)

low, high = min_max([3.5, 9.1, 0.4])

for name, score in [("ana", 91), ("ben", 78)]:
    print(f"{name}: {score}")

Unpacking also works with * and ** in calls: f(*args) spreads a list into positional arguments, f(**options) spreads a dict into keyword arguments, and {**defaults, **overrides} merges dictionaries (later keys win).

enumerate and zip instead of indexes

The clumsy version:

python
names = ["ana", "ben", "cy"]
scores = [91, 78, 85]
for i in range(len(names)):
    print(i + 1, names[i], scores[i])

The Pythonic version:

python
for rank, (name, score) in enumerate(zip(names, scores, strict=True), start=1):
    print(rank, name, score)
  • enumerate gives you the position and the item together.
  • zip walks several sequences in step. strict=True (Python 3.10+) raises an error if their lengths differ, instead of silently stopping at the shortest, which is almost always a bug.

dict(zip(keys, values)) builds a dictionary from two lists, and zip(*rows) transposes a table.

Comprehensions and generator expressions

Building collections from other collections is so common that Python has syntax for it:

python
squares = [n * n for n in range(10)]
evens = [n for n in range(20) if n % 2 == 0]
lengths = {word: len(word) for word in ["pear", "fig", "banana"]}
initials = {name[0] for name in ["ana", "anna", "ben"]}    # a set: {'a', 'b'}

Swap the brackets for parentheses and you get a generator expression, which produces values one at a time instead of building a list. Pass one straight to a function that consumes an iterable:

python
total = sum(order.total for order in orders)
has_admin = any(user.role == "admin" for user in users)
all_paid = all(invoice.paid for invoice in invoices)
longest = max(words, key=len)

any and all stop as soon as the answer is known, and nothing is stored in memory. The rule of thumb: if a comprehension needs more than one for and one if, or doesn't fit comfortably on a line or two, write a normal loop.

Truthiness

Every value in Python is either "truthy" or "falsy" in a condition. Falsy values are False, None, 0, 0.0, and empty collections: "", [], {}, set(). Everything else is truthy. So:

python
if len(items) > 0:      # clumsy
if items:               # Pythonic

if name == "":          # clumsy
if not name:            # Pythonic

One case where truthiness bites: when 0 or "" is a valid value, test for None explicitly:

python
def apply_discount(price: float, discount: float | None = None) -> float:
    if discount is not None:      # `if discount:` would ignore a discount of 0
        return price * (1 - discount)
    return price

Always compare with None using is and is not, never ==.

Ask forgiveness, not permission

Python style prefers EAFP ("easier to ask forgiveness than permission"): try the operation and handle the failure, rather than LBYL ("look before you leap") checks that can go stale:

python
# LBYL: two lookups, and the file could vanish between check and open
if os.path.exists(path):
    with open(path) as f:
        data = f.read()

# EAFP: one operation, no race
try:
    with open(path) as f:
        data = f.read()
except FileNotFoundError:
    data = ""

For dictionaries, the idioms make both styles unnecessary in common cases:

python
count = counts.get(word, 0)                   # default for a missing key
groups.setdefault(key, []).append(item)       # create the list on first use

And the standard library covers the patterns you'd otherwise write by hand:

python
from collections import Counter, defaultdict

Counter("mississippi").most_common(2)          # [('i', 4), ('s', 4)]

by_city: defaultdict[str, list[str]] = defaultdict(list)
for name, city in [("ana", "Oslo"), ("ben", "Rome"), ("cy", "Oslo")]:
    by_city[city].append(name)                 # {'Oslo': ['ana', 'cy'], 'Rome': ['ben']}

Slicing, strings and joining

Slices take start:stop:step, and all three are optional:

python
s = "abcdefgh"
s[:3]      # 'abc'
s[-3:]     # 'fgh'
s[::2]     # 'aceg'
s[::-1]    # 'hgfedcba'  reversed

Never build a string in a loop with += when joining many parts; collect them and join:

python
csv_line = ",".join(str(v) for v in [1, 2.5, "x"])     # '1,2.5,x'

f-strings support = for quick debugging (f"{total=}" prints total=42) and format specs (f"{ratio:.1%}", f"{n:,}").

Conditional expressions and the walrus operator

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

python
label = "adult" if age >= 18 else "minor"

The walrus operator := (Python 3.8+) assigns inside an expression. Use it sparingly, where it removes repetition:

python
while (line := file.readline()):
    process(line)

if (match := pattern.search(text)) is not None:
    print(match.group(1))

match: structural pattern matching

Python 3.10 added match, which compares a value against patterns and can pull data out of it at the same time. It's ideal for dispatching on the shape of data, such as commands or parsed JSON:

python
def handle(event: dict) -> str:
    match event:
        case {"type": "click", "x": x, "y": y}:
            return f"click at {x},{y}"
        case {"type": "key", "key": "Enter" | "Return"}:
            return "submit"
        case {"type": "key", "key": str(key)}:
            return f"key {key}"
        case {"type": t}:
            return f"unhandled event {t}"
        case _:
            return "not an event"

Each case checks the structure (a dict with these keys, a string, one of several literals) and binds names such as x and key. _ matches anything. For a simple chain of equality checks, if/elif is still clearer; match earns its place when you're checking shape and extracting values together.

Practice

Rewrite this working but un-Pythonic function. Keep its behaviour identical: it takes a list of order dictionaries and returns a summary.

python
def summarise(orders):
    result = {}
    count = 0
    for i in range(len(orders)):
        order = orders[i]
        if order["status"] == "paid":
            count = count + 1
            customer = order["customer"]
            if customer in result.keys():
                result[customer] = result[customer] + order["total"]
            else:
                result[customer] = order["total"]
    biggest = None
    biggest_total = 0
    for customer in result.keys():
        if result[customer] > biggest_total:
            biggest_total = result[customer]
            biggest = customer
    has_refund = False
    for order in orders:
        if order["status"] == "refunded":
            has_refund = True
    return {"paid_orders": count, "per_customer": result, "top_customer": biggest, "has_refund": has_refund}
Show solution
python
from collections import defaultdict


def summarise(orders: list[dict]) -> dict:
    paid = [o for o in orders if o["status"] == "paid"]

    per_customer: defaultdict[str, float] = defaultdict(float)
    for order in paid:
        per_customer[order["customer"]] += order["total"]

    top_customer = max(per_customer, key=per_customer.get, default=None)

    return {
        "paid_orders": len(paid),
        "per_customer": dict(per_customer),
        "top_customer": top_customer,
        "has_refund": any(o["status"] == "refunded" for o in orders),
    }

What changed:

  • The index loop became a comprehension that filters paid orders once; the count is simply len(paid).
  • The "key exists?" dance became a defaultdict(float), which starts missing customers at 0.0. Converting back with dict(...) returns an ordinary dictionary, as the original did.
  • The hand-written maximum became max(..., key=per_customer.get, default=None). default handles the case with no paid orders, which the original handled by starting at None.
  • The flag loop became any(...), which also stops at the first refund.

One subtle difference to check whenever you refactor: the original only replaced biggest when a total was strictly greater than 0, so a customer whose orders total 0 or less could never be top. max would pick them. If zero-value orders are possible in your data, that matters; here totals are positive, so the behaviour is the same. Refactoring is the moment to notice those edge cases, and tests are how you prove you kept the behaviour.

Key takeaways

  • Unpack tuples and lists directly, and use * and ** to spread and merge.
  • Prefer enumerate and zip(strict=True) over index counters, and comprehensions or generator expressions over build-a-list loops.
  • Rely on truthiness for emptiness checks, but use is None when 0 or "" are valid values.
  • Prefer EAFP with specific exceptions, and reach for dict.get, setdefault, Counter and defaultdict.
  • Use join for strings, conditional expressions for simple choices, and match when you check the shape of data and extract from it.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What does zip(names, scores, strict=True) do if names has 3 items and scores has 2?

  2. 2.discount can legitimately be 0. Which check correctly tests whether a discount was provided?

  3. 3.Which expression checks whether any user is an admin without building a list?

  4. 4.Why is EAFP (try, then handle the exception) often preferred for opening a file?

Finished reading?

Mark it complete to fill in your roadmap.