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=17

Instead 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)=42

It'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 | overrides

The 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_config

The 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: float

Now 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: str

Now 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
0

New 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.py

Keep 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:

  • ExitStack
  • singledispatch
  • zip(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.