Skip to Content
Functions

Functions

Functions are reusable blocks of code that accept inputs, perform a task, and optionally return a result.

Functions are the backbone of every Python program. Understanding how arguments, scope, closures, and decorators work will separate a confident Python developer from someone who just knows the syntax.

Defining and Calling Functions

A function is defined with def, a name, parentheses, and a colon. The body is indented. Call it by writing its name followed by ().

def greet(): print("Hello") greet() # Hello

Naming: Use lowercase with underscores (snake_case). Names should describe what the function does, not how.

return: A function without an explicit return statement returns None implicitly. return exits immediately; code after it in the same block does not run.

def add(a, b): return a + b result = add(2, 4) # 6

Parameters vs Arguments

These two words are often used interchangeably, but they mean different things.

TermDefinitionExample
ParameterThe variable name listed in the function definitiondef add(a, b): - a and b
ArgumentThe actual value passed when calling the functionadd(2, 4) - 2 and 4

Parameters are placeholders. Arguments fill those placeholders.

Positional Arguments

Arguments are matched to parameters by position - first argument to first parameter, second to second, and so on.

def subtract(x, y): return x - y subtract(8, 2) # 6 (x=8, y=2) subtract(2, 8) # -6 (x=2, y=8) - order matters

Keyword Arguments

Pass arguments by name so order does not matter. Any parameter can be addressed by name.

def describe(name, age): print(f"{name} is {age}") describe("Alice", 20) # positional describe(age=20, name="Alice") # keyword - same result, different order describe("Alice", age=20) # mix: positional first, then keyword

Rule: Positional arguments must come before keyword arguments in a call.

describe(name="Alice", 20) # SyntaxError - keyword before positional describe("Alice", age=20) # correct - positional first, then keyword

Default Parameter Values

Give a parameter a default so it becomes optional.

def power(base, exp=2): return base ** exp power(4) # 16 - exp defaults to 2 power(2, 4) # 16 - exp overridden

Mutable default trap: Do not use a mutable object (list, dict) as a default. Python creates the default value once when the function is defined, not on each call.

# Wrong def append_to(x, lst=[]): lst.append(x) return lst append_to(2) # [2] append_to(4) # [2, 4] - same list reused (surprise!) # Correct def append_to(x, lst=None): if lst is None: lst = [] lst.append(x) return lst

Ordering rule: Parameters with defaults must come after parameters without defaults.

def f(x=2, y): # SyntaxError - default before non-default pass def f(y, x=2): # correct - non-default first, default last pass

*args - Variable Positional Arguments

Use *args to accept any number of positional arguments. Inside the function, args is a tuple.

def total(*args): return sum(args) total(2, 4) # 6 total(2, 4, 6, 8) # 20 total() # 0

Spread with *: When calling, prefix a list or tuple with * to unpack it as positional arguments.

nums = [2, 4, 6] total(*nums) # same as total(2, 4, 6) -> 12

**kwargs - Variable Keyword Arguments

Use **kwargs to accept any number of keyword arguments. Inside the function, kwargs is a dict.

def display(**kwargs): for key, value in kwargs.items(): print(f"{key}: {value}") display(x=2, y=4, z=6) # x: 2 # y: 4 # z: 6

Spread with **: When calling, prefix a dict with ** to unpack it as keyword arguments.

data = {"x": 2, "y": 4} display(**data) # same as display(x=2, y=4)

Parameter Order

When mixing all parameter types, Python requires a specific order:

PositionTypeExample
1Regular positionala, b
2*args*args
3Keyword-only (after *args)c, d
4**kwargs**kwargs
def full(a, b, *args, c=0, **kwargs): print(a, b, args, c, kwargs) full(2, 4, 6, 8, c=10, x=12) # 2 4 (6, 8) 10 {'x': 12}

Keyword-Only Arguments

Any parameter after *args (or a bare *) can only be passed by keyword, not positionally.

def connect(host, *, port=80, secure=False): print(host, port, secure) connect("example.com", port=8080, secure=True) # connect("example.com", 8080) # TypeError - port must be keyword

The bare * signals “no more positional arguments after this point” without collecting them into a tuple.

Positional-Only Arguments (Python 3.8+)

Parameters before a / in the signature can only be passed positionally - they cannot be passed by name.

def distance(x, y, /): return (x ** 2 + y ** 2) ** 0.5 distance(2, 4) # ok # distance(x=2, y=4) # TypeError - x and y are positional-only

This is useful when parameter names are implementation details and callers should not depend on them.

Return Values

return sends a value back to the caller and exits the function.

def square(n): return n * n result = square(4) # 16

Multiple return values: Python actually returns a single tuple. The parentheses are optional.

def min_max(nums): return min(nums), max(nums) # returns tuple (min, max) lo, hi = min_max([2, 8, 4, 6]) # unpack into two names # lo=2, hi=8

Early return: Return can appear anywhere in the body; the first one reached exits the function.

def first_even(nums): for n in nums: if n % 2 == 0: return n return None # none found

Scope and the LEGB Rule

Python resolves names by searching four scopes in this order: Local, Enclosing, Global, Built-in.

ScopeWhereExample
LocalInside the current functionVariable declared in def f():
EnclosingInside an outer function (for nested functions)Outer function’s variables
GlobalModule level (top of the file)x = 2 at module level
Built-inPython’s built-in namespacelen, range, print
x = 2 # global def outer(): x = 4 # enclosing (from inner's perspective) def inner(): x = 6 # local print(x) # 6 - local wins inner() print(x) # 4 - outer's local outer() print(x) # 2 - global unchanged

global and nonlocal

By default, assignment inside a function creates a local variable even if a global with the same name exists. Use global to modify the module-level name. Use nonlocal to modify a variable in the nearest enclosing scope.

count = 0 def increment(): global count count += 2 # modifies module-level count increment() print(count) # 2
def make_counter(): n = 0 def tick(): nonlocal n n += 2 return n return tick counter = make_counter() counter() # 2 counter() # 4

Note: global and nonlocal are rarely needed in well-structured code. Prefer returning values instead of modifying outer state.

Lambda Functions

A lambda is a single-expression anonymous function. It cannot contain statements like if/else blocks or loops (only expressions).

Syntax: lambda parameters: expression

square = lambda n: n * n square(4) # 16 add = lambda a, b: a + b add(2, 6) # 8

Lambdas shine as short callbacks to sorted(), map(), filter(), etc.

pairs = [(2, "b"), (4, "a"), (6, "c")] pairs.sort(key=lambda p: p[1]) # sort by second element # [(4, 'a'), (2, 'b'), (6, 'c')]

Limit: Only use lambdas for genuinely simple cases. A named def is clearer and easier to test whenever the logic is more than a line.

Higher-Order Functions

A higher-order function is a function that accepts functions as arguments or returns a function.

map()

Apply a function to every element of an iterable. Returns a lazy iterator; wrap in list() to get a list.

nums = [2, 4, 6, 8] doubled = list(map(lambda x: x * 2, nums)) # [4, 8, 12, 16]

filter()

Keep only the elements where a function returns truthy. Also lazy.

nums = [2, 4, 6, 8, 10] big = list(filter(lambda x: x > 6, nums)) # [8, 10]

zip()

Pair elements from multiple iterables. Stops at the shortest.

keys = ["a", "b", "c"] vals = [2, 4, 6] pairs = list(zip(keys, vals)) # [('a', 2), ('b', 4), ('c', 6)]

sorted() with key

Returns a new sorted list; original unchanged. The key function is called once per element.

words = ["banana", "fig", "apple", "date"] words_by_length = sorted(words, key=len) # ['fig', 'date', 'apple', 'banana']

List comprehensions vs map/filter: In Python, comprehensions are generally preferred for readability.

nums = [2, 4, 6, 8] doubled = [x * 2 for x in nums] # preferred over map big = [x for x in nums if x > 4] # preferred over filter

Nested Functions

A function defined inside another function is called a nested function (or inner function). It can access variables from the enclosing scope.

def outer(x): def inner(y): return x + y # x from enclosing scope return inner(4) outer(2) # 6

Inner functions are invisible from outside; they cannot be called directly from module level.

Closures

A closure is a nested function that remembers the variables from its enclosing scope even after the outer function has finished executing.

def multiplier(factor): def multiply(n): return n * factor # factor is "closed over" return multiply double = multiplier(2) quadruple = multiplier(4) double(6) # 12 quadruple(6) # 24

Each call to multiplier() creates a separate factor binding. double and quadruple are independent closures.

__closure__: Closures store their captured variables in fn.__closure__. Each cell holds a reference to the captured variable.

Late binding in closures: All closures in a loop capture the same variable, not its current value.

funcs = [lambda: i for i in range(4)] # i goes 0,1,2,3 [f() for f in funcs] # [3, 3, 3, 3] - all see i=3 at call time # Fix: capture the current value as a default argument funcs = [lambda i=i: i for i in range(4)] [f() for f in funcs] # [0, 1, 2, 3]

Decorators

A decorator wraps a function to extend its behavior without modifying its source. Use @decorator_name above the def line.

def shout(fn): def wrapper(*args, **kwargs): result = fn(*args, **kwargs) return str(result).upper() return wrapper @shout def greet(name): return f"hello {name}" greet("world") # 'HELLO WORLD'

@shout is exactly greet = shout(greet). The original greet is replaced by wrapper.

Preserving Metadata with functools.wraps

Without functools.wraps, the wrapped function loses its __name__, __doc__, and other attributes.

import functools def log(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): print(f"calling {fn.__name__}") return fn(*args, **kwargs) return wrapper @log def add(a, b): return a + b add.__name__ # 'add' - preserved (without wraps it would be 'wrapper')

Decorators with Arguments

To pass arguments to a decorator, add one more level of nesting.

def repeat(n): def decorator(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): for _ in range(n): fn(*args, **kwargs) return wrapper return decorator @repeat(2) def say(msg): print(msg) say("hi") # hi # hi

Stacking Decorators

Decorators are applied bottom-up.

@dec_a @dec_b def fn(): pass # equivalent to: fn = dec_a(dec_b(fn))

Recursion

A function calls itself to solve a smaller version of the same problem.

def factorial(n): if n == 0: # base case return 1 return n * factorial(n - 1) # recursive case factorial(4) # 24 factorial(0) # 1

Every recursive function needs a base case (stops the recursion) and a recursive case (calls itself with a simpler input).

Recursion limit: Python has a default recursion limit of 1000 (sys.getrecursionlimit()). Exceeding it raises RecursionError. Deep recursion is better handled with iteration or an explicit stack.

import sys sys.getrecursionlimit() # 1000 (default)

Fibonacci example:

def fib(n): if n <= 1: return n return fib(n - 2) + fib(n - 1) fib(6) # 8 (0, 1, 1, 2, 3, 5, 8)

Note: Naive recursion recomputes the same subproblems. Use functools.lru_cache or convert to iteration for large inputs.

from functools import lru_cache @lru_cache(maxsize=None) def fib(n): if n <= 1: return n return fib(n - 2) + fib(n - 1) fib(8) # 21 - fast because results are cached

Generator Functions

A generator function uses yield instead of return. Each call to next() resumes from where the last yield paused.

def even_up_to(limit): n = 0 while n <= limit: yield n n += 2 gen = even_up_to(8) next(gen) # 0 next(gen) # 2 next(gen) # 4 list(even_up_to(8)) # [0, 2, 4, 6, 8]

Generators are lazy - they produce values on demand rather than building the whole sequence in memory. Useful for large or infinite sequences.

yield from: Delegate to another iterable.

def chain(*iterables): for it in iterables: yield from it list(chain([2, 4], [6, 8])) # [2, 4, 6, 8]

Generator expression: Like a list comprehension but with () instead of []. Lazy.

gen = (x * 2 for x in [2, 4, 6]) list(gen) # [4, 8, 12]

Docstrings and Annotations

Docstrings

A docstring is the first string literal in a function body. It is stored in fn.__doc__.

def add(a, b): """Return the sum of a and b.""" return a + b add.__doc__ # 'Return the sum of a and b.' help(add) # shows the docstring

Type Annotations

Annotations describe expected types. They are not enforced at runtime - they are hints for tools and readers.

def add(a: int, b: int) -> int: return a + b def greet(name: str) -> None: print(f"Hello, {name}")

Access annotations with fn.__annotations__.

add.__annotations__ # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

functools Utilities

UtilityDescription
functools.wraps(fn)Copy metadata (__name__, __doc__) to a wrapper function
functools.lru_cache()Memoize function results (cache by arguments)
functools.partial()Create a new function with some arguments pre-filled
functools.reduce()Fold a sequence to a single value with a binary function
import functools # partial - pre-fill arguments double = functools.partial(pow, exp=2) double(base=4) # 16 # reduce - fold a sequence functools.reduce(lambda acc, x: acc + x, [2, 4, 6, 8]) # 20

Functions as First-Class Objects

In Python, functions are objects. They can be assigned to variables, stored in collections, passed as arguments, and returned from other functions.

def square(n): return n * n operations = [square, abs, lambda x: x + 2] [fn(4) for fn in operations] # [16, 4, 6]

This is what makes higher-order functions and decorators possible.

Pure Functions and Side Effects

A pure function does two things consistently:

  • Returns the same output for the same inputs, every time
  • Does not change anything outside itself
def add(a, b): return a + b # pure: no external state touched, deterministic output

A side effect is any observable change that happens outside the function’s return value:

  • Modifying a global variable
  • Mutating a mutable argument (list, dict)
  • Printing to the console
  • Writing to a file or database
  • Making a network request
results = [] def collect(x): results.append(x) # side effect: mutates an external list return x

Why It Matters for Memoization

functools.lru_cache caches return values keyed by the arguments. This only works correctly on pure functions. Two things go wrong with impure functions:

  1. Stale data - if the function reads external state (a global, a file, the current time), the same arguments can produce different results. The cache will hand back the old answer.
  2. Skipped side effects - if the function has side effects, those effects do not run on cache hits. The caller assumes they ran.
import functools counter = 0 @functools.lru_cache(maxsize=None) def bad_cached(n): global counter counter += 1 # side effect - increments a counter return n * 2 bad_cached(4) # counter becomes 1, returns 8 bad_cached(4) # cache hit - counter stays 1, but you expected 2

Rule of thumb: only memoize pure functions.

Idempotency Is Not the Same as Purity

These two are frequently confused in interviews.

ConceptDefinitionSide effects?
PureSame output for same input, zero side effects❌ None
IdempotentCalling multiple times has the same result as calling once✅ Allowed

A database “upsert” is idempotent - running it ten times leaves the database in the same state as running it once. But it is not pure because it modifies external state.

A pure function is always idempotent. An idempotent function is not necessarily pure.

Mutable Argument Side-Effect Trap

This is different from the mutable default argument trap. Here, there is no default - the caller passes a mutable object explicitly, and the function mutates it.

def double_items(lst): for i in range(len(lst)): lst[i] *= 2 # mutates the caller's list directly original = [2, 4, 6] double_items(original) print(original) # [4, 8, 12] - caller's list was changed without warning

The caller passed original but did not expect it to be modified. This is a hidden side effect.

Fix: Return a new object instead of modifying in place.

def double_items(lst): return [x * 2 for x in lst] # original untouched original = [2, 4, 6] result = double_items(original) print(original) # [2, 4, 6] print(result) # [4, 8, 12]

If in-place mutation is intentional, document it explicitly in the docstring.

Tricky Behaviors

Calling - Positional before keyword

Positional arguments must come before keyword arguments in any call. Mixing them in the wrong order is a SyntaxError, not a runtime error.

Defining - Mutable default argument

Default values are evaluated once at definition time. A mutable default like [] is shared across all calls that omit that argument. Always use None as the default and create the mutable object inside the function body.

Parameters - *args is a tuple, **kwargs is a dict

args inside the function is a tuple (immutable), not a list. Trying to call .append() on it will raise AttributeError. kwargs is a regular dict.

Parameters - Keyword-only after *

A bare * in the signature means “everything from here must be a keyword argument.” It does not collect positional arguments into any tuple.

Return - Implicit None

A function with no return, or with a bare return, returns None. Assigning the result of lst.sort(), lst.append(), or dict.update() is a common bug because all of these return None.

lst = [3, 1, 2] result = lst.sort() # bug: sort() mutates in place and returns None print(result) # None result = sorted(lst) # correct: sorted() returns a new sorted list print(result) # [1, 2, 3]

Return - Tuple packing

return a, b returns a single tuple (a, b). There are no “multiple return values” in Python - just tuple packing and unpacking.

Scope - Assignment creates a local variable

Assigning to a name inside a function makes it local for the entire function, even if the assignment appears after a read. Reading the name before the assignment raises UnboundLocalError.

x = 2 def f(): print(x) # UnboundLocalError - x is local because it is assigned below x = 4 # correct - declare global to read and rebind the module-level name x = 2 def f(): global x print(x) # 2 x = 4 # better - avoid global entirely by passing the value as an argument def f(x): print(x) return x + 2

Scope - global applies to the whole function

global x does not just affect the line where it appears. It makes x refer to the module-level name throughout the entire function body.

Lambda - No statements, only expressions

A lambda body must be a single expression. It cannot contain assignments, loops, return, or multi-line logic. When the body needs more than one step, use a named def.

double = lambda x: x * 2 # ok - single expression # correct - use def when the body needs statements def transform(x): y = x * 2 return y

Closures - Late binding

Closures capture the variable itself, not its value at the time the lambda or nested function was created. In a loop, all closures share the same loop variable, so they all see its final value when called. Fix by using a default argument to capture the value: lambda i=i: i.

Decorators - Without functools.wraps

Wrapping a function without functools.wraps replaces __name__, __doc__, and __annotations__ with those of the wrapper. This breaks introspection and tools like help().

Decorators - Stacking order

@a on top of @b means fn = a(b(fn)) - b is applied first (innermost), then a (outermost). The call order is then a’s wrapper first, then b’s wrapper, then the original function.

Recursion - No tail-call optimization

Python does not optimize tail calls. A recursive function that calls itself at the end still adds a new frame to the call stack each time. Use iteration for deep recursion.

Generators - Consuming once

A generator is exhausted after one full iteration. Calling list() on it a second time gives []. To iterate again, create a new generator.

Generators - return inside a generator

return inside a generator raises StopIteration. The value passed to return becomes the value attribute of the StopIteration exception (used in yield from).

Annotations - Not enforced

Type annotations are hints only. Python does not check them at runtime. def add(a: int, b: int) -> int: return a + b happily accepts strings if called that way.

Pure Functions - Mutable argument mutation

Passing a mutable object to a function gives the function direct access to the same object. Any mutation inside is visible to the caller after the call. This is a hidden side effect. Return a new object or work on a copy to avoid it.

Interview Questions

What is the difference between a parameter and an argument?

A parameter is the name listed in the function definition. An argument is the value passed when the function is called. Parameters are placeholders; arguments are the actual values that fill them.

What happens when a mutable object is used as a default argument?

The default is created once when the function is defined. Every call that omits the argument shares the same mutable object. Mutations in one call persist into future calls. Use None as the default and create the mutable object inside the function body.

What is the difference between *args and **kwargs?

*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dict. Both names are convention; the * and ** are the actual syntax.

What are keyword-only arguments? How do you define them?

Parameters placed after *args or a bare * in the signature. They cannot be passed positionally; the caller must always use the name. Useful when you want to force readable, explicit calls.

What are positional-only parameters? How are they defined?

Parameters listed before a / in the signature can only be passed positionally - the caller cannot use them by name. def f(x, y, /): ... makes both x and y positional-only. Useful when parameter names are implementation details the caller should not depend on.

What is the difference between return a, b and return (a, b)?

They are identical. Python packs the values into a tuple in both cases. The parentheses are optional. On the calling side, x, y = fn() unpacks the tuple into two names.

What is the LEGB rule?

Python searches for names in this order: Local (current function), Enclosing (outer functions, for closures), Global (module level), Built-in (Python’s built-in namespace). The first match wins.

What is an UnboundLocalError and when does it occur?

Python treats any name that is assigned anywhere in a function as local to that entire function. If a read of that name appears before the assignment, Python raises UnboundLocalError because the local variable exists but has no value yet.

What does global do? When should you avoid it?

global x makes x inside the function refer to the module-level variable. Modifying global state makes code harder to test and reason about. Prefer returning values or using class instances to hold state.

When should you use nonlocal?

When a nested function needs to rebind (not just read) a variable in the enclosing function’s scope. Simply reading the enclosing variable works without nonlocal. Assignment without it creates a new local variable and hides the enclosing one.

What are the limitations of a lambda function?

A lambda body is a single expression only. It cannot contain assignments, loops, return, or multi-line logic. It also cannot have a docstring. When the logic needs more than one step, a named def is the right choice.

What is a higher-order function?

A function that takes another function as an argument, returns a function, or both. Examples from the standard library: sorted() with key, map(), filter(), functools.reduce(). Decorators are also higher-order functions.

What is the difference between a list comprehension and map()/filter()?

Both transform or filter an iterable. Comprehensions tend to be more readable in Python and do not require lambda expressions. map() and filter() return lazy iterators; comprehensions return a list immediately. For simple transformations, the idiomatic Python style favors comprehensions.

What is a closure? Give an example.

A closure is a nested function that retains access to variables from its enclosing scope after the outer function has returned. The classic example is a function factory like multiplier(factor) that returns a multiply function which still knows factor.

Why do closures in a loop all see the same value?

Closures capture the variable reference, not the value at the time of creation. When the loop finishes, the captured variable holds the last value. Fix by passing the current value as a default argument: lambda i=i: i.

What does a decorator do?

A decorator replaces a function with a wrapper. @dec above def fn() is shorthand for fn = dec(fn). The wrapper can run code before and after the original function, change arguments, or change the return value.

Why should functools.wraps be used in decorators?

Without it, the wrapper’s own __name__, __doc__, and __annotations__ replace the wrapped function’s. This breaks help(), logging, and any tool that inspects function metadata. @functools.wraps(fn) copies the original metadata onto the wrapper.

How does recursion limit work in Python?

Python limits the call stack to a default depth of 1000 frames (sys.getrecursionlimit()). Exceeding this raises RecursionError. The limit can be changed with sys.setrecursionlimit(), but deep recursion risks a stack overflow. Iterative solutions or an explicit stack are safer for large inputs.

What is the difference between a generator function and a regular function?

A regular function runs to completion and returns once. A generator function uses yield and pauses at each yield, resuming on the next next() call. Generators are lazy - they produce one value at a time without holding the whole sequence in memory.

What happens when a generator is exhausted?

Once the generator function reaches the end (or a bare return), it raises StopIteration. Any for loop or list() call that drives the generator catches this and stops. Calling next() on an exhausted generator raises StopIteration immediately.

Are type annotations enforced at runtime?

No. Annotations like def add(a: int) -> int are stored in fn.__annotations__ but Python does not check them at runtime. They are hints for developers and tools like mypy or IDEs.

How does functools.lru_cache speed up recursive functions?

It memoizes calls by caching the return value for each unique set of arguments. On a repeated call with the same arguments, the cached result is returned immediately instead of recomputing. For Fibonacci, this changes the complexity from exponential to linear.

What is functools.partial?

It creates a new callable by pre-filling some arguments of an existing function. functools.partial(pow, exp=2) returns a callable that computes pow(base, exp=2) for any base. Useful for adapting a function to a narrower interface.

What is a pure function?

A function that always returns the same output for the same inputs and has no side effects. It does not read or modify anything outside its own scope - no globals, no mutations of arguments, no I/O.

What counts as a side effect in a function?

Any observable change outside the return value: modifying a global variable, mutating a mutable argument, printing to the console, writing to a file, or making a network call.

Why does memoization require pure functions?

Memoization caches the return value per unique set of arguments. If the function reads external state, the cache can return stale results when that state changes. If the function has side effects, those effects are skipped on every cache hit. Both cases produce silently wrong behavior.

What is the difference between a pure function and an idempotent function?

A pure function has no side effects and always produces the same output for the same input. An idempotent function may have side effects, but calling it multiple times produces the same net result as calling it once. Every pure function is idempotent, but not every idempotent function is pure - a database upsert is idempotent but not pure.

What is the mutable argument side-effect trap?

When a mutable object (list, dict) is passed to a function and the function mutates it, the caller’s object is changed. This is distinct from the mutable default argument trap - no default is involved. The caller passed the object explicitly but did not expect it to be modified. The fix is to return a new object or clearly document that the function mutates its input.

Last updated on