Why is *args a tuple, in python?

musings by Bast on Sunday August 27th, 2023

A lot of people who aren't intimately familiar with how python does things find the fact that *args is a tuple (instead of a list) confusing. They often find it further so when they learn that **kwargs is a dictionary. Most of the time this comes from the perspective of mutability. Specifically, the fact that tuples are immutable, dictionaries are mutable, lists are mutable, and thus why do the two arg-packing options return objects of different mutabilities? Why is kwargs even mutable in the first place, isn't that unsafe? What if I want to add an argument, why can't I just .append()?

The ultimate answer is because mutability isn't actually what you should be looking at. It's just a symptom of the original decision, not the source of it. Mutability is not actually highly watched in most python code. Python is a language for "trusted adults," so provided you aren't attempting to reduce accidental mutations mutability is often left to the wayside. There's minimal reason to import FrozenDict if accidentally mutating a structure doesn't have confusing or inexplicable consequences. Otherwise, it's simply permission to do things with more power. In the case of kwargs, it's relatively straightforwards to add and remove arguments without having to worry about copying the original dictionary, mixing up mutable and immutable types, and all the other concerns that come from having mutability baked deeply into the language (as is the case in, say, rust).

Consider:

@wraps(other_function)
def my_function(**kwargs):
    kwargs["pass_through"] = True
    if "full_name" in kwargs:
        # Assume first name has no spaces (TOFIX: #gh-6980)
        kwargs["name"], kwargs["last_name"] = full_name.split(maxsplit=1)
    return other_function(**kwargs)

Here we take advantage of a bunch of random python features, tuple packing for name and last name, a forced override of pass_through (we probably should assert it's not present or something to ensure we don't silently prevent the user from overriding our current behavior), and @wraps. @wraps is from functools, and basically "copies" the type signature, documentation, and other attributes from one function to another. Here it's used lazily, I really should write my own docstring. And keep in mind that by default, wraps will overwrite a new docstring within the wrapper function with the wrapped function, so you cannot simply add another docstring and expect it to work (unfortunately).

(Aside: Fortunately, it's relatively trivial to just override this: )

def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS, updated=functools.WRAPPER_UPDATES):
    def wrapper(function):
        assignments = list(assigned)
        updates = list(updated)
        if function.__doc__:
            assignments.remove("__doc__")
            wrapper.__doc__ = function.__doc__
        return functools.wraps(wrapped, assigned=assignments, updated=updates)(function)
    return wrapper

But, back to where we were, here is what we would have to deal with if the dict was immutable:

@wraps(other_function)
def my_function(**kwargs):
    kwargs = dict(**kwarg)

    kwargs["pass_through"] = True
    if "full_name" in kwargs:
        # Assume first name has no spaces (TOFIX: #gh-6980)
        kwargs["name"], kwargs["last_name"] = full_name.split(maxsplit=1)
    return other_function(**kwargs)

Sure, it's just an extra line, but it's a confusing and relatively expensive extra line. Why do we have to create an entire extra copy of our keyword arguments? It's not like they're going to be re-used.. are they?

>>> def a(**kwargs):
…     kwargs["b"] = 1
…     print(*kwargs.items())

>>> a()
('b', 1)
>>> a(**kwargs)
('b', 1) ('c', 4)
>>> kwargs
{'b': 3, 'c': 4}

Thank god.

But this was an important test/lesson! After all, this is the alternative. There are three options here: immutable kwargs, mutable kwargs that the language constructs for you (what python does), and mutable kwargs that the language does not construct for you (are passed through).

Immutable kwargs is slightly clunky. That's about it. Since you need to construct a new set of kwargs once you're passing them in anyway (you'll find out why shortly), there's no reason for it to be locked down. It's already isolated anyway, we don't need to double-wrap.

Mutable kwargs, on the other hand, are a complete nightmare. Imagine if the above worked like this:

>>> def a(**kwargs):
…     kwargs["b"] = 1
…     print(*kwargs.items())

>>> a()
('b', 1)
>>> a(**kwargs)
('b', 1) ('c', 4)
>>> kwargs
{'b': 1, 'c': 4}

Can you imagine the accidental mutations that would happen absolutely everywhere someone wasn't paying attention? And not just that, but consider the case where kwargs are only partially constructed (and we maintain the bad behavior):

>>> def a(**kwargs):
…     kwargs["b"] = 1
…     print(*kwargs.items())

>>> a()
('b', 1)
>>> a(d=4, **kwargs)
('d', 4) ('b', 1) ('c', 4)
>>> kwargs
# …now what?

Does d get added to the kwargs dict (no, that'd be ridiculous). But what if we edit d, say, turn it into a string? Does that get added to the kwargs dict? Probably, and that's horrifying.

What if there's several nested layers, and somewhere at the bottom of the stack someone adds a parameter without doing a proper copy? And it propagates all the way up and causes an error into an entirely unrelated call stack? What an absolute nightmare of a bug.

Clearly, that's the worst option of all of them.

There technically is a fourth option, immutable and language copied, but that's a clear waste of resources so I didn't bother bringing it up.

So, in order to avoid the terror of the "full mutability" option, and to handle the case where you might merge keyword argument sets and the like (you can do ** multiple times in a function signature: )

>>> default_args = {"test": 2, "system": 3}
>>> user_args = {"user": 5}
>>> a(name="test", **default_args, **user_args)
('name', 'test') ('test', 2) ('system', 3) ('user', 5) ('b', 1)

( But you cannot "overlap" names without merging the dictionaries yourself. )

Python constructs a new kwargs dictionary for you, internally. This gets you the best of both world, at the expense of being confusing at the surface level (why is kwargs mutable?).

But then, why are regular args a tuple?

Because tuples are for data where position matters, and lists are for data where position does not.

The clearest example as to what this means and why it is is coordinates:

pos = (x, y, z)
# vs
pos = [x, y, z]

If we were to use a list then several operations no longer make sense. Take list.remove() as an example. It removes the element of a matching value. But that is meaningless when it comes to a coordinate. Sometime it will remove the X coordinate, sometimes the Y, and you get completely different logical types out of it (x, z coordinates, y, z coordinates). It's confusing and unnecessary. A tuple doesn't have .remove(). list.append is another good one: it makes no sense to "Add to the end" unless you're being careful about constraining your order, ensuring it's only called exactly the right amount of times, at which point why are you even using that function?

On a list, list[0] means "the first item".

In a tuple, tuple[0] means "item 0". In our coordinates case, that means pos[0] is the x coordinate, and it will always be the x coordinate. Things won't move, things won't shuffle, and they are exactly where they're expected to be at all times.

Perhaps a better way of saying this is that list[str, str, str] is the same type as list[str], but tuple[str, str, str] is not the same type as tuple[str, str].

This also allows us to better support heterogenous sequences. A tuple of (name, age, subscribed_since) can be a tuple[str, int, datetime], but a string of name, age, subscribed_since has to be a list[str | int | datetime] and thus you cannot rely on elements always being the type you expect.

Because lists are homogenous sequences and tuples are heterogenous sequences.

In python, unlike many other languages, type hints are optional and typing is enforced at time of use. This means that you can place heterogenous types inside a list, so long as all the code using said list functions as expected. There's no reason why you can't print, say, a list of configuration keys and values:

>>> def print_config_table(keys: dict[str, Any]):
…     largest_key = max(len(i) for i in keys.keys())
…     for key, key_value in keys.items():
…         print(key.ljust(largest_key + 1), key_value)

>>> print_config_table({"username": "Bast", "age": 150, "last_updated": 15.2})
username      Bast
age           150
last_updated  15.2

And there's no reason this needs to be any harder than the easy it already is.

On the other side we have namedtuples. Namedtuples are classes constructed from tuples to permit attribute access rather than just index access:

>>> from collections import namedtuple
>>> Coords = namedtuple("Coords", "x y z")
>>> a = Coords(5, 5, 3)
>>> print(a)
Coords(x=5, y=5, z=3)

There are a great way to convert your code from tuples over to proper data-storing classes, one level at a time (individual variables -> tuples -> namedtuples -> dataclasses -> full classes).

Additionally, tuple packing and unpacking are used in plenty of places in the language to make things easier in a straightforwards and implicit way. For example, returning multiple variables from a function:

def find_nearest_couple(location: Coords, people: list[Person]) -> tuple[Person, Person, float]:
    distance = math.inf
    found_pair = (None, None)
    for index, first_person in enumerate(people):
        for second_person in people[index:]:
            separation = distance_between(first_person, second_person)
            if separation < distance:
                distance = separation
                found_pair = (first_person, second_person)

    return *found_pair, distance

>>> find_nearest_couple(Coords(12, 12, 3), people)
(Person(name="Bob"), Person(name="Sam"), 5.5)
>>> first_person, second_person, distance = find_nearest_couple(Coords(12, 12, 3), people)
>>> first_person
Person(name="Bob")

It's absolutely excessive to require a class to be defined just for this specific functions singular return type. There's no need to repeat ourself, or to pack this into a list and then access the items directly. Imagine:

def find_nearest_couple(location: Coords, people: list[Person]) -> tuple[Person, Person, float]:
    distance = math.inf
    found_pair = (None, None)
    for index, first_person in enumerate(people):
        for second_person in people[index:]:
            separation = distance_between(first_person, second_person)
            if separation < distance:
                distance = separation
                found_pair = (first_person, second_person)

    return (found_pair[0], found_pair[1], distance)

>>> find_nearest_couple(Coords(12, 12, 3), people)
(Person(name="Bob"), Person(name="Sam"), 5.5)
>>> temp = find_nearest_couple(Coords(12, 12, 3), people)
>>> first_person = temp[0]
>>> second_person = temp[1]
>>> distance = temp[2]
>>> first_person
Person(name="Bob")

Or for data matching when you know the structure:

PERSON_RECORDS = """
BOB WILLIS | 22 YEARS | 45 42 3
SAM HANKS  | 23 YEARS | 45 47.5 3
"""

people = []
for record in PERSON_RECORDS.strip().split("\n"):
    raw_name, raw_age, raw_location = record.split("|")
    name = raw_name.strip()
    age = int(raw_age.strip().removesuffix("YEARS"))
    x, y, z = (float(i) for i in raw_location.strip().split())
    print(f"{name=} {age=} ({x=} {y=} {z=})")
    people.append(Person(name, age, Coords(x, y, z)))

# output:
name='BOB WILLIS' age=22 (x=45.0 y=42.0 z=3.0)
name='SAM HANKS' age=23 (x=45.0 y=47.5 z=3.0)

This is a very powerful feature that python provides. If all of these tuples were lists we would have to worry about the varying length of everything everywhere, type inference would need to "read through" lists (ew), and we'd lose an important typing tool: the tuple.

That's why *args is a tuple, and immutable, and **kwargs is a dictionary, and mutable. Because, ultimately, in the end, it just makes sense.

You can read the original discussion on this feature here as well as precursor stuff that dates back to 2002.