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.
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
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
FAQ
What is this article about in one sentence?
This article explains the core idea in practical terms and focuses on what you can apply in real work.
Who is this article for?
It is written for engineers, technical leaders, and curious readers who want a clear, implementation-focused explanation.
What should I read next?
Use the related articles below to continue with closely connected topics and concrete examples.