PythonMastery
beginner 18 min read · lesson 13 of 19 in Python Fundamentals

Functions & Scoping

1 · The lesson

read

A function is a named, reusable chunk of behaviour. You give it inputs, it gives you back a value (or it doesn't). Most non-trivial Python files are mostly functions — the script-level code at the bottom is just the conductor calling them in order.

This lesson covers the shape of a function, every way to pass arguments, type hints, docstrings, the four scopes Python searches when it sees a name, and the handful of mistakes that trip everyone up the first time.


1. The Shape of a Function

python
def greet(name):
    message = f"Hi, {name}."
    return message

print(greet("Grace"))           # Hi, Grace.

Four parts: the def keyword, the name, the parameter list in parentheses, and an indented body. return hands a value back to the caller and ends the function immediately — any code after a hit return doesn't run.

A function with no return still returns something — None.

python
def log(msg):
    print(f"[log] {msg}")
    # no return statement

result = log("starting")        # [log] starting
print(result)                   # None

That None is the most common cause of "why is my variable suddenly None?" — you assigned the result of a function that doesn't return anything.


2. Positional vs Keyword Arguments

Pass arguments by position, by name, or both.

python
def greet(name, greeting):
    return f"{greeting}, {name}."

print(greet("Grace", "Hi"))                     # positional — order matters
print(greet(name="Grace", greeting="Hi"))       # keyword    — order doesn't
print(greet("Grace", greeting="Hi"))            # mixed      — positional first

Keyword arguments are self-documenting at the call site. For any function with more than two parameters — or any boolean flag — use keywords. connect("db", True, False, 30) is unreadable; connect("db", ssl=True, retry=False, timeout=30) reads itself.


3. Default Parameter Values

Give a parameter a default and the caller can omit it.

python
def power(base, exponent=2):
    return base ** exponent

print(power(5))                 # 25  — exponent defaults to 2
print(power(5, 3))              # 125
print(power(base=5, exponent=4))# 625

Parameters with defaults must come after parameters without them. def f(x=1, y): is a SyntaxError.


4. *args and **kwargs

When you don't know in advance how many arguments you'll receive, use these two.

python
def total(*numbers):            # *args  — collects extra positionals into a tuple
    return sum(numbers)

print(total(1, 2, 3))           # 6
print(total(1, 2, 3, 4, 5))     # 15


def configure(**options):       # **kwargs — collects keywords into a dict
    for key, value in options.items():
        print(f"{key} = {value}")

configure(host="localhost", port=5432, ssl=True)
+ setup added so this can run · defines numbers, options
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

numbers = _AutoMock('numbers')
options = _AutoMock('options')

The names args and kwargs are convention — the * and ** are what matter. You can combine all four kinds:

python
def call(method, *args, timeout=30, **kwargs):
    ...

Order is fixed: positional → *args → keyword-with-default → **kwargs.


5. Type Hints

Optional annotations describing what types a function expects and returns. The interpreter doesn't enforce them — they're documentation for humans and tools like mypy, pyright, and your editor.

python
def add(a: int, b: int) -> int:
    return a + b

def first_name(full: str) -> str:
    return full.split()[0]

def divide(x: float, y: float) -> float | None:
    if y == 0:
        return None
    return x / y

Skip them in throwaway scripts. Add them to any function others — including future-you — will call. The signature becomes a contract you can read without opening the body.


6. Docstrings

The first statement of a function body can be a triple-quoted string. That's the function's docstring — what help(fn) prints and what IDEs surface on hover.

python
def slugify(text: str) -> str:
    """Convert a string into a URL-safe slug.

    Lowercases, replaces whitespace with hyphens, and strips
    everything that isn't alphanumeric or a hyphen.
    """
    import re
    text = text.lower().strip()
    text = re.sub(r"\s+", "-", text)
    return re.sub(r"[^a-z0-9-]", "", text)

help(slugify)

One-line docstrings are fine for short functions. Reach for the multi-line form once a function has interesting parameters, edge cases, or return shapes worth documenting.


7. LEGB Scope

When Python sees a name, it searches four scopes in this order — Local, Enclosing, Global, Built-in.

  • Local: names defined inside the current function.
  • Enclosing: names in the function that wraps this one (only matters for nested functions).
  • Global: names defined at the top of the module — the .py file.
  • Built-in: names like print, len, range that Python provides without imports.
python
x = "global-x"                  # G

def outer():
    x = "enclosing-x"           # E (relative to inner)

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

    inner()
    print(x)                    # enclosing-x

outer()
print(x)                        # global-x
print(len("abc"))               # B — len is built-in

Python finds the closest binding. Remove the x = "local-x" line inside inner and it falls back to "enclosing-x". Remove the enclosing one too and it falls back to "global-x".


8. global and nonlocal

By default, assigning to a name inside a function creates a new local binding — it does not modify the outer one.

python
count = 0

def bump():
    count = count + 1           # UnboundLocalError — Python sees the assignment
                                # and decides count is local, then reads it before write

To genuinely mutate the outer name, declare it:

python
count = 0

def bump():
    global count
    count += 1

bump(); bump()
print(count)                    # 2

nonlocal is the same idea for the enclosing function (one step out, not all the way to module level):

python
def make_counter():
    n = 0
    def step():
        nonlocal n
        n += 1
        return n
    return step

Both are usually a smell. A function that quietly mutates module-level state is hard to test and reason about. Prefer returning a new value and letting the caller decide what to do with it. Know they exist; reach for them rarely.


9. Pure vs Impure Functions

A pure function depends only on its inputs and produces only its return value. Same inputs → same output, every time. No file writes, no network calls, no mutation of arguments, no reliance on globals.

python
# Pure — no side effects, no hidden inputs
def tax(amount: float, rate: float) -> float:
    return amount * rate

# Impure — touches I/O and the clock
import datetime
def log_tax(amount, rate):
    result = amount * rate
    with open("audit.log", "a") as f:
        f.write(f"{datetime.datetime.now()}: {result}\n")
    return result

Pure functions are trivial to test, safe to cache, and easy to reuse. Impure functions are necessary — programs that don't touch the outside world are just space heaters — but you want the impure parts concentrated at the edges and the bulk of your logic pure.

Default to pure. Push side effects to the boundary.


10. Lambda Expressions

A lambda is an anonymous, single-expression function. Useful as a one-off key= or filter predicate.

python
data = [("apple", 3), ("banana", 1), ("cherry", 2)]

# Sort by the second element of each tuple
data.sort(key=lambda pair: pair[1])
print(data)                     # [('banana', 1), ('cherry', 2), ('apple', 3)]

The body is a single expression — no statements, no return keyword (the expression's value is returned). The moment you want two lines, a conditional, or a docstring, switch to a real def. Naming the function also names the intent:

python
# Reaches the limit of lambda's usefulness:
sorted(data, key=lambda x: (-x[1], x[0].lower()))

# Better:
def by_count_desc_then_name(pair):
    return (-pair[1], pair[0].lower())

sorted(data, key=by_count_desc_then_name)
+ setup added so this can run · defines data
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

data = _AutoMock('data')

Common Mistakes

1. Mutable default arguments — the classic bug

python
def append_item(item, lst=[]):          # BAD — list is created once at def-time
    lst.append(item)
    return lst

print(append_item("a"))                 # ['a']
print(append_item("b"))                 # ['a', 'b']   — the same list!
print(append_item("c"))                 # ['a', 'b', 'c']

The default [] is evaluated once when the function is defined, and every call that omits lst shares that same list object. The fix is to use None as a sentinel and create a fresh list inside:

python
def append_item(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

Same trap applies to {} dicts, set(), or any other mutable default. Memorise this — it bites every Python developer exactly once.

2. Forgetting return

python
def double(x):
    x * 2                       # computed and thrown away

result = double(5)
print(result + 1)               # TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

The function ran, did the multiplication, returned None, and your downstream code blew up somewhere unrelated. If a function's job is to produce a value, the body must return it.

3. Shadowing built-ins as parameter names

python
def find(list, type, id):       # shadows list, type, id inside this function
    ...

Inside find, list no longer refers to the built-in — you can't write list("abc"). Use items, kind, item_id instead. Common offenders: list, dict, type, id, str, input, sum, min, max, filter, map.

4. Mutating a parameter and surprising the caller

python
def add_admin(users):
    users.append("admin")       # mutates the caller's list
    return users

team = ["alice", "bob"]
add_admin(team)
print(team)                     # ['alice', 'bob', 'admin']  — caller's list changed

Lists, dicts, and sets are passed by reference. If a function shouldn't change its inputs, work on a copy: users = list(users) at the top of the body. This connects to the aliasing trap from the Lists lesson.

5. Calling vs referencing

python
def get_user():
    return {"name": "Grace"}

print(get_user)                 # <function get_user at 0x...>  — the function object
print(get_user())               # {'name': 'Grace'}              — the return value

fn is the function. fn() runs it. Forgetting the parentheses on the call is a common source of "why is my variable a function object?" confusion.


🎯 Your Turn — Function Composition

Write compose(f, g) that returns a new function. The returned function, when called with x, computes f(g(x)) — i.e. g runs first, then f runs on the result.

python
def add_one(x):
    return x + 1

def double(x):
    return x * 2

add_one_then_double = compose(double, add_one)
print(add_one_then_double(3))   # (3 + 1) * 2 = 8
+ setup added so this can run · defines compose
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def compose(*_a, **_kw):
    print('-> compose() called')
    return _AutoMock('compose()')

Skeleton:

python
def compose(f, g):
    # TODO 1: define an inner function that takes x
    # TODO 2: have it return f(g(x))
    # TODO 3: return the inner function (don't call it!)
    ...
Hint 1 — Functions returning functions You define a function inside compose, then return it — without parentheses. The parentheses would call it; you want to hand back the function itself for the caller to invoke later.
Hint 2 — Apply g first Inside the inner function, compute g(x) first, then pass that result into f. One line: return f(g(x)).
Show full solution
python
def compose(f, g):
    def composed(x):
        return f(g(x))
    return composed


def add_one(x):
    return x + 1

def double(x):
    return x * 2

add_one_then_double = compose(double, add_one)
print(add_one_then_double(3))   # 8
print(add_one_then_double(10))  # 22

# Compose three? Just nest.
def square(x): return x * x
pipeline = compose(square, compose(double, add_one))
print(pipeline(3))              # ((3+1)*2)^2 = 64

You've just written a higher-order function — a function that takes functions and returns a function. This is the cornerstone of functional programming and a pattern you'll see all over Python: sorted(key=...), map, filter, decorators, every event-driven framework.


What You Learned

  • A function is def name(params): body. No return means it returns None.
  • Arguments pass by position or by keyword. Use keywords for clarity on calls with flags or many params.
  • Defaults go after non-defaults. Never use a mutable object as a default — use None and build inside.
  • *args collects extra positionals into a tuple; **kwargs collects extra keywords into a dict.
  • Type hints are optional but make signatures self-documenting. Docstrings live on the first line of the body.
  • LEGB: Python resolves names by looking Local → Enclosing → Global → Built-in. global and nonlocal exist; use them sparingly.
  • Pure functions are easier to test and reason about. Push side effects to the edges.
  • Lambdas are fine for one-expression callables. Promote to def the moment they grow.

Next: Dictionaries — the workhorse data structure for everything keyed.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.