Python ships with decorators that are available the moment the interpreter starts, without a single import statement. These are @property, @staticmethod, and @classmethod -- three built-in functions that live in global scope and are used inside class definitions to change how methods behave. Beyond these, the functools module in the standard library provides decorators like @wraps, @lru_cache, @cached_property, and @total_ordering that solve common problems with a single line. This article covers each one with complete, runnable code.
All decorators covered here are part of Python itself. The first three (@property, @staticmethod, @classmethod) are built-in functions that exist in the global namespace of every Python program. The remaining decorators require an import from functools, which is a module in the standard library and does not need to be installed. Together, these decorators handle the patterns that appear in virtually every Python codebase: controlled attribute access, alternative constructors, caching, comparison operators, and metadata preservation for custom decorators.
@property: Attribute-Like Access to Methods
The @property decorator turns a method into something that behaves like an attribute. When you access the property, Python calls the method behind the scenes without the caller needing parentheses. This lets you add validation, computation, or logging to attribute access without changing the interface:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
"""Get temperature in Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value):
"""Set temperature, rejecting values below absolute zero."""
if value < -273.15:
raise ValueError("Temperature cannot be below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
"""Computed property: converts Celsius to Fahrenheit."""
return self._celsius * 9 / 5 + 32
temp = Temperature(100)
print(temp.celsius) # 100 (calls the getter, no parentheses)
print(temp.fahrenheit) # 212.0 (computed on access)
temp.celsius = 0 # calls the setter
print(temp.fahrenheit) # 32.0
try:
temp.celsius = -300 # setter rejects invalid value
except ValueError as e:
print(e) # Temperature cannot be below absolute zero
The @property decorator creates a getter. The @celsius.setter decorator creates a setter for the same property. The fahrenheit property has no setter, making it read-only. In a manual class without @property, you would need get_celsius() and set_celsius() methods, and every caller would need to use parentheses. The property decorator makes the interface cleaner while keeping the validation logic in place.
@staticmethod: Methods Without self
A regular method in a class automatically receives the instance as its first argument (self). The @staticmethod decorator removes that automatic argument, turning the method into a plain function that happens to live inside the class namespace:
class Validator:
@staticmethod
def is_valid_ip(address):
"""Check if a string is a valid IPv4 address."""
parts = address.split(".")
if len(parts) != 4:
return False
for part in parts:
if not part.isdigit():
return False
if not 0 <= int(part) <= 255:
return False
return True
@staticmethod
def is_valid_port(port):
"""Check if a port number is in the valid range."""
return isinstance(port, int) and 1 <= port <= 65535
# Called on the class (no instance needed)
print(Validator.is_valid_ip("192.168.1.1")) # True
print(Validator.is_valid_ip("999.0.0.1")) # False
print(Validator.is_valid_port(443)) # True
# Also works on an instance
v = Validator()
print(v.is_valid_port(80)) # True
These methods do not access self or any instance data. They are utility functions grouped under the Validator class for organizational purposes. Without @staticmethod, Python would pass the instance as the first argument, which the method does not need or want.
@classmethod: Methods That Receive the Class
The @classmethod decorator replaces self with cls, giving the method access to the class itself rather than a specific instance. The primary use case is alternative constructors -- methods that create new instances of the class from different types of input:
import json
class Config:
def __init__(self, host, port, debug=False):
self.host = host
self.port = port
self.debug = debug
@classmethod
def from_json(cls, json_string):
"""Create a Config from a JSON string."""
data = json.loads(json_string)
return cls(
host=data["host"],
port=data["port"],
debug=data.get("debug", False),
)
@classmethod
def from_dict(cls, data):
"""Create a Config from a dictionary."""
return cls(
host=data["host"],
port=data["port"],
debug=data.get("debug", False),
)
def __repr__(self):
return (f"Config(host={self.host!r}, port={self.port}, "
f"debug={self.debug})")
# Standard constructor
c1 = Config("localhost", 8080)
print(c1)
# Config(host='localhost', port=8080, debug=False)
# Alternative constructor from JSON
c2 = Config.from_json('{"host": "0.0.0.0", "port": 443, "debug": true}')
print(c2)
# Config(host='0.0.0.0', port=443, debug=True)
# Alternative constructor from dict
c3 = Config.from_dict({"host": "10.0.1.1", "port": 22})
print(c3)
# Config(host='10.0.1.1', port=22, debug=False)
The cls parameter in from_json and from_dict refers to the Config class. Calling cls(...) inside the method creates a new Config instance. This matters for inheritance: if a subclass inherits these methods, cls will refer to the subclass, and the alternative constructors will create subclass instances instead of Config instances.
The key distinction: @staticmethod has no access to the instance or the class. @classmethod has access to the class via cls but not to a specific instance. Regular methods have access to the instance via self and the class via type(self) or self.__class__.
functools.wraps: Preserving Metadata
When you write a custom decorator, the wrapper function replaces the original function. Without intervention, the original function's __name__, __doc__, and __module__ are lost. @functools.wraps copies the original function's metadata onto the wrapper:
from functools import wraps
def log_call(func):
@wraps(func) # <-- preserves func's metadata
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def connect(host, port):
"""Establish a connection to the given host and port."""
return f"Connected to {host}:{port}"
# Metadata is preserved
print(connect.__name__) # connect (not "wrapper")
print(connect.__doc__) # Establish a connection to the given host and port.
Without @wraps(func), the name would be "wrapper" and the docstring would be None. This is not just cosmetic -- frameworks like Flask use function names for URL routing, and documentation generators rely on docstrings. @wraps should be applied in every custom decorator.
functools.lru_cache and functools.cache: Memoization
@lru_cache caches the results of function calls. When the function is called again with the same arguments, the cached result is returned without re-executing the function body. This provides dramatic speedups for recursive or expensive computations:
from functools import lru_cache, cache
import time
@lru_cache(maxsize=128)
def fibonacci(n):
"""Compute the nth Fibonacci number with memoization."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
start = time.perf_counter()
result = fibonacci(300)
elapsed = time.perf_counter() - start
print(f"fibonacci(300) = {result}")
print(f"Computed in {elapsed:.6f} seconds")
# fibonacci(300) = 222232244629420445529739893461909967...
# Computed in 0.000234 seconds
# Inspect cache performance
print(fibonacci.cache_info())
# CacheInfo(hits=298, misses=301, maxsize=128, currsize=128)
# @cache is the unbounded version (Python 3.9+)
@cache
def factorial(n):
"""Compute n! with unbounded caching."""
if n <= 1:
return 1
return n * factorial(n - 1)
print(factorial(100))
# 9332621544394415268169923885626670049071596826438...
print(factorial.cache_info())
# CacheInfo(hits=0, misses=101, maxsize=None, currsize=101)
Without @lru_cache, the recursive Fibonacci function would take exponentially longer because it recomputes the same values repeatedly. With caching, each value is computed once and retrieved from the cache for all subsequent calls. The cache_info() method shows hits (cached returns), misses (new computations), and current cache size. The @cache decorator (Python 3.9+) is a shorthand for @lru_cache(maxsize=None) that caches all calls without eviction.
Only use @lru_cache on functions with hashable arguments (strings, numbers, tuples). Lists and dictionaries are not hashable and will raise TypeError. Also avoid caching functions with side effects or time-dependent outputs like time.time().
functools.total_ordering: Comparison Boilerplate
Implementing all six comparison methods (__eq__, __lt__, __le__, __gt__, __ge__, __ne__) on a class is tedious. The @total_ordering decorator lets you define just __eq__ and one other comparison method, and it generates the rest:
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor, patch):
self.major = major
self.minor = minor
self.patch = patch
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) == \
(other.major, other.minor, other.patch)
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) < \
(other.major, other.minor, other.patch)
def __repr__(self):
return f"Version({self.major}.{self.minor}.{self.patch})"
v1 = Version(1, 0, 0)
v2 = Version(1, 2, 3)
v3 = Version(2, 0, 0)
# All comparison operators work, even though we only defined __eq__ and __lt__
print(v1 < v2) # True
print(v2 <= v2) # True
print(v3 > v2) # True
print(v1 >= v3) # False
print(v1 != v2) # True
# Sorting works because all comparisons are available
versions = [v3, v1, v2]
versions.sort()
print(versions) # [Version(1.0.0), Version(1.2.3), Version(2.0.0)]
Without @total_ordering, you would need to write all four additional comparison methods by hand. The decorator generates them from the two you provide, reducing five method definitions to two.
Quick Reference Table
| Decorator | Import Required | Applies To | Purpose |
|---|---|---|---|
@property | None (built-in) | Methods | Attribute-like access with optional getter/setter/deleter |
@staticmethod | None (built-in) | Methods | Removes self, makes method callable without an instance |
@classmethod | None (built-in) | Methods | Receives cls instead of self, used for alternative constructors |
@functools.wraps | from functools import wraps | Wrapper functions | Preserves original function's __name__, __doc__, __module__ |
@functools.lru_cache | from functools import lru_cache | Functions | Memoizes results, evicts least-recently-used when full |
@functools.cache | from functools import cache | Functions | Unbounded memoization (Python 3.9+), no eviction |
@functools.cached_property | from functools import cached_property | Methods | Like @property but caches the result after first access |
@functools.total_ordering | from functools import total_ordering | Classes | Generates missing comparison methods from __eq__ + one other |
Key Takeaways
- Three decorators require no import.
@property,@staticmethod, and@classmethodare built-in functions in Python's global scope. They are available in every Python program without importing anything. @propertyturns methods into attribute-like accessors. The getter runs on attribute access, the setter runs on assignment, and validation logic stays inside the class without changing the external interface.@staticmethodremoves self;@classmethodreplaces self with cls. Static methods are utility functions inside a class. Class methods receive the class and are primarily used for alternative constructors.@functools.wrapsbelongs in every custom decorator. It preserves the decorated function's name, docstring, and module, which frameworks and debugging tools depend on.@lru_cacheand@cacheprovide memoization. They cache function results by argument, eliminating redundant computations. Uselru_cache(maxsize=N)for bounded caches and@cachefor unbounded ones.@total_orderinggenerates comparison methods. Define__eq__and one of__lt__,__le__,__gt__, or__ge__, and the decorator fills in the remaining four comparison operators.
Python's built-in and standard library decorators solve the patterns that recur in every project: controlling attribute access, organizing class methods, caching expensive computations, preserving metadata in custom decorators, and reducing comparison boilerplate. They are production-tested, well-documented, and available without installing anything beyond Python itself. Knowing when to reach for each one is as important as knowing how to write your own.