# Idiomatic Python

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

Python roadmap · Foundations · 17 min read · https://levelcode.org/roadmaps/python/idiomatic-python

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.

> [!TIP]
> Run `import this` in a Python REPL. The "Zen of Python" it prints ("Readability counts", "Explicit is better than implicit", "There should be one obvious way to do it") is the taste behind every idiom in this lesson.

## 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}
```

**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.

## Quiz

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

   A. Stops after 2 pairs silently
   B. Pads the shorter list with None
   C. Raises a ValueError
   D. Repeats the last score

   **Answer: C.** Without strict, zip stops at the shortest input, which usually hides a bug. strict=True raises an error when lengths differ.

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

   A. if discount:
   B. if discount is not None:
   C. if discount != False:
   D. if bool(discount):

   **Answer: B.** 0 is falsy, so a truthiness check would treat a 0 discount as missing. Comparing to None with is not distinguishes 'absent' from 'zero'.

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

   A. len([u for u in users if u.role == "admin"]) > 0
   B. "admin" in users
   C. users.any(role="admin")
   D. any(u.role == "admin" for u in users)

   **Answer: D.** any with a generator expression stops at the first match and stores nothing. The list comprehension version builds a whole list just to count it.

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

   A. Checking first and then opening can race: the file may change between the check and the open
   B. try blocks are faster than if statements
   C. os.path.exists doesn't work on Windows
   D. Python requires exceptions for file access

   **Answer: A.** A separate existence check can go stale before the open happens. Trying the operation and catching FileNotFoundError handles every case in one step.
