AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

Decorators: Enhancing Functions Elegantly

📚 Python Mastery⏱️ 22 min read🎓 Grade 8
✍️ AI Computer Institute Editorial Team Updated: August 2026 CBSE-aligned · Peer-reviewed · 22 min read
Content curated by subject matter experts with IIT/NIT backgrounds. All chapters are fact-checked against official CBSE/NCERT syllabi.

Suppose you are building a small app for your school's attendance portal. You have five different functions — mark_present(), mark_absent(), send_reminder(), update_register(), close_day() — and your teacher asks you to log every single one of them: print a message when the function starts, and another when it finishes, so bugs are easier to trace. The obvious approach is to open all five functions and paste in two print() statements each. It works, but the moment your teacher asks for a sixth function, or wants the log format changed from "Starting X" to "[LOG] X begins", you are back inside every function, editing the same boilerplate five or six times over. This is exactly the kind of repeated, mechanical, error-prone work that good programming languages give you a way to eliminate — and in Python, the tool built for precisely this job is the decorator.

A decorator lets you write the "add logging before and after" logic exactly once, in one place, and then attach it to as many functions as you like with a single line starting with @. Nothing about the original function's own code changes. This chapter builds decorators up from first principles — starting with a fact about Python you may not have noticed yet — so that by the end, the @ syntax feels less like magic and more like a natural consequence of how functions actually work in Python.

Functions Are Values, Too

In Python, a function is not a special, separate kind of thing that lives apart from your data. It is an object, just like the integer 7 or the string "hello" is an object. That means a function can be assigned to a variable, stored in a list, passed as an argument to another function, and returned as the result of a function — the exact same things you already do with numbers and strings.

def greet():
    print("Namaste!")

say_hello = greet   # no parentheses — we are NOT calling greet
say_hello()         # this calls it — prints: Namaste!

Look closely at the difference between greet and greet(). Writing greet (no parentheses) refers to the function object itself — think of it as the function's "name tag." Writing greet() actually runs the function's code. This distinction is the single most important idea behind decorators, so it is worth pausing on: say_hello = greet copies the name tag into a new variable; it does not run anything. Only say_hello(), with the parentheses, triggers execution.

This is not unique to Python — even a language like C, which is far more low-level, lets you pass functions around using function pointers (the comparison function you hand to the standard library's qsort is a classic example). So the idea of "treating a function like a value" is not a Python invention. What Python does differently is make it effortless: no explicit pointer types, no special syntax to fetch a function's address — you just use the function's name, the same way you would use any variable. Because Python functions are ordinary values, they can also be passed into other functions as arguments:

def loud(func):
    func()
    print("(that was loud)")

def greet():
    print("Namaste!")

loud(greet)

Trace this line by line: loud(greet) passes the function object greet into loud, where it is received as the parameter func. Inside loud, the line func() calls whatever function was passed in — here, that means it runs greet's code, printing Namaste!. Then loud prints (that was loud). Output:

Namaste!
(that was loud)

A function like loud, which accepts another function as an argument (or returns one), is called a higher-order function. Decorators are simply higher-order functions used in a particular pattern: take a function in, build a new, improved version of it, and give that new version back.

Building a Wrapper by Hand

Let's return to the attendance-log problem. Instead of a function that just calls func() and stops, we want a function that calls func() and also prints messages before and after. The trick is that our helper function must return a brand-new function — not just call the old one.

def make_noisy(func):
    def wrapper():
        print("Starting:", func.__name__)
        func()
        print("Finished:", func.__name__)
    return wrapper

def print_hello():
    print("Hello, Grade 8!")

noisy_hello = make_noisy(print_hello)
noisy_hello()

Trace this carefully, because every decorator you will ever write follows this exact shape. make_noisy(print_hello) is called. Inside, Python defines a brand-new function called wrapper — at this point wrapper's code has been defined but not yet run. make_noisy then returns this wrapper function object, which gets stored in the variable noisy_hello. Notice that wrapper has access to func even after make_noisy has finished running — this is possible because wrapper was defined inside make_noisy and "remembers" the variables of the scope it was born in. (This memory is called a closure, and it is what lets each wrapper stay attached to the specific function it was built for.)

Now noisy_hello() runs. This calls wrapper(), which prints Starting: print_hello, then calls func() — which is print_hello, printing Hello, Grade 8! — and finally prints Finished: print_hello. Full output:

Starting: print_hello
Hello, Grade 8!
Finished: print_hello

We now have exactly the behaviour we wanted — logging wrapped around a function — without touching a single line inside print_hello itself. The only clumsy part is the two extra lines needed to build noisy_hello and rename it. Python has a shortcut for exactly this pattern.

The @ Symbol: Syntactic Sugar, Not Magic

Instead of writing the function first and separately reassigning it, you can write this:

def make_noisy(func):
    def wrapper():
        print("Starting:", func.__name__)
        func()
        print("Finished:", func.__name__)
    return wrapper

@make_noisy
def print_hello():
    print("Hello, Grade 8!")

print_hello()

This produces the identical output as before. The line @make_noisy placed directly above def print_hello(): tells Python: "as soon as print_hello is defined, immediately pass it through make_noisy, and let the name print_hello refer to whatever make_noisy returns instead." In other words, @make_noisy above a function definition is exactly equivalent to writing this below it:

print_hello = make_noisy(print_hello)

That is the whole trick. There is no new mechanism to learn — @decorator_name is Python's shorthand for "call this higher-order function on the thing I just defined, and rebind the name." A function used this way — one that accepts a function and returns a replacement function — is what we call a decorator.

How Decoration Actually Happens: A Two-Stage Process

It helps to separate decoration into two distinct moments in time, because mixing them up is the single most common source of confusion for learners. Decoration time is when Python reads the @make_noisy line and builds the wrapper — this happens once, immediately, the moment the def statement is executed. Call time is every later moment when someone actually calls print_hello() — this can happen zero, one, or a thousand times, and it runs the wrapper's code fresh each time.

How @make_noisy Builds and Runs a Wrapper STAGE 1 — Decoration time (runs ONCE, when Python reads the def) original function print_hello passed as func make_noisy(func) builds wrapper() closure remembers func returns wrapper print_hello = wrapper name reassigned STAGE 2 — Call time (runs EVERY time you call print_hello()) print_hello() really calls wrapper(): 1. print("Starting:", func.__name__) 2. func() -> runs original print_hello code 3. print("Finished:", func.__name__) runs fresh from the top every single call

Notice the crucial detail in Stage 1: make_noisy(func) runs completely, and returns wrapper, before anyone has called print_hello() even once. If your decorator function contains a print() statement outside the inner wrapper, that statement fires immediately at decoration time — not when the decorated function is later called. Let's verify this with code, since it is easy to get backwards:

def announce(func):
    print("Decorating", func.__name__)   # outside wrapper: runs at decoration time
    def wrapper(*args, **kwargs):
        print("Calling", func.__name__)  # inside wrapper: runs at call time
        return func(*args, **kwargs)
    return wrapper

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

print("Before any call")
print(add(2, 3))
print(add(10, 20))

Trace it: as soon as Python executes the @announce / def add block, it calls announce(add) right away — this prints Decorating add immediately, before the program even reaches the line print("Before any call"). Only afterwards does print("Before any call") run. Then add(2, 3) actually calls wrapper(2, 3), which prints Calling add, computes func(2, 3) which is 2 + 3 = 5, and returns it, so print(add(2, 3)) prints 5. The same happens for add(10, 20), giving 30. Full output, in order:

Decorating add
Before any call
Calling add
5
Calling add
30

Also notice that wrapper now takes *args, **kwargs instead of no arguments at all. This is important: our first wrapper() only worked for functions that take zero parameters. Real functions like add(a, b) take arguments, so a genuinely reusable decorator must accept any combination of positional arguments (*args) and keyword arguments (**kwargs), and forward them straight through to func. This is the standard, professional shape of a decorator's wrapper, and you should use it by default even when today's function happens to take no arguments — tomorrow's might.

A Genuine Misconception, Corrected

A very common mistake at this stage is believing that writing func inside a decorator — without parentheses — somehow calls the function early, "wasting" the chance to add logic before it runs. This is not true, and it goes back to the distinction we opened with. Inside wrapper, the line func(*args, **kwargs) is the only place the original function actually executes, and it executes exactly when wrapper reaches that line — which is precisely when you want it, sandwiched between your "before" and "after" code. Every other appearance of func in the decorator (as a parameter name, inside func.__name__) is just a reference to the function object, not a call. If you ever see unexpected output appearing too early when you write a decorator, check whether you accidentally wrote func() somewhere you meant just func.

Two Practical Decorators

Let's use decorators for something a real program would need. First, timing how long a function takes — useful once your programs start doing real work like sorting large lists or searching big datasets:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.6f} seconds")
        return result
    return wrapper

@timer
def sum_of_squares(n):
    total = 0
    for i in range(1, n + 1):
        total += i * i
    return total

print(sum_of_squares(5))

Trace the arithmetic first, since that is the part you should be able to verify by hand: sum_of_squares(5) adds 1*1 + 2*2 + 3*3 + 4*4 + 5*5 = 1 + 4 + 9 + 16 + 25 = 55. Around that computation, wrapper records the time before calling func(5) and the time after, prints the difference, and then returns 55, which the outer print() displays. So the output is a timing line (a very small number of seconds, since adding five numbers is nearly instant) followed by 55. The real value of timer shows up on expensive computations — like sum_of_squares(10_000_000) — where you actually want to measure and compare performance, which is exactly the kind of algorithmic-efficiency thinking you will formalize later when you study time complexity.

Second, a decorator that makes a slow, repeated computation faster by remembering answers it has already worked out — a technique called memoization:

def memoize(func):
    cache = {}
    def wrapper(n):
        if n in cache:
            return cache[n]
        result = func(n)
        cache[n] = result
        return result
    return wrapper

@memoize
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(10))

Recall the Fibonacci sequence: each number is the sum of the two before it, starting 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 for n = 0 through 10. So fibonacci(10) correctly evaluates to 55. Without @memoize, computing fibonacci(10) recursively recalculates the same smaller values — like fibonacci(5) — over and over, dozens of times, because fibonacci(n-1) and fibonacci(n-2) both eventually need it independently. Because the @ line rebinds the name fibonacci to wrapper before any calls happen, even the recursive calls inside fibonacci's own body — which refer to the function by its name, fibonacci — actually go through wrapper and its cache. The first time a value like fibonacci(4) is needed, it gets computed and stored; every later request for fibonacci(4) is answered instantly from cache instead of being recalculated. This is a genuinely important algorithmic idea in computer science — trading memory for speed — and here it is added to fibonacci without a single line inside fibonacci's own body changing.

Preserving the Function's True Identity

There is a small but real cost to wrapping functions this way: Python tools that inspect a function's name or docstring get fooled. Consider:

def timer(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result
    return wrapper

@timer
def sum_of_squares(n):
    """Return the sum of squares from 1 to n."""
    total = 0
    for i in range(1, n + 1):
        total += i * i
    return total

print(sum_of_squares.__name__)

You might expect sum_of_squares, but this prints wrapper — because after decoration, the name sum_of_squares genuinely refers to the wrapper function object, and wrapper's own __name__ is, unsurprisingly, "wrapper". This matters in real programs: error messages, debuggers, and documentation tools all read __name__ and __doc__ to describe a function, and every one of your decorated functions would misleadingly claim to be called wrapper. Python's standard library fixes this with functools.wraps, a small decorator you apply to your own wrapper function that copies the original function's name, docstring, and other metadata onto it:

import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result
    return wrapper

@timer
def sum_of_squares(n):
    """Return the sum of squares from 1 to n."""
    total = 0
    for i in range(1, n + 1):
        total += i * i
    return total

print(sum_of_squares.__name__)

This now correctly prints sum_of_squares. The rule of thumb: any time you write a decorator whose inner function is called wrapper, put @functools.wraps(func) immediately above its def line. It costs one line and saves you from confusing bugs later.

A Complete, Corrected Worked Example

Let's put validation and reporting together in one Indian-classroom example. The CBSE's actual internal grading scale marks 91–100 as A1, 81–90 as A2, and 71–80 as B1 (the real scale continues further, through B2, C1, C2, D, and E — we will code just the top three bands here to keep the example short, and label anything below B1 accordingly rather than inventing numbers for it). We will write a decorator that checks the marks are a sane value between 0 and 100 before the reporting function ever runs:

import functools

def validate_marks(func):
    @functools.wraps(func)
    def wrapper(name, marks):
        if not (0 <= marks <= 100):
            print(f"Invalid marks for {name}: {marks}. Must be between 0 and 100.")
            return None
        return func(name, marks)
    return wrapper

@validate_marks
def grade_report(name, marks):
    if marks >= 91:
        grade = "A1"
    elif marks >= 81:
        grade = "A2"
    elif marks >= 71:
        grade = "B1"
    else:
        grade = "Below B1"
    print(f"{name}: {marks} marks -> Grade {grade}")

grade_report("Ananya", 93)
grade_report("Rohan", 76)
grade_report("Priya", 105)

Trace all three calls. grade_report("Ananya", 93) really calls wrapper("Ananya", 93); since 0 <= 93 <= 100 holds, it calls the real grade_report, where 93 >= 91 is true, so grade = "A1", printing Ananya: 93 marks -> Grade A1. Next, grade_report("Rohan", 76): the range check passes; 76 >= 91 is false, 76 >= 81 is false, but 76 >= 71 is true, so grade = "B1", printing Rohan: 76 marks -> Grade B1. Finally, grade_report("Priya", 105): 0 <= 105 <= 100 is false, since 105 exceeds 100, so wrapper immediately prints the invalid-marks message and returns None — the real grade_report function never runs at all, and no fabricated grade appears for an impossible mark. Full output:

Ananya: 93 marks -> Grade A1
Rohan: 76 marks -> Grade B1
Invalid marks for Priya: 105. Must be between 0 and 100.

This is the real value decorators bring to a project like a school result-management system: the validation logic lives in exactly one place, validate_marks, and can be attached to grade_report or to any other function that takes a name and a marks value — send_marksheet, update_report_card, flag_for_reevaluation — with a single @validate_marks line each, instead of a copy-pasted if check inside every one of them.

Summary

  • Python functions are objects: they can be assigned to variables, passed as arguments, and returned from other functions, without being called (no parentheses means no execution).
  • A decorator is a function that accepts a function and returns a new, replacement function — usually a wrapper that adds behaviour before and/or after calling the original.
  • @decorator_name above a def is shorthand for name = decorator_name(name) — it is ordinary Python, not a separate language feature.
  • Decoration happens once, immediately, when the def is executed. The wrapper's code runs fresh every time the decorated name is later called — keep these two moments separate in your head.
  • A general-purpose wrapper accepts *args, **kwargs so it works with functions of any signature.
  • Because decoration rebinds the function's name before any calls happen, even a function's own recursive calls to itself pass through its wrapper — which is exactly what makes a memoizing decorator like @memoize work.
  • @functools.wraps(func) on your inner wrapper preserves the original function's __name__ and docstring, which would otherwise be silently replaced.

Check Your Understanding

  1. Without running it, predict the exact output of this code, in order:
    def shout(func):
        print("Wiring up", func.__name__)
        def wrapper(*args, **kwargs):
            print("About to run", func.__name__)
            return func(*args, **kwargs)
        return wrapper
    
    @shout
    def square(x):
        return x * x
    
    print("Setup done")
    print(square(6))
    
  2. A classmate writes a decorator whose wrapper is defined as def wrapper(): with no parameters, then applies it to a function def multiply(a, b): return a * b. When they call multiply(3, 4), Python raises a TypeError. Explain exactly why, in terms of what @decorator actually rebinds multiply to.
  3. Write a decorator called count_calls that keeps track (using a variable stored via a closure, similar to cache in memoize) of how many times the function it decorates has been called, and prints that count every time. Apply it to a function def roll_die(): return 4 (a fixed value is fine for testing) and call it three times — write out the expected printed output.
  4. In the grade_report example, what would wrapper("Kabir", -5) print, and would the real grade_report function ever execute? Justify your answer using the condition 0 <= marks <= 100.

Think About It

Think about this: How would you explain decorators: enhancing functions elegantly to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.

Practice Exercises

Now it is time to practice! Complete these challenges to solidify your understanding:

  • Exercise 1: Write a short program that demonstrates the core concept from this chapter. Test it with at least 3 different inputs.
  • Exercise 2: Find a real-world example where decorators: enhancing functions elegantly is used in an Indian company (like TCS, Infosys, Flipkart, or ISRO). Write a paragraph explaining the connection.
  • Exercise 3: Create a mind-map connecting decorators: enhancing functions elegantly to at least 3 other topics you have studied.
← Generators and Iterators: Lazy EvaluationCareer Paths in Computer Science: India and Beyond →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn