Skip to content

Functions, Arguments & Scope

What this lesson gives you

From positional-only parameters to LEGB lookup, closures, and first-class function gymnastics – the complete map of Python's function machinery.

Estimated time: 28 min read · Part: Functions & the Functional Toolkit

A Python function is simultaneously a reusable block of code, a first-class object that can be passed around like any integer, and the boundary that defines a new scope layer. Understanding how Python matches caller-supplied values to the right parameter – and how it resolves names at runtime – is the difference between writing fragile glue scripts and building composable, maintainable libraries. This lesson walks every step of that machinery, from the strictest positional-only parameter to the catch-all **kwargs basket, then traces the LEGB name-resolution path, and ends with first-class function patterns used daily in production systems.

The Complete Parameter Ordering Rule

Python 3.8 introduced a slash (/) to mark positional-only parameters, completing the full five-zone parameter signature. The zones must appear in this exact order or Python raises a SyntaxError:

flowchart LR
    A["positional-only<br/>(before /)"] --> B["positional-or-keyword<br/>(normal)"] --> C["*args<br/>(var-positional)"] --> D["keyword-only<br/>(after *)"] --> E["**kwargs<br/>(var-keyword)"]
    style A fill:#4f46e5,color:#fff
    style B fill:#7c3aed,color:#fff
    style C fill:#db2777,color:#fff
    style D fill:#ea580c,color:#fff
    style E fill:#16a34a,color:#fff
param_zones.py
def full_signature(
    pos_only_a, pos_only_b, /,   # positional-only (zone 1)
    normal_c, normal_d,          # positional-or-keyword (zone 2)
    *args,                       # var-positional (zone 3)
    kw_only_e, kw_only_f=10,     # keyword-only (zone 4)
    **kwargs                     # var-keyword (zone 5)
):
    print(f"pos_only : {pos_only_a}, {pos_only_b}")
    print(f"normal   : {normal_c}, {normal_d}")
    print(f"args     : {args}")
    print(f"kw_only  : {kw_only_e}, {kw_only_f}")
    print(f"kwargs   : {kwargs}")

full_signature(1, 2, 3, 4, 5, 6, kw_only_e=99, color="red")

# This would FAIL – pos_only_a cannot be passed by name:
# full_signature(pos_only_a=1, pos_only_b=2, normal_c=3, normal_d=4, kw_only_e=9)

output pos_only : 1, 2 | normal : 3, 4 | args : (5, 6) | kw_only : 99, 10 | kwargs : {'color': 'red'}

Why positional-only?

Positional-only parameters let library authors rename parameters in future versions without breaking callers. math.sin(x) uses / so you can never write math.sin(x=1.5) – if Guido ever renames the internal parameter it won't break your code.

Default Arguments and the Mutable Trap

Default values are evaluated once at function-definition time, not at call time. This is fine for immutable defaults like None or 42, but silently catastrophic for mutable objects.

mutable_default.py
# ❌ Classic bug
def append_item_bad(item, lst=[]):
    lst.append(item)
    return lst

print(append_item_bad("a"))   # ['a']
print(append_item_bad("b"))   # ['a', 'b']  ← shared state!
print(append_item_bad("c"))   # ['a', 'b', 'c']

# ✅ Correct pattern: sentinel None, create inside body
def append_item_good(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

print(append_item_good("a"))  # ['a']
print(append_item_good("b"))  # ['b']  ← fresh list every time

# Inspect the shared default object
print(append_item_bad.__defaults__)   # (['a', 'b', 'c'],)

The mutable default is a production bug magnet

This pattern causes real production incidents. Flask route handlers, Django form defaults, and data-pipeline accumulators have all shipped with this bug. Always use None as sentinel for any parameter that holds a list, dict, or set.

LEGB: Python's Name Resolution Order

When Python encounters a name like x, it searches four scope layers in order, stopping at the first hit:

Layer Letter What it is Example
Local L Variables assigned inside the current function x = 1 inside def f()
Enclosing E Variables in any enclosing (outer) function scopes Outer def outer() wrapping def inner()
Global G Names defined at the module's top level PI = 3.14 at module level
Built-in B Python's built-in namespace len, range, print
legb_scope.py
x = "global"          # G

def outer():
    x = "enclosing"   # E (from inner's perspective)

    def inner():
        x = "local"   # L
        print(x)      # → 'local'

    inner()
    print(x)          # → 'enclosing'

outer()
print(x)              # → 'global'
print(len)            # → built-in len function (B layer)

global and nonlocal

global_nonlocal.py
# global: reach up to module scope
counter = 0

def increment():
    global counter       # without this, Python creates a local 'counter'
    counter += 1

increment(); increment()
print(counter)   # 2

# nonlocal: reach up to nearest enclosing function scope (not global)
def make_counter():
    count = 0

    def tick():
        nonlocal count   # bind to enclosing 'count', not module
        count += 1
        return count

    return tick

c = make_counter()
print(c(), c(), c())   # 1 2 3

# nonlocal vs global: key difference
x = 100
def outer2():
    x = 200
    def inner2():
        nonlocal x       # reaches outer2's x=200, NOT module x=100
        x += 1
        print(x)         # 201
    inner2()
    print(x)             # 201

outer2()
print(x)                 # still 100 – module x untouched

nonlocal is about the nearest enclosing scope, not global

nonlocal climbs the enclosing function stack one level at a time until it finds the name. It will never reach the module scope – if the name doesn't exist in any enclosing function, you get a SyntaxError at definition time, not a runtime error.

First-Class Functions

In Python, functions are objects. They can be stored in variables, put in lists, passed to other functions, and returned from functions – everything you can do with an integer or string.

first_class.py
import math

# Store in a variable
my_sqrt = math.sqrt
print(my_sqrt(16))   # 4.0

# Store in a list / dict (dispatch table)
ops = {
    "add": lambda a, b: a + b,
    "mul": lambda a, b: a * b,
    "pow": pow,
}
print(ops["pow"](2, 10))   # 1024

# Higher-order: pass as argument
def apply_twice(fn, value):
    return fn(fn(value))

print(apply_twice(lambda x: x * 3, 2))   # 18

# Higher-order: return from function
def multiplier(n):
    def mult(x):
        return x * n
    return mult

triple = multiplier(3)
print(triple(7))   # 21
print(type(triple))   # <class 'function'>

Function Annotations and the annotations Dict

annotations.py
from typing import Optional

def parse_price(raw: str, currency: str = "USD") -> Optional[float]:
    """Convert a price string like '$12.99' to a float."""
    cleaned = raw.lstrip("$£€")
    try:
        return float(cleaned)
    except ValueError:
        return None

# Annotations are stored on the function object
print(parse_price.__annotations__)
# {'raw': <class 'str'>, 'currency': <class 'str'>, 'return': typing.Optional[float]}

# Inspect module gives richer introspection
import inspect
sig = inspect.signature(parse_price)
for name, param in sig.parameters.items():
    print(f"  {name}: default={param.default!r}, annotation={param.annotation}")

output raw: default=, annotation= | currency: default='USD', annotation=

functools.partial — Partial Application

partial_app.py
from functools import partial

def power(base, exponent):
    return base ** exponent

# Freeze the exponent, create specialised functions
square = partial(power, exponent=2)
cube   = partial(power, exponent=3)

print(square(5))   # 25
print(cube(4))     # 64

# Practical: freeze a logging function's level
import logging
logging.basicConfig(level=logging.DEBUG)
debug = partial(logging.log, logging.DEBUG)
warn  = partial(logging.log, logging.WARNING)

debug("Cache miss for key=%s", "user:42")
warn("Rate limit reached for IP=%s", "10.0.0.1")

# partial preserves the original function for introspection
print(square.func)       # <function power at ...>
print(square.keywords)   # {'exponent': 2}

Story – The API client that broke every Monday

A data team maintained a dozen nearly-identical API-calling functions that differed only in endpoint URL and timeout. Every Monday someone would copy-paste one, forget to update the timeout, and introduce a production incident. The fix was a single make_api_caller(base_url, timeout) factory using functools.partial – twelve functions collapsed to twelve one-liners, each correctly inheriting its parameters from a single, well-tested base function. Bug rate dropped to zero.

Analogy – Function scope is like office building floors

When you're working on Floor 3 (local scope) and need a stapler, you look on your desk first (local), then the shared cupboard on your floor (enclosing), then the supplies room downstairs (global), and finally call building maintenance (built-in). Python stops at the first place it finds what it needs.

Consulting lens – API surface design

When designing a client SDK for a customer, use positional-only (/) for foundational identifiers (like client_id) so you can safely rename internals in future versions without deprecation warnings. Use keyword-only (after *) for every optional flag – timeout=30, retries=3, verify_ssl=True – so callers can't accidentally swap them. This style matches how the Stripe and Twilio Python SDKs are built.

Don't overload **kwargs to avoid thinking

Accepting **kwargs and forwarding it blindly makes your function impossible to document, type-check, or autocomplete. Use it only when you genuinely don't know the keys in advance (e.g., building a query-string encoder). For everything else, declare named parameters so IDEs and type-checkers can help callers.

Knowledge check

InterviewGiven def f(a, /, b, *, c): pass, which call raises a TypeError?

  • f(1, 2, c=3)
  • f(1, b=2, c=3)
  • f(a=1, b=2, c=3)
  • f(1, 2, c=99)
Answer

f(a=1, b=2, c=3)

Parameters before / are positional-only – they cannot be passed by keyword. f(a=1, ...) raises TypeError: f() got some positional-only arguments passed as keyword arguments . Parameters after * are keyword-only and must be passed by name, which c=3 satisfies.

Exercise

Exercise 4.1 · Flexible Data Transformer Write a function transform(data, /, *, fn=None, default=None) that applies fn to every element of data (a list). If fn is None, return data unchanged. If any element raises an exception, substitute default. Use functools.partial to create specialised versions: stringify (fn=str) and safe_int (fn=int, default=0). Demonstrate all three on ["1", "two", "3", None].

Show solution
exercise_4_1.py
from functools import partial

def transform(data, /, *, fn=None, default=None):
    """Apply fn to each element; return default on error."""
    if fn is None:
        return list(data)
    result = []
    for item in data:
        try:
            result.append(fn(item))
        except Exception:
            result.append(default)
    return result

stringify = partial(transform, fn=str)
safe_int  = partial(transform, fn=int, default=0)

sample = ["1", "two", "3", None]
print(stringify(sample))   # ['1', 'two', '3', 'None']
print(safe_int(sample))    # [1, 0, 3, 0]
print(transform(sample))   # ['1', 'two', '3', None]  (unchanged)

Key takeaways

  • Five parameter zones in order: positional-only / positional-or-keyword / *args / keyword-only / **kwargs.
  • Never use a mutable object (list, dict, set) as a default argument; use None and create inside the body.
  • Python resolves names in LEGB order: Local → Enclosing → Global → Built-in.
  • global reaches the module scope; nonlocal reaches the nearest enclosing function scope only.
  • Functions are first-class objects – store, pass, and return them like any value.
  • Use functools.partial to freeze parameters and create specialised variants without subclassing.
  • Annotate with type hints and document with docstrings; use inspect.signature for runtime introspection.