14 Interesting Python Features

A deep dive into 14 powerful Python features you might not be using yet, from type overloading and generics to metaclasses and structural pattern matching, complete with practical code examples.

14 Python features

Python has many features that can make your code more expressive, type-safe, and elegant. Here are 14 interesting features that you may not be using yet — some well-known, others more obscure — but all worth knowing about.

1. Type Overloading

The @overload decorator from Python's typing module lets you define multiple function signatures so that type checkers know exactly what return type to expect for a given input.

from typing import Literal, overload

@overload
def transform(data: str, mode: Literal["split"]) -> list[str]:
    ...

@overload
def transform(data: str, mode: Literal["upper"]) -> str:
    ...

def transform(data: str, mode: Literal["split", "upper"]) -> list[str] | str:
    if mode == "split":
        return data.split()
    else:
        return data.upper()

split_words = transform("hello world", "split")  # Type: list[str]
split_words[0]  # Type checker approves

upper_words = transform("hello world", "upper")  # Type: str
upper_words.lower()  # Type checker approves

upper_words.append("!")  # Error: no "append" attribute for "str"

You can also use overloads for mutually exclusive arguments:

@overload
def get_user(id: int = ..., username: None = None) -> User:
    ...

@overload
def get_user(id: None = None, username: str = ...) -> User:
    ...

def get_user(id: int | None = None, username: str | None = None) -> User:
    ...

get_user(id=1)  # Works!
get_user(username="John")  # Works!
get_user(id=1, username="John")  # Error: no matching overload

Bonus: Literal types let you restrict string arguments to specific values:

def set_color(color: Literal["red", "blue", "green"]) -> None:
    ...

set_color("red")      # OK
set_color("fuchsia")  # Error: Literal['fuchsia'] invalid

2. Positional-Only and Keyword-Only Arguments

Python lets you control how function parameters can be passed using / and * markers in the function signature.

Keyword-only parameters (after *):

def foo(a, *, b):
    ...

foo(a=1, b=2)  # All keyword — allowed
foo(1, b=2)    # Mixed — allowed
foo(1, 2)      # Error: b must be keyword

Positional-only parameters (before /):

def bar(a, /, b):
    ...

bar(1, 2)      # All positional — allowed
bar(1, b=2)    # Mixed — allowed
bar(a=1, b=2)  # Error: a must be positional

These features help API developers enforce strict parameter usage patterns, preventing callers from depending on internal parameter names.

3. Future Annotations

Python's type system began as a hack in Python 3.0 and was formalized in PEP 484 (Python 3.5). The system evaluates type hints at definition time, causing issues with forward references — using types before they're fully defined.

The problem:

# This fails
class Foo:
    def action(self) -> Foo:
        # NameError: Foo not yet fully defined
        ...

# Ugly workaround: string literals
class Bar:
    def action(self) -> "Bar":
        ...

The solution (PEP 563):

from __future__ import annotations

class Foo:
    def bar(self) -> Foo:  # Now works!
        ...

Be aware of runtime consequences — with future annotations, type hints become strings at runtime:

# Normal typing
def foobar() -> int:
    return 1

ret_type = foobar.__annotations__.get("return")
ret_type  # Returns: <class 'int'>
new_int = ret_type()  # Works

# With future annotations
from __future__ import annotations

def foobar() -> int:
    return 1

ret_type = foobar.__annotations__.get("return")
ret_type  # "int" (string!)
new_int = ret_type()  # TypeError: 'str' not callable

The modern solution uses Self from PEP 673:

from typing import Self

class Foo:
    def bar(self) -> Self:
        ...

4. Generics

Python 3.12 introduced elegant native generic syntax that replaces the verbose TypeVar pattern.

Modern syntax (Python 3.12+):

class KVStore[K: str | int, V]:
    def __init__(self) -> None:
        self.store: dict[K, V] = {}

    def get(self, key: K) -> V:
        return self.store[key]

    def set(self, key: K, value: V) -> None:
        self.store[key] = value

kv = KVStore[str, int]()
kv.set("one", 1)
kv.set("two", 2)

Legacy syntax (Python 3.5-3.11):

from typing import Generic, TypeVar

UnBounded = TypeVar("UnBounded")
Bounded = TypeVar("Bounded", bound=int)
Constrained = TypeVar("Constrained", int, float)

class Foo(Generic[UnBounded, Bounded, Constrained]):
    def __init__(self, x: UnBounded, y: Bounded, z: Constrained) -> None:
        self.x = x
        self.y = y
        self.z = z

Variadic generics let you parameterize over a variable number of types:

class Tuple[*Ts]:
    def __init__(self, *args: *Ts) -> None:
        self.values = args

pair = Tuple[str, int]("hello", 42)
triple = Tuple[str, int, bool]("world", 100, True)

Python 3.12 also introduced clean type alias syntax:

type Vector = list[float]

5. Protocols

Protocols implement structural subtyping ("duck typing" for type checkers) without requiring inheritance — if it quacks like a duck, it's a duck.

from typing import Protocol

class Quackable(Protocol):
    def quack(self) -> None:
        ...

class Duck:
    def quack(self):
        print('Quack!')

class Dog:
    def bark(self):
        print('Woof!')

def run_quack(obj: Quackable):
    obj.quack()

run_quack(Duck())  # Works!
run_quack(Dog())   # Type error (not runtime)

For runtime checking, use the @runtime_checkable decorator:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None:
        ...

6. Context Managers

The traditional OOP approach uses __enter__ and __exit__:

class retry:
    def __enter__(self):
        print("Entering Context")

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Exiting Context")

The modern contextlib approach is cleaner:

import contextlib

@contextlib.contextmanager
def retry():
    print("Entering Context")
    yield
    print("Exiting Context")

You can pass variables to the with block via yield:

import contextlib

@contextlib.contextmanager
def context():
    # Setup code
    setup()
    yield (...)  # Variables to pass to with block
    # Cleanup code
    takedown()

7. Structural Pattern Matching

Introduced in Python 3.10, pattern matching provides powerful destructuring capabilities far beyond a simple switch statement.

Basic syntax:

match value:
    case pattern1:
        # code if value matches pattern1
    case pattern2:
        # code if value matches pattern2
    case _:
        # default case

Tuple destructuring:

match point:
    case (0, 0):
        return "Origin"
    case (0, y):
        return f"Y-axis at {y}"
    case (x, 0):
        return f"X-axis at {x}"
    case (x, y):
        return f"Point at ({x}, {y})"

OR patterns:

match day:
    case ("Monday" | "Tuesday" | "Wednesday"
          | "Thursday" | "Friday"):
        return "Weekday"
    case "Saturday" | "Sunday":
        return "Weekend"

Guard clauses:

match temperature:
    case temp if temp < 0:
        return "Freezing"
    case temp if temp < 20:
        return "Cold"
    case temp if temp < 30:
        return "Warm"
    case _:
        return "Hot"

Sequence matching with *:

match numbers:
    case [f]:
        return f"First: {f}"
    case [f, l]:
        return f"First: {f}, Last: {l}"
    case [f, *m, l]:
        return f"First: {f}, Middle: {m}, Last: {l}"
    case []:
        return "Empty list"

Advanced example with walrus operator integration:

packet: list[int] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]

match packet:
    case [c1, c2, *data, footer] if (
        (checksum := c1 + c2) == sum(data) and
        len(data) == footer
    ):
        print(f"Packet received: {data} (Checksum: {checksum})")
    case [c1, c2, *data]:
        print(f"Packet received: {data} (Checksum Failed)")
    case [_, *__]:
        print("Invalid packet length")
    case []:
        print("Empty packet")
    case _:
        print("Invalid packet")

8. Slots

The __slots__ attribute restricts a class to a fixed set of attributes, eliminating the per-instance __dict__ and saving memory.

class Person:
    __slots__ = ('name', 'age')

    def __init__(self, name, age):
        self.name = name
        self.age = age

Without __slots__, each instance has a dictionary:

class FooBar:
    def __init__(self):
        self.a = 1
        self.b = 2
        self.c = 3

f = FooBar()
print(f.__dict__)  # {'a': 1, 'b': 2, 'c': 3}

With __slots__, the dictionary is gone:

class FooBar:
    __slots__ = ('a', 'b', 'c')

    def __init__(self):
        self.a = 1
        self.b = 2
        self.c = 3

f = FooBar()
print(f.__dict__)  # AttributeError
print(f.__slots__)  # ('a', 'b', 'c')

9. Python Miscellany

9.1 For-Else

The else clause on a for loop runs only if the loop completes without hitting a break:

# Instead of this:
found_server = False
for server in servers:
    if server.check_availability():
        primary_server = server
        found_server = True
        break
if not found_server:
    primary_server = backup_server

deploy_application(primary_server)
# Write this:
for server in servers:
    if server.check_availability():
        primary_server = server
        break
else:
    primary_server = backup_server

deploy_application(primary_server)

9.2 Walrus Operator

The := operator assigns and returns a value in a single expression:

# Instead of this:
response = get_user_input()
if response:
    print('You pressed:', response)
else:
    print('You pressed nothing')
# Write this:
if response := get_user_input():
    print('You pressed:', response)
else:
    print('You pressed nothing')

9.3 Short-Circuit Evaluation

Use or to chain fallback values:

# Instead of this:
username, full_name, first_name = get_user_info()

if username is not None:
    display_name = username
elif full_name is not None:
    display_name = full_name
elif first_name is not None:
    display_name = first_name
else:
    display_name = "Anonymous"
# Write this:
username, full_name, first_name = get_user_info()
display_name = username or full_name or first_name or "Anonymous"

9.4 Chained Comparisons

# Instead of this:
if 0 < x and x < 10:
    print("x is between 0 and 10")
# Write this:
if 0 < x < 10:
    print("x is between 0 and 10")

10. Advanced f-string Formatting

f-string formatting

Python f-strings support a rich mini-language for formatting numbers, dates, and strings:

# Debug expressions
print(f"{name=}, {age=}")
# Output: name='Claude', age=3

# Number formatting
print(f"Pi: {pi:.2f}")           # Two decimal places
print(f"Avogadro: {avogadro:.2e}") # Scientific notation
print(f"Big Number: {big_num:,}")  # Thousands separator
print(f"Hex: {num:#0x}")           # Hex with prefix
print(f"Number: {num:09}")         # Zero-padded

# String padding
print(f"Left:   |{word:<10}|")     # Left-aligned
print(f"Right:  |{word:>10}|")     # Right-aligned
print(f"Center: |{word:^10}|")     # Centered
print(f"Center: |{word:*^10}|")    # Centered with fill

# Date formatting
print(f"Date: {now:%Y-%m-%d}")
print(f"Time: {now:%H:%M:%S}")

# Percentage
print(f"Progress: {progress:.1%}")

A complete real-world example:

print(f"{' [ Run Status ] ':=^50}")
print(f"[{time:%H:%M:%S}] Training Run {run_id=} status: {progress:.1%}")
print(f"Summary: {total_samples:,} samples processed")
print(f"Accuracy: {accuracy:.4f} | Loss: {loss:#.3g}")
print(f"Memory: {memory / 1e9:+.2f} GB")

11. @cache and @lru_cache

The functools module provides decorators for memoization — caching function results based on their arguments.

Manual caching (the old way):

FIB_CACHE = {}

def fib(n):
    if n in FIB_CACHE:
        return FIB_CACHE[n]
    if n <= 2:
        return 1
    FIB_CACHE[n] = fib(n - 1) + fib(n - 2)
    return FIB_CACHE[n]

With @cache (Python 3.9+, unlimited):

from functools import cache

@cache
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

With @lru_cache (bounded, LRU eviction):

from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

12. Python Futures

A Future represents an eventual result of an asynchronous operation.

Basic usage:

from concurrent.futures import Future

future = Future()
future.set_result("Hello from the future!")
print(future.result())  # "Hello from the future!"

Callbacks:

from concurrent.futures import Future

future = Future()
future.add_done_callback(lambda f: print(f"Got: {f.result()}"))
future.set_result("Async result")
# Output: "Got: Async result"

With threads:

from concurrent.futures import Future
import time, threading

future = Future()

def background_task():
    time.sleep(2)
    future.set_result("Done!")

thread = threading.Thread(target=background_task)
thread.daemon = True
thread.start()

try:
    result = future.result(timeout=0.5)
except TimeoutError:
    print("Timed out!")

With asyncio:

import asyncio

async def main():
    future = asyncio.Future()
    asyncio.create_task(set_after_delay(future))
    result = await future
    print(result)  # "Worth the wait!"

async def set_after_delay(future):
    await asyncio.sleep(1)
    future.set_result("Worth the wait!")

asyncio.run(main())

With ThreadPoolExecutor:

from concurrent.futures import ThreadPoolExecutor
import time

def slow_task():
    time.sleep(1)
    return "Done!"

with ThreadPoolExecutor() as executor:
    future = executor.submit(slow_task)
    print("Working...")
    print(future.result())

13. Proxy Properties

A proxy property is a custom descriptor that acts as both a property (returning a value when accessed) and a callable method:

from typing import Callable, Generic, TypeVar, ParamSpec, Self

P = ParamSpec("P")
R = TypeVar("R")
T = TypeVar("T")

class ProxyProperty(Generic[P, R]):
    func: Callable[P, R]
    instance: object

    def __init__(self, func: Callable[P, R]) -> None:
        self.func = func

    def __get__(self, instance: object, _=None) -> Self:
        self.instance = instance
        return self

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
        return self.func(self.instance, *args, **kwargs)

    def __repr__(self) -> str:
        return self.func(self.instance)

def proxy_property(func: Callable[P, R]) -> ProxyProperty[P, R]:
    return ProxyProperty(func)

class Container:
    @proxy_property
    def value(self, val: int = 5) -> str:
        return f"The value is: {val}"

# Usage
c = Container()
print(c.value)      # Returns: The value is: 5
print(c.value(7))   # Returns: The value is: 7

14. Metaclasses

Metaclasses let you customize class creation itself. A metaclass is a class whose instances are classes.

Basic metaclass:

class MyMetaclass(type):
    def __new__(cls, name, bases, namespace):
        # Magic happens here
        return super().__new__(cls, name, bases, namespace)

class MyClass(metaclass=MyMetaclass):
    pass

Creating classes dynamically with type:

# These are equivalent:
class MyClass:
    ...

MyClass = type("MyClass", (), {})

# With attributes:
CustomClass = type(
    'CustomClass',
    (object,),
    {'x': 5, 'say_hi': lambda self: 'Hello!'}
)

obj = CustomClass()
print(obj.x)        # 5
print(obj.say_hi())  # Hello!

A practical example — a metaclass that doubles all integer attributes:

class DoubleAttrMeta(type):
    def __new__(cls, name, bases, namespace):
        new_namespace = {}
        for key, val in namespace.items():
            if isinstance(val, int):
                val *= 2
            new_namespace[key] = val
        return super().__new__(cls, name, bases, new_namespace)

class MyClass(metaclass=DoubleAttrMeta):
    x = 5
    y = 10

print(MyClass.x)  # 10
print(MyClass.y)  # 20

Auto-registration pattern:

class RegisterMeta(type):
    registry = []
    def __new__(mcs, name, bases, attrs):
        cls = super().__new__(mcs, name, bases, attrs)
        mcs.registry.append(cls)
        return cls

Note: in most cases, decorators are a cleaner alternative to metaclasses:

def register(cls):
    registry.append(cls)
    return cls

@register
class MyClass:
    pass