Advanced Python
Level: 10 | Version: v1.0 | Author: Meptrasoft
Real programs process large amounts of data — thousands of orders, huge files, endless data streams. Loading everything into memory at once is often wasteful, and repeating the same setup code (logging, timing, checks) around many functions is messy. Iterators, generators, and decorators solve exactly these problems. This module uses a Food Delivery Order System as the running example.
orders = [
{"food": "Burger", "price": 200, "status": "Confirmed"},
{"food": "Pizza", "price": 300, "status": "Pending"},
{"food": "Pasta", "price": 250, "status": "Confirmed"},
]
Iterators
Why Iterators Matter
A for loop already lets us walk through a list. Behind the scenes, Python is
using something called an iterator to do it — understanding that machinery
is the foundation for generators later in this module.
foods = ["Burger", "Pizza", "Pasta"]
for food in foods:
print(food)
Output:
Burger
Pizza
Pasta
Iterable vs Iterator
An iterable is anything that can be looped over (a list, tuple, string, dictionary, set). An iterator is the object that actually remembers where it is and produces the values one at a time.
ITERABLE → CAN BE LOOPED OVER (list, tuple, string, dict, set)
ITERATOR → REMEMBERS POSITION → PRODUCES ONE VALUE AT A TIME
You can get an iterator from an iterable by using the built-in iter()
function.
The Iterator Protocol — iter() and next()
foods = ["Burger", "Pizza", "Pasta"]
food_iterator = iter(foods)
print(next(food_iterator)) # Burger
print(next(food_iterator)) # Pizza
print(next(food_iterator)) # Pasta
Output:
Burger
Pizza
Pasta
iter() returns an iterator; next() asks it for the next value.
StopIteration
Calling next() after the last value raises StopIteration — this is how
Python knows a loop has ended.
foods = ["Burger"]
food_iterator = iter(foods)
next(food_iterator) # Burger
try:
next(food_iterator) # Raises StopIteration
except StopIteration:
print("StopIteration raised!")
Output:
StopIteration raised!
A for loop hides this by catching StopIteration automatically:
foods = ["Burger", "Pizza", "Pasta"]
food_iterator = iter(foods)
while True:
try:
food = next(food_iterator)
print(food)
except StopIteration:
break
Output:
Burger
Pizza
Pasta
for loop → calls iter() ONCE → calls next() REPEATEDLY → stops on StopIteration
Building a Custom Iterator
Any class can become an iterator by implementing two special methods:
__iter__() (returns the iterator itself) and __next__() (returns the next
value, or raises StopIteration).
class OrderIterator:
def __init__(self, orders):
self.orders = orders
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.orders):
raise StopIteration
order = self.orders[self.index]
self.index += 1
return order
foods = OrderIterator(["Burger", "Pizza", "Pasta"])
for food in foods:
print(food)
Output:
Burger
Pizza
Pasta
Because OrderIterator implements __iter__ and __next__, a normal for
loop works on it exactly as it would on a list.
__iter__() → RETURNS THE ITERATOR OBJECT
__next__() → RETURNS NEXT VALUE OR RAISES StopIteration
Iterable vs Iterator — Comparison
| Iterable | Iterator |
|---|---|
Has __iter__() |
Has __iter__() and __next__() |
| Example: list, string, dict, tuple | Example: object returned by iter(list) |
| Can be looped over many times | Gets exhausted after one full pass |
| Does not track position itself | Remembers its current position |
ITERABLE → iter() → ITERATOR → next() → VALUES, ONE AT A TIME
Generators
Why Generators Matter
Imagine the food delivery platform has to process one million orders. Building a list of one million processed orders keeps all of them in memory at once. A generator produces values one at a time, on demand, so only one processed order needs to exist in memory at a time.
LIST → ALL VALUES BUILT AND STORED IN MEMORY AT ONCE
GENERATOR → ONE VALUE PRODUCED AT A TIME, ONLY WHEN ASKED
A generator is the simplest way to create an iterator — Python writes the
__iter__/__next__ machinery for you.
Generator Functions and yield
A generator function looks like a normal function but uses yield
to produce values instead of returning all values at once.
def order_generator(orders):
for food in orders:
yield food
foods = ["Burger", "Pizza", "Pasta"]
gen = order_generator(foods)
print(gen)
Output:
<generator object order_generator at 0x000002B6AB249BE0>
Calling order_generator(foods) does not run the function body — it
returns a generator object. The body only runs when values are pulled out
of it with next() or a for loop.
def order_generator():
yield "Burger"
yield "Pizza"
yield "Pasta"
gen = order_generator()
print(next(gen))
print(next(gen))
print(next(gen))
Output:
Burger
Pizza
Pasta
yield → PAUSES the function, remembers exactly where it stopped, resumes on the next request
return vs yield
return |
yield |
|---|---|
| Ends the function completely | Pauses the function, keeps its state |
| Sends back one value, once | Produces a value each time it is reached |
| Calling the function runs its body | Calling the function returns a generator |
| Function → normal value | Function → generator object |
Looping Over a Generator Directly
Just like any iterator, a generator works naturally in a for loop:
def order_generator(orders):
for food in orders:
yield food
for food in order_generator(["Burger", "Pizza", "Pasta"]):
print(food)
Output:
Burger
Pizza
Pasta
Generator Expressions
A generator expression looks like a list comprehension but uses ()
instead of [], and produces values lazily instead of building a full list.
prices = [200, 300, 150]
priced_with_tax = (price * 1.05 for price in prices)
print(priced_with_tax)
Output:
<generator object <genexpr> at 0x000002B6AB249E50>
prices = [100, 200, 350]
priced_with_tax = (p * 1.05 for p in prices)
print(sum(priced_with_tax))
Output:
682.5
List Comprehension vs Generator Expression
List Comprehension [...] |
Generator Expression (...) |
|---|---|
| Builds the whole list immediately | Produces values one at a time, lazily |
| Uses more memory for large data | Uses far less memory for large data |
| Can be looped over many times | Gets exhausted after one full pass |
[...] → LIST NOW, IN MEMORY
(...) → VALUES LATER, ON DEMAND
A Generator Runs Only Once
Once a generator has produced all its values, it is exhausted — looping over it again produces nothing.
def order_generator(items):
yield from items
gen = order_generator(["Burger", "Pizza"])
print(list(gen))
print(list(gen))
Output:
['Burger', 'Pizza']
[]
To iterate again, create a new generator by calling the function again.
GENERATOR EXHAUSTED AFTER ONE FULL PASS → CALL THE FUNCTION AGAIN FOR A NEW ONE
Chaining Generators — an Order Processing Pipeline
Generators can be chained so each stage only pulls one order at a time from the previous stage — no full intermediate lists are ever built, which matters a lot for large order feeds.
def read_orders(orders):
for order in orders:
yield order
def filter_confirmed(orders):
for order in orders:
if order["status"] == "Confirmed":
yield order
def add_delivery_fee(orders):
for order in orders:
order["final_price"] = order["price"] + 40
yield order
orders = [
{"food": "Burger", "price": 200, "status": "Confirmed"},
{"food": "Pizza", "price": 300, "status": "Pending"},
{"food": "Pasta", "price": 250, "status": "Confirmed"},
]
pipeline = add_delivery_fee(filter_confirmed(read_orders(orders)))
for order in pipeline:
print(order["food"], "->", order["final_price"])
Output:
Burger -> 240
Pasta -> 290
Each function only produces one order when the next one is requested — the pipeline never holds the full processed list in memory.
read_orders() → filter_confirmed() → add_delivery_fee() → ONE ORDER AT A TIME
Iterator vs Generator
| Iterator (class-based) | Generator (yield-based) |
|---|---|
Written with __iter__ and __next__ |
Written with a normal function and yield |
| More code, full manual control | Less code, simpler for most cases |
Custom state stored on self |
State kept automatically by Python |
GENERATOR → THE EASIEST WAY TO BUILD AN ITERATOR
Decorators
Functions Are First-Class Objects
In Python, functions can be assigned to variables, passed as arguments, and returned from other functions — this is exactly what makes decorators possible.
def show_order():
print("Order Received")
send_notification = show_order
send_notification()
Output:
Order Received
def show_order():
print("Order Received")
def run_function(func):
func()
run_function(show_order)
Output:
Order Received
Closures
A closure is an inner function that remembers variables from its enclosing function, even after the outer function has finished running.
def make_discount_applier(discount):
def apply_discount(price):
return price - (price * discount / 100)
return apply_discount
ten_percent_off = make_discount_applier(10)
print(ten_percent_off(200))
Output:
180.0
apply_discount keeps access to discount (10) even though
make_discount_applier already returned. Closures are the mechanism decorators
are built on.
CLOSURE → INNER FUNCTION + REMEMBERED OUTER VARIABLE(S)
Building a Basic Decorator
A decorator is a function that takes a function and returns a new function that adds extra behavior around it — without changing the original function's code.
def log_order(func):
def wrapper():
print("Order function starting...")
func()
print("Order function finished.")
return wrapper
def show_order():
print("Order Received")
show_order = log_order(show_order)
show_order()
Output:
Order function starting...
Order Received
Order function finished.
Using @ Syntax
Python provides @decorator_name as shorthand for reassigning a function to
its decorated version.
def log_order(func):
def wrapper():
print("Order function starting...")
func()
print("Order function finished.")
return wrapper
@log_order
def show_order():
print("Order Received")
show_order()
Output:
Order function starting...
Order Received
Order function finished.
@log_order
def show_order(): ...
↓ exactly the same as ↓
show_order = log_order(show_order)
Decorating Functions That Take Arguments
The wrapper must accept *args and **kwargs so it works with any
decorated function, and it should return the original function's result so
callers still receive the value.
def log_order(func):
def wrapper(*args, **kwargs):
print("Order function starting...")
result = func(*args, **kwargs)
print("Order function finished.")
return result
return wrapper
@log_order
def calculate_total(price, quantity):
return price * quantity
print(calculate_total(200, 2))
Output:
Order function starting...
Order function finished.
400
Preserving Metadata — functools.wraps
A wrapper hides the original function's name and docstring, which can confuse debugging tools and documentation.
def log_order(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_order
def calculate_total(price, quantity):
"""Calculates the order total."""
return price * quantity
print(calculate_total.__name__)
Output:
wrapper
functools.wraps copies the original function's name and docstring onto the
wrapper, fixing this.
from functools import wraps
def log_order(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_order
def calculate_total(price, quantity):
"""Calculates the order total."""
return price * quantity
print(calculate_total.__name__)
Output:
calculate_total
@wraps(func) → KEEPS THE ORIGINAL FUNCTION'S NAME AND DOCSTRING
In real projects, use @wraps(func) when writing decorators.
Decorators That Accept Their Own Arguments
Sometimes the decorator itself needs configuration — for example, how many times to retry a payment that may fail. This needs three levels of functions. This simple retry stops as soon as the call succeeds, and only tries again when the call raises an error.
from functools import wraps
def retry(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
print(f"Attempt {attempt}")
try:
return func(*args, **kwargs) # success → stop retrying
except Exception as error:
print(f"Failed: {error}")
print("All attempts failed")
return wrapper
return decorator
attempts_so_far = 0
@retry(times=3)
def charge_card():
global attempts_so_far
attempts_so_far += 1
if attempts_so_far < 3:
raise ValueError("network timeout")
print("Payment successful")
charge_card()
Output:
Attempt 1
Failed: network timeout
Attempt 2
Failed: network timeout
Attempt 3
Payment successful
The loop retries only after a failure and returns immediately on success — so a working call runs the real logic just once.
retry(times) → returns decorator
decorator(func) → returns wrapper
wrapper(*args, **kw) → runs the real logic
Chaining Multiple Decorators
Several decorators can be stacked on one function. Python applies them bottom-up when building the final function. When the final function is called, the outer wrapper starts first.
from functools import wraps
def log_order(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Logging order...")
return func(*args, **kwargs)
return wrapper
def check_auth(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Checking authentication...")
return func(*args, **kwargs)
return wrapper
@log_order
@check_auth
def place_order(food):
print("Order placed for", food)
place_order("Burger")
Output:
Logging order...
Checking authentication...
Order placed for Burger
@log_order
@check_auth
def place_order(): ...
↓ exactly the same as ↓
place_order = log_order(check_auth(place_order))
Real-World Uses of Decorators
| Use Case | What the Decorator Does |
|---|---|
| Logging | Prints/records details before and after a call |
| Timing | Measures how long a function takes to run |
| Authentication | Checks permissions before allowing a call |
| Caching | Stores results so repeated calls are faster |
| Retry | Re-attempts a failing operation |
| Validation | Checks arguments before the real function runs |
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer
def process_orders(orders):
return sum(order["price"] for order in orders)
orders = [{"price": 200}, {"price": 300}, {"price": 150}]
print(process_orders(orders))
Output:
process_orders took 0.0000 seconds
650
(The exact number will differ on your machine and on every run — only the
format X.XXXX seconds matters.)
Built-in Decorators on Classes — Quick Look
Python also provides a few built-in decorators used inside classes.
| Decorator | Purpose |
|---|---|
@staticmethod |
A method that needs neither self nor the class |
@classmethod |
A method that receives the class (cls) instead of an instance |
@property |
Lets a method be accessed like a plain attribute |
@staticmethod → NO self, NO cls
@classmethod → RECEIVES cls (the class itself)
@property → METHOD USED LIKE AN ATTRIBUTE (no parentheses)
Complete Example — Iterators, Generators, and Decorators Together
from functools import wraps
def log_order(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Starting order report...\n")
result = func(*args, **kwargs)
print("\nReport complete.")
return result
return wrapper
def read_orders(orders):
for order in orders:
yield order
def filter_confirmed(orders):
for order in orders:
if order["status"] == "Confirmed":
yield order
@log_order
def generate_report(orders):
total = 0
for order in filter_confirmed(read_orders(orders)):
print(order["food"], "-", order["price"])
total += order["price"]
return total
orders = [
{"food": "Burger", "price": 200, "status": "Confirmed"},
{"food": "Pizza", "price": 300, "status": "Pending"},
{"food": "Pasta", "price": 250, "status": "Confirmed"},
]
print("Grand Total:", generate_report(orders))
Output:
Starting order report...
Burger - 200
Pasta - 250
Report complete.
Grand Total: 450
generate_report is wrapped by a decorator (log_order), consumes a
lazy generator pipeline (read_orders → filter_confirmed), and each
stage is walked using the same iterator protocol that powers every for loop
in Python.
Common Fresher Mistakes
| ✗ Mistake | ✓ Correct Understanding |
|---|---|
| Thinking a list and an iterator are the same thing | A list is iterable; an iterator is what iter() produces from it |
Calling next() after exhaustion and being surprised by an error |
StopIteration is expected — that is how loops know to stop |
| Thinking calling a generator function runs its body immediately | It only returns a generator object; the body runs on next()/iteration |
| Reusing an already-exhausted generator | Create a new generator by calling the function again |
Using [...] when data is huge |
Prefer (...) (generator expression) to save memory |
Forgetting *args, **kwargs in a decorator's wrapper |
Without them the decorator breaks on any decorated function with arguments |
Forgetting to return the result inside wrapper |
Callers get None instead of the real value |
Forgetting @wraps(func) in a real decorator |
The decorated function's name/docstring get overwritten |
| Confusing decorator order when chaining | Decorators are applied bottom-up; during the call, the outer wrapper starts first |
Placement Quick Points
- An iterable can be looped over; an iterator remembers its position and produces values one at a time.
iter()returns an iterator;next()asks it for the next value;StopIterationsignals the end.- A custom class becomes an iterator by implementing
__iter__()and__next__(). - A generator function uses
yieldand is the simplest way to build an iterator. - Calling a generator function returns a generator object — the body runs lazily, on demand.
- A generator expression uses
(...)and produces values lazily, unlike a list comprehension's[...]. - A generator is exhausted after one full pass; a new one must be created to iterate again.
- Generators can be chained into a pipeline that processes one item at a time, saving memory.
- A decorator is a function that takes a function and returns a new function with added behavior.
@decoratorabove a function is shorthand forfunction = decorator(function).- A decorator's
wrappershould accept*args, **kwargsandreturnthe wrapped function's result. functools.wrapspreserves the original function's name and docstring.- Decorators that take their own arguments need three nested function levels.
- Chained decorators are applied bottom-up when the function is built; when called, the outer wrapper starts first.
Interview Questions
- What is an iterable?
- An object that can be looped over, such as a list, tuple, string, or dictionary.
- What is an iterator?
- An object that remembers its position and produces values one at a time using
__next__(). - What is the difference between an iterable and an iterator?
- An iterable can be looped over; an iterator is created from it with
iter()and tracks position while producing values. - What does
next()do? - It returns the next value from an iterator, or raises
StopIterationwhen there are no more values. - What is
StopIteration? - The exception an iterator raises to signal that no more values remain.
- What two methods make a class an iterator?
__iter__(), which returns the iterator itself, and__next__(), which returns the next value.- What is a generator?
- An iterator-like object that produces values one at a time. A generator function creates it using
yield. - What happens when a generator function is called?
- It does not run the function body; it returns a generator object. The body runs as values are requested.
- What is the difference between
returnandyield? returnends the function and sends back one value.yieldpauses the function, remembers its state, and can produce more values later.- What is a generator expression?
- An expression like
(x for x in iterable)that produces values lazily, similar to a list comprehension but memory-efficient. - Can a generator be iterated over more than once?
- No. Once exhausted, a new generator must be created by calling the function again.
- Why are generators memory-efficient?
- They produce one value at a time instead of building the entire result in memory upfront.
- What is a closure?
- An inner function that remembers variables from its enclosing function even after that function has finished running.
- What is a decorator?
- A function that takes another function and returns a new function that adds behavior around it.
- What does
@decorator_nameabove a function do? - It reassigns the function to the value returned by
decorator_name(function). - Why should a decorator's
wrapperaccept*args, **kwargs? - So the decorator works with any function regardless of how many arguments it takes.
- Why should
wrapperreturn the result of the original function? - So callers of the decorated function still receive the correct return value.
- What does
functools.wrapsdo? - It copies the original function's name and docstring onto the wrapper function.
- How does a decorator accept its own arguments?
- By adding an extra outer function level: the outer function takes the decorator's arguments and returns the actual decorator.
- In what order are chained decorators applied?
- They are applied bottom-up when Python builds the final function. When the function is called, the outer wrapper starts first.
Practice
Use a Food Delivery Order System.
- Create a list of food items and loop over it with a
forloop. - Create an iterator from that list using
iter(). - Call
next()on the iterator three times and print each result. - Call
next()one time too many and observeStopIteration. - Rewrite the loop manually using
while True,next(), andtry/except StopIteration. - Build a custom
OrderIteratorclass with__iter__and__next__. - Use the custom iterator in a
forloop. - Explain the difference between an iterable and an iterator.
- Write a generator function
order_generator()that yields each food item. - Print the generator object before consuming it.
- Consume the generator with
next()and then with aforloop. - Write a generator expression that adds 5% tax to a list of prices.
- Compare the generator expression to an equivalent list comprehension.
- Try iterating over the same generator twice and observe the result.
- Build a three-stage generator pipeline: read orders, filter confirmed orders, add a delivery fee.
- Explain why the pipeline does not build an intermediate list at each stage.
- Write a closure
make_discount_applier(discount)that returns a function applying that discount. - Write a basic decorator
log_orderthat prints messages before and after calling a function. - Apply it using
@log_ordersyntax. - Update the decorator's
wrapperto accept*args, **kwargs. - Verify the decorated function still returns the correct value.
- Add
functools.wrapsand confirm__name__is preserved. - Write a decorator
retry(times)that accepts its own argument. - Chain two decorators (
log_orderandcheck_auth) on one function and predict the print order. - Write a
timerdecorator that prints how long a function took to run. - Combine a decorator, a generator pipeline, and a
forloop in one complete order-report example.
Predict the output:
def numbers():
yield 1
yield 2
yield 3
gen = numbers()
print(next(gen))
print(next(gen))
print(list(gen))
def log(func):
def wrapper(*args, **kwargs):
print("before")
result = func(*args, **kwargs)
print("after")
return result
return wrapper
@log
def add(a, b):
return a + b
print(add(2, 3))
gen = (x * 2 for x in [1, 2, 3])
print(list(gen))
print(list(gen))
Find the mistake:
def log(func):
def wrapper():
print("before")
func()
print("after")
return wrapper
@log
def show_total(total):
print(total)
show_total(400)
Final task: Build a small Food Delivery Order Report that reads a list
of orders through a chained generator pipeline (filter confirmed orders, add a
delivery fee), wraps the reporting function with a @log_order decorator that
prints a start/end message, and prints each order followed by the grand total.
Output:
ITERATOR → REMEMBERS POSITION, ONE VALUE AT A TIME
GENERATOR → SIMPLEST WAY TO BUILD AN ITERATOR, USES yield
DECORATOR → WRAPS A FUNCTION TO ADD BEHAVIOR AROUND IT
End of Level 10