In Python, a function is a first-class object defined with def — it accepts parameters, can have defaults, returns a value (or None), captures its enclosing scope as a closure, and can be passed to and returned from other functions.
Writing reusable blocks of code
What you'll learn: How to define functions, pass arguments, return values, and set default parameter values. After this tab you can break your programs into named, reusable pieces — which is the single most important organisational skill in programming.
How to read this tab: For every function in the code examples, trace through it manually: what goes in (arguments), what happens inside, what comes out (return value). This mental model — input → process → output — applies to every function you'll ever write.
A function is a recipe. You define it once, name it, then call it as many times as you need. You hand it ingredients (arguments), it follows steps, and hands you a result (return value). The beauty: the recipe doesn't change what's in your kitchen unless you explicitly tell it to.
The most important habit: name your functions as verbs — calculate_total(), not total(). A function does something; its name should say what. This makes code readable without comments.
Defining and calling functions
# Define a function with def
def greet(name):
return f"Hello, {name}!"
# Call it
print(greet("Priya")) # Hello, Priya!
# Multiple parameters
def add(a, b):
return a + b
print(add(3, 4)) # 7
# Default parameters — must come after required params
def greet_formal(name, title="Ms"):
return f"Hello, {title} {name}."
print(greet_formal("Sharma")) # Hello, Ms Sharma.
print(greet_formal("Sharma", "Dr")) # Hello, Dr Sharma.
# Return multiple values (actually returns a tuple)
def min_max(numbers):
return min(numbers), max(numbers)
lo, hi = min_max([3, 1, 4, 1, 5, 9])
print(lo, hi) # 1 9
# Functions are objects — assign to variables, pass around
f = greet
print(f("Rahul")) # Hello, Rahul!The mutable default argument gotcha — memorise this. Never write def f(items=[]). Default values are evaluated ONCE when the function is defined, not on each call. The list is shared across all calls. Use def f(items=None) and then if items is None: items = [] inside the function. This is the most common Python beginner bug after mutability.
Arguments: positional, keyword, *args, **kwargs
# Positional — order matters
def describe(name, age, city):
return f"{name}, {age}, from {city}"
describe("Priya", 28, "Mumbai") # positional
describe(city="Mumbai", name="Priya", age=28) # keyword — any order
# *args — collect extra positional arguments as a tuple
def total(*numbers):
return sum(numbers)
print(total(1, 2, 3, 4, 5)) # 15
# **kwargs — collect extra keyword arguments as a dict
def profile(**info):
for key, val in info.items():
print(f" {key}: {val}")
profile(name="Priya", city="Mumbai", role="Engineer")
# Combining — positional, *args, keyword-only, **kwargs
def full_example(required, *args, keyword_only=True, **kwargs):
print(required, args, keyword_only, kwargs)
full_example("a", "b", "c", keyword_only=False, x=1, y=2)
# a ('b', 'c') False {'x': 1, 'y': 2}
# Unpack when calling
nums = [1, 2, 3]
print(add(*nums)) # same as add(1, 2, 3) — wait, add takes 2 args
opts = {"a": 10, "b": 20}
# If we had: def f(a, b): ...
# f(**opts) would call f(a=10, b=20)The rule that explains every NameError. When Python encounters a name, it searches: Local (inside the current function) → Enclosing (any outer functions) → Global (module level) → Built-in (Python's built-ins). The first match wins. Understanding LEGB makes closures, decorators, and import errors immediately intuitive.
Scope: LEGB
Python looks up names in four scopes, in order: Local (inside the current function), Enclosing (outer function, for nested functions), Global (module level), Built-in (Python's built-ins like len, print). If a name is not found in any of these, Python raises NameError.
x = "global" # global scope
def outer():
x = "enclosing" # enclosing scope
def inner():
x = "local" # local scope
print(x) # local — found first
inner()
print(x) # enclosing
outer()
print(x) # global
# Modify global from inside a function
count = 0
def increment():
global count # declare intent to modify global
count += 1
increment()
print(count) # 1
# Without global: you just create a local variable
def bad_increment():
count = count + 1 # UnboundLocalError!
# Python sees assignment -> treats count as local
# then tries to read count before assignmentUse sparingly. Lambda is useful for simple one-expression functions passed as arguments (e.g., sorted(items, key=lambda x: x['name'])). For anything requiring more than one expression or a clear name, use a regular def. A named function is almost always more readable.
Lambda functions
A lambda is a small, anonymous function — useful for short operations passed to functions like sorted() or map(). They are limited to a single expression.
# Lambda: lambda arguments: expression
square = lambda x: x * x
print(square(5)) # 25
# Useful with sorted(), map(), filter()
names = ["Rahul", "Priya", "Anita", "Zara"]
sorted_names = sorted(names, key=lambda n: len(n))
print(sorted_names) # by length
numbers = [1, 2, 3, 4, 5, 6]
evens = list(filter(lambda n: n % 2 == 0, numbers))
doubled = list(map(lambda n: n * 2, numbers))
# For anything more complex, use a regular function
# PEP 8: never assign a lambda to a name directly
# Wrong: f = lambda x: x*2
# Right: def f(x): return x * 2✅ Beginner tab complete — check your understanding
- I can define a function with def, give it parameters, and return a value
- I can call a function with both positional and keyword arguments
- I know how to set default parameter values
- I understand the difference between a parameter (the name) and an argument (the value passed in)
LEGB scope, *args, **kwargs, and lambda
What you'll learn: Python's LEGB scope rule (how Python finds a name), *args and **kwargs for flexible argument lists, and lambda for short anonymous functions. These are the patterns you see everywhere in professional code.
How to read this tab: The LEGB section is the most important. After reading it, open the REPL and test the examples — scope behaviour is something you have to see to really understand.
What a closure is: A function that "closes over" variables from its enclosing scope — captures them and carries them along even after the enclosing function returns. The canonical example is a counter factory: def make_counter(): count = 0; def counter(): nonlocal count; count += 1; return count; return counter. Each call to make_counter() creates an independent counter.
Closures
A closure is a function that remembers the variables from the enclosing scope in which it was created — even after that enclosing scope has finished executing. In Python, a function becomes a closure when it references a free variable (a variable defined in an enclosing scope but not in its own local scope).
def make_counter(start=0):
count = start # free variable — captured by inner
def increment():
nonlocal count # declare we're modifying enclosing count
count += 1
return count
return increment
counter = make_counter(10)
print(counter()) # 11
print(counter()) # 12
# Each call to make_counter creates an independent closure
counter2 = make_counter(0)
print(counter2()) # 1
print(counter()) # 13 — unaffected
# Inspect what a closure has captured
print(counter.__code__.co_freevars) # ('count',)
print(counter.__closure__[0].cell_contents) # 13Decorators are everywhere in Python. Flask uses @app.route. Django uses @login_required. pytest uses @pytest.fixture. unittest uses @patch. dataclasses uses @dataclass. Understanding what a decorator actually does (it's just func = decorator(func) — syntactic sugar) means you can read and write library code confidently.
Decorators
A decorator is a function that takes a function, wraps it with additional behaviour, and returns the wrapped version. The @decorator syntax is sugar for func = decorator(func). Decorators are how Python adds logging, caching, authentication, retry logic, and timing without touching the underlying function.
import functools
import time
def timer(func):
@functools.wraps(func) # preserve original function metadata
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(1_000_000) # slow_sum took 0.04s
# Decorator with arguments — needs an extra wrapper layer
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 say(msg):
print(msg)
say("hello") # prints hello 3 times
# Built-in decorators
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def area(self): # access as circle.area, not circle.area()
return 3.14159 * self._radius ** 2
@staticmethod
def info():
return "A circle has no corners."
@classmethod
def unit(cls): # cls = the class itself
return cls(radius=1)Functions that pause and resume. A generator function uses yield instead of return. When called, it returns a generator object — nothing runs yet. When you call next() on it, it runs until the next yield, returns that value, and pauses. This is how Python implements lazy sequences, infinite iterators, and coroutines.
Generators
A generator function uses yield instead of return. Calling it returns a generator object — an iterator that produces values lazily, one at a time. The function body is paused at each yield and resumed on the next next() call. Generators are O(1) memory: they produce values on demand instead of building the entire sequence.
# Generator function
def count_up(start, stop):
current = start
while current <= stop:
yield current # pause here, return value
current += 1 # resume here on next call
for n in count_up(1, 5):
print(n) # 1 2 3 4 5
# Infinite generator — O(1) memory regardless of how far you go
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
gen = fibonacci()
print([next(gen) for _ in range(10)]) # [0,1,1,2,3,5,8,13,21,34]
# Generator expression (like list comprehension but lazy)
squares_gen = (x**2 for x in range(1_000_000)) # O(1) memory
first_five = [next(squares_gen) for _ in range(5)]
# send() — pass a value back into the generator
def accumulator():
total = 0
while True:
value = yield total # yield current total, receive next value
if value is None: break
total += value
acc = accumulator()
next(acc) # prime the generator
acc.send(10) # 10
acc.send(20) # 30
print(acc.send(5)) # 35nonlocal is not the same as global. global refers to the module-level scope. nonlocal refers to the nearest enclosing function scope — not global. Use nonlocal inside a nested function to modify a variable in an outer (but not global) function.list(gen) consumes the generator entirely. To iterate again, you must create a new generator. Lists can be iterated any number of times.@functools.wraps is not optional when writing decorators. Without it, the wrapped function loses its __name__, __doc__, and __module__. This breaks debugging, help(), and any tool that introspects function metadata. Always use @functools.wraps(func) in your wrappers.✅ Intermediate tab complete — check your understanding
- I can explain LEGB: Local → Enclosing → Global → Built-in
- I can write a function that accepts any number of positional arguments with *args
- I can write a function that accepts any keyword arguments with **kwargs
- I can write a lambda for a simple one-expression function
- I know that using global inside a function is a code smell
Closures, decorators, generators, and function internals
What you'll learn: Closures — functions that capture their enclosing scope. Decorators — functions that wrap other functions (@syntax). Generator functions with yield. The function object internals and the descriptor protocol.
How to read this tab: Decorators especially are worth reading slowly — write each example out yourself. They underpin a huge amount of Python library code (Flask routes, unittest.mock.patch, dataclasses, etc.).
The formal version of the LEGB rule from the Python Language Reference. Read this if you want the precise specification — what counts as "enclosing", how class scope interacts with LEGB, and the exact order of lookup.
How Python resolves names: the LEGB rule formally
The Python Language Reference §4.2.2 defines the LEGB rule: Python looks up names in the local namespace first, then the namespaces of enclosing functions (from inner to outer), then the module's global namespace, then the built-in namespace. Each function creates its own local namespace at call time. The global namespace is the module's __dict__. The built-in namespace is builtins.__dict__. The global statement (§7.13) marks a name as coming from the global namespace; nonlocal (§7.14) marks it as coming from the nearest enclosing non-global scope.
Function objects and the descriptor protocol
Functions in Python are first-class objects — instances of function. They have attributes: __name__, __doc__, __defaults__, __code__, __globals__, __closure__. When accessed through a class instance, functions become bound methods via the descriptor protocol (Python Reference §3.3.2.4): instance.method calls function.__get__(instance, type(instance)), which returns a bound method object that prepends self.
PEP 342 — Coroutines via Enhanced Generators
PEP 342 (Python 2.5, 2005) enhanced generators with send(), throw(), and close() methods, turning generators into bidirectional coroutines. This was the foundation for Python's async model: PEP 492 (Python 3.5) built async/await syntax on top of these enhanced generators. A async def function is, at the bytecode level, a specialised kind of generator — await is syntactic sugar for yield from in a coroutine context.
✅ Expert tab complete
- I can write a closure that captures a variable from the enclosing scope
- I can write a decorator that wraps a function and preserves its signature with functools.wraps
- I can write a generator function with yield and iterate it with next() or a for loop
- I understand why decorators are just syntactic sugar for func = decorator(func)