Cursor pagination avanzada y paginacion en relaciones anidadas

Avanzado
Django
Django
Actualizado: 19/04/2026

Por que OFFSET es mala idea con datos cambiantes

PageNumberPagination y LimitOffsetPagination ejecutan SQL del estilo:

SELECT * FROM productos ORDER BY created_at DESC LIMIT 20 OFFSET 980;

Dos problemas serios:

  • Rendimiento: Postgres tiene que leer y descartar 980 filas para devolver 20. En tablas con 10 millones de registros, paginar al final es prohibitivo (OFFSET 9000000 puede tardar segundos).
  • Inconsistencia con escrituras: si entre la pagina 1 y la 2 alguien inserta 5 productos nuevos, esos 5 desplazan todo: la pagina 2 muestra duplicados de la pagina 1, y se saltan items reales.

Para datasets pequenos (<10k filas) o feeds de solo lectura, PageNumberPagination es ideal. Para feeds activos (chats, redes sociales, logs, e-commerce), necesitas cursor.

CursorPagination: como funciona

El cursor es un puntero a una posicion concreta del orden, no a un numero de pagina. Postgres lo resuelve con un WHERE en vez de OFFSET:

SELECT * FROM productos
WHERE (created_at, id) < ('2026-04-18 10:00:00', 1234)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Esto es rapido siempre (usa el indice) y consistente (un nuevo producto no afecta a la pagina siguiente: o sigue antes del cursor o despues).

# core/pagination.py
from rest_framework.pagination import CursorPagination

class ProductoCursor(CursorPagination):
    page_size       = 20
    ordering        = "-created_at"   # estable, monotono creciente
    cursor_query_param = "cursor"
    page_size_query_param = "limit"
    max_page_size   = 100
class ProductoViewSet(ModelViewSet):
    pagination_class = ProductoCursor
    ...

Respuesta:

{
  "next":     "http://api/productos/?cursor=cD0yMDI2LTA0LTE4KzEwOjAwOjAw",
  "previous": null,
  "results": [...]
}

El cursor es opaque: el cliente lo trata como string opaco que reenvia tal cual.

Eleccion del campo de ordering

El cursor solo funciona bien si el campo de ordering es monotono y unico. Buenas opciones:

  • created_at DESC (TimeField/DateTimeField). Si dos rows tienen el mismo timestamp, anade desempate con id DESC -> ordering = ("-created_at", "-id").
  • id DESC solo (UUID v7 o BigAutoField).

Malas opciones:

  • precio (no monotono, cambia). Insertar un producto barato hace saltar paginas.
  • nombre alfabetico (puede haber empates raros, ediciones frecuentes).

Si necesitas ordenar por precio, NO uses cursor; usa PageNumberPagination consciente del trade-off.

Cursor con ordering dinamico

A veces necesitas que el cliente decida el ordering pero conservar cursor. Solucion: inferir el ordering desde el cursor o exigirlo en el query string.

class FlexCursor(CursorPagination):
    page_size = 20
    ordering  = "-created_at"

    def get_ordering(self, request, queryset, view):
        param = request.query_params.get("ordering")
        if param in {"-created_at", "-id", "created_at", "id"}:
            return (param, "-id" if "id" not in param else "-created_at")
        return ("-created_at", "-id")

El cliente envia ?ordering=-id&cursor=... y la paginacion se mantiene consistente.

Paginar relaciones anidadas dentro del recurso padre

GET /api/pedidos/42/ devuelve un pedido con 500 lineas. Mandar las 500 en el JSON principal es excesivo. Patron: paginar las lineas como sub-recurso.

# pedidos/views.py
class PedidoViewSet(RetrieveModelMixin, GenericViewSet):
    queryset = Pedido.objects.all()
    serializer_class = PedidoSerializer

    @action(detail=True, methods=["get"], url_path="lineas")
    def lineas(self, request, pk=None):
        pedido = self.get_object()
        qs     = pedido.lineas.select_related("producto").order_by("-created_at", "-id")
        paginator = LineaCursor()
        page = paginator.paginate_queryset(qs, request, view=self)
        ser  = LineaSerializer(page, many=True)
        return paginator.get_paginated_response(ser.data)

class LineaCursor(CursorPagination):
    page_size = 50
    ordering  = ("-created_at", "-id")

El cliente:

GET /api/pedidos/42/                          -> pedido + summary (sin lineas)
GET /api/pedidos/42/lineas/                   -> primera pagina de lineas
GET /api/pedidos/42/lineas/?cursor=ABC&limit=100

Buena practica: en el JSON principal del pedido, devolver lineas_count y un link lineas_url para que el cliente sepa cuantas hay y donde paginarlas.

Cursor firmado para evitar manipulacion

CursorPagination de DRF no firma el cursor por defecto. Un usuario podria modificarlo y leer datos de otros tenants si tu get_queryset no filtra adecuadamente. Para defensa en profundidad, firma con HMAC:

import base64, hmac, hashlib
from django.conf import settings

def firmar_cursor(cursor: str) -> str:
    sig = hmac.new(
        settings.CURSOR_SECRET.encode(),
        cursor.encode(),
        hashlib.sha256,
    ).hexdigest()[:8]
    raw = f"{cursor}.{sig}".encode()
    return base64.urlsafe_b64encode(raw).decode()

def verificar_cursor(opaque: str) -> str:
    raw = base64.urlsafe_b64decode(opaque.encode()).decode()
    cursor, sig = raw.rsplit(".", 1)
    expected = hmac.new(
        settings.CURSOR_SECRET.encode(),
        cursor.encode(),
        hashlib.sha256,
    ).hexdigest()[:8]
    if not hmac.compare_digest(sig, expected):
        raise ValueError("cursor invalido")
    return cursor

Es defensa en profundidad, no autorizacion. Sigue filtrando get_queryset por usuario.

Combinar Cursor + FilterSet

Cursor se aplica DESPUES del filtrado, asi que no hay incompatibilidad. Pero ojo: si el cliente cambia el filtro a mitad de paginacion (ej: anadir precio_min), el cursor anterior ya no significa lo mismo. Devuelve 400 si detectas que el cursor no encaja.

Mas pragmatico: documentar que al cambiar filtros se reinicia la paginacion y el frontend debe volver a la primera pagina.

Diagrama: OFFSET frente a cursor

flowchart TB
    subgraph OFFSET["OFFSET (lento + inconsistente)"]
        O1[Pagina 1: OFFSET 0 LIMIT 20]
        O2[INSERT 5 nuevos]
        O3[Pagina 2: OFFSET 20 LIMIT 20]
        O3 -.->|reaparece| O1
    end
    subgraph CURSOR["CURSOR (rapido + consistente)"]
        C1[Pagina 1 hasta cursor=C1]
        C2[INSERT 5 nuevos]
        C3[Pagina 2 con cursor=C1<br>devuelve solo lo siguiente real]
    end

Migracion sin romper clientes

Si tienes una API publica con PageNumberPagination y quieres migrar a Cursor:

  1. Anade un nuevo endpoint (/api/v2/productos/) con cursor, manten el viejo deprecated.
  2. Comunica deprecation con cabecera Sunset: Sat, 31 Dec 2026 23:59:59 GMT.
  3. Documenta la migracion en tu changelog.
  4. Tras 6-12 meses, retira el viejo.

Limites finales

  • No saltar a una pagina arbitraria: cursor solo permite next/previous secuencial. Si necesitas "ir a la pagina 50", usa OFFSET.
  • No conoces el total: cursor no devuelve count por defecto (calcular count(*) en cada pagina mata el rendimiento). Si lo necesitas, pidelo en un endpoint separado y caches el valor 1 minuto.
  • Cursor expira: si el orden cambia (renombras la columna ordering), los cursors viejos pueden devolver basura. Versiona el cursor (v1.<base64>) y rechaza versions viejas con 400.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Django es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Django

Explora más contenido relacionado con Django y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Entender cuando OFFSET es inviable (datos que cambian). Implementar CursorPagination con ordering dinamico. Generar cursors opacos en base64 firmados. Paginar relaciones anidadas (lineas dentro de un pedido). Combinar Cursor con FilterSet sin perder consistencia. Migrar de PageNumber a Cursor sin romper clientes (links Link headers).

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje