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() # HelloNaming: 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) # 6Parameters vs Arguments
These two words are often used interchangeably, but they mean different things.
| Term | Definition | Example |
|---|---|---|
| Parameter | The variable name listed in the function definition | def add(a, b): - a and b |
| Argument | The actual value passed when calling the function | add(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 mattersKeyword 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 keywordRule: 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 keywordDefault 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 overriddenMutable 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 lstOrdering 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() # 0Spread 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: 6Spread 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:
| Position | Type | Example |
|---|---|---|
| 1 | Regular positional | a, b |
| 2 | *args | *args |
| 3 | Keyword-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 keywordThe 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-onlyThis 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) # 16Multiple 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=8Early 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 foundScope and the LEGB Rule
Python resolves names by searching four scopes in this order: Local, Enclosing, Global, Built-in.
| Scope | Where | Example |
|---|---|---|
| Local | Inside the current function | Variable declared in def f(): |
| Enclosing | Inside an outer function (for nested functions) | Outer function’s variables |
| Global | Module level (top of the file) | x = 2 at module level |
| Built-in | Python’s built-in namespace | len, 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 unchangedglobal 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) # 2def make_counter():
n = 0
def tick():
nonlocal n
n += 2
return n
return tick
counter = make_counter()
counter() # 2
counter() # 4Note: 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) # 8Lambdas 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 filterNested 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) # 6Inner 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) # 24Each 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
# hiStacking 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) # 1Every 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 cachedGenerator 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 docstringType 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
| Utility | Description |
|---|---|
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]) # 20Functions 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 outputA 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 xWhy 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:
- 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.
- 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 2Rule of thumb: only memoize pure functions.
Idempotency Is Not the Same as Purity
These two are frequently confused in interviews.
| Concept | Definition | Side effects? |
|---|---|---|
| Pure | Same output for same input, zero side effects | ❌ None |
| Idempotent | Calling 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 warningThe 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 + 2Scope - 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 yClosures - 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.