Таблица с сортировкой в Django: что знать до начала
Как вывести список записей в шаблоне Django с сортировкой по клику на заголовок столбца. От модели до ссылок в шаблоне — по шагам, без сторонних библиотек.

К концу этого гайда у вас будет страница со списком записей из базы, где клик по заголовку столбца сортирует таблицу по возрастанию, повторный клик — по убыванию. Всё на чистом Django, без сторонних пакетов. Займёт 20–30 минут, если проект уже создан и в нём есть хотя бы одна модель с данными. Написано для Django 5.x, но тот же код работает и на 4.2 LTS.
Что нужно иметь до начала
Проверьте это до того, как писать код. Иначе застрянете на середине.
- Установленный Python 3.10+ и Django 5.x (
python -m django --versionпокажет версию). - Рабочий проект Django с приложением. Здесь приложение называется
catalog. - Модель с несколькими полями и хотя бы десяток записей в базе — иначе сортировать будет нечего. Данные можно добавить через админку или
manage.py shell. - Применённые миграции:
python manage.py migrate.
В качестве примера возьмём модель товара — название, цена, дата добавления.
# catalog/models.py
from django.db import models
class Product(models.Model):
name = models.CharField("Название", max_length=200)
price = models.DecimalField("Цена", max_digits=10, decimal_places=2)
created_at = models.DateTimeField("Добавлен", auto_now_add=True)
def __str__(self):
return self.name
Шаг 1. Разрешённые поля сортировки
Первое, что нужно понять: параметр сортировки придёт из URL, то есть от пользователя. Передавать его напрямую в order_by() нельзя — иначе кто угодно сможет отсортировать по служебному полю или сломать запрос. Заведите белый список.
# catalog/views.py
from django.views.generic import ListView
from .models import Product
# Только эти поля разрешено сортировать
ALLOWED_SORT_FIELDS = {"name", "price", "created_at"}
DEFAULT_SORT = "name"
Проверка: если открыть python manage.py shell и выполнить from catalog.views import ALLOWED_SORT_FIELDS, импорт пройдёт без ошибок.
Шаг 2. View, которая читает параметр и отдаёт данные
Возьмём параметры из строки запроса: ?sort=price&dir=desc. View сверяет поле с белым списком, собирает выражение для order_by() и кладёт в контекст текущее состояние сортировки — оно понадобится шаблону, чтобы рисовать ссылки.
# catalog/views.py
class ProductListView(ListView):
model = Product
template_name = "catalog/product_list.html"
context_object_name = "products"
paginate_by = 20
def get_ordering(self):
# Поле из URL, если оно в белом списке; иначе значение по умолчанию
sort = self.request.GET.get("sort", DEFAULT_SORT)
if sort not in ALLOWED_SORT_FIELDS:
sort = DEFAULT_SORT
direction = self.request.GET.get("dir", "asc")
prefix = "-" if direction == "desc" else ""
return f"{prefix}{sort}"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Текущее состояние — чтобы шаблон знал, какое поле и в какую сторону
context["current_sort"] = self.request.GET.get("sort", DEFAULT_SORT)
context["current_dir"] = self.request.GET.get("dir", "asc")
return context
ListView.get_ordering() — штатный метод: то, что он вернёт, применяется к queryset автоматически, отдельно вызывать order_by() не нужно. Это поведение есть в Django давно и в 5.x не менялось.
Один нюанс с пагинацией
Если сортировать по полю с повторами (например, много товаров с одной ценой), порядок внутри группы не гарантирован, и на разных страницах записи могут «прыгать». Добавьте вторичный ключ — обычно pk:
def get_ordering(self):
sort = self.request.GET.get("sort", DEFAULT_SORT)
if sort not in ALLOWED_SORT_FIELDS:
sort = DEFAULT_SORT
direction = self.request.GET.get("dir", "asc")
prefix = "-" if direction == "desc" else ""
# Вторичная сортировка по pk даёт стабильный порядок
return [f"{prefix}{sort}", "pk"]
Сортировка без вторичного ключа по неуникальному полю — самая частая причина «мигающих» строк при пагинации. База имеет право вернуть записи с одинаковым значением в любом порядке, и на каждой странице он может быть свой.
Шаг 3. Маршрут
# catalog/urls.py
from django.urls import path
from .views import ProductListView
urlpatterns = [
path("products/", ProductListView.as_view(), name="product-list"),
]
Проверка: откройте /products/ в браузере — должна появиться пустая или ещё не оформленная страница без ошибки 500. Если видите TemplateDoesNotExist, значит дело за шаблоном — переходим к нему.
Шаг 4. Шаблон с кликабельными заголовками
Задача заголовка — вести на тот же URL, но с нужным sort и dir. Логика простая: если кликнули по уже активному столбцу — меняем направление на противоположное, иначе ставим asc. Проще всего вынести это в отдельный include-шаблон, чтобы не дублировать код для каждого столбца.
<!-- catalog/templates/catalog/_sort_header.html -->
{# Ожидает: field (имя поля), label (подпись), current_sort, current_dir #}
{% if current_sort == field and current_dir == "asc" %}
{% with next_dir="desc" arrow="▲" %}
<a href="?sort={{ field }}&dir={{ next_dir }}">{{ label }} {{ arrow }}</a>
{% endwith %}
{% elif current_sort == field and current_dir == "desc" %}
<a href="?sort={{ field }}&dir=asc">{{ label }} ▼</a>
{% else %}
<a href="?sort={{ field }}&dir=asc">{{ label }}</a>
{% endif %}
И сама таблица:
<!-- catalog/templates/catalog/product_list.html -->
<table>
<thead>
<tr>
<th>{% include "catalog/_sort_header.html" with field="name" label="Название" %}</th>
<th>{% include "catalog/_sort_header.html" with field="price" label="Цена" %}</th>
<th>{% include "catalog/_sort_header.html" with field="created_at" label="Добавлен" %}</th>
</tr>
</thead>
<tbody>
{% for product in products %}
<tr>
<td>{{ product.name }}</td>
<td>{{ product.price }}</td>
<td>{{ product.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
Проверка: откройте /products/. Должна появиться таблица с данными и кликабельными заголовками. Кликните по «Цена» — строки перестроятся, рядом с подписью появится стрелка вверх. Кликните ещё раз — стрелка развернётся вниз, порядок сменится.
Пагинация теряет сортировку — так это чинится
Если добавите ссылки «следующая страница», по умолчанию они дадут ?page=2 и сбросят sort и dir. Нужно тащить текущие параметры в ссылку постранички:
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}&sort={{ current_sort }}&dir={{ current_dir }}">
Следующая
</a>
{% endif %}
Если не получилось
TemplateDoesNotExist at /products/— шаблон лежит не там, где Django его ищет. Файл должен быть по путиcatalog/templates/catalog/product_list.html, аcatalog— вINSTALLED_APPS.FieldError: Cannot resolve keyword 'xxx' into field— вorder_by()попало имя, которого нет в модели. Проверьте, что все значения вALLOWED_SORT_FIELDSточно совпадают с именами полей.- Стрелка не появляется, но сортировка работает — символы стрелок в примере заданы как HTML-сущности (
▲). Убедитесь, что не экранируете их автоматом; в include они выводятся как есть. - Клик по заголовку сбрасывает страницу пагинации на первую — это ожидаемо: ссылка заголовка не содержит
page. Обычно после смены сортировки и нужно вернуться на первую страницу, так что чинить нечего. - Строки «прыгают» между страницами — вернитесь к шагу 2 и добавьте
pkвторым ключом сортировки.
Что делать дальше
Базовая сортировка готова. Дальше по мере роста списка стоит смотреть в сторону трёх вещей. Первое — индексы в базе: сортировка по полю без индекса на десятках тысяч строк заметно замедляет запрос, добавьте db_index=True на часто сортируемые поля и проверьте план запроса. Второе — сборка querystring в шаблоне: вручную клеить &sort=...&dir=...&page=... быстро надоедает, вынесите это в свой шаблонный тег, который берёт текущие GET-параметры и подменяет только нужные. Третье — если таблиц много и сортировка нужна везде одинаковая, посмотрите пакет django-tables2: он даёт сортировку, пагинацию и рендер таблицы из коробки, но добавляет зависимость и свой способ описывать колонки. Фильтрация списка — отдельная тема, здесь разбирали только сортировку.
Карточки для маркетплейсов: Таблица товаров и папка фотографий на входе — комплект слайдов и описаний на выходе, по требованиям Wildberries и Ozon.
Спросить за 5 ₽Источники: Django: ListView и get_ordering (документация), Django QuerySet API: order_by()
Частые вопросы
А если у меня функциональная view, а не ListView?
Можно ли передавать имя поля в order_by прямо из GET-параметра без проверки?
У меня Django 4.2, код заработает?
Сортировка тормозит на большой таблице — что делать?
Как сортировать по полю связанной модели, например по имени категории?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.