Placement Prep

Python Decorators: @syntax, functools.wraps, and Practical Examples

Learn Python decorators step by step: @decorator syntax, functools.wraps, parametrized decorators, class-based decorators, and placement interview examples.

By FACE Prep Team 5 min read
python decorators functools oop placement-prep programming higher-order-functions

A Python decorator wraps a callable to extend its behaviour without modifying its source code, applied with a single @name line above the function definition.

That single line is syntactic sugar. Under the hood, Python executes func = decorator(func) at the moment the decorated function is defined, not when it is called. Understanding that substitution makes every decorator pattern tractable, and the pattern comes up often enough in placement technical rounds to be worth knowing precisely.

How a Decorator Works

The clearest way to understand a decorator is to write one without the @ shorthand.

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        return result
    return wrapper

def add(a, b):
    return a + b

add = log_call(add)  # manual decoration
print(add(2, 3))

Output:

Calling add
5

log_call is a decorator. It accepts a function, defines a new function (wrapper) that adds logging behaviour, and returns wrapper. The assignment add = log_call(add) replaces the original add with the wrapper. From that point on, every call to add routes through wrapper first.

Three things make this pattern reusable across any function:

  • *args and **kwargs in the wrapper signature accept any combination of positional and keyword arguments.
  • The wrapper delegates to func(...) for the original logic.
  • The wrapper returns whatever the original function returns, so callers see no change in outcome.

For a grounding in the function patterns that get decorated in placement tests, the Python example programs collection covers the building blocks.

The @decorator Syntax and functools.wraps

The @ syntax is shorthand for the manual assignment shown above.

@log_call
def subtract(a, b):
    return a - b

This is equivalent to writing subtract = log_call(subtract) immediately after the function definition. The @ version is preferred because the decorator stays visibly attached to the function it modifies.

There is one problem with the decorator as written: the wrapper replaces the original function object, so the decorated function’s name, docstring, and other metadata become the wrapper’s own:

print(subtract.__name__)  # prints "wrapper", not "subtract"

functools.wraps fixes this. It copies __name__, __doc__, __module__, __qualname__, and __annotations__ from the original function onto the wrapper:

import functools

def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        return result
    return wrapper

@log_call
def subtract(a, b):
    return a - b

print(subtract.__name__)   # subtract

Using @functools.wraps(func) on every wrapper is standard practice. It keeps help(), logging output, and stack traces accurate. The placement-round MCQ “what does functools.wraps do?” has a precise answer: it copies the wrapped function’s metadata onto the wrapper so introspection tools see the original, not the wrapper.

Passing Arguments to Decorators

A plain decorator takes a function and returns a function. A decorator that also takes arguments needs one extra layer: a factory function that accepts the arguments and returns a decorator.

PEP 318, which introduced the @ syntax in Python 2.4, anticipated this pattern. The three levels are:

  • Outer factory: accepts the decorator’s arguments, returns a decorator.
  • Middle decorator: accepts the function, returns a wrapper.
  • Inner wrapper: calls the original function with the original arguments.
import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    print(f"Hello, {name}")

greet("Priya")

Output:

Hello, Priya
Hello, Priya
Hello, Priya

When Python evaluates @repeat(3), it first calls repeat(3), which returns decorator. That returned decorator is then applied to greet, exactly as @log_call was applied above. The nesting is the only structural difference from a plain decorator.

Class-Based Decorators

A class can act as a decorator by implementing __init__ and __call__. __init__ stores the decorator’s configuration; __call__ receives the function being decorated and returns the wrapper.

import functools

class Retry:
    def __init__(self, max_attempts):
        self.max_attempts = max_attempts

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, self.max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == self.max_attempts:
                        raise
                    print(f"Attempt {attempt} failed: {e}. Retrying...")
        return wrapper

@Retry(max_attempts=3)
def fetch_data(url):
    raise ConnectionError("Simulated timeout")

try:
    fetch_data("https://example.com/data")
except ConnectionError as e:
    print(f"All attempts failed: {e}")

Output:

Attempt 1 failed: Simulated timeout. Retrying...
Attempt 2 failed: Simulated timeout. Retrying...
All attempts failed: Simulated timeout

@Retry(max_attempts=3) calls Retry.__init__ with max_attempts=3, producing a Retry instance. That instance is then applied to fetch_data via Retry.__call__. Class-based decorators are worth using when the decorator’s internal state is complex, needs introspection, or requires multiple configuration options.

The range(1, self.max_attempts + 1) produces [1, 2, 3] for max_attempts=3. The condition if attempt == self.max_attempts: raise re-raises the exception on the final attempt rather than silently swallowing it after the last retry.

For comparison, a calculator program in Python shows the same “wrap a discrete operation” pattern at a smaller scale. The decorator extends that delegation idea into a class that persists state across calls.

Three Patterns Placement Tests Favour

These three patterns cover the majority of decorator questions in technical rounds at both service-tier and product-tier companies. CSE and IT students typically encounter them in MCQ rounds and in live coding interviews.

Logging Decorator

import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_calls
def is_armstrong(n):
    digits = [int(d) for d in str(n)]
    return n == sum(d ** len(digits) for d in digits)

is_armstrong(153)

Output:

Calling is_armstrong with args=(153,)
is_armstrong returned True

Applying a logging decorator to an Armstrong number checker shows the pattern on a function students already know. Nothing inside is_armstrong changes; the decorator handles all the instrumentation.

Timing Decorator

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} completed in {elapsed:.6f} seconds")
        return result
    return wrapper

@timer
def sort_list(data):
    return sorted(data)

time.time() returns the current wall-clock time as a float. The difference time.time() - start gives the elapsed duration. The .6f format string rounds to 6 decimal places.

Access-Check Decorator

import functools

def require_admin(func):
    @functools.wraps(func)
    def wrapper(*args, role="guest", **kwargs):
        if role != "admin":
            print(f"Access denied: role '{role}' cannot call {func.__name__}")
            return None
        return func(*args, **kwargs)
    return wrapper

@require_admin
def delete_record(record_id, role="guest"):
    print(f"Record {record_id} deleted")

delete_record(42, role="admin")   # Record 42 deleted
delete_record(42, role="guest")   # Access denied

The access-check pattern gates a function behind a condition and is reusable across any function that needs the same guard. No condition logic is duplicated.

A common MCQ variant: what is the output when stacking two decorators? The rule is that decorators apply bottom-up. The decorator closest to the function wraps first, so @dec_a above @dec_b above def f produces dec_a(dec_b(f)). The outermost decorator runs first at call time.


The retry pattern in the class-based decorator above is the same pattern used when wrapping LLM API calls: catching a rate-limit error or network timeout, backing off, and retrying. If you want to apply Python patterns like these in a real project rather than a placement prep exercise, TinkerLLM provides a working LLM environment to do that at ₹299.

Primary sources

Frequently asked questions

What does @functools.wraps do in a Python decorator?

functools.wraps copies the wrapped function's metadata (name, docstring, module, qualname, annotations) onto the wrapper function. Without it, the wrapper's own name and docstring override the original's, which breaks help(), logging, and debugging tools that rely on __name__.

Can Python decorators take arguments?

Yes. A parametrized decorator uses three layers: an outer factory function that accepts the arguments and returns a decorator, a middle decorator that accepts the function, and an inner wrapper that calls it. The @syntax then looks like @decorator(arg) instead of @decorator.

What is a class-based decorator in Python?

A class-based decorator stores configuration in __init__ and implements the wrapping logic in __call__. When Python applies the decorator, it calls __init__ with any arguments; when the decorated function is invoked, __call__ runs the wrapper logic and delegates to the original function.

How do stacked decorators work in Python?

Decorators apply bottom-up: the decorator closest to the function definition wraps first. So @dec_a above @dec_b means the result is dec_a(dec_b(func)). The outermost decorator runs first when the decorated function is called.

What is the difference between a decorator and a closure in Python?

A closure is any inner function that captures variables from an enclosing scope. A decorator is a design pattern that uses closures: the wrapper function closes over the original function and any injected logic. All function-based decorators are closures, but not all closures are decorators.

Can decorators be applied to class methods in Python?

Yes. Python ships several built-in method decorators: @staticmethod (no self or cls), @classmethod (cls as first argument), and @property (converts a method to an attribute-style accessor). Custom decorators work on methods too, but need to handle self correctly in the wrapper signature.

Build AI projects

A self-paced playground for building with LLMs.

TinkerLLM is FACE Prep's sister property. A guided environment for shipping real LLM applications, the kind of project that earns a paragraph on your resume, not a line.

Try TinkerLLM (₹299 launch)
Free AI Roadmap PDF