Cuando Python ejecuta una sentencia def, no solo compila el cuerpo de la función — también evalúa inmediatamente todos los valores por defecto de sus parámetros y los guarda como atributos del objeto función. Ese detalle cambia todo.
Los valores por defecto viven en function.__defaults__, una tupla que Python construye exactamente una vez: en el momento de la definición, no en cada llamada. Para tipos inmutables como int, str o tuple, esto es invisible — no importa cuántas veces reutilices el mismo entero 10, siempre obtienes 10. El problema aparece cuando el valor por defecto es un objeto mutable: una lista, un diccionario, un conjunto. Ahí todas las llamadas que no pasen ese argumento comparten el mismo objeto, y cualquier mutación persiste entre llamadas.
¿Por qué Python funciona así? Porque es completamente coherente con su modelo de objetos. En Python, un nombre es una referencia a un objeto. Cuando escribes def f(items=[]), el [] es una expresión que se evalúa una sola vez y produce un objeto lista en memoria. El parámetro items es simplemente un nombre que, por defecto, apunta a ese objeto. No hay magia especial: es el mismo mecanismo de asignación de toda la vida.
¿Cuándo necesitas esto? Los defaults mutables son casi siempre un bug involuntario, pero en rarísimas ocasiones se usan deliberadamente como caché persistente entre llamadas (un patrón oscuro que hoy se reemplaza mejor con functools.cache o atributos de clase). En la práctica, la regla es simple: nunca uses un objeto mutable como default directamente. Usa None como centinela.
El bug es silencioso y difícil de rastrear porque la función parece funcionar bien en la primera llamada, y empieza a dar resultados extraños solo a partir de la segunda.
# ── El bug clásico ──────────────────────────────────────────────────────
def append_item_buggy(value, items=[]):
items.append(value)
return items
# ── El patrón correcto con None como sentinel ───────────────────────────
def append_item(value, items=None):
if items is None:
items = [] # Se crea un objeto NUEVO en cada llamada que omita items
items.append(value)
return items
# ── Demostración de ambos comportamientos ───────────────────────────────
def main():
# Versión buggy: las tres llamadas comparten la misma lista
r1 = append_item_buggy("a")
r2 = append_item_buggy("b")
r3 = append_item_buggy("c")
print("Buggy:")
print(r1) # ['a', 'b', 'c'] ← no es ['a'], aunque solo pasamos "a"
print(r2) # ['a', 'b', 'c'] ← mismo objeto
print(r3) # ['a', 'b', 'c'] ← mismo objeto
print(r1 is r2 is r3) # True: la misma lista en memoria
print()
# Versión correcta: cada llamada sin argumento recibe una lista fresca
s1 = append_item("a")
s2 = append_item("b")
s3 = append_item("c")
print("Correcto:")
print(s1) # ['a']
print(s2) # ['b']
print(s3) # ['c']
print(s1 is s2) # False: objetos distintos
# Puedes seguir pasando una lista existente si lo necesitas
shared = [1, 2]
append_item("x", shared)
append_item("y", shared)
print(shared) # [1, 2, 'x', 'y']
# Inspeccionar el default de la función buggy revela el estado acumulado
print(append_item_buggy.__defaults__) # (['a', 'b', 'c'],)
if __name__ == "__main__":
main()
Desglosando lo que pasa
La clave está en append_item_buggy.__defaults__. Esa tupla contiene la lista que creaste cuando Python procesó el def. Cada vez que llamas a append_item_buggy("a") sin pasar items, Python no evalúa [] de nuevo — simplemente toma la referencia almacenada en __defaults__[0] y la asigna a items. Cuando haces items.append(value), estás mutando ese objeto, que seguirá ahí para la próxima llamada.
El patrón None como sentinel rompe este ciclo porque None es inmutable — no importa que sea el mismo objeto singleton en toda la aplicación, no puedes modificarlo. La línea if items is None: items = [] reasigna el nombre local items a un objeto lista nuevo en cada invocación, sin tocar los defaults de la función.
Fíjate también en que usar is None en lugar de == None no es solo estilo: is compara identidad de objeto (es el centinela exacto que pusimos), mientras que == invocaría __eq__, que para tipos personalizados podría devolver cosas inesperadas. Para comparar contra centinelas siempre usa is.
Un último detalle: este comportamiento no afecta a los keyword arguments que pasan un nuevo objeto en cada llamada. Si el caller escribe append_item("a", items=[]), ese [] sí se evalúa en el momento de la llamada — el problema solo existe cuando se usa el valor almacenado en __defaults__.
Errores que debes conocer
Error: usar un diccionario vacío como default y añadir claves dentro de la función, acumulando estado entre llamadas.
# ❌ Wrong
def register(key, value, registry={}):
registry[key] = value
return registry
register("a", 1)
register("b", 2)
print(register("c", 3)) # {'a': 1, 'b': 2, 'c': 3} — no {'c': 3}
# ✅ Right
def register(key, value, registry=None):
if registry is None:
registry = {}
registry[key] = value
return registry
El mismo objeto diccionario se reutiliza en cada llamada; el sentinel None garantiza una instancia nueva cuando el caller no provee el argumento.
Error: asumir que redefinir la función resetea el default acumulado en la versión anterior — no lo hace si ya tienes referencias a la función antigua.
# ❌ Wrong
def grow(items=[]):
items.append(1)
return items
f = grow
grow("irrelevante") # error de tipo aparte, el punto es que f y grow
# comparten __defaults__ hasta que grow se redefine
# ✅ Right
# Usa el patrón None. No dependas de redefinición para limpiar estado.
Si guardaste una referencia a la función antes de redefinirla, esa referencia sigue apuntando al objeto función original con su __defaults__ intacto — la redefinición crea un objeto función nuevo, no modifica el anterior.
N° 55