Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Python Optional Arguments: Default Values, the Mutable Trap, and *args

Sean

Platform Writer

Jul 18, 2026
6 min read

You make a Python argument optional by giving it a default value: def connect(host, port=5432). Now port can be omitted and defaults to 5432. That is the whole mechanism, and it is clean - but it hides the single most infamous bug in the language. Never use a mutable object like a list or dict as a default value. def f(items=[]) creates one list shared across every call, and it accumulates state between calls in ways that will baffle you. The fix is def f(items=None) and building the real default inside.

Python Optional Arguments: Default Values, the Mutable Trap, and *args

Table of contents

Default values make arguments optional

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

greet("Sam")                 # 'Hello, Sam'   - greeting defaulted
greet("Sam", "Hi")           # 'Hi, Sam'      - greeting provided

Any parameter with an =default in the definition is optional - callers can supply it or let it default. This is Python’s whole optional-argument system: no overloading, no separate signatures, just defaults.

One rule the syntax enforces: parameters with defaults must come after parameters without them:

def f(a, b=2, c=3):     # fine
def f(a=1, b):          # SyntaxError - non-default after default

Required arguments first, optional ones after. This makes sense - Python matches positional arguments left to right, so it cannot have a required argument sitting after an optional one it might have to skip. Order your parameters required-then-optional and the syntax stays out of your way.

The mutable default argument trap

This is the bug worth burning into memory:

def add_item(item, basket=[]):     # LOOKS fine, is broken
    basket.append(item)
    return basket

add_item("a")      # ['a']
add_item("b")      # ['a', 'b']   - wait, where did 'a' come from?
add_item("c")      # ['a', 'b', 'c']

The default basket=[] is evaluated once, when the function is defined - not on each call. So every call that omits basket shares the same single list, and it accumulates across calls. The function appears to remember previous calls, which is almost never what anyone wants.

The reason is that default values are created a single time at definition and stored on the function object. For an immutable default like 0 or "Hello" this is invisible. For a mutable one - a list, dict, or set - it is a shared, mutating object, and that shared state is the bug.

The None sentinel fix

The correct pattern for any mutable default is None plus build-it-inside:

def add_item(item, basket=None):
    if basket is None:
        basket = []            # a fresh list every call
    basket.append(item)
    return basket

add_item("a")      # ['a']
add_item("b")      # ['b']   - correct, independent

None is immutable, so using it as the default is safe. Inside the function, if basket is None: basket = [] creates a brand-new list on every call that did not supply one. Now each call gets its own fresh default and there is no shared state.

This pattern - param=None, then check is None and build the real default - is idiomatic Python and it applies to every mutable default: lists, dicts, sets, and any object that might be mutated. Make it automatic: the moment you type =[] or ={} as a default, stop and change it to =None. It is one of the highest-value habits in the language.

Keyword arguments and clarity

Optional arguments are often clearer when passed by name at the call site:

def create_user(name, active=True, admin=False, verified=False):
    ...

create_user("Sam", admin=True)              # clear which flag is set
create_user("Sam", True, False, True)       # what do these mean?

Passing admin=True by keyword says exactly which option you are setting; passing bare positionals leaves the reader counting arguments to figure out which True is which. For functions with several boolean or optional parameters, encourage keyword calls.

You can even force it. A bare * in the signature makes everything after it keyword-only:

def create_user(name, *, active=True, admin=False):
    ...

create_user("Sam", admin=True)     # required
create_user("Sam", True)           # TypeError - must use keywords

Everything after the * must be passed by name. This is worth using for functions with multiple flags, because it prevents the unreadable string-of-booleans call and makes every option explicit at the call site.

*args and **kwargs for the flexible cases

When you do not know how many arguments there will be, *args and **kwargs collect the extras:

def total(*args):
    return sum(args)              # args is a tuple of all positionals

total(1, 2, 3)                    # 6
total(1, 2, 3, 4, 5)              # 15

def configure(**kwargs):
    for key, value in kwargs.items():   # kwargs is a dict
        print(f"{key} = {value}")

configure(debug=True, level=3)    # accepts any keyword arguments

*args gathers any number of positional arguments into a tuple; **kwargs gathers any number of keyword arguments into a dict. They are how you write functions that accept a variable number of inputs, and how wrappers pass arguments through to another function without knowing them:

def logged(func, *args, **kwargs):
    print(f"calling {func.__name__}")
    return func(*args, **kwargs)   # forward everything

That forwarding pattern - accept *args, **kwargs and pass them straight on - is the backbone of decorators and wrappers. For everyday functions, explicit named parameters read better; reach for *args/**kwargs when the count is genuinely variable or when you are writing something that wraps another function.

How this fits the rest of the stack

The mutable-default bug is a perfect example of a mistake that passes every quick test and then corrupts state under real usage - exactly the class of bug that only shows up once code is a long-running service handling many requests. Getting defaults right is cheap; diagnosing shared-state corruption in production is not. The RunxBuild hosting calculator lays out the service, database, storage, and bandwidth as separate line items, and the RunxBuild dashboard is where the team watches deploys, logs, and restarts as they happen.

Useful related references:

FAQ

How do I make an argument optional in Python?

Give it a default value in the function definition: def connect(host, port=5432). Callers can then omit port and it defaults to 5432. Parameters with defaults must come after parameters without them, so order required arguments first.

What is the mutable default argument trap?

Using a mutable object like a list or dict as a default - def f(items=[]) - creates one object that is shared across all calls, because defaults are evaluated once at definition time. It accumulates state between calls. Use def f(items=None) and build the real default inside the function.

Why should I use None as a default instead of an empty list?

Because a default list is created once and shared by every call, so it accumulates values across calls. None is immutable and safe as a default; checking if items is None: items = [] inside the function gives each call its own fresh list. It is the standard fix for any mutable default.

**What is the difference between *args and kwargs?

*args collects any number of positional arguments into a tuple; **kwargs collects any number of keyword arguments into a dict. Use them when the argument count is variable, or in wrappers that forward arguments to another function with func(*args, **kwargs).

How do I force arguments to be passed by keyword in Python?

Put a bare * in the signature - def f(name, *, admin=False) - and everything after it must be passed by name, so f('Sam', admin=True) works but f('Sam', True) raises a TypeError. This is useful for functions with several flags, since it prevents unreadable strings of positional booleans.

#python optional arguments#python#default arguments#kwargs#dev-infra