Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 22 of 899 min

Arguments: positional, named, default, and the trap in the default

Four ways to hand a value to a function

python
def send(message, to, retries=3, urgent=False):
    ...

send("hello", "asha@example.com")                    # positional
send("hello", to="asha@example.com")                 # mixed
send(to="asha@example.com", message="hello")         # all named, any order
send("hello", "asha@example.com", 5, True)           # all positional

Positional arguments are matched by order. Named ones are matched by name and can appear in any order, but every positional argument must come before every named one, or you get SyntaxError: positional argument follows keyword argument.

That last call, send("hello", "asha@example.com", 5, True), is legal and bad. Six months later nobody knows what 5 and True mean. Passing booleans and bare numbers by name — retries=5, urgent=True — costs eight characters and removes a whole category of misreading.

Defaults are evaluated once, when the function is defined

This is the most famous trap in Python, and it is worth meeting deliberately:

python
def add_item(item, basket=[]):
    basket.append(item)
    return basket

add_item("rice")     # ['rice']
add_item("dal")      # ['rice', 'dal']   — not what anyone expected

The empty list in the def line was created once, when Python executed the def statement, and the same list is reused on every call that does not supply one. Items accumulate across calls forever.

One list in a def line, four callsImportdef add(item, basket=[]) runs. One empty list is created, now and never again.Call 1add("milk") uses the default. Returns ['milk'].Call 2add("rice") uses the same list. Returns ['milk', 'rice'] — the surprise.Call 3add("dal", []) passes a fresh list, so the default is untouched. Returns ['dal'].Call 4add("atta") is back on the default, which still holds two items. Returns three.The list was created once, when the def statement ran, and it belongs to the function rather than toany call. The fix is always the same: default to None, and make the real list inside the body.
One list in a def line, four callsImportdef add(item, basket=[]) runs. One empty listis created, now and never again.Call 1add("milk") uses the default. Returns ['milk'].Call 2add("rice") uses the same list. Returns['milk', 'rice'] — the surprise.Call 3add("dal", []) passes a fresh list, so thedefault is untouched. Returns ['dal'].Call 4add("atta") is back on the default, which stillholds two items. Returns three.The list was created once, when the def statementran, and it belongs to the function rather than toany call. The fix is always the same: default toNone, and make the real list inside the body.

The mechanism is worth holding on to, because it explains the whole rule: a def line is executed like any other statement, and the default expressions in it are evaluated at that moment, not at call time.

The fix is always the same:

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

Immutable defaults — numbers, strings, True, None, tuples — cannot be modified, so they are safe. Never use a list, dict or set as a default value.

The same trap has a subtler form: def log(msg, when=datetime.now()) stamps every message with the time the module was imported.

*args and **kwargs

To accept any number of positional arguments, put a star in front of a name:

python
def total(*amounts):
    return sum(amounts)

total(10, 20, 30)      # amounts is the tuple (10, 20, 30)

Two stars collects any named arguments into a dictionary:

python
def make_request(url, **options):
    print(url, options)

make_request("/chat", timeout=30, stream=True)
# /chat {'timeout': 30, 'stream': True}

The names args and kwargs are convention only; the stars do the work. You will meet them constantly in library code and in wrappers that pass arguments through to something else:

python
def logged_call(func, *args, **kwargs):
    print(f"calling {func.__name__}")
    return func(*args, **kwargs)

A star in a call does the reverse: it unpacks. func(*[1, 2]) calls func(1, 2), and func(**{"a": 1}) calls func(a=1). Same symbol, opposite direction, decided by whether you are defining or calling.

Forcing arguments to be named

Anything after a bare * in the parameter list can only be passed by name:

python
def resize(image, *, width, height):
    ...

resize(img, width=800, height=600)     # fine
resize(img, 800, 600)                  # TypeError

This is how a library stops you from silently swapping two same-typed arguments, and it is worth doing in your own code for any function with more than about three parameters. It also lets you reorder or add parameters later without breaking callers.

The mirror image is /, which forces the parameters before it to be positional. You mostly meet it in the standard library's documentation rather than writing it yourself.

Arguments are passed by object reference

Python does not copy what you pass in and does not hand over a pointer to your variable. The function receives the same object the caller had:

python
def spoil(items):
    items.append("surprise")

basket = ["rice"]
spoil(basket)
print(basket)        # ['rice', 'surprise']

The function mutated the caller's list. But rebinding does not escape:

python
def replace(items):
    items = ["new"]      # rebinds the local name only

basket = ["rice"]
replace(basket)
print(basket)        # ['rice']

The one rule that covers both: mutating the object is visible to the caller; assigning a new object to the parameter name is not. Half of all confusion about Python's argument passing dissolves once that sentence is in place.

If a function must not modify what it is given, copy at the top of it, and say so in the docstring. If it is meant to modify in place, return None, following the convention that mutating operations do not hand back a value.

The one thing to keep

Default values are created once when the `def` line runs, so a mutable default is shared by every call, and a function can mutate the object you passed but cannot rebind your name.

Before you move on

A caching helper is written as `def remember(key, store={}): store[key] = time.time(); return store`. It is called from two unrelated modules and each reports seeing the other's keys. What is the mechanism?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly