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.
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:
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 variableIt shines in loops over pairs and functions that return several values:
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:
names = ["ana", "ben", "cy"]
scores = [91, 78, 85]
for i in range(len(names)):
print(i + 1, names[i], scores[i])The Pythonic version:
for rank, (name, score) in enumerate(zip(names, scores, strict=True), start=1):
print(rank, name, score)enumerategives you the position and the item together.zipwalks 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:
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:
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:
if len(items) > 0: # clumsy
if items: # Pythonic
if name == "": # clumsy
if not name: # PythonicOne case where truthiness bites: when 0 or "" is a valid value, test for None explicitly:
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 priceAlways 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:
# 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:
count = counts.get(word, 0) # default for a missing key
groups.setdefault(key, []).append(item) # create the list on first useAnd the standard library covers the patterns you'd otherwise write by hand:
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:
s = "abcdefgh"
s[:3] # 'abc'
s[-3:] # 'fgh'
s[::2] # 'aceg'
s[::-1] # 'hgfedcba' reversedNever build a string in a loop with += when joining many parts; collect them and join:
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:
label = "adult" if age >= 18 else "minor"The walrus operator := (Python 3.8+) assigns inside an expression. Use it sparingly, where it removes repetition:
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:
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.
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
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 at0.0. Converting back withdict(...)returns an ordinary dictionary, as the original did. - The hand-written maximum became
max(..., key=per_customer.get, default=None).defaulthandles the case with no paid orders, which the original handled by starting atNone. - 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
enumerateandzip(strict=True)over index counters, and comprehensions or generator expressions over build-a-list loops. - Rely on truthiness for emptiness checks, but use
is Nonewhen0or""are valid values. - Prefer EAFP with specific exceptions, and reach for
dict.get,setdefault,Counteranddefaultdict. - Use
joinfor strings, conditional expressions for simple choices, andmatchwhen you check the shape of data and extract from it.
Check yourself
Quiz
0/4 correct · 4 left
1.What does zip(names, scores, strict=True) do if names has 3 items and scores has 2?
2.discount can legitimately be 0. Which check correctly tests whether a discount was provided?
3.Which expression checks whether any user is an admin without building a list?
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.