Django ORM

Сортировка, срезы, пагинация | Курс Django ORM урок 2.4

Сортировка, срезы, пагинация | Курс Django ORM урок 2.4
Mikhail
Автор
Mikhail
Опубликовано 16.03.2026
0,0
Views 6

Цель урока

Разобрать order_by(), reverse(), distinct() и срезы QuerySet. Понять, во что они транслируются в SQL. Разобрать Django Paginator и объяснить, почему пагинация OFFSET деградирует на больших таблицах и что с этим делать.

Необходимые знания


order_by()

# По возрастанию
Product.objects.order_by("price")

# По убыванию (минус = DESC)
Product.objects.order_by("-price")

# По нескольким полям
Product.objects.order_by("category", "-price")

# Сброс Meta.ordering
Product.objects.order_by()
-- order_by("price")
SELECT * FROM shop_product ORDER BY price ASC;

-- order_by("-price")
SELECT * FROM shop_product ORDER BY price DESC;

-- order_by("category", "-price")
SELECT * FROM shop_product ORDER BY category_id ASC, price DESC;

-- order_by() без сортировки
SELECT * FROM shop_product;

order_by() с пустым списком аргументов сбрасывает Meta.ordering. Это важно когда нужен QuerySet без гарантированного порядка, например, для агрегаций, где сортировка добавляет лишний GROUP BY.

Сортировка через связанные модели

# Сортировка по полю связанной модели
Product.objects.order_by("category__name", "name")
SELECT shop_product.*
FROM shop_product
INNER JOIN shop_category ON shop_product.category_id = shop_category.id
ORDER BY shop_category.name ASC, shop_product.name ASC;

Django автоматически добавляет JOIN. Это удобно, но нужно понимать, JOIN для сортировки происходит при каждом запросе, даже если данные категории не нужны.

Сортировка по аннотациям

from django.db.models import Avg

# Сортировка по вычисляемому значению
Product.objects.annotate(avg_rating=Avg("reviews__rating")).order_by("-avg_rating")
SELECT shop_product.*, AVG(shop_review.rating) AS avg_rating
FROM shop_product
LEFT OUTER JOIN shop_review ON shop_product.id = shop_review.product_id
GROUP BY shop_product.id
ORDER BY avg_rating DESC;

Аннотации в order_by() подробно разберем в уроке 3.3.

nulls_first и nulls_last

В PostgreSQL значения NULL по умолчанию идут последними при ASC и первыми при DESC. Это можно контролировать:

from django.db.models import F

# NULL значения в конце при сортировке по убыванию
Product.objects.order_by(F("discount_price").desc(nulls_last=True))

# NULL значения в начале при сортировке по возрастанию
Product.objects.order_by(F("discount_price").asc(nulls_first=True))
ORDER BY discount_price DESC NULLS LAST
ORDER BY discount_price ASC NULLS FIRST

reverse()

# Разворачивает текущий порядок сортировки
Product.objects.order_by("price").reverse()
# эквивалентно order_by("-price")

reverse() работает только если QuerySet уже имеет ordering, из Meta или явный order_by(). Без определенного порядка reverse() ничего не делает.

Практически полезен для last(), Django внутри использует reverse().first():

Product.objects.filter(is_active=True).last()
# = Product.objects.filter(is_active=True).reverse().first()

distinct()

Убирает дублирующиеся строки в результате:

# Дублирующиеся категории из-за JOIN с products
Category.objects.filter(products__is_active=True).distinct()
SELECT DISTINCT shop_category.*
FROM shop_category
INNER JOIN shop_product ON shop_category.id = shop_product.category_id
WHERE shop_product.is_active = true
ORDER BY shop_category.name;

DISTINCT в SQL сравнивает все колонки в SELECT. В PostgreSQL есть DISTINCT ON (field), возвращает первую строку для каждого уникального значения поля. Django поддерживает это через distinct("field"):

# Первый продукт каждой категории (по Meta.ordering)
Product.objects.order_by("category_id", "name").distinct("category_id")
SELECT DISTINCT ON (category_id) shop_product.*
FROM shop_product
ORDER BY category_id, name;

distinct("field") работает только в PostgreSQL и требует чтобы первый order_by() совпадал с полем в distinct().

distinct() и order_by() через связи

Проблема: если в order_by() есть поле из таблицы JOIN, DISTINCT может не убрать дубли корректно, потому что технически строки различаются по полю из JOIN.

# Потенциально не работает как ожидается
Category.objects.filter(products__is_active=True).distinct().order_by("products__price")

В таком случае лучше переформулировать запрос или использовать values() + агрегацию.


Срезы QuerySet

QuerySet поддерживает срезы в стиле Python, которые транслируются в LIMIT и OFFSET:

# Первые 10 продуктов
Product.objects.all()[:10]

# Продукты с 10 по 20 (offset=10, limit=10)
Product.objects.all()[10:20]

# Один объект по индексу (эквивалент offset=5, limit=1)
Product.objects.all()[5]
-- [:10]
SELECT * FROM shop_product ORDER BY name LIMIT 10;

-- [10:20]
SELECT * FROM shop_product ORDER BY name LIMIT 10 OFFSET 10;

-- [5]
SELECT * FROM shop_product ORDER BY name LIMIT 1 OFFSET 5;

Ограничения срезов

После среза QuerySet "замораживается", нельзя добавить фильтр:

qs = Product.objects.all()[:10]
qs.filter(is_active=True)  # TypeError: Cannot filter a query once a slice has been taken

Всегда фильтруйте до среза:

Product.objects.filter(is_active=True)[:10]

Отрицательные индексы не поддерживаются:

Product.objects.all()[-1]  # AssertionError
# Вместо этого используйте last()
Product.objects.last()

Пагинация с помощью Paginator

Django предоставляет Paginator, стандартный инструмент разбивки QuerySet на страницы:

from django.core.paginator import Paginator

products = Product.objects.filter(is_active=True).order_by("-created_at")
paginator = Paginator(products, per_page=20)  # 20 объектов на странице

# Получить конкретную страницу
page = paginator.page(1)

print(paginator.num_pages)     # общее количество страниц
print(paginator.count)         # общее количество объектов
print(page.object_list)        # объекты текущей страницы
print(page.has_next())         # есть ли следующая страница
print(page.has_previous())     # есть ли предыдущая страница
print(page.next_page_number()) # номер следующей страницы

SQL при paginator.count:

SELECT COUNT(*) FROM shop_product WHERE is_active = true;

SQL при page.object_list (страница 1, per_page=20):

SELECT * FROM shop_product WHERE is_active = true ORDER BY created_at DESC LIMIT 20 OFFSET 0;

SQL для страницы 5:

SELECT * FROM shop_product WHERE is_active = true ORDER BY created_at DESC LIMIT 20 OFFSET 80;

Проблема OFFSET на больших таблицах

OFFSET не пропускает строки, PostgreSQL читает все строки с начала и отбрасывает первые N, только потом возвращает следующие M. Страница 1000 при per_page=20 означает OFFSET 19 980: база прочитает 20 000 строк и вернет только 20.

Время выполнения растет линейно с номером страницы:

Страница 1:     OFFSET 0     - быстро
Страница 100:   OFFSET 1980  - заметно медленнее
Страница 1000:  OFFSET 19980 - медленно
Страница 10000: OFFSET 199980 - очень медленно

Для интернет-магазина с сотнями товаров это не проблема. Но если страниц тысячи, нужен другой подход.

Cursor-based пагинация

Вместо OFFSET используйте значение поля последней записи предыдущей страницы:

# Обычная пагинация через OFFSET (деградирует)
def get_page_offset(page_number, per_page=20):
    offset = (page_number - 1) * per_page
    return Product.objects.filter(is_active=True).order_by("id")[offset:offset + per_page]


# Cursor-based не деградирует
def get_page_cursor(last_id=None, per_page=20):
    qs = Product.objects.filter(is_active=True).order_by("id")
    if last_id:
        qs = qs.filter(id__gt=last_id)
    return qs[:per_page]
-- Cursor-based: всегда быстро, использует индекс на id
SELECT * FROM shop_product
WHERE is_active = true AND id > 12340
ORDER BY id
LIMIT 20;

Ограничения cursor-based пагинации: нельзя перейти на произвольную страницу (только "следующая"), курсор должен быть по уникальному полю (id, created_at с уникальностью). Поэтому cursor-based подходит для бесконечной прокрутки в API, но не для классической пагинации с номерами страниц.


Производительность сортировки

Сортировка без индекса

-- Если нет индекса на price, PostgreSQL делает filesort
SELECT * FROM shop_product ORDER BY price DESC LIMIT 20;
-- EXPLAIN: Sort  (cost=1500..1550 rows=100)

Сортировка с индексом

-- С индексом на price, Index Scan в обратном порядке
-- CREATE INDEX ON shop_product (price DESC);
SELECT * FROM shop_product ORDER BY price DESC LIMIT 20;
-- EXPLAIN: Index Scan Backward  (cost=0.4..50 rows=20)

Если вы часто сортируете по полю и берёте только первые N записей (LIMIT), индекс на это поле ускоряет запрос. Подробно об индексах и EXPLAIN, в уроках 5.1 и 5.2.


Практическое задание

  1. Получите 5 самых дорогих активных продуктов. Посмотрите SQL.

  2. Получите продукты на "второй странице" (с 11 по 20) отсортированные по имени. Какой OFFSET в SQL?

  3. Используйте Paginator для разбивки всех активных продуктов по 5 штук. Выведите информацию о странице 2: объекты, есть ли следующая, номер следующей страницы.

  4. Реализуйте пагинацию на основе курсора: получите первые 5 продуктов по id, затем вызовите функцию снова передав last_id последнего продукта. Сравните SQL с вариантом с OFFSET.

  5. Найдите все уникальные категории, у которых есть хотя бы один активный продукт. Добавьте order_by("name") и distinct(). Посмотрите SQL.


Возможные ошибки

Сортировка без индекса в большой таблице

# Медленно если нет индекса на created_at
Order.objects.order_by("-created_at")

# Добавьте индекс в Meta
class Meta:
    indexes = [models.Index(fields=["-created_at"])]

OFFSET пагинация для API с большими данными

# Деградирует на больших страницах
Product.objects.all()[10000:10020]

# Для API cursor-based
Product.objects.filter(id__gt=last_id).order_by("id")[:20]

Фильтрация после среза

# TypeError
products = Product.objects.all()[:10]
products.filter(is_active=True)  # ошибка

# Правильно фильтр до среза
products = Product.objects.filter(is_active=True)[:10]

Не сбрасывать Meta.ordering перед агрегацией

# Meta.ordering = ["name"] добавляет GROUP BY name, лишний столбец в GROUP BY
Product.objects.values("category_id").annotate(count=Count("id"))

# Правильно сбросить ordering
Product.objects.values("category_id").annotate(count=Count("id")).order_by()

Этот паттерн разберем подробно в уроке 3.3.


Связь со следующим уроком

В уроке 2.5 завершаем блок базовых операций: обновление и удаление данных. Разберем чем save() отличается от update() на уровне QuerySet, как работает delete() на QuerySet и в чем разница между каскадным удалением в Django и в PostgreSQL. Также введем bulk_create() и bulk_update(), инструменты для работы с большими объемами данных.


<< Урок 2.3

Урок 2.5 >>


Подписывайтесь на мой Telegram канал

Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы