Engineering Notes
Home/Languages/Python/Functions and *args / **kwargs
Languages · Python

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.

💻 Languages● Beginner⏱ 6 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 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

python
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 reordered

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.

2Positional, Keyword, and the * / / Separators

python
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

python
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

python
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

python
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

python
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:

python
def append_item(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

7Lambdas

python
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

python
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')
Call arguments and the __defaults__ tuple are bound into the callee frame's fast-locals array
Call arguments and the __defaults__ tuple are bound into the callee frame's fast-locals array

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

python
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)  # True

Counter.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

  1. Mutable default arguments (def f(x=[])) — evaluated once, shared across all calls that don't override it. Use None as the sentinel default and create the mutable object inside the function body instead.
  2. Forgetting *args/**kwargs unpacking order at call sites — f(*args, **kwargs) unpacks positionally then by keyword; passing them in the wrong order relative to explicit arguments raises SyntaxError at the call site (f(**kwargs, *args) is invalid).
  3. Shadowing builtins as parameter names — def process(list, dict): ... works but shadows the builtins list/dict for the whole function body, which can cause confusing bugs later in that function.
  4. Assuming **kwargs preserves 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

python
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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 = [].

Follow-ups interviewers ask
  • Why is None rather than or? or replaces 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.