---
title: "GraphQL com Go: DataLoader no gqlgen sem N+1 Queries"
url: "https://golang.com.br/blog/graphql-go-gqlgen-dataloader-n-mais-1/"
markdown_url: "https://golang.com.br/blog/graphql-go-gqlgen-dataloader-n-mais-1.MD"
description: "Aprenda a eliminar N+1 queries em APIs GraphQL com Go, gqlgen, DataLoader e PostgreSQL: batching, cache por request, erros, testes e métricas."
date: "2026-08-31"
author: "Golang Brasil"
---

# 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:

```graphql
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:

```graphql
query Posts {
  posts(limit: 100) {
    id
    title
    author {
      id
      name
    }
  }
}
```

O resolver de `Query.posts` executa uma consulta:

```sql
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:

```go
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 é:

```text
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:

```text
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:

```sql
SELECT id::text, name
FROM users
WHERE id::text = ANY($1::text[]);
```

Agora o custo típico é:

```text
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:

```bash
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`:

```go
*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](/tutoriais/go-graphql-gqlgen/). O DataLoader entra depois que queries, resolvers e persistência básica já funcionam.

## Estrutura recomendada

Uma organização possível:

```text
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.

```go
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:

```text
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`:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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](/blog/pgxpool-go-postgresql-producao/) 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:

```go
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:

```go
type CommentLoader = dataloadgen.Loader[string, []*model.Comment]
```

A query batch:

```sql
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`:

```go
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](/blog/context-timeout-cancelamento-go/) 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:

```go
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](/blog/go-opentelemetry-observabilidade-tracing-metricas/) 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:

```go
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](/blog/go-testcontainers-testes-integracao-containers/) 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.
- [ ] `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](/blog/api-go-producao-checklist/).

## 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](/tutoriais/go-graphql-gqlgen/), o guia de [pgxpool em produção](/blog/pgxpool-go-postgresql-producao/), [Testcontainers](/blog/go-testcontainers-testes-integracao-containers/) e [OpenTelemetry](/blog/go-opentelemetry-observabilidade-tracing-metricas/).

## Fontes

- [gqlgen — Dataloaders e otimização de N+1](https://gqlgen.com/reference/dataloaders/)
- [dataloadgen — documentação do pacote](https://pkg.go.dev/github.com/vikstrous/dataloadgen)
- [gqlgen — documentação oficial](https://gqlgen.com/)
- [pgx v5 — documentação oficial](https://pkg.go.dev/github.com/jackc/pgx/v5)
