Introdução
Paginar resultados parece simples: adicione .Skip(offset).Take(pageSize) e pronto. Mas quando a tabela tem 10 milhões de registros, o usuário está na página 5.000, o banco é Oracle 11g, ou o cliente exige scroll infinito sem duplicatas — essa simplicidade desaparece rapidamente.
Paginação é uma das decisões arquiteturais mais impactantes em APIs REST, e a escolha errada pode significar queries de 8 segundos, resultados inconsistentes ou um backend que trava sob carga. Existem pelo menos cinco estratégias distintas, cada uma com trade-offs claros: Offset, Keyset (Seek), Cursor de banco, Token opaco e Time-based. Cada banco de dados — SQL Server, Oracle e PostgreSQL — implementa essas estratégias com sintaxes e comportamentos ligeiramente diferentes.
Neste artigo você vai entender em profundidade quando usar cada estratégia, como cada banco a implementa, e como codificar tudo com EF Core 8.0+ em C#. Se você quer uma visão mais ampla sobre gargalos de leitura e escrita em banco de dados, leia também o artigo Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação.
Pré-requisitos: C# intermediário, EF Core básico, familiaridade com SQL. Recomenda-se também o artigo sobre programação assíncrona com C#.
📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.
Por Que Paginação Errada Destrói a Performance
Antes de ver as estratégias, vale entender o que acontece internamente em cada banco quando você pagina:
O Problema do OFFSET Profundo
-- SQL Server: página 5000 com 20 itens por página
SELECT Id, Nome, Valor FROM Pedidos
ORDER BY DataCriacao
OFFSET 99980 ROWS FETCH NEXT 20 ROWS ONLY;
O banco não “pula” para a linha 99.981 magicamente. Ele precisa:
- Ordenar (ou usar o índice de ordenação)
- Varrer 99.980 linhas e descartá-las
-
Retornar as próximas 20
O custo cresce linearmente com o offset. Para
OFFSET 0o custo é quase zero; paraOFFSET 1.000.000o custo pode ser segundos. Isso é o problema do late pagination, e se manifesta em qualquer banco.
Por Que Ordering Estável é Mandatório
// ❌ SEM OrderBy — resultado não determinístico
var dados = await context.Pedidos.Skip(40).Take(20).ToListAsync();
// ✅ COM OrderBy — resultado determinístico + índice pode ser usado
var dados = await context.Pedidos
.OrderBy(p => p.DataCriacao)
.ThenBy(p => p.Id) // Desempate pelo campo único garante estabilidade
.Skip(40).Take(20)
.ToListAsync();
Sem OrderBy, o banco pode retornar os 20 itens em qualquer ordem, e você corre o risco de ver os mesmos registros em páginas diferentes ou pular registros. Isso é especialmente crítico no Oracle, que tem comportamento de ordenação menos previsível que o SQL Server por padrão.
Estratégia 1: Offset Pagination (SKIP / TAKE)
Quando Usar
- UIs com número de páginas visível (página 1, 2, 3…)
- Relatórios com totais exatos necessários
- Volumes pequenos ou médios (até ~100k registros na tabela)
- Quando o usuário precisa saltar diretamente para qualquer página
O SQL por Banco
-- SQL Server (2012+)
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;
-- Oracle 12c+
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;
-- Oracle 11g (sem suporte nativo — ROW_NUMBER workaround)
SELECT * FROM (
SELECT p.*, ROWNUM AS rn
FROM (
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
) p
WHERE ROWNUM <= 60
)
WHERE rn > 40;
-- PostgreSQL
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
LIMIT 20 OFFSET 40;
💡 Dica: O provider Oracle.EntityFrameworkCore detecta automaticamente a versão do Oracle e gera a sintaxe correta (com FETCH FIRST para 12c+ ou ROWNUM para 11g). Não precisa escrever SQL raw para isso.
Implementação com EF Core 8
// Models reutilizáveis para toda a API
public record PaginacaoRequest(
int Pagina = 1,
int TamanhoPagina = 20)
{
public int TamanhoSeguro => Math.Clamp(TamanhoPagina, 1, 100);
public int OffsetSeguro => (Math.Max(Pagina, 1) - 1) * TamanhoSeguro;
}
public record PaginaResultado<T>(
IReadOnlyList<T> Dados,
int PaginaAtual,
int TamanhoPagina,
long TotalRegistros,
int TotalPaginas,
bool TemProxima,
bool TemAnterior);
public class PedidoOffsetService(AppDbContext context)
{
public async Task<PaginaResultado<PedidoDto>> ListarAsync(
PaginacaoRequest req,
CancellationToken ct = default)
{
var query = context.Pedidos
.AsNoTracking()
.OrderBy(p => p.DataCriacao)
.ThenBy(p => p.Id);
// EF Core 8: ExecuteCount() separado sem materializar a coleção
var total = await query.LongCountAsync(ct);
var dados = await query
.Skip(req.OffsetSeguro)
.Take(req.TamanhoSeguro)
.Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status))
.ToListAsync(ct);
var totalPaginas = (int)Math.Ceiling(total / (double)req.TamanhoSeguro);
return new PaginaResultado<PedidoDto>(
Dados: dados,
PaginaAtual: req.Pagina,
TamanhoPagina: req.TamanhoSeguro,
TotalRegistros: total,
TotalPaginas: totalPaginas,
TemProxima: req.Pagina < totalPaginas,
TemAnterior: req.Pagina > 1);
}
}
⚠️ Atenção: Para tabelas com mais de 500k linhas, o LongCountAsync() por si só pode ser lento (table scan). Uma estratégia comum é cachear o total por 30–60 segundos, ou usar uma coluna de contagem materializada.
Estratégia 2: Keyset Pagination (Seek / WHERE-based)
Quando Usar
- APIs REST com scroll infinito ou “next page” token
- Tabelas com milhões de registros onde o OFFSET degrada
- Feeds de dados em tempo real onde novos itens são inseridos constantemente
- Exportação assíncrona de grandes volumes
Como Funciona
Em vez de dizer “pule N linhas”, você diz “me dê os registros após este ponto de referência”. O banco usa o índice diretamente para posicionar-se no cursor, sem varrer os registros anteriores.
-- SQL Server / Oracle 12c+ / PostgreSQL — keyset com chave composta
SELECT TOP(21) Id, ClienteId, Valor, DataCriacao
FROM Pedidos
WHERE DataCriacao > '2025-06-01T12:00:00'
OR (DataCriacao = '2025-06-01T12:00:00' AND Id > 'abc-def-123')
ORDER BY DataCriacao ASC, Id ASC;
O TOP 21 (ou LIMIT 21) é intencional: busca-se um registro a mais para saber se há próxima página, sem fazer um COUNT.
Índice Obrigatório para Keyset
-- SQL Server — índice covering para a query de keyset
CREATE INDEX IX_Pedidos_Keyset
ON Pedidos (DataCriacao ASC, Id ASC)
INCLUDE (ClienteId, Valor, Status);
-- Oracle
CREATE INDEX IX_Pedidos_Keyset ON Pedidos (DataCriacao ASC, Id ASC);
-- PostgreSQL
CREATE INDEX IX_Pedidos_Keyset ON Pedidos (DataCriacao ASC, Id ASC)
INCLUDE (ClienteId, Valor, Status);
Sem esse índice, o banco recai em um full scan mesmo com a cláusula WHERE do keyset.
Implementação com EF Core 8
// Token de cursor — serializado em Base64 para o cliente (opaco)
public record KeysetCursor(Guid UltimoId, DateTime UltimaData)
{
public string Encode() =>
Convert.ToBase64String(
JsonSerializer.SerializeToUtf8Bytes(this));
public static KeysetCursor? Decode(string? token)
{
if (string.IsNullOrEmpty(token)) return null;
try
{
return JsonSerializer.Deserialize<KeysetCursor>(
Convert.FromBase64String(token));
}
catch { return null; }
}
}
public record KeysetResultado<T>(
IReadOnlyList<T> Dados,
bool TemProximaPagina,
string? ProximoToken); // Token opaco para o cliente
public class PedidoKeysetService(AppDbContext context)
{
public async Task<KeysetResultado<PedidoDto>> ListarAsync(
string? token,
int limite = 20,
CancellationToken ct = default)
{
limite = Math.Clamp(limite, 1, 100);
var cursor = KeysetCursor.Decode(token);
// Expressão de keyset — funciona sem cursor (primeira página)
// e com cursor (páginas seguintes)
var query = context.Pedidos
.AsNoTracking()
.Where(p =>
cursor == null ||
p.DataCriacao > cursor.UltimaData ||
(p.DataCriacao == cursor.UltimaData &&
p.Id.CompareTo(cursor.UltimoId) > 0))
.OrderBy(p => p.DataCriacao)
.ThenBy(p => p.Id)
.Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));
// Busca limite+1 para detectar se há próxima página
var dados = await query.Take(limite + 1).ToListAsync(ct);
var temProxima = dados.Count > limite;
if (temProxima) dados.RemoveAt(dados.Count - 1);
string? proximoToken = null;
if (temProxima && dados.Count > 0)
{
var ultimo = dados[^1];
proximoToken = new KeysetCursor(ultimo.Id, ultimo.DataCriacao).Encode();
}
return new KeysetResultado<PedidoDto>(dados, temProxima, proximoToken);
}
}
O token gerado é opaco para o cliente — ele não sabe o que está dentro, apenas passou para a próxima chamada. Isso permite mudar a implementação interna sem quebrar os clientes.
Estratégia 3: Cursor de Banco de Dados (Server-Side Cursor)
O Que é um Cursor de Banco e Quando Usar
Um cursor de banco de dados é diferente do keyset cursor. Aqui, o próprio banco mantém uma posição de leitura aberta entre as chamadas. O servidor reserva recursos (memória, locks ou um snapshot) para aquele conjunto de resultados enquanto o cliente vai buscando blocos.
É a estratégia ideal para:
- Exportações longas onde o cliente consome os dados em blocos ao longo de segundos ou minutos
- Processamento ETL linha por linha sem carregar tudo na memória
- Streaming de dados via WebSocket ou Server-Sent Events
- Situações onde o conjunto de resultados não pode mudar durante a leitura (snapshot consistency) > ⚠️ Atenção: Cursores de banco consomem recursos do servidor enquanto estão abertos. Em SQL Server e Oracle, cursores mal gerenciados (esquecidos abertos) causam memory pressure e até bloqueios. PostgreSQL lida melhor com cursores em transações, mas ainda exige cuidado.
PostgreSQL: DECLARE CURSOR (o mais completo)
O PostgreSQL tem o suporte mais rico a cursores server-side. Para usá-los com EF Core, é necessário executar SQL raw dentro de uma transação, pois os cursores do PostgreSQL existem apenas no escopo de uma transação:
// dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
public class PedidoCursorService(AppDbContext context)
{
public async IAsyncEnumerable<PedidoDto> StreamAsync(
int blocoPorFetch = 500,
[EnumeratorCancellation] CancellationToken ct = default)
{
// Cursor PG exige transação ativa durante toda a leitura
await using var transaction = await context.Database
.BeginTransactionAsync(ct);
// DECLARE abre o cursor no servidor
await context.Database.ExecuteSqlRawAsync(
"DECLARE pedidos_cursor NO SCROLL CURSOR FOR " +
"SELECT p.\"Id\", p.\"ClienteId\", p.\"Valor\", p.\"DataCriacao\", p.\"Status\" " +
"FROM \"Pedidos\" p ORDER BY p.\"DataCriacao\", p.\"Id\"",
ct);
try
{
while (!ct.IsCancellationRequested)
{
// FETCH busca um bloco por vez — nunca tudo na memória
var bloco = await context.Database
.SqlQueryRaw<PedidoDto>(
$"FETCH {blocoPorFetch} FROM pedidos_cursor")
.ToListAsync(ct);
if (bloco.Count == 0) break; // Fim do cursor
foreach (var item in bloco)
yield return item;
if (bloco.Count < blocoPorFetch) break; // Último bloco parcial
}
}
finally
{
// CLOSE libera recursos no servidor — SEMPRE executar
await context.Database.ExecuteSqlRawAsync(
"CLOSE pedidos_cursor", CancellationToken.None);
await transaction.CommitAsync(CancellationToken.None);
}
}
}
SQL Server: FAST_FORWARD Cursor via Raw SQL
O SQL Server suporta cursores T-SQL, mas para APIs REST o padrão mais eficiente é usar IAsyncEnumerable com AsAsyncEnumerable() do EF Core ou um cursor via ADO.NET direto:
// SQL Server: streaming com IAsyncEnumerable do EF Core 8
// Internamente usa DataReader que consome linha por linha
public class PedidoStreamService(AppDbContext context)
{
public async IAsyncEnumerable<PedidoDto> StreamComEfCoreAsync(
DateTime? dataInicio = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
// AsAsyncEnumerable() não materializa a coleção —
// mantém o DataReader aberto e lê sob demanda
var query = context.Pedidos
.AsNoTracking()
.Where(p => dataInicio == null || p.DataCriacao >= dataInicio)
.OrderBy(p => p.DataCriacao)
.ThenBy(p => p.Id)
.Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));
await foreach (var item in query.AsAsyncEnumerable().WithCancellation(ct))
{
yield return item;
}
}
}
💡 Dica: AsAsyncEnumerable() no EF Core é implementado como um DataReader que permanece aberto enquanto o IAsyncEnumerable está sendo consumido. Internamente, ele funciona como um cursor de banco implicit. A diferença para um cursor SQL explícito é que o DataReader não permite pausar a leitura e retomar mais tarde em uma nova conexão.
Oracle: REF CURSOR e SYS_REFCURSOR
No Oracle, o equivalente é o REF CURSOR, geralmente exposto via stored procedure. Com o provider Oracle.EntityFrameworkCore, você pode executá-lo assim:
// Oracle: REF CURSOR via stored procedure
// A procedure retorna um SYS_REFCURSOR como parâmetro OUT
public class PedidoOracleService(AppDbContext context)
{
public async Task<List<PedidoDto>> BuscarViaCursorAsync(
DateTime dataInicio,
int quantidade,
CancellationToken ct = default)
{
var param_dataInicio = new OracleParameter("p_data", OracleDbType.Date)
{ Value = dataInicio };
var param_qtd = new OracleParameter("p_qtd", OracleDbType.Int32)
{ Value = quantidade };
var param_cursor = new OracleParameter("p_cursor", OracleDbType.RefCursor)
{ Direction = ParameterDirection.Output };
// Chama a stored procedure que faz OPEN cursor FOR SELECT ...
await context.Database.ExecuteSqlRawAsync(
"BEGIN PKG_PEDIDOS.BUSCAR_PAGINADO(:p_data, :p_qtd, :p_cursor); END;",
param_dataInicio, param_qtd, param_cursor, ct);
// Lê o REF CURSOR retornado pelo Oracle
var refCursor = (OracleRefCursor)param_cursor.Value;
using var reader = refCursor.GetDataReader();
var resultado = new List<PedidoDto>();
while (await reader.ReadAsync(ct))
{
resultado.Add(new PedidoDto(
Id: reader.GetGuid(0),
ClienteId: reader.GetString(1),
Valor: reader.GetDecimal(2),
DataCriacao: reader.GetDateTime(3),
Status: reader.GetString(4)));
}
return resultado;
}
}
Estratégia 4: Time-based Pagination
Quando Usar
- Dados que têm dimensão temporal natural (logs, eventos, transações)
- APIs de auditoria ou histórico com filtros por janela de tempo
- Quando o cliente quer dados de uma hora específica (e.g. “pedidos de ontem”)
- Integração com sistemas de streaming (Kafka, Event Sourcing)
Implementação
A paginação time-based é uma forma especializada de keyset, com a coluna de data como cursor primário. A diferença é que o cliente escolhe a janela de tempo explicitamente:
public record TimePaginacaoRequest(
DateTime DataInicio,
DateTime DataFim,
DateTime? UltimaDataVista = null, // Cursor dentro da janela
Guid? UltimoIdVisto = null,
int Limite = 50);
public class PedidoTimeService(AppDbContext context)
{
public async Task<KeysetResultado<PedidoDto>> ListarPorJanelaAsync(
TimePaginacaoRequest req,
CancellationToken ct = default)
{
var limite = Math.Clamp(req.Limite, 1, 200);
var query = context.Pedidos
.AsNoTracking()
// Janela de tempo fixa — mantém o conjunto estável
.Where(p => p.DataCriacao >= req.DataInicio &&
p.DataCriacao < req.DataFim)
// Cursor dentro da janela (para paginação sequencial)
.Where(p =>
req.UltimaDataVista == null ||
p.DataCriacao > req.UltimaDataVista ||
(p.DataCriacao == req.UltimaDataVista &&
p.Id.CompareTo(req.UltimoIdVisto!.Value) > 0))
.OrderBy(p => p.DataCriacao)
.ThenBy(p => p.Id)
.Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));
var dados = await query.Take(limite + 1).ToListAsync(ct);
var temProxima = dados.Count > limite;
if (temProxima) dados.RemoveAt(dados.Count - 1);
string? proximoToken = null;
if (temProxima && dados.Count > 0)
{
var ultimo = dados[^1];
proximoToken = new KeysetCursor(ultimo.Id, ultimo.DataCriacao).Encode();
}
return new KeysetResultado<PedidoDto>(dados, temProxima, proximoToken);
}
}
💡 Dica: A grande vantagem da paginação time-based sobre keyset puro é que o cliente pode escolher janelas imutáveis (e.g. “todo o dia 2025-01-01”), o que permite cache agressivo dessas janelas no servidor, já que o conteúdo não muda depois que a janela fecha.
Estratégia 5: Token Opaco e Link HATEOAS
Quando Usar
- APIs públicas onde você não quer expor detalhes de implementação ao cliente
- Quando a estratégia de paginação pode mudar sem quebrar os clientes
- APIs que precisam de HATEOAS (links de próxima/anterior página na resposta) O token opaco encapsula qualquer estratégia de cursor internamente:
// Resposta no padrão HATEOAS com links de navegação
public record PaginacaoHateoasResultado<T>(
IReadOnlyList<T> Dados,
PaginacaoLinks Links,
PaginacaoMeta Meta);
public record PaginacaoLinks(
string? Primeiro,
string? Anterior,
string? Proximo,
string? Ultimo);
public record PaginacaoMeta(
int Limite,
long TotalRegistros,
bool TemProxima);
// Endpoint que monta a resposta HATEOAS
app.MapGet("/api/v1/pedidos", async (
HttpContext httpCtx,
[FromQuery] string? cursor,
[FromQuery] int limite = 20,
PedidoKeysetService service,
CancellationToken ct) =>
{
var resultado = await service.ListarAsync(cursor, limite, ct);
var baseUrl = $"{httpCtx.Request.Scheme}://{httpCtx.Request.Host}/api/v1/pedidos";
return Results.Ok(new PaginacaoHateoasResultado<PedidoDto>(
Dados: resultado.Dados,
Links: new PaginacaoLinks(
Primeiro: $"{baseUrl}?limite={limite}",
Anterior: null, // Keyset não suporta voltar sem histórico
Proximo: resultado.TemProximaPagina
? $"{baseUrl}?cursor={resultado.ProximoToken}&limite={limite}"
: null,
Ultimo: null),
Meta: new PaginacaoMeta(
Limite: limite,
TotalRegistros: -1, // Keyset não calcula total
TemProxima: resultado.TemProximaPagina)));
});
Comparativo: Qual Estratégia Usar em Cada Situação
| Critério | Offset | Keyset / Seek | Cursor Server-Side | Time-based | Token Opaco |
|---|---|---|---|---|---|
| Total de registros | ✅ Sim | ❌ Não | ❌ Não | ⚠️ Por janela | ❌ Não |
| Salto de página | ✅ Direto | ❌ Apenas sequencial | ❌ Apenas sequencial | ✅ Por janela | ❌ Apenas sequencial |
| Performance em pg. tardias | ❌ Degrada | ✅ Constante | ✅ Constante | ✅ Constante | ✅ Constante |
| Registros novos entre páginas | ❌ Drift | ✅ Consistente | ✅ Snapshot | ✅ Janela fixa | ✅ Consistente |
| Complexidade de impl. | ⭐ Simples | ⭐⭐ Média | ⭐⭐⭐ Alta | ⭐⭐ Média | ⭐⭐ Média |
| Recursos no servidor | Baixo | Baixo | 🔴 Alto (cursor aberto) | Baixo | Baixo |
| Suporte EF Core nativo | ✅ Completo | ✅ Com Where custom | ⚠️ Raw SQL / ADO | ✅ Com Where custom | ✅ Sobre keyset |
| SQL Server | ✅ OFFSET FETCH | ✅ WHERE seek | ✅ FAST_FORWARD cursor | ✅ WHERE range | ✅ |
| Oracle | ✅ 12c+ / ROWNUM 11g | ✅ WHERE seek | ✅ REF CURSOR | ✅ WHERE range | ✅ |
| PostgreSQL | ✅ LIMIT OFFSET | ✅ WHERE seek | ✅ DECLARE CURSOR | ✅ WHERE range | ✅ |
| Melhor para | UI clássica | APIs / mobile | ETL / streaming | Auditoria / logs | APIs públicas |
Índices: A Fundação de Toda Paginação
Nenhuma estratégia de paginação funciona bem sem os índices certos. Para cada banco:
-- ============================================
-- SQL Server — Índices para paginação
-- ============================================
-- Para Offset por DataCriacao
CREATE INDEX IX_Pedidos_DataCriacao
ON Pedidos (DataCriacao ASC, Id ASC)
INCLUDE (ClienteId, Valor, Status);
-- Para filtros frequentes + paginação
CREATE INDEX IX_Pedidos_ClienteId_Data
ON Pedidos (ClienteId ASC, DataCriacao ASC, Id ASC)
INCLUDE (Valor, Status);
-- ============================================
-- Oracle — Equivalentes
-- ============================================
CREATE INDEX IX_Pedidos_DataCriacao
ON Pedidos (DataCriacao ASC, Id ASC);
-- Oracle: statistics importantes para o otimizador
EXECUTE DBMS_STATS.GATHER_TABLE_STATS('SCHEMA', 'PEDIDOS');
-- ============================================
-- PostgreSQL — Com partial index opcional
-- ============================================
CREATE INDEX IX_Pedidos_DataCriacao
ON Pedidos (DataCriacao ASC, Id ASC)
INCLUDE (ClienteId, Valor, Status);
-- Partial index para status frequente (ex.: apenas pedidos ativos)
CREATE INDEX IX_Pedidos_Ativos
ON Pedidos (DataCriacao ASC, Id ASC)
WHERE Status = 'Ativo';
⚠️ Atenção Oracle: O Oracle não suporta INCLUDE columns em índices regulares (diferente de SQL Server e PostgreSQL). Para covering indexes no Oracle, use Composite Indexes ou Index-Organized Tables (IOT) para tabelas de alto volume de leitura.
Middleware de Paginação Reutilizável para ASP.NET Core
Para APIs REST com múltiplos endpoints, criar um middleware de paginação evita duplicação:
// Extensions para registrar os serviços e configurar resposta padrão
public static class PaginacaoExtensions
{
// Header padrão de paginação (common em APIs REST)
public static IEndpointConventionBuilder ComPaginacao(
this IEndpointConventionBuilder builder)
{
// Adiciona headers de documentação no OpenAPI
builder.WithOpenApi(op =>
{
op.Parameters.Add(new OpenApiParameter
{
Name = "cursor",
In = ParameterLocation.Query,
Required = false,
Schema = new OpenApiSchema { Type = "string" },
Description = "Token de cursor para próxima página (keyset pagination)"
});
op.Parameters.Add(new OpenApiParameter
{
Name = "limite",
In = ParameterLocation.Query,
Required = false,
Schema = new OpenApiSchema { Type = "integer", Default = new OpenApiInteger(20) }
});
return op;
});
return builder;
}
// Adiciona Link header HTTP padrão RFC 5988
public static void AdicionarLinkHeader<T>(
this HttpContext ctx,
KeysetResultado<T> resultado,
string baseUrl)
{
if (resultado.ProximoToken != null)
ctx.Response.Headers.Append(
"Link",
$"<{baseUrl}?cursor={resultado.ProximoToken}>; rel=\"next\"");
}
}
Dicas e Boas Práticas
- Nunca retorne sem Take/Limit: Sempre aplique um limite máximo na camada de serviço, independente do que o cliente enviou. Exponha isso via validação de request.
-
Keyset exige chave composta estável: Use sempre
(coluna_ordenacao, id_unico)como cursor composto. Apenas a coluna de ordenação pode ter duplicatas, oIdgarante unicidade e estabilidade. -
Cursor de banco: sempre feche: Para cursores server-side (PostgreSQL
DECLARE, OracleREF CURSOR), usetry/finallypara garantir oCLOSE. Um cursor esquecido aberto no Oracle ou SQL Server consome recursos do servidor indefinidamente. - Documente o tipo de paginação na OpenAPI: Indique no Swagger qual estratégia cada endpoint usa — clientes precisam saber se podem usar salto de página ou apenas avançar sequencialmente.
-
Versione a estratégia de paginação: Se você mudar de Offset para Keyset em um endpoint existente, versione a API (
/v2/pedidos) para não quebrar clientes que dependem do campototalPaginas. -
Monitor de queries lentas: Ative o Query Store (SQL Server), AWR (Oracle) ou
pg_stat_statements(PostgreSQL) para capturar queries de paginação que ultrapassam SLAs. - Prefira projeções com Select(): Nunca retorne a entidade completa em endpoints de listagem. Selecione apenas os campos necessários para reduzir I/O e alocação de memória.
Conclusão
Paginar dados em APIs REST com C# e EF Core 8 vai muito além de .Skip().Take(). Cada estratégia — Offset, Keyset, Cursor server-side, Time-based e Token opaco — existe por um motivo e serve a cenários distintos. A escolha errada resulta em queries lentas, inconsistências de dados ou consumo desnecessário de recursos no banco.
O caminho mais seguro é: comece com Offset para UIs simples com volumes controlados, migre para Keyset conforme a tabela cresce e o offset começa a degradar, use Cursor server-side apenas para streaming e ETL com cuidado no gerenciamento do ciclo de vida, e Time-based para dados com dimensão temporal natural.
SQL Server, Oracle e PostgreSQL suportam todas essas estratégias — as diferenças estão na sintaxe e nos detalhes de implementação, mas o EF Core 8 abstrai a maior parte delas. O que o EF Core não faz por você é criar os índices certos: essa é sempre sua responsabilidade.
Se você chegou aqui buscando entender como gargalos de banco afetam não só a leitura, mas também a escrita em massa, continue com o artigo Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação, que aborda a solução via mensageria (RabbitMQ e Azure Service Bus) para o lado da escrita.
Leia Também
- EF Core 8 com Fluent API: Mapeamento, ORM e Desacoplamento Total
- EF Core Migrations em Multi-Projeto: Secrets, Scaffolding e Gestão em Times
- Full-Text Search em APIs REST com C#: SQL Server, PostgreSQL e Oracle
- Arquitetura de Software e os Padrões GoF: do Código à Nuvem, do Monólito ao Microserviço
- Design de APIs REST: Sem Verbos na URL, Métodos HTTP e Binding de Parâmetros no ASP.NET Core
- Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação
- Programação Assíncrona em C#: async/await do Fundamento à Produção
- Paralelismo em C#: Parallel, PLINQ e Tasks do Fundamento à Produção
- .NET Worker e Background Service para Alto Volume
Referências
- EF Core — Querying Data (Microsoft Docs) — Documentação oficial de consultas com EF Core
- EF Core — Pagination — Guia oficial de paginação com EF Core (Offset e Keyset)
- PostgreSQL — Cursors — Documentação oficial de cursores no PostgreSQL
- SQL Server — Cursor T-SQL — Referência de cursores T-SQL no SQL Server
- Oracle — REF CURSOR — Documentação oficial de REF CURSOR no Oracle PL/SQL
- Use the PostgreSQL pg_stat_statements — Monitoramento de queries lentas no PostgreSQL
- Repositório blog-zocateli-sample — Pagination — Código-fonte completo dos exemplos deste artigo 📬
👉 Artigo completo com todos os exemplos de código: Paginação em APIs REST com C# e EF Core: Guia Prático
Top comments (0)