Every language has features that set it apart — patterns and tools that feel native to that language and are rarely found elsewhere in the same form. Python has a rich set of these. Some you will encounter immediately in other people's code; others you will reach for as your Python fluency grows. This topic covers the features that are distinctly Pythonic — the ones that make experienced Python developers recognise well-written code at a glance.
List, Dictionary, and Set Comprehensions
Comprehensions are a concise, readable syntax for creating collections by transforming or filtering existing iterables. They are one of Python's most recognisable features.
List comprehensions
Covered in the Lists and String Methods topic — here as context for the broader family:
squares = [x ** 2 for x in range(1, 6)]
evens = [x for x in range(10) if x % 2 == 0]
Dictionary comprehensions
words = ["apple", "banana", "cherry"]
lengths = {word: len(word) for word in words}
# {"apple": 5, "banana": 6, "cherry": 6}
Set comprehensions
numbers = [1, 2, 2, 3, 3, 3, 4]
unique_squares = {x ** 2 for x in numbers}
# {1, 4, 9, 16}
Nested comprehensions
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [n for row in matrix for n in row]
# [1, 2, 3, 4, 5, 6, 7, 8, 9]
Generator Expressions and Generator Functions
A generator produces values one at a time on demand, rather than building an entire collection in memory. For large or infinite sequences, generators are dramatically more memory-efficient than lists.
Generator expressions
Identical syntax to list comprehensions but with parentheses instead of square brackets:
# List comprehension — builds the entire list in memory
squares_list = [x ** 2 for x in range(1_000_000)]
# Generator expression — produces values one at a time
squares_gen = (x ** 2 for x in range(1_000_000))
next(squares_gen) # 0 — get the next value
next(squares_gen) # 1
next(squares_gen) # 4
Generator functions — yield
A function becomes a generator when it uses yield instead of return. When called, it returns a generator object. Each call to next() on the generator runs the function until the next yield, then pauses:
def countdown(n):
while n > 0:
yield n
n -= 1
for number in countdown(5):
print(number) # 5, 4, 3, 2, 1
The function body does not run when the generator is created — it runs lazily, one step at a time. This is what makes generators useful for large data streams, file reading, and infinite sequences.
Practical use: reading a large file line by line
def read_large_file(filepath):
with open(filepath, "r") as file:
for line in file:
yield line.strip()
for line in read_large_file("huge_log.txt"):
if "ERROR" in line:
print(line)
The entire file is never loaded into memory — lines are yielded one at a time.
Decorators
A decorator is a function that wraps another function to add behaviour before or after it runs — without modifying the original function's code. Decorators use the @ syntax:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} finished")
return result
return wrapper
@log_calls
def greet(name):
return f"Hello, {name}!"
greet("Wariz")
# Calling greet
# greet finished
@log_calls is syntactic sugar for greet = log_calls(greet).
Preserving the wrapped function's metadata — functools.wraps
Without functools.wraps, the wrapper function replaces the original's name and docstring. Use @wraps to preserve them:
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
Decorators with arguments
To create a decorator that accepts its own arguments, add another layer of nesting:
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
func(*args, **kwargs)
return wrapper
return decorator
@repeat(3)
def say_hello():
print("Hello!")
say_hello()
# Hello!
# Hello!
# Hello!
Common real-world decorators
Decorators appear throughout Python's ecosystem:
# Built-in decorators
@property # Turns a method into a read-only attribute
@classmethod # Covered in OOP topic
@staticmethod # Covered in OOP topic
# Flask (web framework)
@app.route("/") # Maps a URL to a function
# functools
@functools.lru_cache(maxsize=128) # Memoises function results
Context Managers — with and __enter__/__exit__
Context managers define setup and teardown behaviour that wraps a block of code. You have used them throughout this series with with open(...). Now here is how they work — and how to write your own.
The protocol
A context manager implements two methods:
__enter__— runs when entering thewithblock, returns the resource.__exit__— runs when leaving, handles cleanup and optionally suppresses exceptions.
class Timer:
import time
def __enter__(self):
self.start = self.time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
elapsed = self.time.time() - self.start
print(f"Elapsed: {elapsed:.4f} seconds")
return False # Don't suppress exceptions
with Timer():
total = sum(range(1_000_000))
# Elapsed: 0.0312 seconds
Using contextlib.contextmanager
The contextlib module lets you write a context manager as a generator function — simpler than a full class:
from contextlib import contextmanager
@contextmanager
def managed_resource(name):
print(f"Setting up {name}")
try:
yield name # The value yielded becomes the 'as' variable
finally:
print(f"Tearing down {name}")
with managed_resource("database connection") as resource:
print(f"Using {resource}")
# Setting up database connection
# Using database connection
# Tearing down database connection
Unpacking and the * and ** Operators
Python's unpacking operators are expressive tools for working with sequences and dictionaries.
Basic unpacking
a, b, c = [1, 2, 3]
first, *rest = [1, 2, 3, 4, 5] # first=1, rest=[2, 3, 4, 5]
*init, last = [1, 2, 3, 4, 5] # init=[1, 2, 3, 4], last=5
first, *middle, last = [1, 2, 3, 4, 5] # middle=[2, 3, 4]
Unpacking in function calls
def add(a, b, c):
return a + b + c
numbers = [1, 2, 3]
add(*numbers) # 6 — unpacks list as positional args
settings = {"a": 1, "b": 2, "c": 3}
add(**settings) # 6 — unpacks dict as keyword args
Merging sequences and dictionaries
list1 = [1, 2, 3]
list2 = [4, 5, 6]
combined = [*list1, *list2] # [1, 2, 3, 4, 5, 6]
dict1 = {"a": 1, "b": 2}
dict2 = {"c": 3, "d": 4}
merged = {**dict1, **dict2} # {"a": 1, "b": 2, "c": 3, "d": 4}
enumerate, zip, and map as Pythonic Patterns
These were introduced in the control flow and functions topics. Here they are consolidated as part of Python's idiomatic toolkit:
# enumerate — index + value, no manual counter
for i, name in enumerate(["Wariz", "Ada", "Aliyu"], start=1):
print(f"{i}. {name}")
# 1. Wariz
# 2. Ada
# 3. Aliyu
# zip — parallel iteration
names = ["Wariz", "Ada"]
scores = [95, 87]
for name, score in zip(names, scores):
print(f"{name}: {score}")
# zip with unequal lengths — stops at the shortest
# Use itertools.zip_longest to fill missing values with a default
Walrus Operator := (Python 3.8+)
The walrus operator assigns a value and returns it in the same expression — useful for avoiding a repeated computation:
# Without walrus — input() called twice if needed in condition
data = input("Enter something: ")
if data:
print(data)
# With walrus — assign and check in one step
if data := input("Enter something: "):
print(data)
Common use in while loops:
import re
while chunk := file.read(8192):
process(chunk)
Type Hints and Annotations (Python 3.5+)
Python is dynamically typed, but type hints let you annotate variables and function signatures with expected types. They are not enforced at runtime — they serve as documentation and enable static analysis tools like mypy:
def greet(name: str, age: int) -> str:
return f"Hello, {name}. You are {age}."
def process(items: list[int]) -> dict[str, int]:
return {"total": sum(items), "count": len(items)}
Common type hint syntax
from typing import Optional, Union, Any
def find_user(user_id: int) -> Optional[str]:
# Returns str or None
pass
def parse(value: Union[str, int]) -> str:
# Accepts str or int
return str(value)
In Python 3.10+, X | Y replaces Union[X, Y]:
def parse(value: str | int) -> str:
return str(value)
Type hints are widely used in modern Python projects and are expected in professional code alongside docstrings.
Dataclasses (Python 3.7+)
@dataclass automatically generates __init__, __repr__, __eq__, and other methods for classes that primarily hold data:
from dataclasses import dataclass, field
@dataclass
class User:
name: str
age: int
tags: list = field(default_factory=list)
user = User("Wariz", 20)
print(user) # User(name='Wariz', age=20, tags=[])
print(user.name) # "Wariz"
user2 = User("Wariz", 20)
print(user == user2) # True — __eq__ compares all fields
For frozen (immutable) dataclasses:
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
p.x = 3.0 # ❌ FrozenInstanceError
Dataclasses reduce boilerplate significantly for data-holding classes.
The match Statement — Structural Pattern Matching (Python 3.10+)
Introduced earlier in control flows, match is powerful enough to warrant deeper coverage here. It matches not just values but structures:
def process_command(command):
match command:
case {"action": "create", "name": name}:
print(f"Creating {name}")
case {"action": "delete", "id": id}:
print(f"Deleting item {id}")
case {"action": action}:
print(f"Unknown action: {action}")
case _:
print("Invalid command format")
process_command({"action": "create", "name": "Wariz"})
# Creating Wariz
Matching against class instances:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def describe(point):
match point:
case Point(x=0, y=0):
return "Origin"
case Point(x=0, y=y):
return f"On Y-axis at {y}"
case Point(x=x, y=0):
return f"On X-axis at {x}"
case Point(x=x, y=y):
return f"Point at ({x}, {y})"
Summary
| Feature | What It Does |
|---|---|
| List/dict/set comprehension | Concise one-line collection creation with optional filtering |
| Generator expression | Like a comprehension but lazy — values produced on demand |
yield | Turns a function into a generator |
Decorator @func | Wraps a function to add behaviour |
@contextmanager | Creates a context manager from a generator function |
*args unpacking | Spreads a list as positional arguments |
**kwargs unpacking | Spreads a dict as keyword arguments |
Walrus operator := | Assigns and returns a value in one expression (3.8+) |
| Type hints | Annotates expected types — not enforced, aids tooling |
@dataclass | Auto-generates common methods for data-holding classes (3.7+) |
match / structural patterns | Matches values, structures, and class instances (3.10+) |