Skip to content

Fix: Python Decorator Not Working — Function Signature Lost or Decorator Not Applied

FixDevs · (Updated: )

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's

Or a decorator with arguments doesn’t work:

@retry(times=3)   # TypeError: retry() takes 1 positional argument but 2 were given
def fetch_data():
    pass

Or decorators stack in the wrong order:

@cache
@validate
def process(data):
    pass
# Validation runs AFTER cache lookup — caches invalid data

Or 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 one

Why 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 accessible

functools.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) -> str

IDE 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():
    pass

Make 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(): pass

Fix 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 that

Practical 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 state

Fix 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 += amount

Dataclass 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 version

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 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 signature

Decorator 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) -> dict

VS 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: 3

Verify 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 picklepickle 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._value

For 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles