6

Como servir os 68 milhões de CNPJs da Receita com ~10ms de latência em Go

Todo dev brasileiro que já precisou consultar CNPJ conhece o dilema: ou você
usa uma API que faz proxy da Receita (3 a 10 segundos por consulta, quando não
cai), ou baixa o dump de dados abertos e monta a própria base — e descobre que
"baixar um CSV" era a parte fácil.

Eu montei a própria base. Este post é o diário honesto do que funcionou, do
que quebrou e dos números reais — 217 milhões de linhas servidas em ~10ms de
p50 dentro do datacenter, num Postgres de 1 vCPU.

A arquitetura em uma frase

Não consulte a Receita em tempo real. Ingira o dump mensal e sirva da sua
infra.
O resto é decorrência.

Receita (dump mensal, ~6GB zip) ──▶ ingestão Go (COPY) ──▶ Postgres ──▶ API (chi)
CGU (CEIS/CNEP, zip diário)     ──▶ job diário          ──┘

O dump da Receita: as pegadinhas que ninguém documenta

O layout oficial existe, mas o que quebra parser de verdade é o que está fora dele:

  1. Encoding latin1 (ISO-8859-1) — acento vira lixo se você ler como UTF-8.
    Em Go: charmap.ISO8859_1.NewDecoder() num transform.Reader streaming.
  2. Decimal com vírgula ("1000000,00") e datas YYYYMMDD onde 0 e
    00000000 significam nulo.
  3. CNPJ quebrado em 3 colunas (básico 8 + ordem 4 + DV 2). A chave de
    junção entre empresas, estabelecimentos e sócios é o básico — errar isso
    custa um dia.
  4. As partições 0–9 não se alinham entre arquivos. O estabelecimento da
    partição 3 pode ser de uma empresa da partição 7. Foreign key rígida entre
    as tabelas = COPY quebrando no meio da carga. A solução: sem FK; a
    integridade vem da fonte.
  5. Bytes NUL (0x00) no meio dos dados. O Postgres rejeita NUL em text.
    Um strings.ReplaceAll(s, "\x00", "") no parser economizou três recargas.
  6. Desde jan/2026 o repositório é um Nextcloud do SERPRO+ com WebDAV
    público
    — dá pra listar meses com PROPFIND e baixar com o token do
    share como usuário. Adeus, scraping.

COPY ou morte

A diferença entre INSERT em lote e o protocolo COPY não é incremental — é
outra categoria. Com pgx.CopyFrom e lotes de 50k:

  • 28,1 milhões de empresas em 1m28s (~320k linhas/s) num MacBook
  • A base completa (68,6M empresas + 71,9M estabelecimentos + 27,8M sócios +
    49M de registros do Simples) entra em cerca de uma hora numa VM modesta

O truque é não materializar nada: latin1→UTF-8→parse→COPY, tudo streaming,
memória constante.

O índice que paga o aluguel

A feature mais pedida por quem faz compliance é a busca reversa: de um nome
de pessoa para todas as empresas em que ela é sócia. Com 27,8 milhões de sócios:

CREATE INDEX idx_socios_nome_trgm ON socios USING gin (nome_socio gin_trgm_ops);

Lição aprendida: ORDER BY similarity(...) avalia todos os matches antes
do LIMIT. Com threshold 0.4 num nome comum ("JOSE..."), timeout; com 0.6,
~1s. O operador % respeita o set_limit() da sessão — alinhe o threshold do
índice com o do filtro ou o plano de query mente pra você.

Cruzar sanções com o QSA (o pulo do gato)

O CEIS/CNEP (empresas e pessoas punidas, CGU) publica CPF completo. O QSA
da Receita publica CPF mascarado: ***XXXXXX** — os 6 dígitos do meio.

Então o match sócio-sancionado é: substring(cpf_mascarado, 4, 6) = substring(cpf_completo, 4, 6) + similaridade trigram de nome ≥ 0.5. Não é
prova — é sinal forte, e em compliance sinal forte com explicação vale mais que
caixa-preta. Numa base real: empresa limpa, sócio proibido de contratar com o
poder público até 2030. É esse cruzamento que consulta nenhuma te dá de graça.

Números de produção (Cloud Run + Cloud SQL 1 vCPU)

OperaçãoLatência
Ficha completa (3 queries + JOINs de tradução)~8ms no datacenter, ~134ms na minha casa
Checagem de sanções (empresa + sócios)14–34ms
Score com sinais~96ms
Busca reversa em 27,8M de nomes~1s

Custo total da infra: ~R$ 300/mês. O concorrente que faz proxy da Receita
responde em 3.000–10.000ms e cai junto com ela.

Coisas que quebraram e viraram conhecimento

  • O Google Frontend reserva /healthz nos domínios *.run.app — o request
    nem chega no container. Use /health.
  • Cloud Run com scale-to-zero: primeiro request paga ~6s de cold start (pool
    do Postgres). Aceitável para o meu caso; min-instances=1 resolve por ~R$ 30/mês.
  • gcloud sql connect não funciona se seu ISP te dá IPv6. cloud-sql-proxy
    autentica via IAM e ignora o problema.
  • Payment Links do Stripe suportam assinatura de R$ 0 sem cartão
    (payment_method_collection: if_required) — free tier com identidade real,
    zero código de checkout.

O resultado

A API está no ar em fides.api.br — tem playground sem
cadastro na landing e um free tier de 50 consultas/mês que pede só email. Se
você só precisa de razão social e endereço, a BrasilAPI é grátis e resolve;
eu mantenho uma comparação honesta que
inclui quando não usar o meu produto.

O código do parser de latin1 e a estratégia de COPY descritos aqui são
genéricos — servem pra qualquer dataset dos dados abertos brasileiros.

Dúvidas sobre o pipeline? Comenta que eu respondo — a parte de infra rendeu
mais história do que coube aqui.

Carregando publicação patrocinada...
2

Muito bom o post.

O que eu mais gostei é que não ficou naquela ladainha de “Go é rápido” como se a linguagem fosse fazer milagre sozinha.

Pra mim o ponto forte aí é outro: você respeitou o caminho dos dados.

Em problema desse tamanho, a galera geralmente quer começar pelo final: cache, Redis, fila, microsserviço, arquitetura bonita no desenho. Aí vai ver o básico está errado: INSERT linha por linha, arquivo gigante carregado inteiro em memória, encoding tratado de qualquer jeito, índice ruim, parse frágil e banco apanhando por culpa da aplicação.

Go encaixa bem nesse tipo de problema justamente porque ele não tenta ser mágico. Lê stream, valida, transforma, joga no Postgres com COPY e segue. É simples, previsível e bruto no bom sentido.

Mas acho que a parte mais importante é: responder em 10ms é bonito, mas responder a base certa é mais importante.

Com dump mensal da Receita, eu teria paranoia com ingestão. Não trataria isso só como “rodei o importador e funcionou”. Eu colocaria validação pesada no processo: versão da base carregada, quantidade de linhas lidas, linhas rejeitadas, checksum dos arquivos, detecção de mudança de layout, carga em staging e só depois swap para a base final.

Porque performance em cima de dado errado é só mentira rápida.

Também gostei da parte de não cair automaticamente em denormalização. “Tabela larga é mais rápida” parece técnico, mas muitas vezes é só chute com confiança. Antes de duplicar dado e aumentar custo de rebuild mensal, tem que medir com EXPLAIN ANALYZE, consulta real e índice real.

E Redis nesse caso também precisa pagar aluguel. Se a tabela auxiliar é pequena, Postgres provavelmente já resolve bem, ou até um map em memória no boot da API. Colocar Redis para economizar lookup barato pode ser só adicionar mais uma peça para quebrar.

No fim, o que eu achei mais forte no projeto foi isso: pouca firula, pipeline streaming, Postgres bem usado e benchmark com número.

Go ajudou, claro. Mas o que ganhou o jogo foi não fazer burrice no caminho quente.

1

"Performance em cima de dado errado é só mentira rápida" resume bem. Responder em 10ms uma consulta sobre um CNPJ que não existe mais, ou com sócio desatualizado, é pior do que não responder. O dado da Receita é a matéria-prima do produto inteiro; se a carga mensal degrada silenciosamente, todo o resto vira fachada.

Hoje o pipeline já rejeita linha inválida e loga contagem por arquivo, mas a sua lista é basicamente meu backlog de hardening: checksum dos arquivos baixados, detecção de mudança de layout (a Receita já mudou formato sem avisar antes, então isso é questão de quando, não se), e principalmente carga em staging com validação de sanidade antes do swap — comparar contagem de linhas e distribuição com a base anterior, e abortar se o desvio for anômalo. Swap atômico de schema no Postgres torna isso barato de fazer, então não tem desculpa.

Sobre denormalização e Redis: concordo, e foi por isso que ainda não fiz nenhum dos dois. O EXPLAIN ANALYZE com índice certo mostrou que o join nem aparece no perfil da consulta. Cada peça nova precisa pagar aluguel — e por enquanto Postgres bem indexado está pagando o de todo mundo.

2

Muito bom.
Eu também uso copy e alguns tratamentos igual você fez ai, demora algumas horas no meu Pc que não é um super, mas que dá para trabalhar normal enquanto faz todo o processo.
Algo que eu faço diferente e que demora é importar para tabelas temporárias e depois eu fazer um Join e importar para uma tabela que reune registros de várias tabelas em uma, desta forma fica mais rápido o select do que com join, já que não vou fazer join em empresas e estabelecimento, além das demais.
O foda é que isto aumenta o espaço de armazenamento.
Algo que eu vou testar é as tabelas pequenas de natureza, cidades e etc eu preencher na requisição a partir do redis.

1

Boa! Dois trade-offs que resolvi diferente:

Tabela larga vs JOIN: fiquei nos JOINs em query — a ficha completa (7 LEFT JOINs) sai em ~8ms num Postgres de 1 vCPU, porque é tudo lookup por PK/índice. Denormalizar ganha em consulta analítica (scan), mas pra lookup pontual o join é ruído — e você paga o dobro de storage + rebuild na recarga mensal.

Redis pras auxiliares: testa antes — são ~7k linhas, o Postgres já mantém tudo em shared_buffers (memória local). Redis adiciona um hop de rede pra ganhar quase nada. Se quiser otimizar mesmo, carrega num map em memória do próprio processo no boot: zero rede, zero join.

Quanto tá teu p50 na tabela larga? Se os números baterem, um comparativo das duas arquiteturas dava um post.

2

Estava dando uns 80 ou 90GB se eu não me engano.
O meu objetivo em fazer desta forma sempre foi em dar uma resposta mais rápido do que economizar espaço de disco.
Quanto menos join, mais rápido será e alguns valores que são de tabelas auxiliares podem ficar na memória, assim retorna mais rápido.

1

Faz sentido — 80–90GB vs meus ~50GB bate com o ~2x que eu estimava.
Só um adendo na intuição "menos join = mais rápido": ela é verdadeira em geral, mas em lookup por PK o join custa microssegundos — o gargalo vira rede/parse muito antes. Vale rodar um EXPLAIN ANALYZE nas duas formas com a mesma consulta: se a diferença for <1ms, a tabela larga tá te custando 40GB pra empatar. Se der mais que isso, aí teu desenho ganha e eu que aprendo.

1

Amigo, será que tem como você fazer as duas formas e fazer uma análise e postar aqui no Tabnews?
Já tem algum tempo que não estou fazendo o processo de importação dos cnpj, estou focando em oturas coisas no momento, mas gostaria muito de ver se esta teoria faz sentido ou não.
Seria bom ter um post no Tabnews sobre isto.

-1
1

Valeu! 🙏 A parte mais divertida foi cruzar o CEIS/CNEP com o QSA — o CPF vem mascarado no dump da Receita (*XXXXXX) mas os 6 dígitos do meio batem com o CPF completo das sanções, então dá pra flagrar sócio sancionado numa empresa "limpa". Se quiser brincar, tem playground sem cadastro na landing (fides.api.br) — bota qualquer CNPJ e vê o JSON na hora. Qualquer feedback técnico eu recebo bem, tô iterando.