Resposta rápida: em uma API GraphQL com Go e gqlgen, use um DataLoader por request para transformar várias consultas individuais de um resolver em uma única query batch. Em vez de buscar o autor de cada post com SELECT ... WHERE id = $1, o loader reúne os IDs e executa SELECT ... WHERE id = ANY($1). O ganho vem de menos round trips ao PostgreSQL. Para funcionar corretamente, a função batch precisa devolver resultados na mesma ordem das chaves, representar IDs ausentes, respeitar context e nunca compartilhar cache de autorização entre requests.
O problema N+1 é uma das armadilhas mais comuns quando uma API GraphQL sai do tutorial e recebe dados reais. O schema parece elegante, cada resolver é pequeno e os testes funcionam. Porém, uma consulta que retorna 100 itens pode disparar 101 queries no banco — ou centenas a mais quando existem relações aninhadas.
Este guia mostra como identificar o N+1, implementar DataLoader com github.com/vikstrous/dataloadgen, gqlgen e PostgreSQL, injetar os loaders no contexto, testar o batching e decidir quando outra solução é melhor.
O que é N+1 em GraphQL?
Considere este schema simplificado:
type User {
id: ID!
name: String!
}
type Post {
id: ID!
title: String!
author: User!
}
type Query {
posts(limit: Int!): [Post!]!
}
O cliente pede posts e autores:
query Posts {
posts(limit: 100) {
id
title
author {
id
name
}
}
}
O resolver de Query.posts executa uma consulta:
SELECT id, title, author_id
FROM posts
ORDER BY created_at DESC
LIMIT 100;
Depois, o gqlgen chama Post.author para cada post. Um resolver ingênuo faz isto:
func (r *postResolver) Author(
ctx context.Context,
obj *model.Post,
) (*model.User, error) {
const query = `
SELECT id::text, name
FROM users
WHERE id = $1
`
var user model.User
err := r.pool.QueryRow(ctx, query, obj.AuthorID).Scan(
&user.ID,
&user.Name,
)
if err != nil {
return nil, fmt.Errorf("buscar autor: %w", err)
}
return &user, nil
}
O resultado é:
1 query para posts
100 queries para autores
= 101 queries
Se vários posts pertencem à mesma pessoa, o resolver pode buscar o mesmo autor repetidamente. Se cada post também resolve comentários e o autor de cada comentário, a multiplicação fica ainda pior.
N+1 não é exclusivo de GraphQL. Ele também aparece em ORMs, templates e loops de aplicação. GraphQL apenas facilita a ocorrência porque cada campo pode ter seu próprio resolver e o cliente escolhe a forma da consulta.
Como o DataLoader reduz as queries
O DataLoader combina duas ideias:
- batching: acumula chaves solicitadas quase ao mesmo tempo e busca todas em uma operação;
- cache por request: se a mesma chave for pedida novamente na mesma operação GraphQL, reutiliza o resultado.
O fluxo muda para:
Query.posts busca 100 posts
Post.author solicita IDs ao loader
loader reúne os IDs únicos
uma query busca todos os autores
resultados voltam para os resolvers correspondentes
No banco:
SELECT id::text, name
FROM users
WHERE id::text = ANY($1::text[]);
Agora o custo típico é:
1 query para posts
1 query batch para autores
= 2 queries
Isso não significa que toda consulta GraphQL passará a executar apenas duas queries. Cada relação pode precisar de seu próprio loader. O objetivo é fazer o número de consultas crescer por tipo de relação necessária, não por quantidade de itens retornados.
Instalando dataloadgen
No módulo Go da API:
go get github.com/vikstrous/dataloadgen
O dataloadgen usa generics e oferece um loader tipado. Neste exemplo, a chave é string e o valor é *model.User:
*dataloadgen.Loader[string, *model.User]
A biblioteca não conhece seu banco nem seu modelo de autorização. Você fornece uma função batch que recebe várias chaves e devolve valores e erros correspondentes.
Se sua API ainda não está montada, comece pelo tutorial de GraphQL com Go e gqlgen. O DataLoader entra depois que queries, resolvers e persistência básica já funcionam.
Estrutura recomendada
Uma organização possível:
internal/
graph/
generated.go
resolver.go
schema.resolvers.go
model/
loaders/
loaders.go
users.go
repository/
users.go
cmd/
api/
main.go
Não existe obrigação de criar três camadas para um projeto pequeno. O ponto importante é separar:
- o resolver, que traduz o campo GraphQL;
- o loader, que coordena batch e cache;
- a query, que recupera os dados com contexto e limites.
Implementando a função batch
A função batch recebe IDs e deve devolver uma posição para cada ID recebido. O PostgreSQL não garante que o resultado de WHERE ... ANY(...) siga a ordem do array, então você precisa montar um mapa e reconstruir a saída.
package loaders
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"example.com/myapi/internal/graph/model"
)
type UserBatcher struct {
pool *pgxpool.Pool
}
func NewUserBatcher(pool *pgxpool.Pool) *UserBatcher {
return &UserBatcher{pool: pool}
}
func (b *UserBatcher) Load(
ctx context.Context,
ids []string,
) ([]*model.User, []error) {
users := make([]*model.User, len(ids))
errs := make([]error, len(ids))
if len(ids) == 0 {
return users, errs
}
const query = `
SELECT id::text, name
FROM users
WHERE id::text = ANY($1::text[])
`
rows, err := b.pool.Query(ctx, query, ids)
if err != nil {
batchErr := fmt.Errorf("buscar usuários em batch: %w", err)
for i := range errs {
errs[i] = batchErr
}
return users, errs
}
defer rows.Close()
byID := make(map[string]*model.User, len(ids))
for rows.Next() {
var user model.User
if err := rows.Scan(&user.ID, &user.Name); err != nil {
batchErr := fmt.Errorf("ler usuário do batch: %w", err)
for i := range errs {
errs[i] = batchErr
}
return users, errs
}
byID[user.ID] = &user
}
if err := rows.Err(); err != nil {
batchErr := fmt.Errorf("percorrer usuários do batch: %w", err)
for i := range errs {
errs[i] = batchErr
}
return users, errs
}
for i, id := range ids {
user, ok := byID[id]
if !ok {
errs[i] = fmt.Errorf("%w: user %s", pgx.ErrNoRows, id)
continue
}
users[i] = user
}
return users, errs
}
O consumidor pode usar errors.Is(err, pgx.ErrNoRows) quando precisar distinguir a ausência do registro; nesse caso, importe errors no arquivo que faz essa decisão.
A propriedade mais importante é esta:
ids[0] → users[0] ou errs[0]
ids[1] → users[1] ou errs[1]
ids[2] → users[2] ou errs[2]
Não devolva apenas as linhas na ordem recebida do banco. Se o PostgreSQL retornar IDs em outra ordem ou omitir uma chave inexistente, autores serão associados aos posts errados — um bug de dados muito mais grave que uma query lenta.
Versão limpa sem import ilustrativo
Em produção, a assinatura pode ficar assim, sem errors:
func (b *UserBatcher) Load(
ctx context.Context,
ids []string,
) ([]*model.User, []error) {
// query, mapa por ID e reconstrução na ordem original
}
Mantenha o tratamento de “não encontrado” coerente com seu schema. Um campo author: User! provavelmente deve devolver erro. Um campo opcional reviewer: User pode aceitar nil sem transformar a operação inteira em falha.
Criando loaders por request
Agora agrupe os loaders usados pela API:
package loaders
import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/vikstrous/dataloadgen"
"example.com/myapi/internal/graph/model"
)
type Loaders struct {
UserByID *dataloadgen.Loader[string, *model.User]
}
func New(pool *pgxpool.Pool) *Loaders {
users := NewUserBatcher(pool)
return &Loaders{
UserByID: dataloadgen.NewLoader(
users.Load,
dataloadgen.WithWait(1*time.Millisecond),
),
}
}
WithWait define uma pequena janela para agrupar chamadas. Um valor maior pode formar batches maiores, mas adiciona latência artificial. Não copie 1ms como regra universal: meça a distribuição do tamanho dos batches e a latência total da operação.
O loader deve nascer dentro do request, não no startup global da aplicação. Isso mantém o cache restrito à operação GraphQL e evita três problemas:
- dados antigos permanecendo entre requests;
- memória crescendo continuamente;
- resposta de um usuário ou tenant sendo reutilizada para outro contexto de autorização.
Injetando no contexto com middleware
Use uma chave privada e tipada:
package loaders
import (
"context"
"fmt"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
)
type contextKey struct{}
func Middleware(pool *pgxpool.Pool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestLoaders := New(pool)
ctx := context.WithValue(r.Context(), contextKey{}, requestLoaders)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func FromContext(ctx context.Context) (*Loaders, error) {
value, ok := ctx.Value(contextKey{}).(*Loaders)
if !ok || value == nil {
return nil, fmt.Errorf("dataloaders ausentes no contexto")
}
return value, nil
}
No servidor:
schema := generated.NewExecutableSchema(generated.Config{
Resolvers: resolver,
})
graphQLHandler := handler.NewDefaultServer(schema)
handlerWithLoaders := loaders.Middleware(pool, graphQLHandler)
mux.Handle("/query", handlerWithLoaders)
A ordem dos middlewares importa. Se a função batch aplica autorização com dados colocados no contexto, o middleware de autenticação precisa executar antes do middleware ou do handler que consome essas informações.
Usando o loader no resolver
O resolver deixa de consultar o banco diretamente:
func (r *postResolver) Author(
ctx context.Context,
obj *model.Post,
) (*model.User, error) {
requestLoaders, err := loaders.FromContext(ctx)
if err != nil {
return nil, err
}
user, err := requestLoaders.UserByID.Load(ctx, obj.AuthorID)
if err != nil {
return nil, fmt.Errorf("resolver autor %s: %w", obj.AuthorID, err)
}
return user, nil
}
Cada chamada parece individual, mas o loader reúne as chaves solicitadas dentro da janela configurada. O resolver continua simples e o batching fica centralizado.
Evite esconder qualquer query dentro de uma goroutine manual no resolver. gqlgen já executa campos conforme sua estratégia; concorrência adicional sem limite pode aumentar pressão sobre o pool pgx em produção e tornar cancelamento mais difícil.
Cache por request não é cache de aplicação
DataLoader costuma memorizar uma chave durante o request. Isso é útil quando a mesma pessoa aparece como autora de vários posts. Porém, ele não substitui Redis, cache local com invalidação ou CDN.
| Cache do DataLoader | Cache compartilhado |
|---|---|
| dura uma operação/request | dura entre requests |
| reduz duplicação interna | reduz acesso repetido entre usuários |
| herda o contexto do request | exige política de TTL e invalidação |
| pequeno e descartável | precisa de limite, observabilidade e segurança |
Se uma mutation altera um usuário e a mesma operação volta a ler esse usuário, o cache pode conter o valor anterior. Dependendo da biblioteca e do fluxo, limpe a chave, carregue o novo valor ou evite misturar leitura pós-escrita com um cache já preenchido.
Para multi-tenant, nunca use apenas userID como chave quando o mesmo identificador possa existir em tenants diferentes. Modele a chave:
type UserKey struct {
TenantID string
UserID string
}
A função batch deve incluir tenant_id na query e na reconstrução. A regra é simples: tudo que muda autorização ou identidade do registro precisa fazer parte da chave ou do escopo seguro do loader.
Relações de um para muitos
Para Post.comments, um loader não devolve um único valor, mas uma lista por post:
type CommentLoader = dataloadgen.Loader[string, []*model.Comment]
A query batch:
SELECT id::text, post_id::text, body
FROM comments
WHERE post_id::text = ANY($1::text[])
ORDER BY created_at ASC;
Depois, agrupe por post_id:
byPostID := make(map[string][]*model.Comment)
for rows.Next() {
var comment model.Comment
if err := rows.Scan(&comment.ID, &comment.PostID, &comment.Body); err != nil {
// propaga erro para o batch
}
byPostID[comment.PostID] = append(byPostID[comment.PostID], &comment)
}
result := make([][]*model.Comment, len(postIDs))
for i, postID := range postIDs {
result[i] = byPostID[postID]
if result[i] == nil {
result[i] = []*model.Comment{}
}
}
Para uma relação [Comment!]!, devolver lista vazia costuma ser mais correto que nil quando não existem comentários.
Cuidado com paginação. Um loader que busca “todos os comentários” para 100 posts pode retornar centenas de milhares de linhas. Se o campo aceita first, after, filtros e ordenação, esses argumentos fazem parte da chave. Em alguns bancos, buscar os primeiros N itens por pai exige LATERAL JOIN, window functions ou uma query desenhada especificamente para paginação por grupo.
Limites: DataLoader não resolve tudo
DataLoader é uma otimização importante, mas uma API GraphQL de produção também precisa controlar:
Profundidade e complexidade
Uma consulta pode pedir relações profundamente aninhadas ou muitos aliases. Mesmo com batching, a quantidade total de dados e trabalho pode ser enorme. Aplique limite de complexidade, profundidade, tamanho da requisição e timeout.
Paginação
Não permita listas sem teto. Defina limites máximos para first ou limit, use cursor quando apropriado e faça o banco aplicar a paginação.
Índices
A coluna usada no batch precisa de índice. Evite casts que impeçam o uso eficiente do índice. No exemplo didático, id::text simplifica o tipo Go, mas em uma aplicação real com UUID você pode preferir pgtype.UUID ou outro mapeamento que consulte WHERE id = ANY($1::uuid[]) diretamente.
Autorização
Não carregue registros e filtre depois em memória se a query pode restringir por tenant, organização ou permissão. A função batch deve devolver “não encontrado” para chaves fora do escopo autorizado, sem revelar se o registro existe em outro tenant.
Timeout e cancelamento
Use o ctx recebido pela função batch. Se o cliente desconectar ou a operação atingir o prazo, o pgx deve cancelar a query e liberar a conexão. O guia de context, timeout e cancelamento cobre esse contrato.
Como observar se o batching funciona
Não espere o banco saturar para descobrir N+1. Instrumente:
- quantidade de queries por operação GraphQL;
- duração total da operação;
- nome estável da operação, nunca a query inteira como label;
- tamanho de cada batch;
- quantidade de chaves únicas;
- cache hits dentro do request;
- tempo de espera por conexão no pool;
- erros e cancelamentos da função batch.
Durante desenvolvimento, logs temporários ajudam:
logger.DebugContext(ctx, "batch de usuários",
"keys", len(ids),
)
Em produção, uma métrica com histograma de batch_size mostra se a janela está agrupando chamadas. Se quase todos os batches têm tamanho 1, verifique se loaders estão sendo recriados dentro do resolver, se a consulta realmente resolve campos em conjunto ou se a janela está curta demais.
Use OpenTelemetry em Go para correlacionar a operação GraphQL, a função batch e o PostgreSQL. Não coloque IDs de usuário como labels de Prometheus; isso cria alta cardinalidade.
Testando a função batch
A função batch merece teste de integração porque mocks não reproduzem tipos do PostgreSQL, ordem arbitrária de linhas, cancelamento ou comportamento do pool.
Casos mínimos:
- vários IDs existentes retornam na ordem solicitada;
- IDs solicitados fora da ordem do banco continuam corretos;
- uma chave duplicada retorna o mesmo usuário nas duas posições;
- ID ausente produz erro apenas na posição correspondente;
- lista vazia não executa query desnecessária;
- contexto cancelado retorna erro;
- tenant A não acessa registro do tenant B;
- todas as linhas e conexões são liberadas depois do resultado.
Exemplo de asserção de ordem:
func TestUserBatcherLoadPreservesKeyOrder(t *testing.T) {
batcher := loaders.NewUserBatcher(pool)
users, errs := batcher.Load(ctx, []string{carolID, anaID, brunoID})
require.NoError(t, errs[0])
require.NoError(t, errs[1])
require.NoError(t, errs[2])
assert.Equal(t, carolID, users[0].ID)
assert.Equal(t, anaID, users[1].ID)
assert.Equal(t, brunoID, users[2].ID)
}
Depois, teste o comportamento do loader: faça várias chamadas Load dentro da mesma janela e conte queries com um repository instrumentado ou com telemetria de teste. O objetivo é confirmar que dez resolvers não geram dez consultas.
Testcontainers em Go ajuda a subir uma versão real do PostgreSQL, aplicar migrations e validar o caminho completo.
Erros comuns ao adicionar DataLoader
Criar um loader global
O cache atravessa requests, pode vazar autorização e cresce além do necessário. Crie uma instância por request.
Devolver resultados na ordem do banco
A função batch precisa preservar a ordem das chaves. Use mapa e reconstrução.
Ignorar chaves ausentes
O slice de saída deve manter o mesmo tamanho. Coloque nil e erro na posição correta ou siga a semântica opcional definida pelo schema.
Fazer uma query por ID dentro da função batch
Isso apenas move o N+1 para outro arquivo. A função batch deve executar uma consulta set-based.
Usar batch sem limite
Uma operação maliciosa pode solicitar milhares de chaves. Limite complexidade e paginação; quando necessário, divida batches grandes em blocos controlados.
Colocar autorização apenas no resolver principal
Campos relacionados também precisam respeitar o escopo. O loader deve consultar somente dados permitidos para aquele request.
Confundir batching com JOIN obrigatório
DataLoader não é sempre superior a JOIN. Para uma consulta fixa em que a relação sempre é necessária, um join ou uma query dedicada pode ser mais simples e rápida. GraphQL valoriza DataLoader quando o campo é opcional para o cliente e resolvido sob demanda.
DataLoader, JOIN ou preloading?
| Estratégia | Use quando | Cuidado principal |
|---|---|---|
| DataLoader | campos relacionados opcionais, muitos resolvers e chaves repetidas | ordem, escopo, cache por request |
JOIN | relação sempre necessária e cardinalidade controlada | duplicação de linhas e payload |
query com IN/ANY manual | caso de uso específico fora dos resolvers | lógica de agrupamento repetida |
| preloading no repository | a aplicação conhece antecipadamente todas as relações | pode buscar dados que o cliente não pediu |
| materialized view/read model | leitura complexa e frequente | atualização e consistência do modelo |
Não transforme DataLoader em regra religiosa. Meça o plano SQL, round trips, volume retornado e legibilidade. Em uma API pequena, uma query especializada pode ser a solução mais segura.
Checklist para gqlgen em produção
- Cada relação com risco de N+1 foi medida.
- Loaders são criados por request.
- A função batch executa uma query set-based.
- Resultados são reconstruídos na ordem das chaves.
- IDs ausentes têm semântica explícita.
- Tenant e autorização fazem parte do escopo seguro.
-
contextchega ao pgx e possui deadline. - Listas têm paginação e limite máximo.
- Colunas usadas no batch possuem índice adequado.
- Batches grandes têm limite ou divisão controlada.
- Métricas observam tamanho, duração e espera no pool.
- Testes de integração cobrem ordem, ausência e cancelamento.
- Complexidade e profundidade da operação GraphQL são limitadas.
- O servidor segue o restante do checklist de API Go em produção.
Perguntas frequentes
O que é N+1 em GraphQL?
É o padrão em que uma query carrega N itens e cada resolver relacionado executa mais uma consulta. Uma lista de 100 posts pode gerar uma query para a lista e 100 para autores. Relações adicionais podem multiplicar ainda mais o trabalho.
DataLoader sempre faz apenas uma query?
Ele agrupa chamadas que chegam dentro da janela e do mesmo loader. Dependendo da execução, limites e timing, uma operação pode formar mais de um batch. O objetivo é reduzir drasticamente consultas individuais, não prometer uma única query em qualquer cenário.
O cache deve durar quanto tempo?
Na configuração comum de GraphQL, apenas o request. Cache entre requests exige TTL, invalidação, limite de memória e análise de autorização — é uma camada diferente.
Posso usar DataLoader com database/sql em vez de pgx?
Sim. A ideia é independente do driver: receba as chaves, monte uma consulta parametrizada, leia as linhas, agrupe por chave e devolva na ordem original. Com database/sql, ajuste placeholders e representação de arrays ao driver usado.
DataLoader substitui limite de complexidade?
Não. Uma query profunda pode gerar muito trabalho mesmo com cada relação carregada em batch. Use os dois: DataLoader para round trips e limites de complexidade, profundidade, paginação e timeout para proteger capacidade.
Conclusão
DataLoader é uma das otimizações mais importantes para uma API GraphQL com gqlgen porque alinha a flexibilidade dos resolvers com a forma eficiente de consultar um banco relacional. O padrão correto é simples de resumir: um loader por request, uma query por batch, resultados na ordem das chaves e autorização aplicada no banco.
Comece medindo uma operação real que apresenta N+1. Implemente o loader para a relação mais cara, teste com PostgreSQL e observe tamanho dos batches e espera do pool. Depois expanda para outras relações apenas onde os dados mostrarem necessidade.
Para continuar, leia o tutorial de GraphQL com gqlgen, o guia de pgxpool em produção, Testcontainers e OpenTelemetry.