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