Python lleva años resolviendo dos necesidades muy comunes con patrones improvisados: crear un valor especial que signifique “no me pasaron nada” y representar un diccionario que no pueda modificarse.
Python 3.15 convierte ambos patrones en conceptos incorporados al lenguaje con dos nuevos built-ins: sentinel y frozendict.
No son cambios espectaculares de sintaxis, pero sí eliminan bastante código ceremonial y hacen explícitas dos intenciones que antes había que inferir.
Nota de versión: al publicar este artículo, Python 3.15 está todavía en fase release candidate. La versión final está prevista para el 1 de octubre de 2026, según el calendario oficial de Python 3.15.
sentinel: cuando None no significa “no se proporcionó”
Un patrón clásico de Python consiste en usar None como valor por defecto:
def set_name(name=None):
if name is None:
...
Eso funciona hasta que None es, precisamente, un valor válido.
Imaginemos una API que permita actualizar el nombre de un usuario. Tenemos tres estados distintos:
- no se envió
name: no hay que cambiar nada; - se envió
name="Ada": hay que actualizarlo; - se envió
name=None: hay que borrar explícitamente el nombre.
Con None como valor por defecto perdemos la diferencia entre los casos 1 y 3.
Durante años, una solución habitual ha sido crear un objeto único:
MISSING = object()
def set_name(name=MISSING):
if name is MISSING:
return "no cambiar"
if name is None:
return "borrar"
return f"cambiar a {name}"
Funciona, pero tiene detalles incómodos. Su representación es poco descriptiva:
>>> MISSING
<object object at 0x...>
Python 3.15 estandariza ese patrón:
MISSING = sentinel("MISSING")
def set_name(name=MISSING):
if name is MISSING:
return "no cambiar"
if name is None:
return "borrar"
return f"cambiar a {name}"
Ahora la representación es clara:
>>> MISSING
MISSING
La idea parece pequeña, pero expresa mejor el contrato de la función:
MISSING → el caller no tomó una decisión
None → el caller decidió usar None
otro → el caller proporcionó un valor
¿Por qué comparar con is?
Un sentinel representa una identidad, no un contenido.
Por eso el patrón correcto es:
if value is MISSING:
...
y no depender de una comparación de igualdad convencional.
Cada llamada a sentinel(...) crea un objeto distinto:
A = sentinel("MISSING")
B = sentinel("MISSING")
assert A is not B
Aunque tengan el mismo nombre visible, no son el mismo sentinel. Si queremos compartir uno entre varias partes del programa, debemos crear una sola instancia y reutilizarla.
Mejor integración con typing
Una de las ventajas importantes del PEP 661 es que los sentinels pueden participar directamente en expresiones de tipos.
Por ejemplo:
MISSING = sentinel("MISSING")
def load_timeout(value: float | None | MISSING = MISSING):
...
Ese tipo documenta tres estados diferentes sin inventar una clase auxiliar:
float → timeout concreto
None → timeout desactivado
MISSING → usar la configuración heredada/default
Este patrón aparece mucho en librerías, validadores, ORMs, clientes HTTP, configuración por capas y APIs PATCH.
Copias y pickle
Un sentinel estándar también corrige otra debilidad de varias implementaciones caseras.
Los objetos sentinel preservan su identidad al copiarlos. Además, cuando el sentinel puede localizarse por módulo y nombre, puede serializarse con pickle y recuperar la misma identidad al deserializarse.
Un sentinel definido a nivel de módulo puede usarse así:
MISSING = sentinel("MISSING")
La intención es que este tipo de valor especial deje de necesitar pequeñas clases, enums o convenciones privadas solo para comportarse de manera predecible.
frozendict: un diccionario que no cambia
El otro nuevo tipo incorporado es frozendict.
Su uso básico resulta muy familiar:
config = frozendict({
"host": "localhost",
"port": 5432,
})
Podemos leerlo como cualquier mapping:
print(config["port"])
pero no modificarlo:
config["port"] = 9999
# TypeError: 'frozendict' object does not support item assignment
Esto hace explícita una propiedad que antes solíamos comunicar mediante comentarios, wrappers o disciplina del equipo:
este mapping es un valor, no un contenedor mutable
No es simplemente un dict bloqueado
frozendict no hereda de dict. Es un tipo independiente que implementa el comportamiento de un mapping inmutable.
Eso importa para código que hace comprobaciones demasiado concretas:
isinstance(value, dict)
Si una función conceptualmente acepta cualquier mapping, normalmente es mejor programarla contra la abstracción:
from collections.abc import Mapping
def process(data: Mapping):
...
Así puede recibir tanto dict como frozendict y otros tipos compatibles.
¿Por qué importa que pueda ser hashable?
Un dict normal no puede utilizarse como clave de otro diccionario ni como elemento de un set:
hash({"x": 1})
# TypeError
Un frozendict sí es hashable si todas sus claves y todos sus valores también lo son:
point = frozendict(x=10, y=20)
cache = {
point: "resultado calculado"
}
Esto abre casos interesantes para memoización, configuración declarativa, claves compuestas y estructuras de datos funcionales.
También hace posible tratar ciertos mappings como auténticos valores:
a = frozendict(x=1, y=2)
b = frozendict(y=2, x=1)
assert a == b
assert hash(a) == hash(b)
El orden de inserción se conserva al iterar, pero no forma parte de la igualdad.
Inmutabilidad superficial, no magia profunda
Hay una distinción importante: que el mapping no pueda modificarse no significa que todo lo contenido dentro de él se vuelva automáticamente inmutable.
Por ejemplo, un valor interno podría seguir siendo una lista mutable:
config = frozendict({
"servers": ["a", "b"]
})
config["servers"].append("c")
El frozendict no permite sustituir la clave servers, pero la lista almacenada sigue siendo una lista.
Además, en ese caso el frozendict tampoco será hashable, porque contiene un valor que no lo es.
Por tanto:
frozendict = mapping inmutable
frozendict ≠ congelación recursiva de todo el grafo de objetos
Dos built-ins, dos intenciones más claras
Los dos cambios comparten una filosofía.
Antes escribíamos convenciones:
MISSING = object()
Ahora podemos expresar directamente:
MISSING = sentinel("MISSING")
Y antes una API podía devolver un dict acompañado de la promesa informal “no lo modifiques”. Ahora puede representar esa propiedad en el propio tipo:
settings = frozendict(...)
En ambos casos, el lenguaje gana vocabulario para expresar intención.
Dónde usaría sentinel
Es especialmente útil cuando una función necesita distinguir entre “ausente” y un valor que ya tiene significado propio, como None, 0, False o una cadena vacía.
Casos típicos:
- argumentos opcionales de APIs;
- actualizaciones parciales tipo PATCH;
- configuración con herencia;
- parsers;
- cachés;
- ORMs y acceso a bases de datos;
- librerías que necesitan representar not supplied por separado de null.
No hace falta reemplazar todos los None del código. None sigue siendo el valor adecuado cuando solo necesitamos representar “ningún valor”. El sentinel aporta valor cuando hay que separar dos estados semánticos distintos.
Dónde usaría frozendict
Encaja cuando la identidad del objeto depende de que su mapping permanezca estable:
- configuraciones ya resueltas;
- claves de caché;
- metadatos inmutables;
- estructuras compartidas entre componentes;
- APIs que quieren garantizar que el consumidor no altere el mapping recibido;
- modelos donde igualdad y hash deben depender de un conjunto de pares clave/valor.
No sustituye a dict. Lo complementa de la misma forma que frozenset complementa a set.
Una pequeña mejora de diseño
Es fácil mirar estas novedades y pensar que son solo dos formas nuevas de escribir algo que ya podíamos construir.
Eso es cierto, pero precisamente ahí está su valor.
Los lenguajes maduros mejoran muchas veces al convertir patrones repetidos en construcciones estándar. sentinel elimina varias implementaciones privadas de “MISSING”. frozendict permite declarar inmutabilidad sin depender de una convención.
El resultado no es únicamente menos código. Es código que comunica mejor su contrato:
NOT_GIVEN = sentinel("NOT_GIVEN")
DEFAULTS = frozendict(timeout=30, retries=3)
Incluso sin leer la implementación, sabemos bastante sobre lo que esos dos objetos quieren decir.