🐍 Python Course · Stage 1 · Lesson 10 of 89 · Functions & Scope
Course Overview →

🟩 Start with the Beginner tab. Read it fully before moving on.

← Iterators & Iterables Lambda Functions →
thecodex.expert · The Codex Family of Knowledge
Python

Functions and Scope in Python

Functions are first-class objects in Python — they can be passed around, returned, and wrapped. Here's everything about how they work.

Python 3.13 docs.python.org Last verified:
Canonical Definition

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.

🟩 Beginner

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.

⏱ 45 min 📄 2 sections 🔶 Prerequisite: Control Flow & Loops
The analogy

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

Pythonfunctions_basics.py
# 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

Pythonarguments.py
# 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.

Pythonscope_legb.py
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 assignment
📎

Use 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.

Pythonlambda_functions.py
# 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)

Continue to Object-Oriented Python →

🔵 Intermediate

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.

⏱ 45 min 📄 3 sections 🔶 Prerequisite: Beginner tab
💡

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).

Pythonclosures.py
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)  # 13

Decorators 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.

Pythondecorators.py
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.

Pythongenerators.py
# 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))   # 35
Commonly confused
nonlocal 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.
A generator is not a list. Iterating a generator exhausts it — you can only go through it once. 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

Continue to Object-Oriented Python →

🔴 Expert

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.).

⏱ 60 min 📄 5 sections 🔶 Prerequisite: After Stage 1
📎

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)

Continue to Object-Oriented Python →

Sources

1
Python Language Reference §4 — Execution model. docs.python.org/3/reference/executionmodel.html.
2
Python Language Reference §7.13 — The global statement. docs.python.org/3/reference/simple_stmts.html#global.
3
PEP 342 — Coroutines via Enhanced Generators. peps.python.org/pep-0342/.
4
PEP 318 — Decorators for Functions and Methods. peps.python.org/pep-0318/.
Source confidence: High Last verified: Primary source: Python Language Reference · docs.python.org/3/reference/