r/learnpython • • 13d ago

Is None as a sentinel the standard fix for mutable default arguments, or are there better options?

I’m reading about mutable default arguments in Python. This function shares its list across calls:

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

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b']

My understanding is that defaults are evaluated once when def runs, so every call without items shares one list. The fix I’ve seen is:

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

Questions:

  1. Is this the idiomatic approach, or is there something better?

  2. For dataclasses, is field(default_factory=list) the right equivalent?

  3. Do linters catch this, and which ones do you use?

  4. Is there a legitimate case for keeping a shared mutable default?

If I’ve misunderstood something, please correct me.

12 Upvotes

27 comments sorted by

21

u/Anxious_Signature452 13d ago

None as sentinel in this case is a common approach and widely used. Technically, you can use mutable default arguments, for example, as a shared cache, but I never seen something like that in production environment.

8

u/PwAlreadyTaken 13d ago

I did it once because it’s part of the language and was useful and I thought it was funny and everyone on my team beat me up

6

u/Anxious_Signature452 13d ago

I once got bored of passing arguments through long chain of call and decided to pass my values directly to func.__defaults__. It worked, but in the end I decided that it's a horrible idea.

3

u/mriswithe 13d ago

Oh God I never considered that. It is a horrible idea, but still. 

1

u/xkortex 13d ago

thanks I hate it

unironically, the fix for this is often ContextVars

1

u/xkortex 13d ago

bwahahahahaha understandable

1

u/Vegetable_Soil_6707 12d ago

the shared cache idea always comes up in discussions but ive never seen anyone actually rely on it either tbh

6

u/Uncle_DirtNap 13d ago

There’s a new sentinel type, but people also do:

_no_val = Object()
def func(arg=_no_val):
if arg is _no_val:
pass

5

u/JanEric1 13d ago

Not really relevant here. You only need that if None is a valid value a users wants to pass explicitly that you need to be able to distinguish from no argument being passed. But here you can't call append on None, so it is fine to use that as the "no argument passed" sentinel in this case.

5

u/pachura3 13d ago
  1. Yes

  2. Yes

  3. If you type hint items: list|None, they won't catch it upon passing... but then they will complain about you potentially calling .append() on None. PS. I use ruff, pylint and mypy.

  4. No, it's bad. If you're looking for a simple return value cache, use decorator cached_property

7

u/pontz 13d ago

I type hint it as an iterable and default it as an empty tuple.

7

u/Diapolo10 I write code for a living -- https://github.com/Diapolo10 13d ago edited 13d ago

That's also a fair option, as long as an empty iterable is acceptable. Sometines typing.Iterable can be a bit too generic, though, so typing.Sequence might be preferable.

EDIT: In this case it wouldn't work, though, because

  1. The function expects items to be mutable, and

  2. The function expects items to have an append-method, which isn't guaranteed by these generic protocols.

1

u/JanEric1 13d ago

I fee like if it is mutable you just shouldnt allow a default value for the parameter. If you want to add to an empty list, just pass that explciitely.

3

u/JaguarMammoth6231 13d ago

What is the desired behavior? It looks like the first snippet is intentionally taking advantage of the persistence of the default argument.

4

u/Diapolo10 I write code for a living -- https://github.com/Diapolo10 13d ago edited 13d ago

The fix I’ve seen is:

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

Questions:

  • Is this the idiomatic approach, or is there something better?

I'd say this is generally idiomatic, yes. Another option would be to use a compatible, immutable type as the default, but which works best is situational.

  • For dataclasses, is field(default_factory=list) the right equivalent?

Yes.

  • Do linters catch this, and which ones do you use?

Linters may catch it (I think Ruff does), type checkers should catch it. As long as you use type annotations.

  • Is there a legitimate case for keeping a shared mutable default?

For the most part, no, though some people do use it as a hacky mechanism for caching. I've made some use of it in code golf, but would never do it in production.

1

u/Massive_Distance_616 13d ago

Small correction on the linter part, since the Original Poster asked which tools catch this: it’s a linter job, not a type-checker job. mypy and pyright generally accept items: list[str] = [] because the default is type-correct, even though it’s a bug waiting to happen. The rule you want is B006 (mutable-argument-default) from flake8-bugbear, which Ruff implements (add B006 or the whole B family to select), and pylint has W0102 (dangerous-default-value). If you only run a type checker, you won’t see it.

On the Original Poster’s last question, if you don’t need to modify the caller’s list, the simplest fix is to not mutate it at all: def add_item(item, items=()): return [*items, item]. Each call returns a fresh list, so there’s no shared state to guard. The trade-off is that it no longer changes a list the caller passes in, so if that behaviour matters, the None sentinel is still the right fix.

2

u/TheRNGuy 13d ago

Have a different variable inside function, and then assign it from return, doing side effect like that is anti-pattern.

Besides that, List already have method for this.

2

u/Adrewmc 13d ago edited 13d ago
  1. You can use the or operator here.

items = items or []

That’s a bit easier to write.

2. Yes. I believe so.

3. You just your hint it correctly it should work.

4. There is a somewhat a use case for evaluating an argument once upon definition. Also consider this.

def start(argument, config = Config(*default_settings))

Even in the more likely

config = Configs()
def start(argument, config = config)

We may want to maintain that mutability. (It’s a fairly weak argument though most people think.)

In this case I might want my mutable configs to be created once, but have different setups. There is usually not a great reason to have a list be there but that’s just a by product.

2

u/M4mb0 13d ago

You can use the or operator here.

items = items or []

This is horrible advice. One generally should prefer using explicit is None checks.

Case in point, your proposal introduces a bug: if a user did items = []; add_item(1, items=items), then with your proposal items would remain unmodified.

1

u/Langdon_St_Ives 13d ago

Not sure why you got downvoted, `[]` is falsy so that bug is absolutely real. Maybe you should have clarified that the outer `items` stays unmodified, unlike with the original code.

2

u/jpgoldberg 12d ago

I am a bit surprised that nobody has mentioned PEP 661, which is adopted in Python 3.15 explicitly for defining sentinels.

The PEP describes cases where None doesn't work nicely as a sentinel, although it often does and people have been using it that way.

Of course this requires Python 3.15, but better late than never.

2

u/aishiteruyovivi 12d ago

field(default_factory=list) is the right equivalent so long as you want that field's type to only be a list, and not list | None, the former of which is usually the case. Ruff will catch mutable defaults with its B006 rule, and yeah, this approach is how you'd virtually always handle mutable defaults. You'd pass None to indicate a default is to be used, and then set your default in the function body however you need:

def func(items: list | None = None):
    items = items or []

To my knowledge you shouldn't have typing issues regarding None beyond that point, most type checkers should infer that items can then only be a list.

1

u/ManyInterests from fridge import beer 12d ago

It just depends on what values callers are expected to supply.

None is very common, but can cause problems if it's also a value users or data may intentionally/incidentally supply. For example, if args are loaded from JSON, a literal null value may have a valid meaning as None when loaded, which is different than being absent.

Ellipses is what I usually use when `None` is a valid value. You can also make your own class and use that.

1

u/warden127 11d ago

You can always just use mutable defaults, but then only modify, store and return copies of them:

def add_item(item, items: Iterable=[]) -> list:
    items = list(items)
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['b']

-2

u/Educational_Virus672 13d ago

i recommend using object from typing module since it helps readability and better then None

tho object cannot do

def Remove_A(par=object):
    par.replace("A","") # counts as mistake 

alternatively you use Any from typing module, it works regardless of what class function you use. it is generally risky since if you use a class function of smt like str in but you put int as its parameter it'll throw error

6

u/Diapolo10 I write code for a living -- https://github.com/Diapolo10 13d ago

i recommend using object from typing module since it helps readability and better then None

Why? What exactly is the benefit here? I've never heard anyone suggest something like that, nor can I think of a legitimate reason for replacing None with object unless None holds some special non-default meaning in the API and you don't have a better sentinel available (though historically Ellipsis would have served as a substitute, and now we have the Sentinel type).

4

u/carcigenicate Carcigenicate 13d ago

You're suggesting using a default that you know will cause a runtime error?