Last updated: 2026-05-22

Python specific features

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 the with block, 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

FeatureWhat It Does
List/dict/set comprehensionConcise one-line collection creation with optional filtering
Generator expressionLike a comprehension but lazy — values produced on demand
yieldTurns a function into a generator
Decorator @funcWraps a function to add behaviour
@contextmanagerCreates a context manager from a generator function
*args unpackingSpreads a list as positional arguments
**kwargs unpackingSpreads a dict as keyword arguments
Walrus operator :=Assigns and returns a value in one expression (3.8+)
Type hintsAnnotates expected types — not enforced, aids tooling
@dataclassAuto-generates common methods for data-holding classes (3.7+)
match / structural patternsMatches values, structures, and class instances (3.10+)