frozendict no Python 3.15: Dicionário Imutável

Conheça o frozendict do Python 3.15 (PEP 814): dicionário imutável e hashable nativo, com exemplos práticos de uso em cache, sets e thread safety.

6 min de leitura Equipe python.dev.br

Python sempre teve frozenset como versão imutável de set, mas nunca ofereceu o equivalente para dicionários. Isso muda no Python 3.15 com a aprovação da PEP 814, que adiciona frozendict como tipo built-in — sem necessidade de instalar pacotes externos.

O frozendict é um dicionário imutável e hashable que pode ser usado como chave de outros dicionários, elemento de conjuntos e compartilhado entre threads sem preocupação com mutações acidentais. É uma adição que profissionais de Python pediam há mais de uma década.

Por que Python precisava de um frozendict?

Cenários onde dicionários imutáveis fazem falta são comuns no dia a dia:

  • Configurações constantes que não devem ser alteradas após a inicialização
  • Chaves compostas em dicionários ou caches (dicts não são hashable)
  • Programação funcional onde imutabilidade evita efeitos colaterais
  • Thread safety sem locks — se o dado não muda, não há race condition
  • Caching com functools.lru_cache que exige argumentos hashable

Até agora, a solução era usar types.MappingProxyType (somente leitura, mas não hashable) ou bibliotecas de terceiros. Nenhuma era ideal.

Se você está revisando conceitos de estruturas de dados em Python, vale conferir nosso artigo sobre dicionários e o guia completo de estruturas de dados.

Criando um frozendict

A API do construtor é idêntica à de dict:

# Vazio
vazio = frozendict()

# A partir de keyword arguments
config = frozendict(host="localhost", porta=5432, db="meubanco")

# A partir de um dict existente
dados = {"nome": "Maria", "idade": 30}
imutavel = frozendict(dados)

# A partir de iterável de pares
pares = frozendict([("a", 1), ("b", 2), ("c", 3)])

# Combinando dict + kwargs
completo = frozendict({"x": 10}, y=20, z=30)

O frozendict preserva a ordem de inserção, assim como dict desde o Python 3.7.

Operações suportadas

O frozendict implementa a interface collections.abc.Mapping, então todas as operações de leitura funcionam normalmente:

config = frozendict(host="localhost", porta=5432, ssl=True)

# Acesso por chave
print(config["host"])       # 'localhost'
print(config.get("ssl"))    # True
print(config.get("timeout", 30))  # 30

# Iteração
for chave in config:
    print(chave)
# host, porta, ssl

# Verificação de pertencimento
print("porta" in config)    # True

# Tamanho
print(len(config))          # 3

# Views
print(list(config.keys()))    # ['host', 'porta', 'ssl']
print(list(config.values()))  # ['localhost', 5432, True]
print(list(config.items()))   # [('host', 'localhost'), ('porta', 5432), ('ssl', True)]

O que NÃO funciona (e é proposital)

Todas as operações de mutação lançam TypeError:

config = frozendict(host="localhost", porta=5432)

config["host"] = "remoto"      # TypeError
del config["porta"]            # TypeError
config.update(ssl=True)        # AttributeError — método não existe
config.pop("host")             # AttributeError
config.clear()                 # AttributeError
config.setdefault("timeout")   # AttributeError
config.popitem()               # AttributeError

Isso é garantido pelo design: frozendict não herda de dict, então não há como burlar a imutabilidade via métodos herdados.

Hashabilidade: a grande vantagem

A principal diferença prática entre frozendict e um dict somente leitura é que frozendict é hashable (quando todos os valores também são hashable):

# Usar como chave de dicionário
permissoes = {
    frozendict(role="admin", nivel=3): ["ler", "escrever", "deletar"],
    frozendict(role="editor", nivel=2): ["ler", "escrever"],
    frozendict(role="leitor", nivel=1): ["ler"],
}

# Usar em conjuntos (sets)
configs_unicas = {
    frozendict(env="prod", regiao="us-east"),
    frozendict(env="prod", regiao="eu-west"),
    frozendict(env="staging", regiao="us-east"),
}

O hash é calculado de forma independente da ordem de inserção:

a = frozendict(x=1, y=2)
b = frozendict(y=2, x=1)

print(a == b)            # True
print(hash(a) == hash(b))  # True

Internamente, o hash equivale a hash(frozenset(fd.items())).

Se valores não hashable estiverem presentes, hash() levanta TypeError:

fd = frozendict(dados=[1, 2, 3])  # Criação funciona (listas como valor)
hash(fd)  # TypeError: unhashable type: 'list'

Para entender melhor hashabilidade em Python, veja nosso glossário sobre sets e tuplas.

Operador de merge ( | )

O frozendict suporta os operadores | e |=, seguindo o mesmo padrão de dict desde o Python 3.9:

base = frozendict(host="localhost", porta=5432)
extra = frozendict(porta=3306, db="mysql")

# Merge cria novo frozendict (chaves duplicadas usam o valor da direita)
resultado = base | extra
print(resultado)  # frozendict({'host': 'localhost', 'porta': 3306, 'db': 'mysql'})

# Funciona com dict normal no lado direito
resultado2 = base | {"ssl": True}
print(resultado2)  # frozendict({'host': 'localhost', 'porta': 5432, 'ssl': True})

O operador |= não muta o frozendict — ele reatribui a variável:

config = frozendict(debug=False)
config |= frozendict(verbose=True)
# config agora aponta para um NOVO frozendict
print(config)  # frozendict({'debug': False, 'verbose': True})

Type annotations

O frozendict suporta anotação genérica direta, sem precisar de typing:

def carregar_config() -> frozendict[str, str | int]:
    return frozendict(host="localhost", porta=5432)

config: frozendict[str, int] = frozendict(timeout=30, retries=3)

Isso se integra bem com ferramentas de tipagem como mypy e o ty. Para mais sobre tipagem em Python, veja nosso artigo sobre type hints e tipagem estática com mypy.

Casos de uso práticos

Configurações imutáveis de aplicação

from functools import lru_cache

DB_CONFIG = frozendict(
    host="db.producao.interno",
    porta=5432,
    database="app_principal",
    ssl=True,
)

CACHE_CONFIG = frozendict(
    host="redis.producao.interno",
    porta=6379,
    ttl=3600,
)

def conectar_db(config: frozendict[str, str | int | bool]):
    """Conexão segura — config não pode ser alterada acidentalmente."""
    print(f"Conectando em {config['host']}:{config['porta']}")

Cache com chaves compostas

from functools import lru_cache

@lru_cache(maxsize=256)
def buscar_dados(filtros: frozendict[str, str]) -> list:
    """Cache funciona porque frozendict é hashable."""
    print(f"Consultando banco com filtros: {filtros}")
    # ... query real aqui
    return []

# Chamadas com mesmos filtros usam cache
filtro = frozendict(status="ativo", regiao="sudeste")
buscar_dados(filtro)  # Executa query
buscar_dados(filtro)  # Retorna do cache

Antes do frozendict, esse padrão exigia converter o dict para tuple(sorted(d.items())) — feio e propenso a bugs. Para mais sobre decoradores e caching, veja nosso guia de decoradores em Python.

Thread safety sem locks

import threading

# Configuração compartilhada entre threads — segura por ser imutável
SHARED_CONFIG = frozendict(
    workers=4,
    timeout=30,
    max_retries=3,
)

def worker(config: frozendict):
    """Cada thread lê a config sem risco de race condition."""
    print(f"Worker usando timeout={config['timeout']}")

threads = [
    threading.Thread(target=worker, args=(SHARED_CONFIG,))
    for _ in range(SHARED_CONFIG["workers"])
]
for t in threads:
    t.start()
for t in threads:
    t.join()

Para mais sobre concorrência em Python, confira nosso artigo sobre threading e multiprocessing.

Pattern matching com frozendict

O frozendict funciona com match/case usando a sintaxe de mapping:

def processar_evento(evento: frozendict):
    match evento:
        case {"tipo": "login", "usuario": usuario}:
            print(f"Login de {usuario}")
        case {"tipo": "erro", "codigo": codigo}:
            print(f"Erro {codigo}")
        case _:
            print("Evento desconhecido")

evento = frozendict(tipo="login", usuario="maria@exemplo.com")
processar_evento(evento)  # Login de maria@exemplo.com

Comparação com alternativas existentes

AspectodictMappingProxyTypefrozendict (3.15)
MutávelSimNão*Não
HashableNãoNãoSim
Built-inSimtypes moduleSim
Chave de dictNãoNãoSim
Elemento de setNãoNãoSim
Thread-safe (leitura)Race conditions possíveisSimSim
Union operator |SimNãoSim

*MappingProxyType é uma view somente leitura de um dict, mas o dict original ainda pode ser mutado.

Igualdade com dict

frozendict e dict são comparáveis entre si:

fd = frozendict(a=1, b=2)
d = {"a": 1, "b": 2}

print(fd == d)   # True
print(d == fd)   # True

Isso facilita migração gradual: você pode substituir dict por frozendict em configurações sem quebrar comparações existentes.

Quando usar frozendict

Use frozendict quando:

  • A estrutura não deve mudar após a criação
  • Precisa de um mapping como chave de dicionário ou em sets
  • Quer cache com lru_cache usando dicts como argumento
  • Compartilha dados entre threads sem locks
  • Quer sinalizar no código que o dado é constante

Continue usando dict quando:

  • Precisa modificar o conteúdo
  • Performance de escrita é crítica
  • Está trabalhando com dados temporários que mudam frequentemente

Conclusão

O frozendict é uma adição que deveria ter chegado ao Python há muito tempo. Assim como frozenset completou set e tuple completou list, agora frozendict completa dict.

A aprovação da PEP 814 pelo Steering Council mostra que a comunidade reconhece o valor de tipos imutáveis nativos. Para quem trabalha com configurações, caching, programação funcional ou sistemas concorrentes, essa é provavelmente a adição mais prática do Python 3.15 — junto com as d-strings e os lazy imports.

Fique de olho no blog para mais novidades sobre Python 3.15 e outras atualizações do ecossistema.

E

Equipe python.dev.br

Contribuidor do Python Brasil — Aprenda Python em Português