Functions and *args / **kwargs
Functions are objects, defined with def (or lambda for single-expression anonymous functions), and can be passed around, stored in data structures, and returned from other functions like any other value.
🎯 What you'll learn
- Function Basics
- Positional, Keyword, and the
*//Separators *args— Variadic Positional Arguments**kwargs— Variadic Keyword Arguments- Combining Everything — and the Required Order
1Function Basics
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
greet("Ana") # "Hello, Ana!"
greet("Ana", "Hi") # "Hi, Ana!"
greet(name="Ana", greeting="Hi") # same, by keyword
greet(greeting="Hi", name="Ana") # keyword args can be reorderedFunctions are objects, defined with def (or lambda for single-expression anonymous functions), and can be passed around, stored in data structures, and returned from other functions like any other value.
2Positional, Keyword, and the * / / Separators
def f(a, b, /, c, d, *, e, f):
# ^-------^ ^ ^-------^
# positional | keyword-only
# only positional-or-keyword
pass
f(1, 2, 3, d=4, e=5, f=6) # valid
f(a=1, b=2, c=3, d=4, e=5, f=6) # TypeError — a, b are positional-only
f(1, 2, 3, 4, 5, 6) # TypeError — e, f are keyword-only- Parameters before
/must be passed positionally (Python 3.8+). - Parameters after
*must be passed by keyword. - Everything in between can be passed either way.
This matters for API design: positional-only parameters let you rename them later without breaking callers; keyword-only parameters force call sites to be self-documenting for options that are easy to mix up (e.g. sort(data, *, reverse=True) instead of a bare trailing True).
3*args — Variadic Positional Arguments
def total(*args):
return sum(args)
total(1, 2, 3) # 6 — args is the tuple (1, 2, 3)
total() # 0 — args is ()
def show(first, *rest):
print(first, rest)
show(1, 2, 3) # 1 (2, 3)*args collects any extra positional arguments into a tuple. It can also be used at a call site to unpack an iterable into positional arguments: total(*[1, 2, 3]).
4**kwargs — Variadic Keyword Arguments
def describe(**kwargs):
return kwargs
describe(name="Ana", age=30) # {"name": "Ana", "age": 30} — a dict
def connect(host, **options):
print(host, options)
connect("localhost", timeout=5, retries=3)**kwargs collects extra keyword arguments into a dict, in the order they were passed (dicts preserve insertion order). At a call site, **some_dict unpacks a dict into keyword arguments: connect("localhost", **{"timeout": 5}).
5Combining Everything — and the Required Order
def full(a, b=2, *args, c, d=4, **kwargs):
return a, b, args, c, d, kwargs
full(1, c=3)
# (1, 2, (), 3, 4, {})
full(1, 2, 3, 4, c=5, e=6)
# (1, 2, (3, 4), 5, 4, {'e': 6})The required order in a def is: positional-only, positional-or-keyword (with defaults after non-defaults), *args, keyword-only, **kwargs. Anything after a bare *args (or a bare *) is automatically keyword-only.
6The Mutable Default Argument Trap
def append_item(item, bucket=[]): # DANGER
bucket.append(item)
return bucket
append_item(1) # [1]
append_item(2) # [1, 2] — NOT [2]! Same list reused across calls.Default argument values are evaluated exactly once, at def time — not on every call. A mutable default is therefore shared and accumulates state across unrelated calls. The idiomatic fix:
def append_item(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket7Lambdas
square = lambda x: x ** 2
sorted(["ccc", "a", "bb"], key=lambda s: len(s)) # ["a", "bb", "ccc"]lambda creates a function limited to a single expression (no statements, no annotations before 3.something in some forms) — used almost exclusively for short throwaway callbacks like sort keys, not for anything that deserves a name.
Under the HoodArgument Binding and the Function Object▾
def creates a function object once; every call reuses it
def f(a, b=1):
return a + b
print(f.__defaults__) # (1,) — the tuple of default values, built once at def time
print(f.__code__.co_argcount) # 2
print(f.__code__.co_varnames) # ('a', 'b')A def statement executes once, creating a single function object holding a reference to a compiled code object plus the tuple of default values captured at that moment. This is exactly why mutable defaults leak state between calls — bucket=[] allocates one list object when the def line runs, and __defaults__ holds a reference to that same list forever, handed out fresh to every call that doesn't override it.
Binding arguments to parameters is a real algorithm, not magic
When you call f(1, b=2), CPython runs an explicit argument-binding procedure roughly equivalent to: fill positional-or-keyword slots left to right from positional args, then fill remaining slots (and **kwargs) from keyword args, checking for duplicates and missing required parameters, then apply defaults for anything still unfilled. Modern CPython (3.8+, the "vectorcall" protocol) does this without first packing arguments into an intermediate tuple/dict for ordinary calls — it's optimized to bind straight from the C-level argument array into the callee's frame's fast-locals array for the common case, falling back to the slower generic path when *args/**kwargs unpacking is genuinely needed at the call site.
Bound methods are functions wrapped at attribute-access time
class Counter:
def increment(self):
return "incremented"
c = Counter()
m = c.increment # a NEW bound-method object created right here
print(m.__self__ is c) # True
print(m.__func__ is Counter.increment) # TrueCounter.increment is a plain function stored on the class. Accessing c.increment doesn't call it — it goes through the function object's descriptor protocol (__get__), which produces a fresh bound method object pairing that function with c as self. This is why c.increment() and Counter.increment(c) do exactly the same thing: the bound method's job is only to pre-fill the first positional argument.
⚠️ Common Pitfalls
- Mutable default arguments (
def f(x=[])) — evaluated once, shared across all calls that don't override it. UseNoneas the sentinel default and create the mutable object inside the function body instead. - Forgetting
*args/**kwargsunpacking order at call sites —f(*args, **kwargs)unpacks positionally then by keyword; passing them in the wrong order relative to explicit arguments raisesSyntaxErrorat the call site (f(**kwargs, *args)is invalid). - Shadowing builtins as parameter names —
def process(list, dict): ...works but shadows the builtinslist/dictfor the whole function body, which can cause confusing bugs later in that function. - Assuming
**kwargspreserves any particular required order — its iteration order is insertion order (dict semantics), but you should not design an API that depends on the caller passing keyword arguments in a specific order; use explicit named parameters if order-independent meaning matters.
📋 Quick Reference
def f(a, b, /, c, *, d): ... # a,b positional-only; d keyword-only
def f(*args): ... # args: tuple of extra positional args
def f(**kwargs): ... # kwargs: dict of extra keyword args
f(*some_list) # unpack list/tuple into positional args
f(**some_dict) # unpack dict into keyword args
def f(x=None): # correct mutable-default pattern
if x is None:
x = []
f.__defaults__ # tuple of default values, set once at def time🎤 Interview Q&A
Q1Explain *args and **kwargs.core▾
*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. At call sites, * and ** unpack sequences and mappings into arguments.
- Common use? Wrapper functions and decorators forwarding arguments.
Q2What's the mutable default argument pitfall?core▾
Default values are evaluated once at function definition: def f(items=[]) shares the same list across calls. Use None and create inside: items = items or []… better if items is None: items = [].
- Why
is Nonerather thanor?orreplaces legitimately empty values.
Q3What are keyword-only and positional-only parameters?core▾
Parameters after * are keyword-only (def f(a, *, timeout)); parameters before / are positional-only (def f(x, /)). They make APIs clearer and allow renaming internals safely.
- Why keyword-only flags?
f(True)is unclear;f(force=True)isn't.
Q4How are arguments passed in Python?senior▾
By object reference ('pass by assignment'): the function receives references to the same objects; rebinding a parameter doesn't affect the caller, but mutating a mutable argument does.
- How to avoid side effects? Copy or avoid mutating inputs.
Q5What are first-class functions and lambdas?core▾
Functions are objects: assignable, passable, returnable. lambda creates small anonymous expression functions, often for key= arguments; use def for anything non-trivial.
- Sort by key?
sorted(users, key=lambda u: u.age).
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.