1088 words
5 minutes
Avoiding boilerplate by using immutable default arguments in Python

There is a well-known gotcha in Python. Namely, the default arguments retaining the mutation if mutated.

Example from The Hitchhiker’s Guide to Python#

def append_to(element, to=[]):
    to.append(element)
    return to

append_to(12)
my_other_list = append_to(42)
print(my_other_list)

Running this code will result in [12, 42] being printed, due to the aforementioned mutation retention.

TLDR#

You can:

from collections.abc import Mapping, Sequence, Set
from types import MappingProxyType

def fn(
    set_: Set[int] = frozenset({1, 2, 3}),
    list_: Sequence[str] = ("a", "b", "c"),
    dict_: Mapping[str, int] = MappingProxyType({"a": 3}),
): ...

if you are not going to mutate the arguments. Types can, of course, be different.

Generally used solution#

The proposed solution is kinda bolierplaty, and awkward to type:

def append_to(element, to=None):
    if to is None:
        to = []
    to.append(element)
    return to

This, of course, works, but becomes really awkward once you have more arguments and want typing.

def fn(
    set_arg: set[int] | None = None,
    list_arg: list[str] | None = None,
    dict_arg: dict[str, int] | None = None,
): 
    if set_arg is None:
        set_arg = {1,2,3}
    if list_arg is None:
        list_arg = ["a", "b", "c"]
    if dict_arg is None:
        dict_arg = {"a": 3}

And even after this the type checker will still tell you that each of those variables can be None, and to avoid this you should initialise a new variable for each argument.

def fn(
    set_arg: set[int] | None = None,
    list_arg: list[str] | None = None,
    dict_arg: dict[str, int] | None = None,
): 
    if set_arg is not None:
        set_var = set_arg
    else:
        set_var = {1,2,3}
    if list_arg is not None:
        list_var = list_arg
    else:
        list_var = ["a", "b", "c"]
    if dict_arg is not None:
        dict_var = dict_arg
    else:
        dict_var = {"a": 3}

or less abhorrent:

def fn(
    set_arg: set[int] | None = None,
    list_arg: list[str] | None = None,
    dict_arg: dict[str, int] | None = None,
): 
    set_var = set_arg if set_arg is not None else {1,2,3}
    list_var = list_arg if list_arg is not None else ["a", "b", "c"]
    dict_var = dict_arg if dict_arg is not None else {"a": 3}

Note that you cannot simply:

def bad_example(
    set_arg: set[int] | None = None,
    list_arg: list[str] | None = None,
    dict_arg: dict[str, int] | None = None,
): 
    # don't
    set_var = set_arg or {1,2,3}
    list_var = list_arg or ["a", "b", "c"]
    dict_var = dict_arg or {"a": 3}

Because if user inputs empty set, list, or dict they would be disregarded and the defaults used.

Example#

Disclaimer: the example functions are far from sophisticated.

Assuming you have the following code (yielding no ruff nor pyright errors):

def is_in_set[T](x: T, set_: set[T]) -> bool:
    return x in set_


def ith_elem[T](i: int, list_: list[T]) -> T:
    return list_[i]


def at_key[T](key: str, dict_: dict[str, T]) -> T:
    return dict_[key]


if __name__ == "__main__":
    print(is_in_set(1, {1, 2, 3}))
    print(is_in_set(4, {1, 2, 3}))
    print(ith_elem(0, ["a", "b", "c"]))
    print(ith_elem(2, ["a", "b", "c"]))
    print(at_key("a", {"a": 3}))

And want to add defaults to the set_, list_, and dict_ arguments.

If you do it like this:

def is_in_set[T](x: T, set_: set[T] = {1, 2, 3}) -> bool:
    return x in set_


def ith_elem[T](i: int, list_: list[T] = ["a", "b", "c")]) -> T:
    return list_[i]


def at_key[T](key: str, dict_: dict[str, T] = {"a": 3}) -> T:
    return dict_[key]


if __name__ == "__main__":
    print(is_in_set(1, {1, 2, 3}))
    print(is_in_set(4, {1, 2, 3}))
    print(is_in_set(1))
    print(is_in_set(4))
    print(ith_elem(0, ["a", "b", "c"]))
    print(ith_elem(2, ["a", "b", "c"]))
    print(ith_elem(0))
    print(ith_elem(2))
    print(at_key("a", {"a": 3}))
    print(at_key("a"))

it will work and leave pyright happy, but doing ruff check . will yield

main.py:1:39: B006 Do not use mutable data structures for argument defaults
  |
1 | def is_in_set[T](x: T, set_: set[T] = {1, 2, 3}) -> bool:
  |                                       ^^^^^^^^^ B006
2 |     return x in set_
  |
  = help: Replace with `None`; initialize within function

main.py:5:42: B006 Do not use mutable data structures for argument defaults
  |
5 | def ith_elem[T](i: int, list_: list[T] = ["a", "b", "c"]) -> T:
  |                                          ^^^^^^^^^^^^^^^ B006
6 |     return list_[i]
  |
  = help: Replace with `None`; initialize within function

main.py:9:47: B006 Do not use mutable data structures for argument defaults
   |
 9 | def at_key[T](key: str, dict_: dict[str, T] = {"a": 3}) -> T:
   |                                               ^^^^^^^^ B006
10 |     return dict_[key]
   |
   = help: Replace with `None`; initialize within function

Found 3 errors.
No fixes available (3 hidden fixes can be enabled with the `--unsafe-fixes` option).

Using immutable default arguments would lead to a code like this:

from types import MappingProxyType


def is_in_set[T](x: T, set_: set[T] = frozenset({1, 2, 3})) -> bool:
    return x in set_


def ith_elem[T](i: int, list_: list[T] = ("a", "b", "c")) -> T:
    return list_[i]


def at_key[T](key: str, dict_: dict[str, T] = MappingProxyType({"a": 3})) -> T:
    return dict_[key]


if __name__ == "__main__":
    print(is_in_set(1, {1, 2, 3}))
    print(is_in_set(4, {1, 2, 3}))
    print(is_in_set(1))
    print(is_in_set(4))
    print(ith_elem(0, ["a", "b", "c"]))
    print(ith_elem(2, ["a", "b", "c"]))
    print(ith_elem(0))
    print(ith_elem(2))
    print(at_key("a", {"a": 3}))
    print(at_key("a"))

But it will lead to pyright errors:

/home/artur/Projects/tmp/defaults/main.py
  /home/artur/Projects/tmp/defaults/main.py:1:39 - error: Expression of type "frozenset[int]" cannot be assigned to parameter of type "set[T@is_in_set]"
    "frozenset[int]" is not assignable to "set[T@is_in_set]" (reportArgumentType)
  /home/artur/Projects/tmp/defaults/main.py:5:42 - error: Expression of type "tuple[Literal['a'], Literal['b'], Literal['c']]" cannot be assigned to parameter of type "list[T@ith_elem]"
    "tuple[Literal['a'], Literal['b'], Literal['c']]" is not assignable to "list[T@ith_elem]" (reportArgumentType)
  /home/artur/Projects/tmp/defaults/main.py:9:47 - error: "MappingProxyType" is not defined (reportUndefinedVariable)
  /home/artur/Projects/tmp/defaults/main.py:16:11 - error: Argument of type "frozenset[int]" cannot be assigned to parameter "set_" of type "set[T@is_in_set]" in function "is_in_set"
    "frozenset[int]" is not assignable to "set[T@is_in_set]" (reportArgumentType)
  /home/artur/Projects/tmp/defaults/main.py:17:11 - error: Argument of type "frozenset[int]" cannot be assigned to parameter "set_" of type "set[T@is_in_set]" in function "is_in_set"
    "frozenset[int]" is not assignable to "set[T@is_in_set]" (reportArgumentType)
  /home/artur/Projects/tmp/defaults/main.py:20:11 - error: Argument of type "tuple[Literal['a'], Literal['b'], Literal['c']]" cannot be assigned to parameter "list_" of type "list[T@ith_elem]" in function "ith_elem"
    "tuple[Literal['a'], Literal['b'], Literal['c']]" is not assignable to "list[T@ith_elem]" (reportArgumentType)
  /home/artur/Projects/tmp/defaults/main.py:21:11 - error: Argument of type "tuple[Literal['a'], Literal['b'], Literal['c']]" cannot be assigned to parameter "list_" of type "list[T@ith_elem]" in function "ith_elem"
    "tuple[Literal['a'], Literal['b'], Literal['c']]" is not assignable to "list[T@ith_elem]" (reportArgumentType)
7 errors, 0 warnings, 0 informations 

Adjusting the types int he last example to be less strict will lead to the following code:

from collections.abc import Mapping, Sequence, Set
from types import MappingProxyType


def is_in_set[T](x: T, set_: Set[T] = frozenset({1, 2, 3})) -> bool:
    return x in set_


def ith_elem[T](i: int, list_: Sequence[T] = ("a", "b", "c")) -> T:
    return list_[i]


def at_key[T](key: str, dict_: Mapping[str, T] = MappingProxyType({"a": 3})) -> T:
    return dict_[key]


if __name__ == "__main__":
    print(is_in_set(1, {1, 2, 3}))
    print(is_in_set(4, {1, 2, 3}))
    print(is_in_set(1))
    print(is_in_set(4))
    print(ith_elem(0, ["a", "b", "c"]))
    print(ith_elem(2, ["a", "b", "c"]))
    print(ith_elem(0))
    print(ith_elem(2))
    print(at_key("a", {"a": 3}))
    print(at_key("a"))

which works and makes both ruff and pyright succeed.

Note that since Python 3.9 you should import Mapping, Sequence, Set and a myriad of other types from collections.abc and not typing.

https://docs.python.org/3/library/typing.html#typing.Mapping

https://docs.python.org/3/library/typing.html#typing.Sequence

https://docs.python.org/3/library/typing.html#typing.Set

Avoiding boilerplate by using immutable default arguments in Python
https://vulwsztyn.codeberg.page/posts/avoiding-boilerplate-by-using-immutable-default-arguments-in-python/
Author
Artur Mostowski
Published at
2025-07-22
License
CC BY-NC-SA 4.0