Fix: Python Decorator Not Working — Function Signature Lost or Decorator Not Applied
Quick Answer
How to fix Python decorator issues — functools.wraps, decorator factories with arguments, class decorators, stacking order, async function decorators, and common pitfalls.
The Problem
A Python decorator doesn’t preserve the wrapped function’s metadata:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name: str) -> str:
"""Return a greeting."""
return f"Hello, {name}"
print(greet.__name__) # 'wrapper' — expected 'greet'
print(greet.__doc__) # None — docstring lost
help(greet) # Shows wrapper's signature, not greet'sOr a decorator with arguments doesn’t work:
@retry(times=3) # TypeError: retry() takes 1 positional argument but 2 were given
def fetch_data():
passOr decorators stack in the wrong order:
@cache
@validate
def process(data):
pass
# Validation runs AFTER cache lookup — caches invalid dataOr an async function wrapped by a sync decorator loses its async nature:
@log_calls
async def fetch_user(user_id: int):
return await db.get(user_id)
result = fetch_user(42)
# TypeError: object coroutine can't be used in 'await' expression
# The decorator returned a sync wrapper instead of an async oneWhy This Happens
Python decorators are functions that take a function and return a function. Several pitfalls exist:
- Missing
functools.wraps— without it, the wrapper function replaces the original’s__name__,__doc__, and__wrapped__attributes. Tools likehelp(),sphinx, and introspection break. - Decorator factory syntax —
@retry(times=3)callsretry(times=3)first, which must return a decorator. Ifretryis written as a plain decorator (accepting a function), passing arguments causes aTypeError. - Decorator application order — decorators apply bottom-up.
@A @B def f(): ...meansf = A(B(f)). The innermost decorator (closest to the function) wraps first. - Async function handling — a synchronous wrapper (
def wrapper(*args, **kwargs)) can’tawaitanything. Wrapping an async function with a sync wrapper makes the result a regular function that returns a coroutine — but calling it won’t await automatically. - Decorator called vs applied —
@decorator()(with parentheses) calls the function immediately;@decorator(without) passes the function to the decorator. Mixing these is a common source ofTypeError.
Fix 1: Always Use functools.wraps
@functools.wraps(func) copies the wrapped function’s attributes to the wrapper:
import functools
# WRONG — wrapper replaces the original's metadata
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
# CORRECT — wraps preserves __name__, __doc__, __annotations__, __module__
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name: str) -> str:
"""Return a greeting."""
return f"Hello, {name}"
print(greet.__name__) # 'greet' — preserved
print(greet.__doc__) # 'Return a greeting.' — preserved
print(greet.__wrapped__) # <function greet> — original function accessiblefunctools.wraps also sets __wrapped__, which allows inspect.unwrap() to access the original function — important for frameworks like FastAPI that inspect function signatures:
import inspect
@log_calls
def greet(name: str) -> str:
return f"Hello, {name}"
# Unwrap to get the original function's signature
original = inspect.unwrap(greet)
print(inspect.signature(original)) # (name: str) -> strFix 2: Write Decorators with Arguments Correctly
A decorator with arguments (@retry(times=3)) requires an extra function layer:
import functools
import time
# WRONG — @retry(times=3) calls retry(times=3), passing 'times' as 'func'
def retry(func, times=3):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise
time.sleep(1)
return wrapper
# CORRECT — decorator factory: retry(times=3) returns the actual decorator
def retry(times=3, delay=1.0, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except exceptions as e:
last_error = e
if attempt < times - 1:
time.sleep(delay)
raise last_error
return wrapper
return decorator
# Usage
@retry(times=3, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url: str) -> dict:
return requests.get(url).json()
# Works as a plain decorator too (with defaults)
@retry()
def connect():
passMake a decorator work both with and without arguments:
def log_calls(_func=None, *, level='INFO'):
"""Works as @log_calls and @log_calls(level='DEBUG')"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"[{level}] Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
if _func is not None:
# Called as @log_calls (no arguments)
return decorator(_func)
# Called as @log_calls(level='DEBUG')
return decorator
@log_calls # Works without arguments
def greet(): pass
@log_calls(level='DEBUG') # Works with arguments
def compute(): passFix 3: Fix Async Function Decorators
Async functions must be wrapped by async wrappers:
import functools
import asyncio
# WRONG — sync wrapper around async function
def measure_time(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs) # Returns a coroutine, doesn't await it
elapsed = time.time() - start # Measured near-zero time
print(f"{func.__name__} took {elapsed:.3f}s")
return result # Returns the unawaited coroutine
return wrapper
# CORRECT — async wrapper for async functions
def measure_time(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.time()
result = await func(*args, **kwargs) # Properly awaits
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result
return wrapper
@measure_time
async def fetch_user(user_id: int) -> dict:
await asyncio.sleep(0.1) # Simulated DB call
return {"id": user_id, "name": "Alice"}
# Usage
result = asyncio.run(fetch_user(42))Handle both sync and async functions in one decorator:
import inspect
import functools
def log_calls(func):
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
print(f"Calling async {func.__name__}")
result = await func(*args, **kwargs)
print(f"Done: {func.__name__}")
return result
return async_wrapper
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Done: {func.__name__}")
return result
return sync_wrapper
@log_calls
def sync_greet(name): return f"Hello, {name}"
@log_calls
async def async_greet(name): return f"Hello, {name}"Fix 4: Understand Decorator Stacking Order
Decorators apply bottom-up (the one closest to def wraps first):
def decorator_a(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("A: before")
result = func(*args, **kwargs)
print("A: after")
return result
return wrapper
def decorator_b(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print("B: before")
result = func(*args, **kwargs)
print("B: after")
return result
return wrapper
@decorator_a
@decorator_b
def greet():
print("Hello")
greet()
# Output:
# A: before ← A runs first (outermost)
# B: before ← B runs second
# Hello
# B: after
# A: after
# Equivalent to:
# greet = decorator_a(decorator_b(greet))
# decorator_b wraps greet first, then decorator_a wraps thatPractical stacking — order matters for caching + validation:
# CORRECT — validate first, then cache (don't cache invalid results)
@cache
@validate_input
def process(data: dict) -> dict:
return expensive_operation(data)
# Execution: validate_input runs first, then cache stores the valid result
# WRONG for security — cache runs first, may return cached result without re-validating
@validate_input
@cache
def process(data: dict) -> dict:
return expensive_operation(data)Fix 5: Write Class-Based Decorators
For stateful decorators (tracking call count, rate limiting), use classes:
import functools
class retry:
"""Retry decorator with call tracking."""
def __init__(self, times: int = 3, delay: float = 1.0):
self.times = times
self.delay = delay
self.call_count = 0
self.failure_count = 0
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
self.call_count += 1
for attempt in range(self.times):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == self.times - 1:
self.failure_count += 1
raise
time.sleep(self.delay)
return wrapper
# Usage as a decorator factory
@retry(times=3, delay=0.5)
def connect():
pass
# Access state
print(connect.call_count) # AttributeError — call_count is on the decorator instance
# Fix — store state accessible from the wrapper:
class rate_limit:
def __init__(self, calls_per_second: float):
self.min_interval = 1.0 / calls_per_second
self.last_call = 0.0
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
elapsed = now - self.last_call
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self.last_call = time.time()
return func(*args, **kwargs)
wrapper._rate_limiter = self # Attach instance to wrapper for access
return wrapper
@rate_limit(calls_per_second=2)
def api_call():
pass
api_call._rate_limiter.last_call # Access limiter stateFix 6: Fix Class and Method Decorators
Decorators on class methods need to handle self correctly:
# Decorator on an instance method
def validate_positive(func):
@functools.wraps(func)
def wrapper(self, value, *args, **kwargs):
if value <= 0:
raise ValueError(f"value must be positive, got {value}")
return func(self, value, *args, **kwargs)
return wrapper
class Account:
def __init__(self, balance: float):
self.balance = balance
@validate_positive
def deposit(self, amount: float) -> None:
self.balance += amount
# Using descriptors for class-method aware decorators
class class_property:
"""A read-only class property."""
def __init__(self, func):
self.func = func
functools.update_wrapper(self, func)
def __get__(self, obj, owner):
return self.func(owner)
class Config:
_instance = None
@class_property
def instance_count(cls) -> int:
return 42Fix 7: Debug Decorator Issues
When a decorator doesn’t behave as expected:
import inspect
def debug_decorator(func):
"""Inspect what the decorator receives."""
print(f"Decorating: {func}")
print(f" Name: {func.__name__}")
print(f" Module: {func.__module__}")
print(f" Signature: {inspect.signature(func)}")
print(f" Is coroutine: {inspect.iscoroutinefunction(func)}")
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Called with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"Returned: {result!r}")
return result
return wrapper
@debug_decorator
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
# Output when module loads:
# Decorating: <function add at 0x...>
# Name: add
# Signature: (a: int, b: int) -> int
# Is coroutine: False
add(1, 2)
# Called with args=(1, 2), kwargs={}
# Returned: 3Verify functools.wraps worked:
# Check preserved attributes
print(greet.__name__) # Should be 'greet'
print(greet.__doc__) # Should be the original docstring
print(greet.__wrapped__) # Should be the original function
# Unwrap all decorators to get the original function
original = inspect.unwrap(greet)
print(inspect.signature(original))Still Not Working?
Decorator applied at class definition time — class-level decorators run when the class is defined, not when instances are created. If a decorator expects instance state, it won’t have access to it.
@staticmethod and @classmethod interaction — always put @staticmethod or @classmethod as the outermost (topmost) decorator. Custom decorators below them receive the raw function, not the static/class method descriptor.
Decorators and type checkers — mypy and pyright may not correctly infer types through decorators. Use ParamSpec and TypeVar for typed decorators:
from typing import TypeVar, Callable
from typing import ParamSpec
P = ParamSpec('P')
R = TypeVar('R')
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
# mypy now correctly infers the wrapped function's signatureMultiple functools.wraps in nested decorators — if you have multiple wrapper levels, apply @functools.wraps(func) to the innermost wrapper that gets returned. Applying it to intermediate wrappers in a decorator factory is unnecessary.
For related Python issues, see Fix: Python asyncio Blocking the Event Loop and Fix: Python mypy Type Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Python contextmanager Not Working — GeneratorExit, Missing yield, or Cleanup Not Running
How to fix Python context manager issues — @contextmanager generator, __enter__ and __exit__, exception handling inside with blocks, async context managers, and common pitfalls.
Fix: Python Protocol Not Working — Type Checker Rejects Compatible Class, runtime_checkable Fails, or Protocol Not Recognized
How to fix Python Protocol class issues — structural subtyping vs nominal typing, runtime_checkable, Protocol inheritance, TypeVar constraints, and common mypy/pyright errors with Protocol.
Fix: Python pathlib Not Working — Path Object Errors, Joins, and Common Pitfalls
How to fix Python pathlib issues — TypeError with string concatenation, path joining, glob patterns, reading files, cross-platform paths, and migrating from os.path.
Fix: Python asyncio.gather Not Handling Errors — Exceptions Swallowed or All Tasks Cancelled
How to fix asyncio.gather error handling — return_exceptions parameter, partial failures, task cancellation propagation, TaskGroup alternatives, and exception isolation patterns.