Part of Python for Beginners

Python Decorators, @classmethod & @property Explained. #23

Sandy LaneSandy Lane

Video: Python Decorators, @classmethod & @property Explained. #23 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python Decorators: @wraps, @classmethod, parameterized decorators

A decorator is a function that takes a function and returns a (usually wrapping) function. @deco above a def is shorthand for f = deco(f). Use them for cross-cutting concerns: timing, logging, caching, auth, retries.

You've already seen @property, @classmethod, @staticmethod, @abstractmethod. Today: writing your own.

The minimal decorator

def shout(func):
  def wrapper(*args, **kwargs):
    result = func(*args, **kwargs)
    return result.upper()
  return wrapper

@shout
def greet(name):
  return f"hello, {name}"

print(greet("Alice"))    # HELLO, ALICE

The @shout syntax is shorthand for:

def greet(name):
  return f"hello, {name}"

greet = shout(greet)

shout takes a function, returns a new function (wrapper). The new function calls the original and post-processes the result.

What's happening

A decorator is just:

def decorator(func):
  def wrapper(*args, **kwargs):
    # before
    result = func(*args, **kwargs)
    # after
    return result
  return wrapper
  • The outer function takes func (the function being decorated).
  • The inner function wrapper accepts whatever arguments the original takes — *args, **kwargs is the universal form.
  • Inside, you call func(*args, **kwargs) and do whatever wrapping you want.
  • Return wrapper.

A timer decorator

import time

def timer(func):
  def wrapper(*args, **kwargs):
    start = time.time()
    result = func(*args, **kwargs)
    elapsed = time.time() - start
    print(f"{func.__name__} took {elapsed:.4f}s")
    return result
  return wrapper

@timer
def slow_sum(n):
  return sum(range(n))

slow_sum(1_000_000)
# slow_sum took 0.0312s

The decorated function works exactly like the original — same return value, same arguments — but with timing instrumentation.

This is the decorator pattern: cross-cutting behavior added without modifying the original function.

@wraps preserves metadata

Without @wraps, the decorated function loses its name and docstring:

def shout(func):
  def wrapper(*args, **kwargs):
    return func(*args, **kwargs).upper()
  return wrapper

@shout
def greet(name):
  """Say hi."""
  return f"hello, {name}"

print(greet.__name__)   # "wrapper" — confusing!
print(greet.__doc__)    # None

Fix it with functools.wraps:

from functools import wraps

def shout(func):
  @wraps(func)
  def wrapper(*args, **kwargs):
    return func(*args, **kwargs).upper()
  return wrapper

@shout
def greet(name):
  """Say hi."""
  return f"hello, {name}"

print(greet.__name__)   # "greet"
print(greet.__doc__)    # "Say hi."

@wraps(func) copies func's __name__, __doc__, __module__, etc. onto wrapper. Always use it when writing decorators.

Decorators with arguments

def repeat(times):
  def decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
      for _ in range(times):
        result = func(*args, **kwargs)
      return result
    return wrapper
  return decorator

@repeat(3)
def say_hello():
  print("Hello!")

say_hello()
# Hello!
# Hello!
# Hello!

Three layers of nesting:

  1. repeat(times) — factory; takes the decorator's argument.
  2. decorator(func) — the actual decorator; takes the function.
  3. wrapper(*args, **kwargs) — the replacement; takes the function's arguments.

@repeat(3) is repeat(3) (which returns decorator), then decorator(say_hello) (which returns wrapper).

A logging decorator

def log_calls(func):
  @wraps(func)
  def wrapper(*args, **kwargs):
    arg_str = ", ".join([repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()])
    print(f"-> {func.__name__}({arg_str})")
    result = func(*args, **kwargs)
    print(f"<- {func.__name__} returned {result!r}")
    return result
  return wrapper

@log_calls
def add(a, b):
  return a + b

add(2, 3)
# -> add(2, 3)
# <- add returned 5

Logging at function boundaries is a classic decorator use.

Caching with @lru_cache

from functools import lru_cache

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

print(fib(100))    # instant — without lru_cache, this would take forever

@lru_cache memoizes the function — same args, same return, no recomputation. Built into the standard library; one of the most useful decorators in Python.

Multiple decorators

@timer
@log_calls
def slow_sum(n):
  return sum(range(n))

Stack reads bottom-up:

  1. slow_sum = log_calls(slow_sum) — wraps with logging.
  2. slow_sum = timer(log_calls(slow_sum)) — wraps that with timing.

So calling slow_sum(...): - timer's wrapper starts timing. - log_calls's wrapper logs the call. - The original runs. - log_calls logs the return. - timer prints elapsed.

Order matters. Closer-to-def runs first.

@classmethod and @staticmethod (revisited)

class DateProfile:
  count = 0

  def __init__(self, name, birth_year):
    self.name = name
    self.birth_year = birth_year
    DateProfile.count += 1

  @classmethod
  def from_age(cls, name, age):
    return cls(name, date.today().year - age)

  @staticmethod
  def is_valid_year(year):
    return 1900 <= year <= date.today().year

  @property
  def age(self):
    return date.today().year - self.birth_year

p = DateProfile.from_age("Bob", 25)
print(p.age)
print(DateProfile.is_valid_year(2000))

@classmethod — receives cls. Use for alternative constructors.

@staticmethod — receives nothing special. Use for namespaced utilities.

@property — turns a method into attribute access (covered in lesson 21).

These are all built-in decorators. The pattern is the same — they take a function and return a transformed object.

Class-based decorators

A decorator can also be a class with __call__:

class CountCalls:
  def __init__(self, func):
    self.func = func
    self.count = 0

  def __call__(self, *args, **kwargs):
    self.count += 1
    return self.func(*args, **kwargs)

@CountCalls
def greet(name):
  return f"Hello, {name}!"

greet("Alice")
greet("Bob")
print(greet.count)   # 2

@CountCalls replaces greet with a CountCalls instance. The instance is callable (__call__), so greet("Alice") works as before.

Useful when the decorator needs state.

Real-world decorators

You'll see decorators everywhere:

# Flask
@app.route("/")
def home():
  return "Hi"

# pytest
@pytest.fixture
def client():
  ...

@pytest.mark.skip
def test_x():
  ...

# Click CLI
@click.command()
@click.argument("name")
def hello(name):
  click.echo(f"Hello, {name}!")

# Standard library
@functools.cache
@functools.lru_cache(maxsize=128)
@dataclass
@total_ordering

Frameworks use decorators to register handlers without you editing a registry. The decoration is the registration.

Common stumbles

Forgetting @wraps. func.__name__ becomes "wrapper". Always use @wraps(func).

Forgetting *args, **kwargs. Hardcoded parameter list breaks when applied to functions with different signatures.

Forgetting return. wrapper doesn't return the result → all your decorated functions return None.

Decorators with arguments without the extra layer. def deco(arg, func): ... won't work — needs three layers (factory → decorator → wrapper).

Side effects at decoration time. Code outside wrapper runs once when the function is decorated, not per call. If you need per-call work, put it in wrapper.

Stacking order confusion. @a @b @cc is applied first (closest to def), then b, then a. Reads bottom-up.

What's next

Lesson 24: iterators and generators. __iter__, __next__, yield, generator functions, generator expressions.

Recap

A decorator wraps a function to add behavior — timing, logging, caching, validation. Pattern: outer takes func, inner takes *args, **kwargs, returns the original's result (possibly modified). Always @wraps(func) to preserve metadata. For arguments, add another layer: factory → decorator → wrapper. Use functools.lru_cache for memoization. Stack decorators bottom-up.

Next lesson: iterators and generators.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.