Módulo functools do Python: lru_cache, partial, reduce e wraps
Guia prático do functools em Python: lru_cache, cache, partial, reduce, wraps e singledispatch com exemplos de API, ETL e automação em português brasileiro.
O functools é o módulo da biblioteca padrão do Python dedicado a funções de ordem superior — ferramentas que recebem ou devolvem outras funções. Ele concentra memoização (lru_cache, cache), fixação de argumentos (partial), agregação (reduce), preservação de metadados em decoradores (wraps) e despacho por tipo (singledispatch). Se você já reescreveu o mesmo cálculo em loop, criou lambdas só para preencher um parâmetro ou perdeu o nome original de uma função decorada no stack trace, o functools provavelmente já tinha a peça certa.
Este guia cobre o que importa no dia a dia de quem escreve APIs, pipelines de dados, CLIs e automações em português brasileiro. Ele complementa Decoradores em Python, o módulo itertools, o módulo collections, Context Managers com with e Geradores e Iteradores. A meta não é decorar a API inteira: é saber quando cada ferramenta paga o custo cognitivo.
Por que o functools existe
Python trata funções como valores de primeira classe. Isso permite padrões poderosos — e também muita repetição. O functools empacota os padrões mais comuns:
- Memoização: não recalcular o que já foi calculado com os mesmos argumentos.
- Partial application: fixar parte dos parâmetros e reutilizar o resto.
- Redução: combinar uma sequência em um único valor com uma operação binária.
- Decoradores corretos: manter
__name__,__doc__e assinatura da função original. - Polimorfismo simples: uma função, várias implementações por tipo do primeiro argumento.
Tudo isso sem dependência externa, em C onde importa (cache), e com comportamento estável entre versões do Python 3. Para quem vem de JavaScript (bind, lodash.memoize) ou de programação funcional, o functools é o canto da stdlib que fala a mesma língua.
lru_cache: memoização com limite de memória
lru_cache é o motivo pelo qual muita gente abre o functools pela primeira vez. Ele envolve uma função pura (ou quase pura) e guarda o resultado de cada combinação de argumentos. Chamadas repetidas devolvem o valor em memória, sem reexecutar o corpo.
from functools import lru_cache
import time
@lru_cache(maxsize=128)
def consulta_cep(cep: str) -> dict:
"""Simula uma chamada cara a uma API de CEP (ViaCEP, BrasilAPI, etc.)."""
time.sleep(0.3) # latência de rede
# Em produção: return httpx.get(f"https://viacep.com.br/ws/{cep}/json/").json()
return {"cep": cep, "cidade": "São Paulo", "uf": "SP"}
print(consulta_cep("01310-100")) # demora ~0,3s
print(consulta_cep("01310-100")) # instantâneo — veio do cache
print(consulta_cep.cache_info())
# CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)
maxsize, typed e cache_clear
maxsize=128(padrão): mantém até 128 entradas; ao encher, remove a menos usada recentemente (Least Recently Used).maxsize=None: cache ilimitado — equivalente moderno afunctools.cache.typed=True: trata3e3.0como chaves diferentes (útil quando a função se comporta diferente por tipo).cache_clear(): esvazia o cache — essencial em testes e quando a fonte de dados mudou.cache_info(): devolve hits, misses, tamanho atual e máximo — ótimo para observabilidade em produção.
from functools import lru_cache
@lru_cache(maxsize=32)
def preco_produto(sku: str, tabela: str = "varejo") -> float:
# consulta cara a banco ou planilha
return 19.90
preco_produto("SKU-001")
preco_produto("SKU-001")
print(preco_produto.cache_info().hits) # 1
# Depois de um reajuste de tabela:
preco_produto.cache_clear()
Quando NÃO usar lru_cache
Memoização assume que o resultado depende só dos argumentos e que reutilizar o valor antigo é seguro. Evite em:
- Funções com efeito colateral (grava arquivo, envia e-mail, debita estoque).
- Funções que leem relógio, aleatoriedade ou estado global mutável.
- Argumentos não-hashable (
list,dict,set). Converta paratuple/frozensetou extraia uma chave estável. - Resultados que expiram com o tempo (cotação de dólar, status de pedido) — a menos que você controle a invalidação com
cache_clearou um TTL próprio.
Para HTTP com retries e timeouts, combine com httpx e cacheie só o que for idempotente e barato de reter.
cache: o atalho do Python 3.9+
A partir do Python 3.9 existe functools.cache, equivalente a @lru_cache(maxsize=None) com sintaxe mais limpa:
from functools import cache
@cache
def fib(n: int) -> int:
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(35)) # rápido graças à memoização recursiva
Use cache quando o domínio de entradas for pequeno (IDs de config, enums, caminhos de arquivo conhecidos). Use lru_cache(maxsize=…) quando a função pode ver milhares de chaves distintas ao longo do dia — por exemplo, CEPs consultados por um bot de Telegram ou SKUs de um catálogo grande.
partial: fixar argumentos sem lambda
partial cria uma nova callable a partir de uma função existente, já preenchendo parte dos parâmetros. O restante você passa na hora da chamada.
from functools import partial
from pathlib import Path
# Abre sempre em UTF-8, modo leitura — só falta o caminho
ler_texto = partial(Path.open, mode="r", encoding="utf-8")
with ler_texto(Path("vendas/sp.csv")) as f:
cabecalho = f.readline()
O padrão clássico em pipelines brasileiros: configurar um cliente HTTP ou uma função de log uma vez e reutilizar:
from functools import partial
import logging
log = logging.getLogger("faturamento")
# logging.Logger.log(level, msg, *args, **kwargs)
aviso = partial(log.log, logging.WARNING)
erro = partial(log.log, logging.ERROR)
aviso("NF %s sem XML", "352607123456")
erro("Falha ao transmitir NF %s", "352607123456")
Outro caso de ouro: map, filter e callbacks de UI/CLI que esperam um callable de um único argumento.
from functools import partial
def aplicar_desconto(preco: float, percentual: float) -> float:
return round(preco * (1 - percentual / 100), 2)
# Lista de preços com 10% off na Black Friday
black_friday = partial(aplicar_desconto, percentual=10)
precos = [100.0, 59.9, 12.5]
print(list(map(black_friday, precos)))
# [90.0, 53.91, 11.25]
partial vs lambda vs def
| Abordagem | Quando preferir |
|---|---|
partial | Só falta preencher parâmetros de uma função já nomeada |
lambda | Expressão curta e descartável, sem reuso |
def nomeada | Lógica com mais de uma linha, documentação ou testes próprios |
partial também preserva melhor a intenção em stack traces e se documenta sozinho: partial(aplicar_desconto, percentual=10) lê melhor do que lambda p: aplicar_desconto(p, 10) seis meses depois.
reduce: agregar uma sequência a um valor
reduce(funcao, iteravel[, inicial]) aplica funcao de forma cumulativa: pega o acumulador e o próximo item, devolve o novo acumulador, repete até o fim.
from functools import reduce
from operator import mul, add
numeros = [2, 3, 4, 5]
print(reduce(mul, numeros, 1)) # 120 — produto
print(reduce(add, numeros, 0)) # 14 — soma (preferível: sum(numeros))
Onde o reduce brilha de verdade é em merges e operações que não têm builtin óbvio:
from functools import reduce
# Mesclar vários dicionários de configuração (último vence)
configs = [
{"host": "localhost", "port": 5432, "ssl": False},
{"port": 5433},
{"ssl": True, "user": "app"},
]
merged = reduce(lambda acc, d: {**acc, **d}, configs, {})
print(merged)
# {'host': 'localhost', 'port': 5433, 'ssl': True, 'user': 'app'}
Outro exemplo comum em conciliação financeira e ETL: calcular o máximo de um campo sem materializar listas intermediárias, ou combinar conjuntos de permissões:
from functools import reduce
from operator import or_
permissoes_por_papel = [
{"ler", "listar"},
{"ler", "escrever"},
{"admin", "ler"},
]
todas = reduce(or_, permissoes_por_papel, set())
print(todas) # {'ler', 'listar', 'escrever', 'admin'}
reduce vs sum / loop for
- Soma, produto simples, concatenação de strings curtas: use
sum, um gerador ou"".join. - Agregação binária com tipo complexo (dict, set, objeto de domínio):
reduceou umforexplícito — os dois são válidos; escolha o que a equipe lê mais rápido. - Precisa de índice ou de early-exit: o
forganha.
Lembre-se: no Python 3 o reduce não está nos builtins; esqueceu o import e o nome some. Isso é de propósito — a linguagem empurra você para construções mais legíveis quando bastam.
wraps: o detalhe que salva decoradores
Todo decorador que envolve uma função sem wraps apaga o nome e a docstring originais. Em produção isso vira stack trace inútil e documentação quebrada.
from functools import wraps
import time
def medir_tempo(func):
@wraps(func)
def wrapper(*args, **kwargs):
inicio = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
duracao = time.perf_counter() - inicio
print(f"{func.__name__} levou {duracao:.3f}s")
return wrapper
@medir_tempo
def gerar_relatorio(mes: int) -> str:
"""Gera o relatório mensal de vendas."""
return f"relatorio-{mes:02d}.pdf"
print(gerar_relatorio.__name__) # gerar_relatorio (sem wraps seria 'wrapper')
print(gerar_relatorio.__doc__) # Gera o relatório mensal de vendas.
wraps copia __module__, __name__, __qualname__, __doc__, __annotations__ e atualiza __dict__. Em decoradores de FastAPI, CLIs com argparse ou jobs agendados, isso é higiene obrigatória — não é micro-otimização.
singledispatch: uma função, vários tipos
singledispatch registra implementações diferentes conforme o tipo do primeiro argumento. É o polimorfismo de função avulsa — útil quando você não controla (ou não quer) uma hierarquia de classes.
from functools import singledispatch
from datetime import date, datetime
from decimal import Decimal
@singledispatch
def serializar(valor):
raise TypeError(f"tipo não suportado: {type(valor)!r}")
@serializar.register
def _(valor: date) -> str:
return valor.isoformat()
@serializar.register
def _(valor: datetime) -> str:
return valor.isoformat(timespec="seconds")
@serializar.register
def _(valor: Decimal) -> str:
return format(valor, "f")
@serializar.register
def _(valor: int) -> str:
return str(valor)
print(serializar(date(2026, 7, 22))) # 2026-07-22
print(serializar(Decimal("19.90"))) # 19.90
print(serializar(datetime(2026, 7, 22, 10, 0))) # 2026-07-22T10:00:00
Para métodos de instância existe singledispatchmethod (Python 3.8+), que despacha pelo tipo do segundo argumento (o primeiro é self). Combina bem com dataclasses e com parsers de arquivos heterogêneos (CSV, JSON, XML) num pipeline de ingestão.
total_ordering: menos boilerplate em classes comparáveis
Se sua classe define __eq__ e um entre __lt__, __le__, __gt__ ou __ge__, o decorator @total_ordering gera o restante:
from functools import total_ordering
@total_ordering
class FaixaSalarial:
def __init__(self, minimo: float, maximo: float):
self.minimo = minimo
self.maximo = maximo
def __eq__(self, other):
if not isinstance(other, FaixaSalarial):
return NotImplemented
return (self.minimo, self.maximo) == (other.minimo, other.maximo)
def __lt__(self, other):
if not isinstance(other, FaixaSalarial):
return NotImplemented
return self.maximo < other.minimo # faixas disjuntas, ordenação simples
junior = FaixaSalarial(4_000, 7_000)
pleno = FaixaSalarial(8_000, 14_000)
print(junior < pleno) # True
print(junior <= pleno) # True — gerado pelo total_ordering
Útil em objetos de domínio (faixas, versões, prioridades de fila). Para estruturas genéricas de contagem e fila, continue preferindo Counter e deque do collections.
Receita prática: cache + partial em um client de API
Juntando as peças num fluxo realista — consultar uma API pública, cachear por endpoint e reutilizar headers:
from functools import lru_cache, partial
import json
from urllib.request import Request, urlopen
def _get_json(url: str, token: str | None = None) -> dict:
headers = {"Accept": "application/json", "User-Agent": "python.dev.br-tutorial"}
if token:
headers["Authorization"] = f"Bearer {token}"
req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
# partial fixa o token do ambiente de homologação
get_homolog = partial(_get_json, token="homolog-secret")
@lru_cache(maxsize=64)
def buscar_empresa(cnpj: str) -> dict:
# Exemplo didático — troque pela API real (BrasilAPI, ReceitaWS, etc.)
url = f"https://httpbin.org/get?cnpj={cnpj}"
return get_homolog(url)
dados = buscar_empresa("00.000.000/0001-91")
print(buscar_empresa.cache_info())
Em produção você trocaria urllib por httpx com timeouts e retries, guardaria o token em variáveis de ambiente / pydantic-settings e exporia cache_clear num endpoint admin ou num job noturno.
functools + itertools + collections: o trio da stdlib
Os três módulos se completam:
| Módulo | Papel | Exemplo |
|---|---|---|
itertools | Construir e combinar iteradores lazy | chain, groupby, islice |
collections | Estruturas especializadas | Counter, defaultdict, deque |
functools | Transformar e memoizar funções | lru_cache, partial, reduce |
Pipeline típico de vendas por UF:
from collections import Counter
from functools import partial
from itertools import groupby
def chave_uf(linha: dict) -> str:
return linha["uf"]
ordenar_por_uf = partial(sorted, key=chave_uf)
vendas = [
{"uf": "SP", "valor": 100},
{"uf": "RJ", "valor": 80},
{"uf": "SP", "valor": 50},
{"uf": "MG", "valor": 40},
]
for uf, grupo in groupby(ordenar_por_uf(vendas), key=chave_uf):
total = sum(item["valor"] for item in grupo)
print(uf, total)
print(Counter(v["uf"] for v in vendas))
# Counter({'SP': 2, 'RJ': 1, 'MG': 1})
Quando o volume cresce, avalie geradores antes de materializar listas e, se o gargalo for I/O, async/await em vez de cache cego.
Checklist rápido
Use este mapa mental antes de importar:
- Mesma entrada, mesmo resultado caro? →
lru_cache/cache. - Sempre passo os mesmos kwargs para uma API? →
partial. - Preciso fundir uma sequência num valor sem builtin óbvio? →
reduceoufor. - Estou escrevendo um decorador? →
wraps(sempre). - Comportamento muda só pelo tipo do argumento? →
singledispatch. - Classe comparável com um único operador real? →
total_ordering.
Erros comuns (e como evitar)
Cachear função impura. Sintoma: testes flaky, dados velhos em produção. Solução: só memoize funções determinísticas; se o mundo externo muda, exponha cache_clear ou não use cache.
Argumento mutável como chave. Sintoma: TypeError: unhashable type: 'list'. Solução: tuple(lista), frozenset(conjunto) ou uma chave string canônica (json.dumps(d, sort_keys=True) com cuidado).
Esquecer wraps. Sintoma: __name__ == "wrapper", OpenAPI/FastAPI documentando o nome errado. Solução: @wraps(func) em todo decorador.
reduce ilegível de três níveis. Sintoma: ninguém na code review entende. Solução: extraia a função binária com def nomeada ou troque por um loop de 4 linhas.
maxsize grande demais. Sintoma: processo cresce sem parar. Solução: meça com cache_info(), defina maxsize realista e monitore hits/misses.
Conclusão
O functools não é um módulo “avançado demais para o dia a dia” — é a caixa de ferramentas que tira ruído de código que você já escreve. lru_cache e cache cortam latência em consultas repetidas; partial elimina lambdas descartáveis; reduce resolve agregações binárias com clareza quando o builtin não basta; wraps é obrigatório em qualquer decorador sério; singledispatch organiza parsers e serializadores sem forçar hierarquia de classes.
Combine com itertools, collections, pathlib e boas práticas Python 2026 e você cobre a maior parte do que a stdlib oferece para código claro e eficiente — sem instalar nada. Na dúvida, comece por @lru_cache na função mais cara do seu script e por @wraps no próximo decorador que você escrever: o retorno aparece no relógio e no stack trace no mesmo dia.
Equipe Python Brasil
Contribuidor do Python Brasil — Aprenda Python em Português