Python Functions Deep Dive: *args, **kwargs, the Mutable Default Trap and LEGB Scope

Trace the shared-list default, unpack extra arguments, and follow Python's LEGB lookup so familiar function snippets stop producing surprising output.

KnowledgeGate Team

Exam prep & CS education

Updated 29 Jul 20265 min read

Python functions look simple, so candidates often skim them and miss the output traps interviewers reuse. Three rules cause most surprises: defaults are created at function definition time, extra arguments are packed in specific containers, and names follow LEGB lookup. Each can change the answer to a two-line snippet.

Positional, keyword, and default arguments

A positional argument binds by position. A keyword argument binds by parameter name. A default supplies a value when the caller omits that parameter.

def greet(name, message="Hello"):
    return f"{message}, {name}"

greet("Asha")                    # "Hello, Asha"
greet("Asha", "Welcome")         # "Welcome, Asha"
greet(name="Asha", message="Hi") # "Hi, Asha"

Parameters without defaults must normally come before parameters with defaults in the same positional-or-keyword group. The deeper rule is that Python evaluates a default expression once, when the def statement executes. It stores that object with the function. The expression is not evaluated again for every call.

An immutable string default rarely exposes this distinction because the function cannot alter the string in place. A mutable list makes it visible immediately.

The mutable default argument trap, traced

Consider this function:

def add(item, target=[]):
    target.append(item)
    return target

There is one list in add.__defaults__. Calls that omit target all receive a reference to that same list.

  1. add(1) appends 1. The shared list becomes [1], and the call returns [1].

  2. add(2) appends 2 to the existing list. It becomes [1, 2], not [2].

  3. add(3) appends 3 to that same object. It becomes [1, 2, 3].

The output is therefore:

[1]
[1, 2]
[1, 2, 3]

Use None as a sentinel when omission should create fresh state:

def add(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

Now the first add(1) creates a new list and returns [1]. A separate add(2) creates another new list and returns [2]. A caller can still pass an existing list deliberately, and the function will append to that object.

The function object add with __defaults__ pointing to one shared list that three calls grow from [1] to [1, 2] to [1, 2, 3], beside the None-sentinel version creating a fresh empty list for each omitted target.

Python Output-Based Questions extend this object-sharing model beyond function defaults.

Packing and unpacking with *args and **kwargs

In a function definition, *args packs extra positional arguments into a tuple. **kwargs packs extra keyword arguments into a dictionary. The names args and kwargs are conventions; the stars create the behaviour.

def f(a, *args, **kwargs):
    print(a)
    print(args)
    print(kwargs)

f(1, 2, 3, x=4, y=5)

The bindings are exact:

  • a = 1

  • args = (2, 3)

  • kwargs = {'x': 4, 'y': 5}

Python's full parameter order can include positional-only parameters before /, then positional-or-keyword parameters, *args, keyword-only parameters, and finally **kwargs.

The same symbols unpack values at the call site:

values = [2, 3]
options = {"x": 4}
f(*values, **options)

This is equivalent to f(2, 3, x=4), so a = 2, args = (3,), and kwargs = {'x': 4}. A one-item tuple needs the comma, which is why the representation is (3,).

Unpacking does not relax duplicate-binding rules. If a positional argument already filled a and the unpacked keywords also contain a, the call raises TypeError because the function received two values for one parameter.

LEGB scope and the assignment trap

Python resolves an unqualified name in this order:

  1. Local: the current function.

  2. Enclosing: any lexically enclosing function scopes, from inner to outer.

  3. Global: the module.

  4. Built-in: names supplied by Python, such as len.

Trace a closure lookup:

x = "global"

def outer():
    x = "enclosing"

    def inner():
        print(x)

    inner()

outer()

inner has no local x. Its enclosing outer scope has x = "enclosing", so lookup stops there and prints enclosing. The global value is never needed.

An LEGB lookup ladder from inner Local, shown empty, to Enclosing with x = "enclosing", then Global with x = "global", then Built-in, with the resolution arrow stopping at Enclosing.

Assignment adds an important compile-time scope decision:

count = 0

def inc():
    count += 1

Calling inc() raises UnboundLocalError. Because the function assigns to count, Python treats count as local throughout inc(). The right side tries to read that local before it has a value.

If the intention is to update the module variable, declare global count. If the variable belongs to an enclosing function, declare it nonlocal in the nested function. Often a cleaner design is to return the new value or store state in an object rather than modify a global.

First-class functions, closures, and late binding

Functions are objects. You can store one in a variable, pass it to another function, and return it from one. A nested function that keeps access to an enclosing variable is a closure.

def twice(fn, value):
    return fn(fn(value))

def make_adder(n):
    def adder(x):
        return x + n
    return adder

add5 = make_adder(5)
print(twice(add5, 1))   # 11

make_adder returns adder, which still reads n from the call that built it, so add5(1) is 6. Handing add5 to twice applies it to its own result and prints 11. That returned adder is the closure: it outlives make_adder and carries n = 5 with it.

Loop-created closures expose another output trap:

funcs = [lambda: i for i in range(3)]
print([fn() for fn in funcs])

The lambdas look up i when called, after the loop has finished with i = 2. All three therefore return 2, producing [2, 2, 2].

Capture the current value through a default:

funcs = [lambda i=i: i for i in range(3)]
print([fn() for fn in funcs])  # [0, 1, 2]

Here each lambda's default is evaluated when that lambda is created. The Python interview questions for freshers provide more chances to separate definition time from call time.

Function traps to check quickly

  • A mutable default is shared across calls that omit the argument.

  • Loop closures use late lookup unless you capture the current value.

  • Assigning to a name makes it local unless global or nonlocal says otherwise.

  • Naming a variable list, dict, or str shadows a useful built-in.

  • Mutating a passed list changes the caller-visible object; rebinding a local name does not replace the caller's variable.

  • *args is a tuple and **kwargs is a dictionary, not two special argument types.

Use the Placement Preparation category to connect these output questions with the wider interview syllabus.

The short version and your next step

Defaults are evaluated once, *args and **kwargs pack extra arguments, names resolve through LEGB, and an assignment can make a name local before the function runs. For the classic traces, remember [1], [1, 2], [1, 2, 3] for the shared default and enclosing for the LEGB example.

KnowledgeGate's question bank holds about 450 Python questions for this kind of drill. Work through them with the Python Programming course, then use the Mera Placement Hoga bundle to practise the same reasoning under interview conditions.