Entendendo o Problema de Consultas N+1
O problema N+1 explicado com soluções em Rails, Django, Hibernate e Laravel, além de eager loading, JOINs e formas de detecção.
O problema de consultas N+1 ocorre quando uma aplicação executa uma consulta para buscar uma lista de N registros e, em seguida, executa uma consulta adicional por registro para carregar uma entidade relacionada: N+1 consultas onde uma ou duas seriam suficientes.
A maioria dos desenvolvedores se depara com ele da mesma forma. Uma página que parecia instantânea em um banco de dados de desenvolvimento populado com dados de seed começa a se arrastar quando encontra dados reais, e o log de consultas revela o mesmo SELECT repetido quatrocentas vezes com um id diferente.
É o bug de performance mais comum em código que usa um mapeador objeto-relacional, e ele aparece de forma idêntica em Rails, Django, Hibernate e Laravel, porque todos compartilham o mesmo comportamento padrão: lazy loading. Este artigo define o padrão, mostra-o em quatro stacks, mapeia a correção exata para cada framework, corrige um equívoco persistente sobre eager fetching e aborda como detectar N+1 antes que chegue à produção.
Principais Conclusões
- O problema de consultas N+1 é uma consulta para carregar N linhas-pai mais N consultas subsequentes para carregar um registro relacionado para cada uma. A contagem de consultas escala linearmente com N.
- Ele acontece porque a maioria dos ORMs faz lazy loading das associações por padrão, então acessar uma relação dentro de um loop dispara silenciosamente uma consulta a cada iteração.
- Existem duas correções corretas, e ambas reduzem N+1 a uma contagem constante de consultas: um único JOIN que carrega pais e filhos juntos, ou uma segunda consulta em lote usando
WHERE id IN (...). - Definir
FetchType.EAGERno JPA não corrige N+1 sob JPQL. Isso muda quando as consultas extras são disparadas, não se elas são agrupadas em lote. - Logs de consultas em desenvolvimento só detectam N+1 nos caminhos de código que você por acaso exercita; um APM em produção captura aqueles que seu conjunto de dados local era pequeno demais para revelar.
O Que É o Problema de Consultas N+1?
Considere um relacionamento posts/authors. Você executa uma consulta para carregar todos os posts, depois itera sobre eles e lê post.author.name em cada um. Esse acesso à propriedade é uma segunda consulta, repetida uma vez por post. Dez posts produzem onze consultas; mil posts produzem mil e uma, e o tempo total de resposta cresce linearmente com o número de registros.
O crescimento linear é o que torna o N+1 perigoso. Um bug N+1 geralmente é invisível em desenvolvimento com cinco linhas de seed e se torna uma indisponibilidade em produção com cinco mil. O endpoint que retornava em 40 ms no seu laptop retorna em 4 segundos, ou dá timeout, assim que os dados reais chegam.
Por Que o N+1 Acontece?
Discover how at OpenReplay.com.
O N+1 acontece porque a maioria dos ORMs faz lazy loading das associações por padrão: um objeto relacionado não é buscado quando você carrega o pai, mas no primeiro acesso. Os relacionamentos do Eloquent se comportam exatamente assim. Ler um deles como propriedade dispara a consulta no momento do acesso, em vez de quando o modelo pai foi carregado, e o eager loading é a alternativa opt-in. O mesmo vale para os proxies do ActiveRecord, os related managers do Django e os proxies lazy do Hibernate.
Dentro de um loop, esse acesso lazy é silencioso e ocorre a cada iteração. Nada no código-fonte o sinaliza: nenhuma palavra-chave N+1, nenhum aviso. É exatamente por isso que ele sobrevive ao code review e só se manifesta sob carga.
Como Isso Aparece no Código
O antes/depois tem a mesma forma em todas as stacks: um loop que toca uma relação, reescrito para carregar essa relação antecipadamente.
Rails (ActiveRecord):
# N+1: 1 query for books + 1 per book for the author
Book.limit(10).each { |book| puts book.author.last_name }
# Fixed: 2 queries total
Book.includes(:author).limit(10).each { |book| puts book.author.last_name }
Django ORM:
# N+1: 1 query for books + 1 per book for the author
for book in Book.objects.all():
print(book.title, book.author.name)
# Fixed: one JOIN
for book in Book.objects.select_related("author"):
print(book.title, book.author.name)
JPA / Hibernate (JPQL):
// N+1: findAll() loads transports, then one SELECT per driver on access
List<Transport> all = transportRepository.findAll();
// Fixed: a single fetch join
@Query("SELECT t FROM Transport t JOIN FETCH t.driver")
List<Transport> findAllWithDriver();
SQL puro: substitua a busca linha a linha por um único LEFT JOIN, usando LEFT para que os pais sem filhos sejam mantidos:
SELECT c.id, c.name, i.id AS item_id, i.name AS item_name
FROM categories c
LEFT JOIN items i ON i.category_id = c.id
ORDER BY c.name, i.name;
Como Corrigir o Problema de Consultas N+1
Existem duas maneiras corretas de corrigir o N+1, e ambas o reduzem a um número constante de consultas: um único JOIN que carrega pais e filhos juntos, ou uma segunda consulta em lote que busca todas as linhas relacionadas com um único WHERE id IN (...). O JOIN usa uma única ida e volta ao banco, mas pode duplicar linhas-pai (e causar explosão cartesiana entre múltiplas coleções); a consulta em lote usa duas idas e voltas, mas não retorna dados duplicados. Todo framework expõe ambas as estratégias sob nomes diferentes.
| Framework | Estratégia JOIN (uma consulta) | Estratégia de consulta em lote (WHERE id IN) |
|---|---|---|
| Rails | eager_load(:assoc) | preload(:assoc) |
| Rails (automático) | includes(:assoc) (o Rails escolhe uma) | includes(:assoc) |
| Django | select_related("assoc") | prefetch_related("assoc") |
| JPA/Hibernate | JOIN FETCH / @EntityGraph / QueryDSL fetchJoin() | batch fetching (@BatchSize) |
| Laravel | — | with('assoc') |
| SQL puro | LEFT JOIN | segundo SELECT ... WHERE fk IN (...) |
Os dois métodos do Django são os mais frequentemente confundidos, então vale ser preciso sobre o que cada um faz. A referência da API de QuerySet do Django traça a linha pela aridade do relacionamento: select_related constrói um JOIN e traz as linhas relacionadas na mesma instrução, o que só funciona quando um pai tem no máximo um registro relacionado, cobrindo portanto ForeignKey e OneToOneField. Já prefetch_related emite sua própria consulta por relacionamento e costura os resultados em Python, o que é o que permite lidar com ManyToManyField e chaves estrangeiras reversas.
O Rails divide a mesma distinção em três métodos. O guia da Active Record Query Interface descreve preload como disparando uma consulta extra por associação nomeada, e eager_load como trazendo tudo por meio de um único LEFT OUTER JOIN. O includes fica entre os dois: a documentação da API o descreve como usando, por padrão, uma consulta separada por associação e mudando para um join apenas quando as condições da consulta o exigem. Em resumo: preload é sempre uma consulta separada, eager_load é sempre um JOIN, e includes deixa o ActiveRecord escolher.
No Laravel, with() é a correção canônica de eager loading, emitindo uma consulta em lote para a relação. O Laravel 12.8 adicionou Model::automaticallyEagerLoadRelationships(), que faz eager loading automático de qualquer relação acessada por uma coleção sem uma chamada explícita a with().
Por Que FetchType.EAGER Não Corrige o N+1
Definir FetchType.EAGER não corrige o N+1 sob JPQL. O eager fetching muda quando as consultas extras são disparadas, não se elas são agrupadas em lote, então você ainda precisa de JOIN FETCH ou de um @EntityGraph. Este é o equívoco mais comum em JPA. O guia do usuário do Hibernate ORM deixa claro: uma consulta JPQL que deixa uma associação eager de fora de seu plano de fetch faz o Hibernate executar um select subsequente por associação eager, o que é N+1 com outro nome, e a própria recomendação do guia é mapear associações como lazy e trazê-las de forma eager consulta por consulta.
O princípio geral vale para todos os ORMs: configurar uma relação como eager no mapeamento é uma decisão de quando, não de agrupamento em lote. Um fetch join ou entity graph é o que de fato carrega a associação em uma única instrução, colapsando pai e filhos em uma única ida e volta ao banco.
Como Detectar Consultas N+1
Comece lendo o SQL que seu ORM emite. O log de desenvolvimento do Rails imprime cada consulta; o Django expõe contagens através da django-debug-toolbar; o Hibernate registra as instruções com spring.jpa.show-sql=true; o Laravel as revela através do Laravel Debugbar. SELECTs repetidos e quase idênticos, diferindo apenas por um id, são a assinatura do problema.
Ferramentas de fail-fast transformam o N+1 em erro durante o desenvolvimento. A gem Bullet alerta sobre associações Rails não otimizadas (apenas em dev/test), a nplusone do Python registra violações e, no Laravel, Model::preventLazyLoading() torna o acesso lazy explícito: com ela ativada, uma relação resolvida posteriormente lança LazyLoadingViolationException em vez de executar silenciosamente outra consulta. Restrinja-a a ambientes que não sejam de produção, para que uma relação esquecida nunca derrube uma requisição em produção.
O detalhe: logs de consultas em desenvolvimento só detectam N+1 nos caminhos de código que você por acaso exercita; um APM em produção captura aqueles que seu conjunto de dados local era pequeno demais para revelar. Monitores de performance de aplicação observam cada consulta em cada requisição e job em background, sinalizando padrões repetidos com o call site exato. Essa é uma cobertura que ferramentas restritas a desenvolvimento, como o Bullet e a debug toolbar, não conseguem oferecer.
Quando o N+1 é aceitável?
Nem todo N+1 precisa de correção. Quando N é pequeno e limitado — digamos, uma página que sempre renderiza exatamente três itens — as consultas extras podem ser mais baratas do que o custo de manutenção de uma cadeia de prefetch. Quando os registros relacionados já são servidos por um cache de consulta ou de aplicação, as consultas “extras” podem nunca chegar ao banco de dados. E, ocasionalmente, um loop explícito com um comentário é mais legível do que um eager load aninhado. Essas são as exceções; trate-as como escolhas deliberadas e documentadas, porque N tende a crescer com o tempo, mesmo quando você tem certeza de que não vai.
O padrão é um só conceito com grafias específicas por framework, então aprenda-o de uma vez: identifique a relação acessada dentro de um loop, escolha um JOIN ou uma consulta em lote, e configure a detecção para que o próximo N+1 falhe na sua máquina em vez de na dos seus usuários.
Perguntas Frequentes
Qual é a diferença entre eager loading baseado em JOIN e eager loading com consulta em lote?
Uma correção baseada em JOIN (eager_load no Rails, select_related no Django, JOIN FETCH no JPA, LEFT JOIN em SQL puro) carrega pais e filhos em uma única consulta, mas pode duplicar linhas-pai e causar explosão cartesiana entre múltiplas coleções. Uma correção com consulta em lote (preload no Rails, prefetch_related no Django, with() no Laravel) executa uma segunda consulta usando WHERE id IN (...), adicionando uma ida e volta ao banco, mas sem retornar linhas duplicadas. Ambas reduzem o N+1 a uma contagem constante de consultas.
Definir FetchType.EAGER corrige o problema N+1 no Hibernate?
Não. Em uma consulta JPQL, FetchType.EAGER não agrupa em lote as entidades associadas; o Hibernate emite um SELECT secundário para cada associação eager de que precisa, o que reproduz o N+1. O eager fetching muda quando as consultas extras são disparadas, não se elas são agrupadas em lote. Para realmente carregar uma associação em uma única instrução, você precisa de JOIN FETCH, de um @EntityGraph ou do fetchJoin() do QueryDSL. Esse comportamento permanece inalterado até o Hibernate 7.
Por que bugs de N+1 passam pelo code review e pelos testes locais, mas quebram em produção?
O N+1 é invisível no código-fonte porque o lazy loading dispara uma consulta silenciosamente ao acessar a relação dentro de um loop, sem nenhuma palavra-chave ou aviso que o sinalize. A contagem de consultas escala linearmente com N, então cinco linhas de seed produzem seis consultas rápidas em desenvolvimento, enquanto cinco mil linhas produzem cinco mil e uma em produção. Além disso, logs de consultas em desenvolvimento só detectam N+1 nos caminhos de código que você por acaso exercita, e é por isso que um APM em produção captura aqueles que seu conjunto de dados local era pequeno demais para revelar.
Qual método do Django devo usar, select_related ou prefetch_related?
Use select_related para relacionamentos ForeignKey e OneToOneField; ele executa um JOIN em SQL e carrega os objetos relacionados na mesma consulta. Use prefetch_related para ManyToManyField e chaves estrangeiras reversas; ele executa uma busca separada por relacionamento e une os resultados em Python. Escolher o método errado é o erro de N+1 mais comum no Django: prefetch_related não pode ser usado para relações diretas de valor único da forma como select_related foi projetado para ser.