← Voltar para o blog

GraphQL com Go: DataLoader no gqlgen sem N+1 Queries

Aprenda a eliminar N+1 queries em APIs GraphQL com Go, gqlgen, DataLoader e PostgreSQL: batching, cache por request, erros, testes e métricas.

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:

  1. batching: acumula chaves solicitadas quase ao mesmo tempo e busca todas em uma operação;
  2. 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 DataLoaderCache compartilhado
dura uma operação/requestdura entre requests
reduz duplicação internareduz acesso repetido entre usuários
herda o contexto do requestexige política de TTL e invalidação
pequeno e descartávelprecisa 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:

  1. vários IDs existentes retornam na ordem solicitada;
  2. IDs solicitados fora da ordem do banco continuam corretos;
  3. uma chave duplicada retorna o mesmo usuário nas duas posições;
  4. ID ausente produz erro apenas na posição correspondente;
  5. lista vazia não executa query desnecessária;
  6. contexto cancelado retorna erro;
  7. tenant A não acessa registro do tenant B;
  8. 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égiaUse quandoCuidado principal
DataLoadercampos relacionados opcionais, muitos resolvers e chaves repetidasordem, escopo, cache por request
JOINrelação sempre necessária e cardinalidade controladaduplicação de linhas e payload
query com IN/ANY manualcaso de uso específico fora dos resolverslógica de agrupamento repetida
preloading no repositorya aplicação conhece antecipadamente todas as relaçõespode buscar dados que o cliente não pediu
materialized view/read modelleitura complexa e frequenteatualizaçã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.
  • context chega 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.

Fontes