But that simplicity can be misleading.
Once you move beyond the usual for loops, classes, functions, and list comprehensions, Python has a surprising number of features that can make everyday code cleaner, safer, and sometimes dramatically faster.
Some of these features are hiding in plain sight.
Others are so specific that you may go years without using them.
And a few can replace surprisingly large amounts of boilerplate with a single expression, decorator, or standard-library function.
Here are 11 Python features worth adding to your toolbox.
1. The Walrus Operator: Assign While You Check
Python's := operator allows you to assign a value as part of an expression.
This is particularly useful when you need a value for both a condition and the following operation.
Without it:
result = fetch_data()
if result and len(result) > 100:
process(result)With the walrus operator:
if (result := fetch_data()) and len(result) > 100:
process(result)The second version avoids separating the assignment from the condition.
It's especially useful when working with:
- regular expression matches
- input streams
- loops
- functions that are expensive to call
For example:
while (line := file.readline()):
process(line)Use it carefully
The walrus operator can reduce repetition, but that doesn't mean every assignment belongs inside a condition.
If the expression becomes difficult to read, the ordinary assignment is usually the better choice.
2. Debugging F-Strings: Let Python Show You the Variable
You've probably written debugging code like this:
print("user:", user)
print("count:", count)
print("config:", config)Python gives you a much nicer option.
Add = inside an f-string:
user = {"id": 42, "name": "Ava"}
count = 17
print(f"{user=}, {count=}")You get:
user={'id': 42, 'name': 'Ava'}, count=17Instead of manually writing the variable name and its value, Python generates the label for you.
It's a tiny feature, but extremely convenient while debugging.
You can even combine it with expressions:
print(f"{len(users)=}")which produces something like:
len(users)=42It's one of those features that feels insignificant until you start using it regularly.
3. iter() With a Sentinel: Turn Repeated Calls Into an Iterator
There are plenty of situations where you want to keep calling a function until it returns a particular value.
The traditional approach looks like this:
while True:
chunk = read_chunk()
if chunk == "":
break
process(chunk)Python's iter() can express the same pattern more directly:
for chunk in iter(read_chunk, ""):
process(chunk)The second argument is the sentinel.
Python repeatedly calls read_chunk() and stops when it returns "".
This pattern is especially useful for:
- file processing
- streams
- network reads
- chunked input
It turns a manual loop-and-break pattern into an iterator.
And because iterators compose naturally with the rest of Python, the result can fit nicely into larger data-processing pipelines.
4. ExitStack: Handle a Dynamic Number of Resources
Normally, context managers are straightforward:
with open("a.txt") as first:
with open("b.txt") as second:
...But what happens when the number of resources isn't known beforehand?
That's where contextlib.ExitStack becomes useful.
from contextlib import ExitStack
paths = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
files = [
stack.enter_context(open(path))
for path in paths
]
contents = [file.read() for file in files]Every file is registered with the stack.
When the with block finishes, Python closes the resources safely.
This avoids constructing deeply nested with blocks when resources are discovered dynamically.
Think of ExitStack as a programmable with
It's particularly useful when your application needs to manage a variable number of:
- files
- locks
- temporary resources
- database-related context managers
- connections
5. cache: Sometimes One Decorator Is Enough to Fix Performance
Repeated calculations are a perfect candidate for memoization.
Consider recursive Fibonacci:
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)The same values are calculated over and over.
Python's functools.cache can remember previous results:
from functools import cache
@cache
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)Now once fib(20) has been calculated, Python can reuse that result instead of calculating it again.
This can transform certain recursive algorithms from painfully slow to practically instantaneous for the same inputs.
You can also use lru_cache when you need control over the maximum cache size.
But caching isn't automatically good
Caching works best when:
- the function is deterministic
- the same inputs occur repeatedly
- the result is relatively expensive to calculate
Be careful with functions that depend on changing external state.
Caching stale information can be worse than recalculating it.
6. Pattern Matching: match Can Understand Data Structures
Python's match statement is much more capable than a traditional switch statement.
It can inspect the structure of the value being matched.
For example, imagine receiving events:
def handle_event(event):
match event:
case {"type": "click", "x": x, "y": y}:
return f"Clicked at ({x}, {y})"
case {"type": "keypress", "value": value}:
return f"Pressed {value}"
case _:
return "Unknown event"Instead of manually checking:
if event.get("type") == "click":
...
elif event.get("type") == "keypress":
...the pattern itself describes the expected structure.
This becomes particularly useful when working with:
- API payloads
- event systems
- parsers
- AST-like structures
- commands
- structured messages
The important shift is that you're not simply comparing values.
You're describing patterns of data.
7. Merge Dictionaries With |
Before Python 3.9, dictionary merging often involved patterns such as:
config = defaults.copy()
config.update(overrides)Modern Python lets you write:
defaults = {
"timeout": 30,
"retries": 3,
}
overrides = {
"timeout": 10,
}
config = defaults | overridesThe result is:
{
"timeout": 10,
"retries": 3,
}The right-hand dictionary wins when keys overlap.
You can also update an existing dictionary:
config |= {"debug": True}This is especially convenient for configuration-building code.
For example:
config = BASE_CONFIG | environment_config | user_configThe precedence becomes immediately visible.
That's often much easier to understand than several update() calls.
8. zip(strict=True): Don't Let Mismatched Data Disappear
Here's a subtle Python behavior that can hide bugs.
Normally:
names = ["Alice", "Bob", "Charlie"]
scores = [90, 85]
for name, score in zip(names, scores):
print(name, score)Python silently stops when the shortest iterable ends.
So "Charlie" simply disappears from the iteration.
Sometimes that's exactly what you want.
Sometimes it's a serious data-integrity bug.
Python 3.10 introduced:
for name, score in zip(names, scores, strict=True):
print(name, score)Now Python raises an error when the iterable lengths don't match.
That's a powerful distinction:
zip()
↓
"Use matching elements until one ends"
zip(strict=True)
↓
"These inputs are expected to have the same length"Use strict=True when equal lengths are part of your program's assumptions.
It turns a silent mistake into an obvious failure.
9. dataclass(slots=True, frozen=True): Compact Models With Useful Guarantees
If you're writing simple data-holding classes manually, you're probably writing more code than necessary.
Consider:
from dataclasses import dataclass
@dataclass(slots=True, frozen=True)
class Point:
x: float
y: floatNow you get a useful model with:
point = Point(2.0, 3.0)
print(point)The dataclass generates common methods for you.
slots=True can reduce per-instance memory usage and restrict arbitrary instance attributes.
frozen=True makes instances immutable after creation.
That combination is useful for value-like objects where you want predictable state.
For example:
@dataclass(slots=True, frozen=True)
class Money:
amount: int
currency: strNow a Money object behaves much more like a value than a mutable container.
The broader lesson isn't "always use slots and frozen."
It's:
Choose the guarantees your data model actually needs, and let Python enforce them.
10. itertools: Stop Writing the Same Data-Processing Loops
The itertools module contains a collection of small utilities that solve common iteration problems.
Two particularly useful ones are pairwise() and batched().
pairwise()
Suppose you need consecutive pairs:
from itertools import pairwise
values = [10, 13, 15, 20]
differences = [
current - previous
for previous, current in pairwise(values)
]
print(differences)Output:
[3, 2, 5]Without pairwise(), you might reach for indexes manually.
The iterator expresses the intention much more clearly.
batched()
When you need to process items in groups:
from itertools import batched
items = range(10)
for batch in batched(items, 3):
print(batch)You'll get:
(0, 1, 2)
(3, 4, 5)
(6, 7, 8)
(9,)This is useful for things like:
- bulk database operations
- API requests
- batch processing
- pagination
- chunked workloads
Instead of maintaining counters and manually slicing lists, you can express the operation directly.
11. singledispatch: Different Behavior Without Giant if Chains
Sometimes a function needs different behavior depending on the type of its argument.
A common solution is:
def normalize(value):
if isinstance(value, str):
...
elif isinstance(value, int):
...
else:
...For extensible code, functools.singledispatch provides another option.
from functools import singledispatch
@singledispatch
def normalize(value):
raise TypeError(f"Unsupported type: {type(value).__name__}")
@normalize.register
def _(value: str):
return value.strip().lower()
@normalize.register
def _(value: int):
return max(0, value)Now:
print(normalize(" Hello "))
print(normalize(-5))produces:
hello
0New types can be supported by registering another implementation.
This can be a clean alternative when type-specific behavior is genuinely part of the design.
It also avoids turning one function into a giant collection of isinstance() checks.
A Few Smaller Python Tricks Worth Remembering
The features above are the ones worth studying in depth, but Python has plenty of smaller conveniences too.
Chained comparisons
Instead of:
if age >= 18 and age < 65:
...you can write:
if 18 <= age < 65:
...It reads almost exactly like the mathematical expression.
any() and all()
Instead of manually looping:
flags = [True, True, False]
if all(flags):
print("Everything passed")Or:
if any(flags):
print("At least one passed")These functions communicate the intent directly.
Counter
If you need frequency counts:
from collections import Counter
words = "to be or not to be".split()
counts = Counter(words)
print(counts.most_common(2))No manual dictionary bookkeeping required.
Don't Try to Memorize Everything
The goal isn't to become the developer who uses the most Python tricks.
That's a trap.
A clever feature that makes code harder for your team to understand isn't an improvement.
The better goal is to recognize patterns.
When you see:
"I'm calling this function repeatedly until it returns X."
Think:
iter(callable, sentinel)
When you see:
"These two collections must have the same length."
Think:
zip(strict=True)
When you see:
"I'm opening a variable number of resources."
Think:
ExitStack
When you see:
"This expensive function keeps receiving the same inputs."
Think:
cache
When you see:
"I'm manually parsing different shapes of structured data."
Think:
match/case
That's when these features become useful.
How to Actually Learn These Features
Reading a list like this isn't enough.
Most Python features disappear from memory if you never use them.
A better approach is to introduce them gradually.
Step 1: Pick one feature
Don't refactor your entire codebase.
Choose one feature that solves a problem you currently have.
For example:
zip(strict=True)if you're working with parallel datasets.
Step 2: Find an old piece of code
Look at something you wrote a few months ago.
Ask:
"Would one of these features make this code clearer?"
Maybe a complicated parser can use match.
Maybe a manually implemented data class can become a dataclass.
Maybe a repetitive batching loop can become itertools.batched().
Step 3: Keep a small reference file
Create something like:
python-patterns.pyKeep short examples of features you actually use.
For each one, record:
What problem does it solve?
When should I use it?
When should I avoid it?That is much more useful than memorizing syntax.
The Real Python Skill Isn't Knowing More Syntax
There is a difference between knowing Python syntax and understanding Python's vocabulary.
You can write perfectly valid Python without ever using:
ExitStacksingledispatchzip(strict=True)- structural pattern matching
functools.cache
But once you recognize the problems these features solve, you start seeing better solutions.
That's where the real improvement happens.
You stop asking:
"How can I make this code work?"
and start asking:
"Does Python already have a better abstraction for this problem?"
That's a much more valuable question.
Python's strength isn't that it lets you write less code.
It's that the language and standard library give you a surprisingly large vocabulary for expressing what your program is trying to do.
You don't need to use every feature.
You just need to know which ones exist when the right problem appears.