Fix: Python Decorator Not Working — Function Signature Lost or Decorator Not Applied
Part of: Python Errors
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 across different Python runtimes and environments.
The first issue is metadata loss. Without functools.wraps, the wrapper function replaces the original’s __name__, __doc__, __module__, and __wrapped__ attributes. Tools like help(), Sphinx, and any framework that introspects function signatures (FastAPI, Click, Flask) break because they see the wrapper’s generic (*args, **kwargs) signature instead of the original’s typed parameters. This is the single most common decorator bug.
The second cluster of issues involves syntax confusion between decorator factories and plain decorators. Writing @retry(times=3) calls retry(times=3) first, which must return a decorator function. If retry is written as a plain decorator that accepts a function as its first argument, passing times=3 causes a TypeError. The related mistake is parentheses confusion: @decorator() (with parentheses) calls the decorator immediately and must return a function, while @decorator (without) passes the decorated function directly. Async function handling also trips developers up: a synchronous def wrapper() around an async def function returns a coroutine object without awaiting it, breaking the call chain.
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) -> strIDE behavior with functools.wraps: Both PyCharm and VS Code (Pylance) use __wrapped__ to resolve the original function’s type hints. Without functools.wraps, autocomplete shows (*args, **kwargs) instead of the actual parameter names and types. PyCharm additionally checks __annotations__ on the wrapper, so functools.wraps directly improves the IDE experience.
Fix 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}"PyPy difference: On PyPy, inspect.iscoroutinefunction() works the same as CPython, but the overhead of function wrapping is significantly lower due to JIT compilation. If you are writing performance-sensitive decorators, the wrapper-function overhead that matters on CPython (roughly 50-100ns per call) is often optimized away on PyPy. Do not add caching or shortcutting logic to avoid decorator overhead on PyPy — the JIT handles it.
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 Decorators with Dataclasses and Descriptors
Decorators interact differently with dataclass fields, class methods, and descriptors.
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 += amountDataclass interaction:
The @dataclass decorator transforms the class by generating __init__, __repr__, and other methods based on annotated fields. Applying custom decorators to a class alongside @dataclass can conflict:
from dataclasses import dataclass
# WRONG — decorator applied before dataclass processing
def add_logging(cls):
original_init = cls.__init__
@functools.wraps(original_init)
def new_init(self, *args, **kwargs):
print(f"Creating {cls.__name__}")
original_init(self, *args, **kwargs)
cls.__init__ = new_init
return cls
@add_logging
@dataclass
class User:
name: str
age: int
# This works — @dataclass runs first (bottom-up), generating __init__
# then @add_logging wraps the generated __init__
@dataclass
@add_logging
class User:
name: str
age: int
# This FAILS — @add_logging wraps the original class (no __init__ yet)
# then @dataclass generates a new __init__, replacing the logged versionUsing 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 42@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:
class Service:
# CORRECT — @classmethod on top
@classmethod
@log_calls
def create(cls, name: str):
return cls(name)
# WRONG — @log_calls wraps the classmethod descriptor, not the function
@log_calls
@classmethod
def create(cls, name: str):
return cls(name)
# TypeError: 'classmethod' object is not callable (Python < 3.10)Fix 7: Type-Safe Decorators with ParamSpec
Type checkers like mypy and Pyright struggle with decorators that use *args, **kwargs. Use ParamSpec (Python 3.10+, or typing_extensions) for full type preservation:
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 signatureDecorator factory with ParamSpec:
from typing import TypeVar, Callable, ParamSpec
P = ParamSpec('P')
R = TypeVar('R')
def retry(times: int = 3) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise
raise RuntimeError("unreachable")
return wrapper
return decorator
@retry(times=3)
def fetch(url: str) -> dict:
...
# mypy and Pyright both infer: fetch(url: str) -> dictVS Code (Pylance) and PyCharm differences: Pylance uses Pyright under the hood and handles ParamSpec well. PyCharm’s type inference for ParamSpec improved in version 2023.2+, but earlier versions may show false positives for wrapped functions. If your team uses mixed IDEs, add # type: ignore comments only as a last resort and prefer upgrading the IDE instead.
Fix 8: 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.
Multiple 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.
CPython vs PyPy __wrapped__ attribute — both CPython and PyPy set __wrapped__ when using functools.wraps, but some third-party decorator libraries (like decorator or wrapt) use their own attribute schemes. If inspect.unwrap() doesn’t find the original function, check whether the library stores it differently.
Decorator breaks pickle — pickle serializes functions by qualified name. A decorator that changes __qualname__ (e.g., the wrapper’s qualname includes wrapper.<locals>) makes the function unpickle-able. This commonly affects multiprocessing, Celery tasks, and Dask delayed functions. Ensure functools.wraps is applied, and if using class-based decorators, verify __qualname__ matches the original.
@property combined with custom decorators — @property returns a descriptor, not a function. Stacking a custom decorator under @property works, but stacking above it wraps the descriptor object, not the getter function:
class Config:
# CORRECT
@property
@log_calls
def value(self):
return self._value
# WRONG — log_calls receives a property descriptor, not a function
@log_calls
@property
def value(self):
return self._valueFor related Python issues, see Fix: Python asyncio Blocking the Event Loop, Fix: Python mypy Type Error, Fix: Python Circular Import, and Fix: Python Dataclass Mutable Default.
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.