Every programming language has its own personality, a preferred way of expressing logic that experienced developers recognize on sight. In Python, that preferred way has a name: Pythonic. Writing Pythonic code means going beyond correct syntax and tapping into the idioms, patterns, and conventions that make Python feel like Python. This article walks through the idioms that matter, with concrete before-and-after examples you can start applying today.
If you have spent time reading open-source Python projects, you have probably noticed that experienced developers write code that looks noticeably different from what a beginner might produce, even when both versions are technically correct. The difference usually comes down to idioms. Pythonic idioms are established patterns that take advantage of Python's built-in features, and learning them is one of the fastest ways to write code that is cleaner, more readable, and often more performant.
What Makes Code "Pythonic"?
The Zen of Python, authored by Tim Peters, is the philosophical backbone of Pythonic style. You can read it any time by opening a Python interpreter and running import this. Among its guiding principles: explicit is better than implicit, simple is better than complex, and readability counts. These are not rules enforced by a linter. They are design values that inform how the Python community evaluates code quality.
Pythonic code favors clarity over cleverness. It uses the built-in tools Python provides, such as enumerate(), zip(), comprehensions, and context managers, instead of reinventing them from scratch. When a seasoned Python developer calls a piece of code "not Pythonic," they typically mean it ignores these conventions and makes the reader work harder than necessary to understand the intent.
Being Pythonic is not about memorizing tricks. It is about developing an intuition for how Python is designed to be used. The idioms in this article are starting points, not an exhaustive list. As you read and write more Python, you will naturally absorb additional patterns.
Iteration Idioms
One of the clearest markers of non-Pythonic code is using range(len()) to iterate over a sequence when the index is needed. Python provides enumerate() specifically for this purpose, and it produces both the index and the value in a single, readable loop.
The Non-Pythonic Way
# Manually tracking an index with range(len())
colors = ["red", "green", "blue"]
for i in range(len(colors)):
print(f"{i}: {colors[i]}")
The Pythonic Way
# Using enumerate() for index-value pairs
colors = ["red", "green", "blue"]
for index, color in enumerate(colors):
print(f"{index}: {color}")
The enumerate() version eliminates the manual index lookup and makes the intent clear at a glance. It also accepts an optional start parameter if you need indexing to begin at a value other than zero.
Similarly, when you need to iterate over two sequences in parallel, zip() is the Pythonic tool for the job. It pairs corresponding elements together and stops at the shortest sequence.
# Pairing names with scores using zip()
names = ["Alice", "Bob", "Carol"]
scores = [92, 87, 95]
for name, score in zip(names, scores):
print(f"{name} scored {score}")
If your sequences are different lengths and you want to iterate over all elements (padding the shorter ones with a default value), use itertools.zip_longest() instead of zip().
Comprehensions and Generator Expressions
List comprehensions are one of Python's signature features. They replace the common pattern of creating an empty list, looping, and appending, with a single expressive line. Beyond lists, Python also supports dictionary comprehensions, set comprehensions, and generator expressions.
The Non-Pythonic Way
# Building a filtered list with a loop
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_squares = []
for n in numbers:
if n % 2 == 0:
even_squares.append(n ** 2)
The Pythonic Way
# A list comprehension does the same thing in one line
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_squares = [n ** 2 for n in numbers if n % 2 == 0]
The comprehension version is more compact, but the real benefit is readability. An experienced Python developer can scan it and immediately understand the transformation being applied, the source data, and the filter condition.
Dictionary and set comprehensions follow the same structure:
# Dictionary comprehension: word lengths
words = ["python", "code", "crack"]
word_lengths = {word: len(word) for word in words}
# {'python': 6, 'code': 4, 'crack': 5}
# Set comprehension: unique first letters
first_letters = {word[0] for word in words}
# {'p', 'c'}
When you do not need a full list in memory, a generator expression is the better choice. It produces values lazily, one at a time, which saves memory when working with large datasets.
# Generator expression: sum of squares without building a list
total = sum(n ** 2 for n in range(1_000_000))
Comprehensions are powerful, but do not nest them too deeply. A list comprehension with three nested loops and multiple conditions is harder to read than an equivalent explicit loop. If you find yourself squinting at your own comprehension, break it into a regular loop instead.
Unpacking and Variable Swapping
Python's unpacking syntax is far more flexible than many developers realize, especially those coming from languages where it does not exist. At its simplest, unpacking lets you assign multiple variables from a sequence in a single statement.
# Basic unpacking
coordinates = (41.8781, -87.6298)
latitude, longitude = coordinates
# Swapping variables without a temp variable
a, b = 1, 2
a, b = b, a # a is now 2, b is now 1
The extended unpacking syntax using the * operator is especially useful when you need the first or last elements of a sequence but want to capture the rest without slicing.
# Extended unpacking with the star operator
first, *middle, last = [1, 2, 3, 4, 5]
# first = 1, middle = [2, 3, 4], last = 5
head, *tail = "Python"
# head = 'P', tail = ['y', 't', 'h', 'o', 'n']
When unpacking produces a value you do not need, the convention is to use an underscore _ as a throwaway variable name. This signals to anyone reading the code that the value is intentionally being discarded.
# Discarding unneeded values during unpacking
filename = "report_final.pdf"
basename, _, extension = filename.rpartition(".")
# basename = 'report_final', extension = 'pdf'
Context Managers and Resource Handling
Whenever you work with resources that need cleanup, such as files, network connections, or database cursors, context managers are the Pythonic way to handle them. The with statement guarantees that cleanup runs even if an exception occurs, replacing the older and more error-prone try/finally pattern.
The Non-Pythonic Way
# Manual file handling with try/finally
f = open("data.txt", "r")
try:
content = f.read()
finally:
f.close()
The Pythonic Way
# Context manager handles closing automatically
with open("data.txt", "r") as f:
content = f.read()
# File is guaranteed to be closed here
Context managers work with any object that implements the __enter__ and __exit__ methods. The standard library's contextlib module makes it easy to create your own using the @contextmanager decorator.
from contextlib import contextmanager
import time
@contextmanager
def timer(label):
start = time.perf_counter()
yield
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.4f} seconds")
with timer("Data processing"):
# Your code runs here
result = sum(range(1_000_000))
Truthiness, Identity, and Comparisons
Python has a well-defined concept of truthiness. Empty collections, zero, None, and False are all falsy. Everything else is truthy. Pythonic code takes advantage of this instead of writing explicit comparisons.
# Non-Pythonic: explicit length check
if len(my_list) > 0:
process(my_list)
# Pythonic: rely on truthiness
if my_list:
process(my_list)
When checking for None, always use the is operator rather than ==. The is operator checks identity (whether two references point to the same object in memory), while == checks value equality. Since None is a singleton in Python, identity is the correct test.
# Non-Pythonic
if result == None:
handle_missing()
# Pythonic
if result is None:
handle_missing()
The same applies to boolean comparisons. Rather than writing if flag == True, just write if flag. Rather than if flag == False, write if not flag.
Python also supports chained comparisons. Instead of writing if x > 0 and x < 100, you can write if 0 < x < 100. This mirrors mathematical notation and reads more naturally.
The Walrus Operator
Introduced in Python 3.8, the walrus operator (:=) allows you to assign a value to a variable as part of an expression. Its name comes from the fact that the := symbol, turned sideways, resembles a walrus's eyes and tusks. It does not enable anything that was previously impossible, but it eliminates redundant computations and makes certain patterns more concise.
A common use case is in while loops where you need to read a value, check a condition, and then use that value inside the loop body.
# Without the walrus operator
line = input("Enter command: ")
while line != "quit":
process(line)
line = input("Enter command: ")
# With the walrus operator
while (line := input("Enter command: ")) != "quit":
process(line)
The walrus operator also works well in conditional checks where you want to avoid calling a function twice:
import re
text = "Order total: 42% discount applied"
# Without walrus: call search, then check
match = re.search(r"(\d+)% discount", text)
if match:
discount = int(match.group(1))
# With walrus: assign and check in one step
if match := re.search(r"(\d+)% discount", text):
discount = int(match.group(1))
Use the walrus operator sparingly. It shines in while loops, if conditions, and comprehension filters. Overusing it in deeply nested expressions can hurt readability, which defeats the purpose of Pythonic code.
Structural Pattern Matching
Python 3.10 introduced the match/case statement, bringing structural pattern matching to the language. It is more powerful than a simple switch statement. Pattern matching can destructure data, bind variables, apply guard conditions, and match against types and shapes.
def handle_command(command):
match command.split():
case ["quit"]:
return "Exiting..."
case ["greet", name]:
return f"Hello, {name}!"
case ["add", *numbers] if all(n.isdigit() for n in numbers):
return sum(int(n) for n in numbers)
case _:
return "Unknown command"
print(handle_command("greet World")) # Hello, World!
print(handle_command("add 1 2 3")) # 6
print(handle_command("quit")) # Exiting...
Pattern matching is especially useful when processing structured data like JSON responses, parsed command-line arguments, or messages from external systems. The case _ arm acts as a catch-all default, similar to an else clause.
String Formatting with F-Strings
F-strings, introduced in Python 3.6, are the preferred way to format strings in modern Python. They are more readable than % formatting, more concise than .format(), and they evaluate expressions directly inside the curly braces.
name = "Alice"
score = 95.678
# Older approaches
print("Hello, %s! Score: %.1f" % (name, score))
print("Hello, {}! Score: {:.1f}".format(name, score))
# Pythonic: f-strings
print(f"Hello, {name}! Score: {score:.1f}")
F-strings also support a debugging shorthand that was added in Python 3.8. By placing an equals sign after a variable name inside the braces, the output includes both the expression and its value.
x = 42
print(f"{x = }") # x = 42
print(f"{x * 2 = }") # x * 2 = 84
Starting with Python 3.12, you can nest f-strings and use more complex expressions inside them thanks to improvements in the parser. While nesting is possible, readability should always take priority over compression.
EAFP Over LBYL
Python favors "Easier to Ask Forgiveness than Permission" (EAFP) over "Look Before You Leap" (LBYL). This means you should generally attempt an operation and handle failures with exception handling, rather than checking preconditions before every action.
LBYL Style (Less Pythonic)
# Check before acting
if "key" in my_dict:
value = my_dict["key"]
else:
value = "default"
EAFP Style (Pythonic)
# Just try it and handle the exception
try:
value = my_dict["key"]
except KeyError:
value = "default"
In this particular case, the most Pythonic solution is to skip both patterns entirely and use dict.get():
# Even more Pythonic: use the built-in method
value = my_dict.get("key", "default")
The EAFP approach is especially valuable when working with file systems, network operations, or type conversions where the precondition check and the actual operation are separate actions that could produce a race condition.
EAFP is not a universal rule. If the "exceptional" case happens frequently rather than rarely, the overhead of raising and catching exceptions can hurt performance. In those situations, a direct check may be the better choice.
Key Takeaways
- Use Python's built-in iteration tools:
enumerate(),zip(), and comprehensions replace manual index tracking and loop-append patterns with cleaner, more expressive alternatives. - Embrace context managers: The
withstatement guarantees proper resource cleanup and should be used anywhere you work with files, connections, or locks. - Take advantage of unpacking: Tuple unpacking, extended unpacking with
*, and variable swapping all reduce boilerplate and communicate intent more clearly. - Lean on truthiness and identity: Use
if my_list:instead ofif len(my_list) > 0:, and useis Noneinstead of== None. - Apply newer features thoughtfully: The walrus operator, structural pattern matching, and f-string enhancements are valuable tools, but readability should always be the deciding factor in whether to use them.
- Favor EAFP over LBYL: Try the operation first and handle exceptions, rather than stacking precondition checks that duplicate work and introduce race conditions.
Pythonic code is not about showing off or writing the shortest possible line. It is about writing code that experienced Python developers can read quickly, understand easily, and maintain confidently. The idioms covered here are the patterns you will encounter in well-maintained open-source projects, professional codebases, and the Python standard library itself. Start applying them one at a time, and they will become second nature before long.