Ephes Blog

Miscellaneous things. Mostly Weeknotes and links I stumbled upon.

Date -

Mutable Defaults in Python: When None Isn't Enough

This post walks through Python's mutable default gotcha and explores modern solutions for when the standard None workaround isn't sufficient.

The Mutable Default Gotcha

Every Python developer eventually learns about the mutable default argument gotcha. Here's what happens:

# BAD - shared mutable default
def add_item(item, items=[]):
    items.append(item)
    return items

# First call works as expected
print(add_item("a"))  # ['a']

# But the second call reveals the problem!
print(add_item("b"))  # ['a', 'b'] - wait, what?

The issue is that default arguments are evaluated once when the function is defined, not each time it's called. So all calls to add_item() without providing items share the same list object!

The Standard Solution

This leads to the standard solution using None:

def add_item(item: str, items: list[str] | None = None) -> list[str]:
    if items is None:
        items = []
    items.append(item)
    return items

This works perfectly well for most cases. The list[str] | None annotation accurately represents what callers can pass, even though it looks a bit ugly and we immediately replace None with an empty list internally.

When None Has Meaning

The real trouble starts when None is a legitimate value in your domain:

  • Database ORMs where None represents NULL
  • Configuration systems where None means "inherit from parent"
  • Data processing where None means "no value provided" (different from "empty collection")

Now you need to distinguish between "no argument provided" and "explicitly passed None". This is where sentinel values come in.

The Search for a Better Pattern

When I read about Luke Plant's Auto sentinel pattern, it seemed elegant. The idea was to use a falsy sentinel:

Continue reading →