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

Decorators and Generators in Python

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

The IRCTC problem: the same three lines, copy-pasted everywhere

Imagine you are building the booking module for a small train-ticketing app, in the spirit of IRCTC. You write a function to book a ticket, another to cancel a ticket, and another to check seat availability. Halfway through testing, your teacher tells you: "Every one of these functions must first check whether the user is logged in, and every one of them must print how long it took to run, for performance monitoring." So you go into book_ticket() and add a login check and a timer at the top and bottom. Then you do the exact same thing, line for line, inside cancel_ticket(). Then again inside check_seats(). By the tenth function, you have copy-pasted the same five lines ten times, and when your teacher later says "actually, change the timer to also log to a file," you have to hunt down and edit all ten copies correctly, without missing one.

This exact problem — "I want many different functions to share the same extra behaviour, without rewriting that behaviour inside each one" — is what decorators solve. A decorator lets you write the login-check-and-timer logic exactly once, then attach it to any function with a single line. The second half of this chapter solves a related but different problem: what happens when you need to process a huge sequence of values — say, every PNR record in a database with ten million rows — without loading all ten million into memory at once. That is what generators are for. Both ideas are about controlling when and how code runs, rather than just what it computes, so we build both from the ground up using the same starting fact about Python: functions are not special magic — they are ordinary values, just like a number or a string.

Step 1: a function is a value you can hold in a variable

Before decorators can make sense, you need to see clearly that a function name is just a label pointing to a block of code, the same way a variable name points to a number. Trace through this carefully:

def calculate_gst(price):
    return price * 1.18

billing_function = calculate_gst
print(billing_function(100))

Read the second line slowly: there are no parentheses after calculate_gst, so we are not calling the function — we are copying the reference to it into a new name, billing_function, exactly as x = 5 copies the value 5 into x. Now both names point to the same underlying function object. Calling billing_function(100) runs the exact same code as calculate_gst(100) would. The output is 118.0 — the price of a ₹100 item after 18% GST. This single fact — a function can be assigned, passed around, and stored, just like any other value — is the entire foundation decorators are built on.

Step 2: a function can build and return another function

The next building block is that a function's job doesn't have to be "compute a number" — it can be "construct and hand back a brand-new function." Consider a ticket-numbering system for a school canteen counter:

def make_counter():
    count = 0
    def counter():
        nonlocal count
        count += 1
        return count
    return counter

next_ticket_number = make_counter()
print(next_ticket_number())
print(next_ticket_number())
print(next_ticket_number())

Trace it: calling make_counter() runs its body once, setting a local variable count = 0, then defines an inner function counter, and returns that inner function — not a number, but a function object — which gets stored in next_ticket_number. Every time you now call next_ticket_number(), you are calling that inner counter function, which increases count by one and returns it. The keyword nonlocal tells Python "don't create a new local count inside counter — reuse the count that belongs to the enclosing make_counter." Because that enclosing variable stays alive and attached to the returned function (this attached memory is called a closure), the three calls print 1, then 2, then 3 — the counter remembers its own history between calls, even though make_counter() already finished running long ago. This "a function that returns a function, and the returned function remembers something" pattern is exactly the shape a decorator has.

Building a decorator by hand, before learning the shortcut

Now combine both ideas to solve the IRCTC-style problem: write one function that takes any other function as input, and returns a new, upgraded version of it. Here is a decorator that measures how long a function takes to run:

import time

def time_it(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took some tiny fraction of a second")
        return result
    return wrapper

def calculate_gst(price):
    return price * 1.18

calculate_gst = time_it(calculate_gst)
print(calculate_gst(100))

Read time_it carefully, because every decorator you will ever write follows this exact shape. time_it receives one argument, func — the original function, calculate_gst, passed in as a value (exactly as in Step 1). Inside time_it, we define a brand-new function called wrapper. Notice wrapper does not compute GST itself — instead, it records the start time, then calls the original func in the middle, records the end time, prints a report, and finally returns whatever the original function returned. time_it then returns this wrapper function (not the original calculate_gst) back to the caller. The line calculate_gst = time_it(calculate_gst) is the key move: it takes the plain, undecorated function, feeds it into time_it, and replaces the name calculate_gst with the new, wrapped version. From this point on, whenever anyone calls calculate_gst(100), they are actually calling wrapper(100), which quietly does its timing work and then calls the real calculation inside. The output is a timing message followed by 118.0. The exact fraction of a second printed will differ every time you run this on your own computer — that number depends on your machine's speed, not on anything fixed — but the pattern of "print a message, then the real answer" will always appear in that order.

The *args, **kwargs in wrapper's parameter list deserve a note: they mean "accept any number of positional arguments and any number of named arguments, whatever they are, and pass them straight through to func." Without this, wrapper would only work for functions that take zero arguments — writing it this way lets the same time_it decorator wrap any function, regardless of how many inputs it needs.

The @ symbol: syntax sugar, not new behaviour

The line calculate_gst = time_it(calculate_gst) is exactly what Python's special @ syntax does automatically. These two programs are identical in every way:

@time_it
def calculate_gst(price):
    return price * 1.18

is exactly the same as writing def calculate_gst(price): return price * 1.18 followed by calculate_gst = time_it(calculate_gst). The @time_it line placed directly above a function definition is Python's shorthand for "as soon as this function is defined, immediately pass it through time_it, and store the result back under the same name." This is the formal definition worth writing down for your exam: a decorator is a function that takes a function as input and returns a function as output, and the @decorator_name syntax is shorthand for calling that decorator on the function defined immediately below it. Nothing about @ is magical — it is purely a more readable way to write the reassignment you already saw in the hand-built time_it example above.

A common misconception: "decorating doesn't change the function's identity"

Many students assume that after decorating, calculate_gst is still, deep down, "the same function, just with extra steps." Test this belief directly:

def time_it(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@time_it
def calculate_gst(price):
    return price * 1.18

print(calculate_gst.__name__)

Every function object in Python carries its own name as an attribute, __name__. You might expect this to print 'calculate_gst'. It actually prints 'wrapper'. This is because, as the reassignment step showed earlier, decorating replaces calculate_gst with the wrapper function object that time_it built — and that object's own name, as far as Python is concerned, really is wrapper, since that is what it was called when it was defined. The original function's identity is not preserved automatically; it is genuinely hidden inside wrapper's closure, invisible from the outside. This matters in practice: if your program's error logs or debugging tools print __name__ to tell you which function failed, every decorated function would confusingly report itself as "wrapper", making bugs harder to trace.

Python's standard library provides the fix: functools.wraps, itself a decorator, applied to your wrapper function, which copies over the original function's name, docstring, and other metadata.

import functools

def time_it(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@time_it
def calculate_gst(price):
    return price * 1.18

print(calculate_gst.__name__)

This now correctly prints 'calculate_gst'. The lesson generalises: whenever you write your own decorator that wraps a function in an inner wrapper, professional Python code always adds @functools.wraps(func) just above the inner function's definition — it costs one line and prevents a whole category of confusing bugs later.

A decorator that takes its own arguments

So far, every decorator we've written takes exactly one argument: the function to wrap. But sometimes you want to configure the decorator itself — for example, "repeat this function's execution 3 times," where 3 should be adjustable. This needs one extra layer of function-returning-function:

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

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

greet("Aarav")

Trace the three layers from the outside in: repeat(3) is called first, with times = 3, and it returns decorator — a genuine decorator, in the same shape as time_it above, except it has access to times through its closure. @repeat(3) then applies that returned decorator to greet, exactly like any other decorator. So greet is reassigned to wrapper, which, when called, loops times times and calls the original greet on each pass. The output is Namaste, Aarav! printed three times in a row. The general rule: a plain decorator is one function taking a function; a decorator factory like repeat is a function that takes ordinary arguments and returns a decorator.

A realistic, complete example: the login check from the opening story

Return now to the IRCTC-style problem this chapter opened with — every booking function should refuse to run unless the user is logged in:

def login_required(func):
    def wrapper(user_logged_in, *args, **kwargs):
        if not user_logged_in:
            print("Please log in to book your IRCTC ticket.")
            return None
        return func(*args, **kwargs)
    return wrapper

@login_required
def book_ticket(train_name):
    print(f"Ticket booked for {train_name}")

book_ticket(True, "Rajdhani Express")
book_ticket(False, "Shatabdi Express")

Here wrapper deliberately takes user_logged_in as its first parameter, checked before the original function ever runs. When we call book_ticket(True, "Rajdhani Express"), we are really calling wrapper(True, "Rajdhani Express"): user_logged_in becomes True, so the check passes, and func("Rajdhani Express") runs, printing Ticket booked for Rajdhani Express. The second call, book_ticket(False, "Shatabdi Express"), sets user_logged_in = False, so the function short-circuits and prints Please log in to book your IRCTC ticket. instead — the original booking code never runs at all. This is the real value of decorators: the login-checking logic exists in exactly one place, login_required, and can be attached to book_ticket, cancel_ticket, or any future function with a single @login_required line, with zero copy-pasting.

How @login_required wraps book_ticket() Caller: book_ticket(True, "Rajdhani Express") wrapper(...) — the new function created by login_required 1. Check: is user_logged_in True? Yes -> continue 2. Original function runs: book_ticket("Rajdhani Express") prints "Ticket booked for Rajdhani Express" 3. wrapper returns result back to the caller

Order matters when you stack decorators

You can apply more than one decorator to the same function by stacking @ lines. When you do, Python applies them from the bottom upward — the decorator closest to def wraps the original function first, and the one above that wraps the already-wrapped result:

@time_it
@login_required
def book_ticket(train_name):
    print(f"Ticket booked for {train_name}")

Reading bottom-to-top: login_required wraps book_ticket first, producing a login-checking version. Then time_it wraps that result, producing a version that times the whole login-check-plus-booking process. If you swapped the order — login_required on top, time_it on the bottom — you would instead time only the raw booking call, and the login check would happen outside the timer entirely. Neither order is "wrong," but they measure and behave differently, so stacking order is a real design decision, not a cosmetic one.

The other half of the chapter: why loading everything into memory can break your program

Now shift to a different, equally practical problem. Suppose you want a list of the first million perfect squares — perhaps to search through them for a school project. The direct way:

def first_n_squares_list(n):
    result = []
    for i in range(1, n + 1):
        result.append(i * i)
    return result

squares = first_n_squares_list(1000000)

This works, but notice what actually happens: Python computes all one million values immediately and stores every single one in a list sitting in memory, even if your program only ever needs to look at the first five of them before deciding it's done. If n were a billion instead of a million — closer to the number of UPI transactions processed across India in a single day — building the full list upfront could exhaust your computer's memory before your program even gets to use the data. This is exactly the situation generators are designed for: producing values one at a time, on demand, instead of computing an entire collection upfront.

The yield keyword: pausing a function instead of ending it

A generator function looks almost like an ordinary function, with one difference: instead of return, it uses yield. This single keyword changes the function's behaviour completely. Trace this example line by line — it is the most important trace in this chapter:

def simple_gen():
    print("Start")
    yield 1
    print("Middle")
    yield 2
    print("End")
    yield 3

g = simple_gen()
print(next(g))
print(next(g))
print(next(g))

The moment you write g = simple_gen(), something surprising happens: none of the code inside simple_gen runs yet — not even the first print("Start"). Calling a generator function does not execute its body; it only creates a special generator object, stored in g, that remembers where to start when asked. The built-in function next() is what actually asks a generator to run: each call to next(g) resumes execution from wherever the generator last stopped, and runs until it hits a yield, at which point execution pauses — not stops, pauses, keeping every local variable exactly as it was — and the yielded value is handed back to whoever called next().

So: the first next(g) starts the function from the top, prints Start, hits yield 1, and pauses there, handing back 1, which gets printed. The second next(g) resumes exactly after that first yield, prints Middle, hits yield 2, and pauses, handing back 2. The third next(g) resumes after the second yield, prints End, hits yield 3, and pauses, handing back 3. The full printed output, in order, is:

Start
1
Middle
2
End
3

If you called next(g) a fourth time, there is no more code left to run before the function naturally ends, so Python raises a special signal called StopIteration — this is precisely the signal a for loop is built to detect automatically, which is why you almost never call next() by hand in real code; you write for value in simple_gen(): ... and let the loop handle calling next() and catching StopIteration for you.

simple_gen() pauses at every yield, resumes on the next next(g) next(g) #1 prints "Start" pauses at yield 1 returns 1 next(g) #2 prints "Middle" pauses at yield 2 returns 2 next(g) #3 prints "End" pauses at yield 3 returns 3 next(g) #4 no code left StopIteration

Rewriting the squares problem as a generator

Now the memory problem from earlier has a clean solution:

def first_n_squares_gen(n):
    for i in range(1, n + 1):
        yield i * i

squares_gen = first_n_squares_gen(1000000)

Just like simple_gen() above, calling first_n_squares_gen(1000000) does not compute a single square yet — it only builds a generator object, remembering that it needs to eventually run a loop from i = 1 to i = 1000000. No memory is spent storing a million numbers; only one value exists at a time, produced the moment something asks for it (for example, a for loop, or a call to next()), and immediately discarded once used, unless you choose to keep it.

A generator that models real growth: the Fibonacci sequence

Generators are especially natural for sequences that are defined step by step, where each new value depends on remembering the previous ones — the Fibonacci sequence is the classic example, and it also appears in CBSE syllabi as a recursion example, so seeing it written as a generator is useful contrast:

def fibonacci(n):
    a, b = 0, 1
    count = 0
    while count < n:
        yield a
        a, b = b, a + b
        count += 1

for num in fibonacci(8):
    print(num, end=" ")

Trace the state carefully, because this is where students most often make an arithmetic slip. Start: a = 0, b = 1, count = 0. Since count < 8, the loop yields a, which is 0 — that is the first value the for loop receives. Only after resuming does the line a, b = b, a + b run: both sides are evaluated first using the old values, so the new a becomes the old b (which is 1), and the new b becomes the old a + b (which is 0 + 1 = 1); count becomes 1. The loop continues, yielding the new a, which is 1. Continue this bookkeeping and the sequence of yielded values comes out as 0, 1, 1, 2, 3, 5, 8, 13 — exactly eight numbers, since the loop stops once count reaches 8. The printed line is:

0 1 1 2 3 5 8 13

Notice the elegance compared to a recursive Fibonacci function: this generator computes each term in constant time using only two stored numbers, a and b, no matter how far out you ask it to go, and it never recomputes a value it already produced.

Generator expressions: the compact cousin of list comprehensions

If you've already learned list comprehensions — the [expression for item in iterable] syntax — generators have an almost identical shorthand, using round brackets instead of square ones:

squares_list = [x * x for x in range(10)]   # a list: all 10 squares computed immediately
squares_gen = (x * x for x in range(10))    # a generator: nothing computed yet

print(sum(squares_gen))

The square brackets build the entire list of ten squares — [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] — and hold all of them in memory before sum() even starts adding. The round brackets instead build a generator: sum() pulls one value at a time from it, adds it to a running total, and immediately forgets that value — at no point does the full list of ten squares exist anywhere in memory. Both approaches give the identical final answer, 285 (you can verify this by hand: 0+1+4+9+16+25+36+49+64+81 = 285), but the generator version uses only a small, fixed amount of memory regardless of whether you sum 10 numbers or 10 million.

A second common misconception: "a generator is just a lazy list, and I can loop over it twice"

This misconception causes real bugs, so test it directly:

gen = (x * x for x in range(5))
print(list(gen))
print(list(gen))

The first list(gen) pulls every remaining value out of the generator by calling next() repeatedly until StopIteration, collecting them into [0, 1, 4, 9, 16]. But a generator does not reset itself after being exhausted — once it has yielded its last value and raised StopIteration, it stays in that finished state forever. So the second list(gen) finds nothing left to give and returns an empty list, []. This is fundamentally different from a real list, which you can loop over as many times as you like. If you need to use the same sequence of values twice, you must either store the results in an actual list the first time, or call the generator function again to create a brand-new generator object.

Bringing decorators and generators together

These two ideas connect naturally: a decorator can wrap a generator function too, though you have to reason carefully about exactly what gets wrapped. Since calling a generator function only creates a generator object — it does not run the loop inside — a decorator around a generator function wraps the moment of creating the generator, not each individual value it later yields:

def count_calls(func):
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        print(f"Generator created. Call number: {wrapper.calls}")
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@count_calls
def even_numbers(limit):
    for i in range(0, limit, 2):
        yield i

gen1 = even_numbers(6)
gen2 = even_numbers(6)
print(list(gen1))

Trace it: gen1 = even_numbers(6) calls wrapper(6), which increments wrapper.calls to 1, prints Generator created. Call number: 1, and then calls func(6) — since func is the original generator function, this call does not run the loop; it just returns a fresh generator object, which becomes gen1. Similarly, gen2 = even_numbers(6) prints Generator created. Call number: 2 and produces a second, independent generator object. Only when we finally call list(gen1) does the loop inside even_numbers actually run, producing [0, 2, 4]. The full printed output, in order, is:

Generator created. Call number: 1
Generator created. Call number: 2
[0, 2, 4]

This example quietly reinforces both halves of the chapter at once: the decorator's counting logic runs the instant the generator is created, while the generator's own yield logic only runs later, when something actually asks it for values — creation and consumption are two separate moments, and keeping that distinction straight is what separates a correct trace from a confused guess.

Summary

A decorator is an ordinary function that accepts another function as its input and returns a new function — usually named wrapper internally — that adds behaviour before and/or after calling the original. The @decorator_name syntax placed above a function definition is shorthand for reassigning that function to the decorator's output; it introduces no new capability, only cleaner syntax. Decorators commonly use *args, **kwargs so they work with functions of any signature, and professional code adds @functools.wraps(func) to the inner wrapper so the decorated function keeps its original __name__ and documentation instead of silently reporting itself as wrapper. A decorator factory, like repeat(times), adds one more layer so the decorator itself can be configured with arguments. Stacking multiple decorators applies them bottom-up, and the order genuinely changes behaviour.

A generator function is any function containing at least one yield statement. Calling it does not run its body — it produces a generator object that only executes code, up to the next yield, when something calls next() on it (which a for loop does automatically). Each yield pauses execution while preserving every local variable, and resumes exactly there on the following call. This lets you process sequences — even conceptually infinite or extremely large ones — one value at a time, using a small, constant amount of memory instead of building a complete list upfront. Generator expressions, written with round brackets instead of square ones, give this same laziness for simple comprehension-style code. A generator is exhausted after being fully consumed once and cannot be replayed; you must create a fresh one to iterate again. Decorators and generators can be combined, but remember that a decorator wrapping a generator function controls the moment of creating the generator, not each value it later yields.

Check your understanding

  1. Trace it: Without running the code, write down the exact output, in order, of this program:
    def shout(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result.upper()
        return wrapper
    
    @shout
    def greeting(name):
        return f"welcome, {name}"
    
    print(greeting("Diya"))
    
  2. Trace it: Given def gen(): yield "a"; yield "b"; yield "c", and g = gen(), what does each of these three lines print, run one after another: print(next(g)), print(next(g)), print(next(g))? What happens if you call next(g) a fourth time?
  3. Conceptual: A classmate says, "Decorators and generators are basically the same idea — both use special syntax to change how a function runs." Explain in 2-3 sentences why this is not accurate, focusing on what each one actually controls: order/extra behaviour around a call, versus pausing and resuming a single function's execution.
  4. Debug it: A student writes a decorator meant to run a function exactly twice, but it only prints output once no matter what:
    def repeat_twice(func):
        def wrapper(*args, **kwargs):
            func(*args, **kwargs)
        return wrapper
    
    Identify precisely what is missing compared to the repeat(times) example in this chapter, and fix it.
  5. Applied: You need a function that produces every multiple of 7 from 7 up to a given limit, but the limit could be as large as ten million. Explain, using the specific memory argument from this chapter, why you should write this as a generator function rather than a function that builds and returns a list, and write the generator function.

Answer check for Q1: the output is WELCOME, DIYAgreeting("Diya") is really wrapper("Diya"), which calls the original greeting to get "welcome, Diya", then applies .upper() before returning it.

← Data Structures: Organizing Information Like a ProBuilding a Blog with Flask and SQLAlchemy →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn