Functions & Scoping
1 · The lesson
readA function is a named block of code that you can reuse. You give it inputs, and it returns a value (or it returns nothing). Most real Python files are made mostly of functions. The code at the bottom of the file only calls those functions in the right 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 mistakes that almost everyone makes at first.
1. The Shape of a Function
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 sends a value back to the caller and ends the function immediately. Once a return runs, the code after it in the function doesn't run.
A function with no return still returns something: None.
def log(msg): print(f"[log] {msg}") # no return statement result = log("starting") # [log] starting print(result) # None
This is the most common reason a variable is unexpectedly 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.
def greet(name, greeting): return f"{greeting}, {name}." print(greet("Grace", "Hi")) # positional (order matters) print(greet(name="Grace", greeting="Hi")) # keyword (order does not matter) print(greet("Grace", greeting="Hi")) # mixed (positional arguments first)
With keyword arguments, the call itself shows what each value means. For any function with more than two parameters, or with a True/False option, use keywords. connect("db", True, False, 30) is hard to understand. connect("db", ssl=True, retry=False, timeout=30) is clear without any comments.
3. Default Parameter Values
Give a parameter a default and the caller can omit it.
def power(base, exponent=2): return base ** exponent print(power(5)) # 25 (exponent uses its default value, 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.
def total(*numbers): # *args collects extra positional arguments 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 keyword arguments 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 only a convention. The * and ** are what matter. You can combine all four kinds:
def call(method, *args, timeout=30, **kwargs): ...
The order is fixed: positional parameters → *args → keyword-only parameters → **kwargs.
5. Type Hints
Type hints are optional annotations that describe what types a function expects and returns. Python doesn't check them when the code runs. They are documentation for people, and for tools such as mypy, pyright and your editor.
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
You can skip them in short scripts that you will delete. Add them to any function that other people (or you, months later) will call. Then the first line of the function tells you what it takes and returns, without reading the body.
6. Docstrings
The first statement of a function body can be a triple-quoted string. That string is the function's docstring. help(fn) prints it, and code editors show it when you hover over the function name.
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. Use the multi-line form when a function has parameters, edge cases or return values that need explaining.
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 level of the module (the
.pyfile). - Built-in: names like
print,len,rangethat Python provides without imports.
x = "global-x" # G def outer(): x = "enclosing-x" # E (enclosing, as seen from 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 a built-in name
Python uses the closest definition of the name. If you remove the x = "local-x" line inside inner, it prints "enclosing-x". If you remove the enclosing one too, it prints "global-x".
8. global and nonlocal
By default, assigning to a name inside a function creates a new local variable. It does not change the variable outside the function.
count = 0 def bump(): count = count + 1 # UnboundLocalError. Python sees the assignment, # so count is local, and it is read before it has a value
To change the outer variable, declare it with global:
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):
def make_counter(): n = 0 def step(): nonlocal n n += 1 return n return step
Needing either one is usually a sign that the design can be better. A function that silently changes module-level variables is hard to test and hard to understand. It is better to return a new value and let the caller decide what to do with it. Know that global and nonlocal exist, but use them rarely.
9. Pure vs Impure Functions
A pure function depends only on its inputs and produces only its return value. The same inputs always give the same output. It doesn't write files, make network calls, change its arguments or read global variables.
# Pure: no side effects and no hidden inputs def tax(amount: float, rate: float) -> float: return amount * rate # Impure: it writes to a file and reads 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 easy to test, safe to cache and easy to reuse. Impure functions are necessary too, because a useful program must read input and write output. But try to keep the impure parts in a few places at the edges of your program, and keep most of your logic pure.
10. Lambda Expressions
A lambda is a function with no name and a single expression. It is useful for a short function that you need only once, such as a key= for sorting or a test for filter.
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 and no return keyword (the value of the expression is returned). As soon as you need two lines, an if statement or a docstring, use a normal def. A named function also tells the reader what the function is for:
# This is about as much as a lambda should do: 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 · re-runs 1 earlier block on this page first
# 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') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ 'data = [("apple", 3), ("banana", 1), ("cherry", 2)]\n\n# Sort by the second element of each tuple\ndata.sort(key=lambda pair: pair[1])\nprint(data) # [(\'banana\', 1), (\'cherry\', 2), (\'apple\', 3)]', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
Common Mistakes
1. Mutable default arguments: a very common bug
def append_item(item, lst=[]): # BAD: the list is created only once, when def runs 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 the default and create a new list inside the function:
def append_item(item, lst=None): if lst is None: lst = [] lst.append(item) return lst
The same problem happens with {} dicts, set() and any other mutable default. Remember this rule, because almost every Python developer makes this mistake once.
2. Forgetting return
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 and returned None. The error then appears later, on a different line. If a function's job is to produce a value, the body must return it.
3. Shadowing built-ins as parameter names
def find(list, type, id): # hides the built-ins list, type and id inside this function ...
Inside find, list no longer refers to the built-in function, so you can't write list("abc"). Use names like items, kind and item_id instead. Built-in names that people often use by mistake: list, dict, type, id, str, input, sum, min, max, filter, map.
4. Mutating a parameter and surprising the caller
def add_admin(users): users.append("admin") # changes the caller's list return users team = ["alice", "bob"] add_admin(team) print(team) # ['alice', 'bob', 'admin'] (the caller's list changed)
Python passes the object itself to the function, not a copy. So if a function changes a list, dict or set, the caller's object changes too. 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
def get_user(): return {"name": "Grace"} print(get_user) # <function get_user at 0x...> (the function object itself) print(get_user()) # {'name': 'Grace'} (the return value)
fn is the function. fn() runs it. If you forget the parentheses, your variable holds the function object instead of the result. Beginners often get confused by this.
🎯 Your Turn: Function Composition
Write compose(f, g) that returns a new function. The returned function, when called with x, computes f(g(x)). That is, g runs first, then f runs on the result.
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:
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 insidecompose, then return it, without parentheses. The parentheses would call it. You want to return the function itself, so the caller can call it later.
Hint 2: Apply g first
Inside the inner function, computeg(x) first, then pass that result into f. One line: return f(g(x)).
Show full solution
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 # To compose three functions, nest the calls. def square(x): return x * x pipeline = compose(square, compose(double, add_one)) print(pipeline(3)) # ((3 + 1) * 2) ** 2 = 64
You have just written a higher-order function: a function that takes functions as arguments and returns a function. This is a central idea in functional programming, and you will see it in many places in Python: sorted(key=...), map, filter and decorators.
What You Learned
- A function is
def name(params): body. Noreturnmeans it returnsNone. - 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
Noneand create the object inside the function. *argscollects extra positionals into a tuple, and**kwargscollects extra keywords into a dict.- Type hints are optional, but they show what a function takes and returns. A docstring is the first statement in the body.
- LEGB: Python looks up names in this order: Local → Enclosing → Global → Built-in.
globalandnonlocalexist, but use them rarely. - Pure functions are easier to test and understand. Keep side effects at the edges of your program.
- Lambdas are fine for one-expression functions. Change them to a
defas soon as they get longer.
Next: Dictionaries, the data structure for looking up values by a key.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.