Skip to content

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

FixDevs ·

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:

  • Missing functools.wraps — without it, the wrapper function replaces the original’s __name__, __doc__, and __wrapped__ attributes. Tools like help(), sphinx, and introspection break.
  • Decorator factory syntax@retry(times=3) calls retry(times=3) first, which must return a decorator. If retry is written as a plain decorator (accepting a function), passing arguments causes a TypeError.
  • Decorator application order — decorators apply bottom-up. @A @B def f(): ... means f = A(B(f)). The innermost decorator (closest to the function) wraps first.
  • Async function handling — a synchronous wrapper (def wrapper(*args, **kwargs)) can’t await anything. 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 of TypeError.

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

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}"

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

Fix 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: 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.

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

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.

For related Python issues, see Fix: Python asyncio Blocking the Event Loop and Fix: Python mypy Type Error.

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