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
| |
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 0 o custo é quase zero; para OFFSET 1.000.000 o 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, 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
| |
💡 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
| |
⚠️ 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.
| |
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
| |
Sem esse índice, o banco recai em um full scan mesmo com a cláusula WHERE do keyset.
Implementação com EF Core 8
| |
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:
| |
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:
| |
💡 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:
| |
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:
| |
💡 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:
| |
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:
| |
⚠️ 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:
| |
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
Ao comentar, você concorda com nossa Política de Privacidade, Termos de Uso e Política de Exclusão de Dados.