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.

11 min de leitura Equipe Python Brasil

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:

  1. Memoização: não recalcular o que já foi calculado com os mesmos argumentos.
  2. Partial application: fixar parte dos parâmetros e reutilizar o resto.
  3. Redução: combinar uma sequência em um único valor com uma operação binária.
  4. Decoradores corretos: manter __name__, __doc__ e assinatura da função original.
  5. 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 a functools.cache.
  • typed=True: trata 3 e 3.0 como 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 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 para tuple / frozenset ou 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_clear ou 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

AbordagemQuando preferir
partialSó falta preencher parâmetros de uma função já nomeada
lambdaExpressão curta e descartável, sem reuso
def nomeadaLó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): reduce ou um for explícito — os dois são válidos; escolha o que a equipe lê mais rápido.
  • Precisa de índice ou de early-exit: o for ganha.

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óduloPapelExemplo
itertoolsConstruir e combinar iteradores lazychain, groupby, islice
collectionsEstruturas especializadasCounter, defaultdict, deque
functoolsTransformar e memoizar funçõeslru_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:

  1. Mesma entrada, mesmo resultado caro?lru_cache / cache.
  2. Sempre passo os mesmos kwargs para uma API?partial.
  3. Preciso fundir uma sequência num valor sem builtin óbvio?reduce ou for.
  4. Estou escrevendo um decorador?wraps (sempre).
  5. Comportamento muda só pelo tipo do argumento?singledispatch.
  6. 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.

E

Equipe Python Brasil

Contribuidor do Python Brasil — Aprenda Python em Português