Defining functions
A function is defined with def, a name, parameters in parentheses, and a body. It runs only when called. Every function returns a value: if there is no return, it returns None.
1def greet(name):2 return f"Hello, {name}!"34message = greet("Alice")5print(message) # Hello, Alice!67# No return statement, implicitly returns None8def log(text):9 print(f"[LOG] {text}")1011result = log("started")12print(result) # None
Type hints
Type hints annotate what types parameters and return values should be. They are not enforced at runtime, Python ignores them during execution, but they make code clearer, enable editor autocompletion, and let type checkers like mypy catch bugs before you run the code.
1def add(x: int, y: int) -> int:2 return x + y34def greet(name: str, loud: bool = False) -> str:5 msg = f"Hello, {name}!"6 return msg.upper() if loud else msg78def first_even(nums: list[int]) -> int | None:9 return next((n for n in nums if n % 2 == 0), None)1011print(add(3, 4)) # 712print(greet("Alice", loud=True)) # HELLO, ALICE!13print(first_even([1, 3, 5, 4, 6])) # 414print(first_even([1, 3, 5])) # None
Early return
Returning early on guard conditions keeps the main logic unindented and easy to read. Sometimes called the guard clause pattern.
1# Nested version, hard to follow2def process(data):3 if data is not None:4 if len(data) > 0:5 return data[0] * 26 return None78# Guard clause version, flat and clear9def process(data):10 if data is None:11 return None12 if len(data) == 0:13 return None14 return data[0] * 21516print(process([5, 10])) # 1017print(process([])) # None
Parameters
Python has five kinds of parameters. Understanding when to use each one makes function signatures clearer and more flexible.
Positional and keyword arguments
1def connect(host, port, timeout=30):2 print(f"connecting to {host}:{port} (timeout={timeout}s)")34# Positional, order matters5connect("localhost", 5432)67# Keyword, order does not matter8connect(port=5432, host="localhost")910# Mix, positional first, then keyword11connect("localhost", timeout=60, port=5432)
Never use a mutable object as a default value. Default values are created once when the function is defined, not on each call. def f(items=[]) shares the same list across all calls. Use def f(items=None) and set the default inside the body.
*args and **kwargs
*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dict. The names args and kwargs are conventions, the * and ** are what matter.
1def summarise(*args, **kwargs):2 print("args:", args)3 print("kwargs:", kwargs)45summarise(1, 2, 3, name="Alice", role="admin")6# args: (1, 2, 3)7# kwargs: {'name': 'Alice', 'role': 'admin'}89# Forwarding, pass everything through to another function10def wrapper(*args, **kwargs):11 print("before")12 result = original(*args, **kwargs)13 print("after")14 return result
Keyword-only and positional-only
1# After *, keyword-only (must be passed by name)2def create_user(name, *, role="viewer", active=True):3 print(f"{name} | role={role} | active={active}")45create_user("Alice", role="admin")6# create_user("Alice", "admin") # TypeError, role is keyword-only78# Before /, positional-only (must be passed by position)9def distance(x, y, /):10 return (x ** 2 + y ** 2) ** 0.51112print(distance(3, 4)) # 5.013# distance(x=3, y=4) # TypeError, x, y are positional-only
See how different call patterns pack into args and kwargs.
def func(*args, **kwargs)Scope
When Python sees a name, it searches four scope levels in order: Local, Enclosing, Global, Built-in. It uses the first match it finds. If the name is not found in any scope, a NameError is raised.
1x = "global" # Global scope23def outer():4 x = "enclosing" # Enclosing scope56 def inner():7 x = "local" # Local scope8 print(x) # local, found in Local first910 inner()11 print(x) # enclosing1213outer()14print(x) # global
global and nonlocal
By default, assigning to a name inside a function creates a local variable. Use global to modify a module-level variable, and nonlocal to modify a variable in the enclosing function. Both are needed rarely, prefer returning values over modifying external state.
1count = 023def increment():4 global count # declare intent to modify the global5 count += 167increment()8increment()9print(count) # 21011# nonlocal, modifying a variable in the enclosing function12def make_counter():13 n = 014 def tick():15 nonlocal n16 n += 117 return n18 return tick1920counter = make_counter()21print(counter()) # 122print(counter()) # 223print(counter()) # 3
Click any variable below to see which scope Python resolves it from.
Lambda and higher-order functions
A lambda is a small anonymous function defined in one expression. It can take any number of arguments but can only have a single expression as its body. Use it when a function is short, used once, and the name would add no clarity.
1# Lambda syntax: lambda params: expression2double = lambda x: x * 23add = lambda x, y: x + y45print(double(5)) # 106print(add(3, 4)) # 778# Most common use, as a key function9pairs = [(3, "banana"), (1, "apple"), (2, "cherry")]10pairs.sort(key=lambda item: item[0])11print(pairs) # [(1, 'apple'), (2, 'cherry'), (3, 'banana')]1213words = ["banana", "fig", "apple", "kiwi"]14words.sort(key=lambda w: len(w))15print(words) # ['fig', 'kiwi', 'apple', 'banana']
If you assign a lambda to a variable and reuse it, write a real def instead. Lambdas are for inline, one-off use. Assigning to a variable defeats the purpose and makes stack traces harder to read.
Functions as arguments
Python functions are first-class objects, you can pass them as arguments, return them, and store them in data structures. This enables a clean style where behavior is passed in rather than hardcoded.
1def apply(func, items):2 return [func(x) for x in items]34print(apply(str.upper, ["hello", "world"])) # ['HELLO', 'WORLD']5print(apply(abs, [-3, 1, -5, 2])) # [3, 1, 5, 2]67# sorted with key= is the canonical example8students = [9 {"name": "Alice", "grade": 92},10 {"name": "Bob", "grade": 78},11 {"name": "Carol", "grade": 85},12]13ranked = sorted(students, key=lambda s: s["grade"], reverse=True)14for s in ranked:15 print(s["name"], s["grade"])
functools.partial
partial() creates a new function with some arguments pre-filled. It is cleaner than a lambda wrapper and preserves the original function's metadata.
1from functools import partial23def power(base, exp):4 return base ** exp56square = partial(power, exp=2)7cube = partial(power, exp=3)89print(square(4)) # 1610print(cube(3)) # 271112# Useful for callbacks that need extra context13def log(level, message):14 print(f"[{level}] {message}")1516info = partial(log, "INFO")17error = partial(log, "ERROR")1819info("server started") # [INFO] server started20error("connection lost") # [ERROR] connection lost
Closures
A closure is a function that remembers the variables from the scope where it was defined, even after that scope has finished executing. The inner function "closes over" the variables it references. This is how Python implements stateful functions without classes.
1def make_multiplier(factor):2 def multiply(x):3 return x * factor # factor is captured from the enclosing scope4 return multiply56double = make_multiplier(2)7triple = make_multiplier(3)89print(double(5)) # 1010print(triple(5)) # 151112# Each closure has its own captured variable13print(double(10)) # 2014print(triple(10)) # 30
Closures with nonlocal state
1def make_accumulator(initial=0):2 total = initial34 def add(n):5 nonlocal total6 total += n7 return total89 return add1011acc = make_accumulator()12print(acc(10)) # 1013print(acc(5)) # 1514print(acc(20)) # 351516# Independent accumulators17a = make_accumulator(100)18b = make_accumulator()19print(a(10)) # 11020print(b(10)) # 10
The loop closure gotcha
Closures capture variables by reference, not by value. Inside a loop, all the closures share the same variable, and by the time they run, the loop has finished and the variable holds its final value.
1# Bug, all functions print 32funcs = []3for i in range(4):4 funcs.append(lambda: i) # all capture the same 'i'56print([f() for f in funcs]) # [3, 3, 3, 3]78# Fix 1, default argument captures the current value9funcs = []10for i in range(4):11 funcs.append(lambda i=i: i)1213print([f() for f in funcs]) # [0, 1, 2, 3]1415# Fix 2, factory function16def make_fn(n):17 return lambda: n1819funcs = [make_fn(i) for i in range(4)]20print([f() for f in funcs]) # [0, 1, 2, 3]
Decorators
A decorator is a function that takes another function and returns a replacement. The @ syntax is shorthand for func = decorator(func). Decorators let you add behavior, logging, timing, caching, authentication, to functions without touching their code.
1# A decorator is just a function that wraps another2def log_calls(func):3 def wrapper(*args, **kwargs):4 print(f"calling {func.__name__}")5 result = func(*args, **kwargs)6 print(f"{func.__name__} returned {result}")7 return result8 return wrapper910# @log_calls is the same as: add = log_calls(add)11@log_calls12def add(x, y):13 return x + y1415add(3, 4)16# calling add17# add returned 7
Preserving metadata with @wraps
Without @wraps, the decorated function loses its name and docstring. Always use it.
1from functools import wraps23def log_calls(func):4 @wraps(func) # copies __name__, __doc__, etc.5 def wrapper(*args, **kwargs):6 print(f"calling {func.__name__}")7 return func(*args, **kwargs)8 return wrapper910@log_calls11def add(x: int, y: int) -> int:12 """Add two numbers."""13 return x + y1415print(add.__name__) # add (not 'wrapper')16print(add.__doc__) # Add two numbers.
Decorators with arguments
1from functools import wraps23def retry(times=3):4 def decorator(func):5 @wraps(func)6 def wrapper(*args, **kwargs):7 for attempt in range(times):8 try:9 return func(*args, **kwargs)10 except Exception as e:11 if attempt == times - 1:12 raise13 print(f"attempt {attempt + 1} failed: {e}, retrying...")14 return wrapper15 return decorator1617@retry(times=3)18def flaky_task():19 import random20 if random.random() < 0.7:21 raise RuntimeError("network error")22 return "done"
Stacking decorators
1from functools import wraps, lru_cache23def timer(func):4 @wraps(func)5 def wrapper(*args, **kwargs):6 import time7 start = time.perf_counter()8 result = func(*args, **kwargs)9 print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")10 return result11 return wrapper1213# Applied bottom-up: lru_cache first, then timer14@timer15@lru_cache(maxsize=128)16def fib(n):17 if n <= 1: return n18 return fib(n - 1) + fib(n - 2)1920fib(30)