Code is idiomatic when it uses the language the way experienced Python programmers do: it reads naturally, leans on the built in tools, and sidesteps traps the language makes easy to avoid. This appendix collects twenty idioms. Each one puts a first draft, code that works or nearly works and that you will meet in real projects, next to an idiomatic version, and explains why the second is better.
The first eight correct a popular series of images that rank code as "junior", "senior" or "expert". Ranking by seniority is catchy, but in several of those images the "senior" or "performance" answer was slower, wrong, or unsafe. The other twelve are everyday idioms from the core units; the links lead back to the full explanations.
You will be able to:
- Recognise common first draft patterns and rewrite them idiomatically
- Explain why a rewrite is clearer, faster or safer, not only that it is shorter
- Spot "clever" code that is actually wrong or slower
| # | Idiom | Rule of thumb |
|---|---|---|
| 1 | First unique character | Count once with Counter, then scan once |
| 2 | The added letter | Subtract Counters; never exec a string |
| 3 | Palindromes | Keep only letters and digits, then casefold |
| 4 | Sum of evens | sum() over a generator; Iterable[int], not typing.List |
| 5 | Records | Unpack, then NamedTuple or @dataclass instead of magic indexes |
| 6 | Joining strings | ", ".join(...), with a list comprehension when you build the parts |
| 7 | Looping | Iterate directly; enumerate for the index, zip for pairs |
| 8 | Counting per year | Counter over a generator; a difference array for long spans |
| 9 | EAFP | Try the operation and catch the specific error |
| 10 | dict.get and setdefault | No if key in d dance for defaults |
| 11 | Unpacking | Swap, return several values, first, *rest |
| 12 | with | Let a context manager close the resource |
| 13 | any and all | No flag variables for "some" and "every" |
| 14 | sorted with key | Say what to sort by; let stability do the rest |
| 15 | is None | Compare with None by identity |
| 16 | Truthiness | if items:, but not when 0 or "" is a real value |
| 17 | Mutable defaults | Default to None and create the object inside |
| 18 | Shadowing builtins | Never name a variable list, sum, id or type |
| 19 | pathlib | Build paths with /, not string concatenation |
| 20 | f-strings | Format with f-strings and a format spec |
1. First unique character
The task: return the index of the first character that occurs exactly once, or -1 if there is none.
Why. The first draft is correct but quadratic: text.count reads the whole string for every character, so doubling the text roughly quadruples the time. The "performance" version is faster and wrong: for "aabb" the rarest character still appears twice, yet it returns 0 (and on an empty string it crashes). The idiomatic version counts once and scans once, both linear. next(generator, -1) returns the first match or the default. A handwritten counting dictionary followed by the same scan is equally good; Counter makes it shorter.
2. The added letter
The task: t is s shuffled with one extra letter inserted. Find the extra letter.
Why. Subtracting one Counter from another keeps only positive counts, so exactly one key survives, and (extra,) = ... unpacks it while checking that there is exactly one. The arithmetic version works because every shared letter's code point cancels out, leaving the code of the extra letter; it is short but needs that explanation in a comment. A third answer you may meet online packs the whole solution into an obfuscated string and runs it through exec() in the name of performance. Never do that: code you cannot read is code you cannot review, and exec on anything that might come from outside is a security hole.
3. Palindromes
The task: does a sentence read the same backwards, ignoring case, spaces and punctuation?
Why. Removing only spaces leaves the comma and question mark in place, so the first draft rejects a genuine palindrome. str.isalnum() keeps letters and digits from any script, and casefold() is the Unicode aware way to ignore case (Text, bytes and Unicode).
4. Sum of even numbers, and modern type hints
Why. Since Python 3.9 the built in types can be subscripted, so write list[int]; typing.List is a deprecated alias. Better still, ask only for what you use: the function only iterates, so Iterable[int] from collections.abc lets callers pass a list, a tuple, a range or a generator. sum() over a generator expression replaces the accumulator loop.
5. Records instead of magic indexes
Why. row[4] forces every reader to remember the layout. Unpacking names the parts; use one throwaway name, _, for everything you ignore (names such as __ and ___ only add noise), and *_ for "the rest". A NamedTuple is immutable, still behaves like a tuple, and gives each field a name. For records you change, a @dataclass is the usual default (Classes and objects).
6. Joining strings
Why. join puts separators only between items, so there is nothing to trim, and it builds the result in one pass, whereas repeated += can copy the growing string again and again. When you build the parts as you join, a list comprehension is slightly faster than a generator expression: join needs all the parts before it can size the result, so it turns a generator into a list internally anyway. Measure it yourself; your timings will differ:
This is one of the rare cases where the list beats the generator expression. Elsewhere, prefer generator expressions: they avoid building a list you do not need.
7. Looping
Why. Iterate over the items themselves. Use enumerate when you need the position and zip to walk several sequences in step; strict=True (since 3.10) raises ValueError if their lengths differ instead of silently stopping at the shorter one. Reach for range(len(...)) only when you really need indexes, for example to modify a list in place.
8. Counting the living per year
The task: given each person's birth and death years, find the year in which the most people were alive. A person counts as alive from their birth year up to, but not including, their death year.
Why. The Counter version states the idea in one line. Its comment worthy detail is that range(birth, death) excludes the death year, which matches the stated rule; if your rule counts the death year too, write range(birth, death + 1). Its cost grows with the total number of years lived. For millions of people with long lives, the difference array records only two changes per person and recovers the counts with one running total from itertools.accumulate, so the cost grows with the number of people plus the number of years.
9. EAFP: try it, then handle the failure
EAFP ("easier to ask forgiveness than permission") means attempting an operation and catching the specific error. Its opposite, LBYL ("look before you leap"), checks first.
Why. The check and the operation disagree: isdigit() rejects "-7" and " 42 ", which int() accepts, and accepts the superscript "²", which int() rejects. Asking the operation itself is the only check that always matches it. The same goes for files: between path.exists() and open(path) another program can delete the file, so catch FileNotFoundError instead (Errors and exceptions).
10. dict.get and setdefault
Why. get(key, default) reads a value with a fallback in one expression. setdefault(key, default) returns the existing value, or inserts the default and returns it, which turns "create the list if needed, then append" into one line. For many such updates, collections.defaultdict(list) is clearer still (Collections and comprehensions).
11. Unpacking
Why. The right hand side builds a tuple before any name is rebound, so a swap needs no temporary variable. Returning a tuple and unpacking it at the call site is how Python returns several values, and a starred name collects whatever is left over.
12. with for resources
Why. A with block closes the file however the block ends, including by an exception. The same pattern releases locks, database connections and temporary directories. Always pass encoding= when you open text files.
13. any and all
Why. all and any say "every" and "some" directly, and both stop at the first item that settles the answer. Remember the empty case: all([]) is True (no item fails) and any([]) is False (no item passes).
14. sorted with a key
Why. key= says what to compare and leaves the items untouched. Sorting is stable: items with equal keys keep their original order, which is why Rui stays ahead of Bea in the second line. A tuple key sorts by several fields, a minus sign reverses a numeric field, and casefold() makes text comparison ignore case. min and max take the same key=.
15. is None
Why. == calls the object's __eq__ method, which can return anything; is checks identity, and there is only one None object. PEP 8 asks for is None and is not None, and linters flag == None.
16. Truthiness, and when not to rely on it
Why. Empty containers, 0, "" and None are all false, so if basket: reads as "if the basket has anything in it". The trap is a value where 0 or "" is meaningful: count or 'unknown' treats a real count of 0 as missing. When None means "missing", test is None explicitly (Operators and expressions).
17. Mutable default arguments
Why. A default value is evaluated once, when def runs, so every call of the first draft appends to the same list. Use None as the default and create a fresh list inside the function (Functions).
18. Shadowing builtins
Why. Built in names are ordinary names, so assigning to sum, list, dict, str, id, type, input or max hides the built in for the rest of that scope, and the error appears far from its cause. Choose a descriptive name instead: total, items, user_id. Linters such as Ruff can flag shadowed builtins.
19. pathlib instead of string paths
Why. The / operator joins path parts with the right separator for the operating system, and a Path object carries the operations you need: suffix, stem, parent, mkdir, read_text, write_text, glob. as_posix() is used above only so the output looks the same on every system (Files and data formats).
20. f-strings
Why. An f-string keeps each value next to the text it belongs in, and the format spec after the colon controls thousands separators, precision and percentages. {total=} prints the expression and its value, which is handy when debugging. One exception: in logging calls, pass the values as arguments (log.info("user %s", name)), so the message is only formatted if it is actually logged (Strings and text formatting).