Saltar al contenido
🐍 Curso de Python
/

Módulo 4 · Lección 36 de 45

Paginación

La clase que hay debajo de paginate_by, el aviso que casi nadie ve, y por qué la página mil tarda más que la primera.

⏱ 14 min de lectura

La lección 23 puso paginate_by = 2 en un ListView y salió una lista paginada. Esta lección abre esa caja, porque hay tres cosas que conviene saber y que allí quedaron apuntadas de pasada: cómo se pagina una vista de función, qué aviso salta cuando la consulta no está ordenada, y por qué este mecanismo se vuelve lento con muchas filas.

Paginator#

python
from django.core.paginator import Paginator

paginador = Paginator(Contacto.objects.order_by('nombre'), 3)
Salida en pantalla
count: 13 | num_pages: 5 | per_page: 3
page_range: [1, 2, 3, 4, 5]

Recibe cualquier cosa contable y troceable —un QuerySet, una lista, una tupla— y el tamaño de página. Y una página es esto:

Salida en pantalla
number: 2 | objetos: ['Con foto', 'Luis Pardo', 'Nuevo MF']
has_next: True | has_previous: True
start_index: 4 | end_index: 6
len(page): 3

start_index y end_index son los que permiten escribir "mostrando del 4 al 6 de 13", que se agradece más de lo que parece. Y la página se recorre directamente con un {% for %}, sin tener que entrar a object_list.

En una vista de función#

contactos/views.py
from django.core.paginator import Paginator


def lista(peticion):
        contactos = Contacto.objects.select_related('grupo').order_by('nombre')
    paginador = Paginator(contactos, 20)
    pagina = paginador.get_page(peticion.GET.get('page'))
    return render(peticion, 'contactos/lista.html', {'pagina': pagina})

Cuatro líneas. En la plantilla, el mismo trozo de la lección 23, con el nombre que hayas elegido en lugar de page_obj:

html
{% for contacto in pagina %}
  <li>{{ contacto.nombre }}</li>
{% endfor %}

Y ahí está la conexión con la lección 23: eso es literalmente lo que hace ListView por dentro, y page_obj es el nombre con el que lo deja en el contexto.

page o get_page#

Los dos sacan una página y se comportan de forma muy distinta con lo que llega de la dirección:

Salida en pantalla
page(99)     -> EmptyPage: Esa página no contiene resultados
page(0)      -> EmptyPage: Este número de página es menor que 1
page('abc')  -> PageNotAnInteger: Este número de página no es un entero
page(None)   -> PageNotAnInteger: Este número de página no es un entero
Salida en pantalla
get_page(99)     -> página 5
get_page(0)      -> página 5
get_page('abc')  -> página 1
get_page(None)   -> página 1

page lanza y get_page nunca falla. Como lo que llega en ?page= es texto que escribe cualquiera, get_page es lo que se usa en las vistas, y por eso la vista de arriba no tiene ni un try.

Hay una rareza ahí que conviene mirar dos veces: get_page(0) devuelve la última página, no la primera. El motivo es que 0 produce un EmptyPage, y ante un EmptyPage la función devuelve la última; los valores que no son números producen PageNotAnInteger y devuelven la primera. Es coherente por dentro y sorprende por fuera.

Cuando quieras el comportamiento de ListView, que es devolver 404, hay que usar page y capturar:

python
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.http import Http404

try:
        pagina = paginador.page(peticion.GET.get('page'))
except PageNotAnInteger:
        pagina = paginador.page(1)
except EmptyPage:
        raise Http404('Esa página no existe')

Las dos posturas son defendibles. Devolver la primera página es más amable; devolver 404 evita que los buscadores indexen mil direcciones con el mismo contenido.

Lo que cuesta#

Salida en pantalla
solo count:  1 consulta  ->  SELECT COUNT(*) FROM "contactos_contacto"
la página:   1 consulta  ->  SELECT ... ORDER BY "nombre" ASC LIMIT 3 OFFSET 3

Dos consultas siempre, como se midió en la lección 23. La primera es el recuento, que hace falta para saber cuántas páginas hay, y la segunda son los datos.

Lo interesante es el OFFSET, porque explica un comportamiento que desconcierta cuando la tabla crece: una base de datos no sabe saltarse filas sin leerlas. Para OFFSET 20000 lee veinte mil filas, las descarta y devuelve las veinte siguientes.

O sea que la página mil tarda mil veces más que la primera. Con tablas pequeñas da igual; con un millón de filas, las últimas páginas son inutilizables.

Y el COUNT(*) tiene el mismo problema por su cuenta: contar un millón de filas no es gratis, y se hace en cada carga de cada página.