Home / Desenvolvimento / Seu ORM não está salvando você. Está te fazendo pagar duas vezes.

Seu ORM não está salvando você. Está te fazendo pagar duas vezes.


meta_title: Seu ORM não está salvando você — o custo real do N+1 problem
meta_description: N+1 problem em ORM Rails: 50 mil queries adicionais no relatório de 2s que virou 4min. Como auditar com EXPLAIN ANALYZE e evitar pagar duas vezes.
slug: orm-n-plus-1-problem-pagando-duas-vezes
focus_keyword: N+1 problem ORM
data: 2026-07-21
autor: Roberto Brandini
categoria_id: 5
categoria_nome: desenvolvimento
tags_ids:
– 39 # orm
– 40 # active-record
– 41 # n-plus-1
– 22 # performance (pré-existente)
– 42 # sql
– 43 # ruby-on-rails
tags_sugeridas: orm, active-record, n-plus-1, performance, sql, ruby-on-rails
capa: orm-rails-n-plus-1-1920×1080.png
estilo_capa: cinema-escuro
modelo_capa: sd_xl_base_1.0
seed_capa: 42
og_image_alt: Diagrama técnico de banco de dados mostrando queries N+1 geradas por ORM Rails e o impacto em performance.

TL;DR

– ORMs vendem abstração de banco de dados. O preço é pago três vezes: em flexibilidade, em previsibilidade de performance e em compreensão de dados.
– O **N+1 problem** é o sintoma mais comum: uma query adicional por linha em loop sobre coleção. Relatório de 2s vira 4min. 600x de speedup é possível com subquery correlacionada.
– Existem queries que o ORM vai gerar ineficiente por design: agregações complexas, full-text search, EXISTS/NOT EXISTS, bulk update condicional, relatórios com subqueries.
– A regra de auditoria é: **se não consegue explicar a query gerada, está usando errado**. `EXPLAIN ANALYZE` é a única forma de auditar o que o ORM está executando.
– O framework: três sinais de que o ORM está te traindo — query inexplicável, mais queries ORM que SQL raw no slow log, ausência de EXPLAIN em code review.
– O dev que escreve SQL entende dados. O dev que usa só ORM torce para que nunca precise otimizar.

# Seu ORM não está salvando você. Está te fazendo pagar duas vezes.

**N+1 problem** em ORM não é bug, é consequência. Em uma plataforma de e-commerce B2B de médio porte, o endpoint `/relatorios/vendas` respondia em dois segundos. Em quatro meses, passou a responder em quatro minutos e meio. O banco não tinha mudado. O índice não tinha sido removido. O volume tinha crescido, mas dentro do esperado. A causa era uma única linha de Ruby:

“`ruby
@pedidos = Pedido.where(created_at: periodo..).includes(:cliente, :itens).limit(500)
“`

O `.includes(:cliente, :itens)` parecia a escolha correta. A documentação do Rails afirma explicitamente que `.includes` resolve o N+1 problem. Para `:cliente` (relacionamento `belongs_to`), resolveu. Para `:itens` (relacionamento `has_many`), gerou um desastre silencioso: cada `@pedido` na coleção disparava `SELECT COUNT(*) FROM itens WHERE pedido_id = ?` na view. Quinhentos pedidos, quinhentas queries adicionais, todas sequenciais.

Esse caso não é exceção. É o padrão. E o custo que ele revela — **pagar duas vezes** pelo mesmo trabalho — aparece em todo time que trata ORM como caixa-preta.

## O custo duplo que ninguém te avisa

ORMs como ActiveRecord (Rails), Django ORM, TypeORM, Hibernate e Sequelize prometem um “sweet spot” entre SQL cru e código de aplicação. Em CRUD, a promessa se cumpre: três linhas de Ruby geram o INSERT correto, com prepared statements, com escape, com tipos mapeados. O ganho de produtividade é real.

O preço aparece em três dimensões, todas silenciosas.

**Primeira: flexibilidade perdida.** Existem queries que o ORM não consegue expressar sem escape hatches. `WINDOW FUNCTIONS`, `CTEs recursivas`, `LATERAL JOIN`, `EXISTS` vs `LEFT JOIN … WHERE … IS NULL`. Toda vez que o ORM não alcança, o dev tem que escrever SQL fragment. O escape hatch existe, mas o framework não incentiva. O caminho feliz do tutorial nunca passa por ele.

**Segunda: previsibilidade de performance perdida.** A query que o ORM gera pode ser diferente da query que o dev escreveu mentalmente. O `includes` do Rails faz eager loading automático, mas a heurística que decide entre split query e single query depende de detalhes que o dev não controla. O `select_related` do Django faz JOIN explícito, mas pode multiplicar linhas quando a relação é `ManyToMany`. O `pluck` que parecia inocente carrega 50.000 linhas em memória quando o dev queria só uma agregação.

**Terceira: compreensão de dados perdida.** Dev que nunca escreve SQL não sabe o que o banco está fazendo. Não reconhece um `Seq Scan` em produção. Não entende por que aquele `GROUP BY` está lento. Não consegue ler um plano de execução. Quando o banco começa a reclamar, o caminho é aumentar máquina. Quando aumentar máquina para de funcionar, o caminho é trocar ORM. Nenhum dos dois resolve o problema real.

A soma é um empréstimo com juros altos. Você paga menos para escrever agora. Paga mais para manter, para debugar, para escalar depois.

## Quando o ORM vai te trair (e você precisa de SQL raw)

Existem padrões onde o ORM vai gerar SQL verboso, lento, ou ambos. Reconhecer os padrões é a primeira defesa.

**Agregações complexas com `GROUP BY` e métricas múltiplas.** Quando o relatório precisa de `SUM(valor), COUNT(DISTINCT cliente_id), AVG(ticket_medio)` em uma única query, o ORM padrão dispara N subqueries aninhadas. SQL puro com `GROUP BY` resolve em 1 query, com plano de execução explícito.

**Full-text search.** PostgreSQL tem `tsvector` + `tsquery` com ranking customizado. Django tem `__search`, mas perde nuance. O ORM incentiva o caminho fácil (`icontains`), que faz `LIKE ‘%termo%’` e ignora índice.

**`EXISTS` e `NOT EXISTS`.** Mais eficientes que `LEFT JOIN … WHERE … IS NULL` em vários cenários. O ORM incentia o JOIN por default. EXISTS é um escape hatch que aparece raramente no código.

**Bulk update com lógica condicional.** `UPDATE pedidos SET status = CASE WHEN … END WHERE id IN (…)` é uma única query. ORM faz N queries individuais, uma por linha.

**Relatórios com subqueries correlacionadas.** Exatamente o caso do estudo anônimo. ORM não tem syntax limpa para `SELECT …, (SELECT COUNT(*) FROM itens WHERE pedido_id = pedidos.id) AS itens_count FROM pedidos`. Subquery correlacionada no SELECT é o caminho.

**Regra prática:** se a query passou de 6 linhas de ORM ou usa mais de 2 subqueries, escreva em SQL. O resultado vai ser mais legível, mais rápido, e mais fácil de auditar.

## Como debugar o N+1 problem com EXPLAIN PLAN

`EXPLAIN` é o comando que mostra o plano de execução que o banco escolheu para uma query. No PostgreSQL, `EXPLAIN ANALYZE` executa a query e retorna o plano com tempos reais. O `ANALYZE` é a diferença entre estimativa e realidade — sem ele, é chute.

**Como ler o plano:**

“`sql
EXPLAIN ANALYZE
SELECT pedidos.*, clientes.nome
FROM pedidos
JOIN clientes ON clientes.id = pedidos.cliente_id
WHERE pedidos.created_at > NOW() – INTERVAL ’30 days’
ORDER BY pedidos.created_at DESC
LIMIT 500;
“`

O resultado mostra:

– **Tipo de operação por nó:** `Seq Scan`, `Index Scan`, `Index Only Scan`, `Nested Loop`, `Hash Join`, `Sort`.
– **Custo estimado e real:** `(cost=0.43..125.50 rows=500 actual time=0.05..2.30 rows=487 loops=1)`.
– **Buffers:** quanto disco/memória foi tocado.

**Sinais de problema:**

– `Seq Scan` em tabela com mais de 10.000 linhas onde deveria haver `Index Scan`. PostgreSQL escolheu varredura completa porque o índice não existe, ou porque a seletividade é tão alta que o índice não compensa.
– `Nested Loop` com `actual time` alto entre duas tabelas grandes. Loop aninhado escala em O(n×m). Para coleções grandes, é armadilha.
– `Sort` com `external merge Disk`. Ordenação estourou `work_mem` e foi para disco. Solução: aumentar `work_mem` ou adicionar índice que já entrega a ordem.
– `rows=500` estimado vs `actual rows=2.400` com diferença >10x. Estatísticas desatualizadas. Rodar `ANALYZE tabela` resolve.

**Atalhos no ORM:**

– **Rails:** `ActiveRecord::Base.logger = Logger.new(STDOLE)` mostra cada query executada. `Pedido.includes(:cliente, :itens).limit(500).explain` retorna o plano.
– **Django:** `from django.db import connection; connection.queries` mostra o histórico. `connection.cursor().execute(“EXPLAIN ANALYZE ” + str(query))` retorna o plano.
– **Hibernate:** `session.doWork(connection -> connection.prepareStatement(“EXPLAIN ANALYZE …”).executeQuery())`.

**Workflow recomendado:**

1. Escrever a query.
2. `EXPLAIN ANALYZE` antes de salvar.
3. Comparar `actual rows` vs estimado.
4. Verificar `Seq Scan` em tabelas grandes.
5. Rodar `ANALYZE tabela` se estatísticas estão velhas.
6. Salvar o plano no PR. Code review de performance exige evidência.

## O framework: três sinais de que o ORM está te traindo

A regra de auditoria é objetiva. Três sinais, todos verificáveis em produção.

**Sinal 1: você não consegue explicar a query gerada.** Se o dev sênior do time não consegue desenhar a query em papel branco, o ORM está fazendo algo que ninguém está revisando. Risco aceito sem baseline. Quando a base crescer, o que ninguém revisa é o que quebra primeiro.

**Sinal 2: o slow query log tem mais queries ORM que queries escritas à mão.** ActiveRecord gera 1-3 queries por action em média. Acima de 5 queries em uma única request, alguma coisa está errada — N+1, counter cache ausente, eager loading mal feita. `pg_stat_statements` no PostgreSQL lista as queries mais lentas e mais executadas. Se as top 10 são todas ORM, há trabalho a fazer.

**Sinal 3: o time não tem `EXPLAIN ANALYZE` no workflow de code review.** Pull request sem análise de plano é PR aceito no escuro. Code review que ignora performance aceita o custo futuro. O `EXPLAIN` antes do merge é o equivalente funcional de rodar teste de carga antes de subir em produção.

A ação corretiva segue uma ordem:

1. Identificar o sinal presente.
2. Medir o impacto (tempo de resposta, custo de banco, throughput perdido).
3. Reescrever a query com SQL raw, ORM com `.select` + subquery, ou `counter_cache` para o caso N+1.
4. Re-medir. Validar o speedup.
5. Adicionar o padrão ao code review.

## Como usar ORM sem cair na armadilha

A recomendação não é abandonar ORM. É usar com critério.

**Regra 1: ORM para CRUD, SQL para leitura complexa.** O ORM resolve 80% do trabalho de aplicação com 20% do código. Para os 20% de queries complexas, SQL raw é a escolha correta. Aceitar que o ORM tem limite evita o dogmatismo que custa performance.

**Regra 2: `counter_cache` para contadores em loop.** Rails tem nativo. Django tem `annotate(Count(…))`. Hibernate tem `@Formula`. Usar evita N+1 de cara. O caso do estudo anônimo teria sido prevenido com `counter_cache: true` na associação `has_many :itens`.

**Regra 3: `select_related` / `prefetch_related` (Django) ou `includes` com `.references` (Rails) quando você sabe que vai iterar.** Não confiar em default. Especificar o tipo de JOIN ou split query.

**Regra 4: instrumentação obrigatória em produção.** APM (New Relic, Datadog, AppSignal, Skylight) + slow query log do banco. Sem isso, o N+1 só aparece quando o cliente reclama. A primeira reclamação é tarde.

**Regra 5: code review de query, não só de código.** PR que adiciona `.all` ou `.where` em coleção grande tem que vir com `EXPLAIN ANALYZE` no comentário. O time que não audita plano de execução na revisão está aceitando performance ruim como característica.

**Regra 6: `EXPLAIN ANALYZE` no setup inicial de qualquer índice novo.** Índice que ninguém usa é índice que custa write e não custa read. O `EXPLAIN` valida se o índice está sendo escolhido pelo planejador.

## O dev que escreve SQL entende dados

O ponto de partida é sempre o mesmo: **medir antes de mudar, ler antes de escrever**. `EXPLAIN ANALYZE` antes de adicionar índice. `pg_stat_statements` antes de otimizar a query errada. O ORM é ferramenta, não muleta.

A diferença entre dev que escala e dev que vira gargalo está em uma pergunta: **”qual query o banco está realmente executando?”** Quem não sabe responder, está operando no escuro. Quem sabe, está operando em produção.

O N+1 problem é o exemplo mais visível, mas é só a superfície. Por trás dele, a regra é maior: **toda abstração tem custo, e o custo aparece quando o sistema precisa escalar**. ORM abstrai o banco. A abstração economiza tempo de escrita e cobra em tempo de leitura, em tempo de debug, e em tempo de incidente em produção.

A pergunta que o ORM deveria trazer na documentação, mas não traz: **”qual query você espera que isso gere, e qual query o banco vai realmente executar?”**. Se a resposta das duas for a mesma, ORM é a escolha certa. Se for diferente, o problema não é o ORM — é o dev que está usando sem entender.

A frase que resume o post, escrita de propósito para colar na parede do time: **o dev que escreve SQL entende dados. O dev que usa só ORM torce para que nunca precise otimizar**. Não existe torcida que sobreviva a 50 mil pedidos em produção.

Esse padrão se repete em mais lugares do que SQL. O [post sobre IA e programação](https://codigo-em-producao.dpsistemas.com.br/ia-nao-substitui-programador/) mostrou a mesma família do lado da AI generativa: ferramenta de moda usada sem medir, vira evidência usada contra o time. O [post sobre ERP lento](https://codigo-em-producao.dpsistemas.com.br/erp-lento-nao-e-infraestrutura/) mostrou do lado da infraestrutura: subir cluster não resolve query mal escrita. A camada muda, a lógica é a mesma. Ferramenta que vira dogma custa caro quando o sistema precisa escalar.

Marcado:

Deixe um Comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *