Python function questions are mostly about:
- Argument binding rules
- Default argument evaluation timing
- Mutability and aliasing
- Scope and closures (LEGB)
- Return behavior with try/finally
If you simulate these in order, most tricky outputs become predictable.
Python binds call arguments in this practical sequence:
- Positional arguments
- Unpacked positional (*iterable)
- Keyword arguments
- Unpacked keyword (**mapping)
- Remaining defaults
Conflict rules:
- Same parameter assigned twice -> TypeError.
- Too many positional args -> TypeError.
- Missing required args -> TypeError.
Keyword argument order in call does not matter for matching by name.
Syntax markers:
- / : parameters before this are positional-only.
-
- : parameters after this are keyword-only (unless captured by *args).
Examples of what these markers enforce are common interview traps.
Default values are created at function definition time, not each call.
Mutable defaults (list, dict, set) persist across calls:
- Using append on a default list reuses same list.
- This causes state leakage between calls.
Safe pattern:
- Use None default, then create new object inside function.
Inside function:
- Mutation changes the same object (visible outside if shared).
- Reassignment binds local name to a new object (caller object unchanged).
Example distinction:
- x.append(4) mutates shared list.
- x = x + [4] creates new list and rebinds local x.
Unpacking expands data into call-site arguments.
Important rules:
-
- provides positional arguments.
- ** provides keyword arguments.
- Multiple unpackings are merged left to right, but duplicate keys/targets raise error.
Closures capture variables by reference (name lookup at call time).
Classic trap:
- Lambdas created in loop all use final loop variable value.
Common fix:
- Bind current value as default parameter, e.g. lambda i=i: i
Lookup order: Local -> Enclosing -> Global -> Builtins.
- Assigning to a name in function creates local binding unless declared otherwise.
- nonlocal updates variable from nearest enclosing function scope.
- global updates module-level variable.
In Python, functions can be:
- Passed as arguments
- Returned from other functions
- Stored in variables/collections
- Wrapped by decorators
Decorator mental model:
- @decorator replaces original function object with wrapper returned by decorator.
finally always executes before function exits.
Critical rule:
- If finally has return, it overrides return from try/except.
This mirrors a common interview edge case and can hide errors.
Useful internals often asked:
- f.code.co_varnames -> local variable names (including params)
- f.code.co_argcount -> positional parameter count
- callable(obj) -> whether object can be invoked with ()
For any function snippet:
- Bind arguments exactly (including *, **, /, and * markers).
- Check for binding conflicts first.
- Determine whether operations mutate or rebind.
- Apply LEGB for each variable read/write.
- For closures, decide if value is late-bound or fixed via default.
- Apply try/finally return override last.
- Mutable default argument pitfalls
- Positional vs keyword binding
- *args / **kwargs unpacking and conflicts
- Positional-only (/) and keyword-only (*) rules
- Mutation vs reassignment for lists
- Closure late binding and lambda fixes
- LEGB with nonlocal/global
- Functions as first-class objects
- Decorator wrapping behavior
- try/finally return override
- map/callable/code object basics
A decorator is a function that takes another function and returns a wrapped function.
Mental model:
decorated_function = decorator(original_function)
@decorator_name is just shorthand for that replacement.
Example:
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[BEFORE] calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"[AFTER] {func.__name__} returned {result}")
return result
return wrapper
@log_call
def add(a, b):
print("Inside add()")
return a + b
total = add(10, 20)
print("Final total:", total)
print("Function name after decoration:", add.__name__)Expected output:
[BEFORE] calling add with args=(10, 20), kwargs={}
Inside add()
[AFTER] add returned 30
Final total: 30
Function name after decoration: add
Why @wraps matters:
- preserves original function metadata (
__name__, docstring) - helps debugging and tooling
def build_url(protocol, /, host, *, port=80, path="/"):
url = f"{protocol}://{host}:{port}{path}"
print("Built URL:", url)
return url
build_url("https", "example.com", port=443, path="/docs")
try:
build_url(protocol="https", host="example.com")
except TypeError as error:
print("Binding error:", error)Expected output:
Built URL: https://example.com:443/docs
Binding error: build_url() got some positional-only arguments passed as keyword arguments: 'protocol'
def add_tag_bad(tag, tags=[]):
tags.append(tag)
print("BAD tags now:", tags)
return tags
def add_tag_good(tag, tags=None):
tags = [] if tags is None else tags
tags.append(tag)
print("GOOD tags now:", tags)
return tags
add_tag_bad("python")
add_tag_bad("django")
add_tag_good("python")
add_tag_good("django")Expected output:
BAD tags now: ['python']
BAD tags now: ['python', 'django']
GOOD tags now: ['python']
GOOD tags now: ['django']
def mutate_list(values):
values.append(99)
print("Inside mutate_list:", values)
def reassign_list(values):
values = values + [99]
print("Inside reassign_list:", values)
numbers1 = [1, 2, 3]
numbers2 = [1, 2, 3]
mutate_list(numbers1)
print("After mutate_list:", numbers1)
reassign_list(numbers2)
print("After reassign_list:", numbers2)Expected output:
Inside mutate_list: [1, 2, 3, 99]
After mutate_list: [1, 2, 3, 99]
Inside reassign_list: [1, 2, 3, 99]
After reassign_list: [1, 2, 3]
bad_funcs = []
for i in range(3):
bad_funcs.append(lambda: i)
print("Late binding result:", [func() for func in bad_funcs])
good_funcs = []
for i in range(3):
good_funcs.append(lambda i=i: i)
print("Fixed closure result:", [func() for func in good_funcs])Expected output:
Late binding result: [2, 2, 2]
Fixed closure result: [0, 1, 2]
def tricky_return():
try:
print("Inside try block")
return "TRY"
finally:
print("Inside finally block")
return "FINALLY"
print("Function returned:", tricky_return())Expected output:
Inside try block
Inside finally block
Function returned: FINALLY
from functools import wraps
def repeat(times):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for run in range(1, times + 1):
print(f"Run {run}/{times}")
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}")
greet("Asha")Expected output:
Run 1/3
Hello, Asha
Run 2/3
Hello, Asha
Run 3/3
Hello, Asha
from functools import wraps
def make_upper(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
transformed = result.upper()
print("make_upper applied")
return transformed
return wrapper
def add_exclamation(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
transformed = result + "!"
print("add_exclamation applied")
return transformed
return wrapper
@add_exclamation
@make_upper
def message():
print("message() executed")
return "python decorators"
print("Final message:", message())Expected output:
message() executed
make_upper applied
add_exclamation applied
Final message: PYTHON DECORATORS!
from functools import wraps
def require_role(required_role):
def decorator(func):
@wraps(func)
def wrapper(user_role, *args, **kwargs):
if user_role != required_role:
print(f"Access denied for role={user_role}")
return None
print(f"Access granted for role={user_role}")
return func(user_role, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user_role, username):
print(f"Deleting user: {username}")
return True
print("Admin attempt:", delete_user("admin", "ravi"))
print("Guest attempt:", delete_user("guest", "ravi"))Expected output:
Access granted for role=admin
Deleting user: ravi
Admin attempt: True
Access denied for role=guest
Guest attempt: None
import time
from functools import wraps
def time_it(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} took {(end - start) * 1000:.2f} ms")
return result
return wrapper
@time_it
def compute_sum(n):
total = sum(range(n + 1))
print("Computed total:", total)
return total
compute_sum(100000)Expected output (time value will vary):
Computed total: 5000050000
compute_sum took 1.23 ms
import asyncio
from functools import wraps
def async_log(func):
@wraps(func)
async def wrapper(*args, **kwargs):
print(f"[ASYNC BEFORE] {func.__name__}")
result = await func(*args, **kwargs)
print(f"[ASYNC AFTER] {func.__name__} -> {result}")
return result
return wrapper
@async_log
async def fetch_profile(user_id):
await asyncio.sleep(0.1)
print(f"Fetching profile for user_id={user_id}")
return {"user_id": user_id, "status": "ok"}
async def main():
data = await fetch_profile(101)
print("Final data:", data)
if __name__ == "__main__":
asyncio.run(main())Expected output:
[ASYNC BEFORE] fetch_profile
Fetching profile for user_id=101
[ASYNC AFTER] fetch_profile -> {'user_id': 101, 'status': 'ok'}
Final data: {'user_id': 101, 'status': 'ok'}
- Write a decorator that retries a function 3 times on exception.
- Write a decorator that caches function results for same arguments.
- Write a decorator that validates all numeric arguments are positive.
- Refactor one script in your notes to use a logging decorator.