Сортировка таблицы в Django: разбор на живом примере
Пользователь кликает по заголовку колонки, и таблица переупорядочивается. Разбираем, как сделать это в Django без библиотек, где ломается наивный подход и чем платит SQL-инъекция через order_by.

Задача звучит просто: вывести список заказов таблицей и дать пользователю сортировать её кликом по заголовку колонки. Наивное решение — взять параметр из URL и подставить в order_by() — открывает дыру, через которую утекут данные или упадёт запрос. Разберём на живом примере: список заказов, три сортируемых колонки, переключение направления и защита от произвольного ввода. Актуально для Django 5.x, но код работает и на 4.2 LTS.
Что за проблема с наивной сортировкой
Первое, что приходит в голову — прочитать request.GET.get('sort') и скормить его в queryset:
# Так делать нельзя
sort = request.GET.get('sort', 'created_at')
orders = Order.objects.order_by(sort)
Проблема не в синтаксисе, а в доверии к вводу. order_by() принимает не только имена полей, но и обращения к связанным таблицам через двойное подчёркивание, и агрегаты. Пользователь может передать ?sort=customer__passport__number и отсортировать по полю, которое вы не собирались показывать — а заодно узнать, существует ли оно. Если поля нет, Django бросит FieldError, и пользователь получит 500 вместо страницы. Ни то, ни другое не годится.
Django ORM параметризует значения в filter(), но имена полей в order_by() он не экранирует как данные — они становятся частью SQL. Прямой SQL-инъекции классического вида тут нет, но контроль над структурой запроса вы отдаёте наружу. Значит, вход надо ограничить белым списком.
Правило простое: пользователь выбирает не имя поля, а один из заранее разрешённых вариантов сортировки. Всё, чего нет в белом списке, схлопывается в значение по умолчанию.
Живой пример: модель и белый список
Пусть есть модель заказа. Показывать будем номер, сумму и дату создания — по ним же и сортировать.
# models.py
from django.db import models
class Order(models.Model):
number = models.CharField(max_length=20)
total = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.number
Белый список сортировок держим отдельно от вьюхи — так его проще переиспользовать и тестировать. Ключ — то, что приходит в URL, значение — реальное поле модели.
# views.py
from django.core.paginator import Paginator
from django.shortcuts import render
from .models import Order
# Разрешённые колонки: ключ из URL -> поле модели
SORTABLE_FIELDS = {
'number': 'number',
'total': 'total',
'created': 'created_at',
}
DEFAULT_SORT = 'created'
def order_list(request):
sort_key = request.GET.get('sort', DEFAULT_SORT)
direction = request.GET.get('dir', 'asc')
# Отбрасываем всё, чего нет в белом списке
if sort_key not in SORTABLE_FIELDS:
sort_key = DEFAULT_SORT
if direction not in ('asc', 'desc'):
direction = 'asc'
field = SORTABLE_FIELDS[sort_key]
order_by = field if direction == 'asc' else f'-{field}'
orders = Order.objects.order_by(order_by)
paginator = Paginator(orders, 25)
page = paginator.get_page(request.GET.get('page'))
return render(request, 'orders/list.html', {
'page': page,
'sort_key': sort_key,
'direction': direction,
})
Теперь любой мусор в ?sort= превращается в сортировку по дате. FieldError невозможен, потому что во order_by() попадают только имена из словаря, который написали вы.
Шаблон: заголовки-ссылки и стрелка направления
Заголовок каждой сортируемой колонки — ссылка, которая переключает направление, если колонка уже активна, и ставит asc, если сортируем впервые. Логику переключения удобнее вынести, но для наглядности покажем прямо в шаблоне.
<!-- orders/list.html -->
<table>
<thead>
<tr>
<th>
<a href="?sort=number&dir={% if sort_key == 'number' and direction == 'asc' %}desc{% else %}asc{% endif %}">
Номер{% if sort_key == 'number' %}{% if direction == 'asc' %} ↑{% else %} ↓{% endif %}{% endif %}
</a>
</th>
<th>
<a href="?sort=total&dir={% if sort_key == 'total' and direction == 'asc' %}desc{% else %}asc{% endif %}">
Сумма{% if sort_key == 'total' %}{% if direction == 'asc' %} ↑{% else %} ↓{% endif %}{% endif %}
</a>
</th>
<th>
<a href="?sort=created&dir={% if sort_key == 'created' and direction == 'asc' %}desc{% else %}asc{% endif %}">
Дата{% if sort_key == 'created' %}{% if direction == 'asc' %} ↑{% else %} ↓{% endif %}{% endif %}
</a>
</th>
</tr>
</thead>
<tbody>
{% for order in page %}
<tr>
<td>{{ order.number }}</td>
<td>{{ order.total }}</td>
<td>{{ order.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
Повторяющиеся {% if %} в каждом <th> — сигнал вынести логику в кастомный тег шаблона1. Для трёх колонок терпимо, для пятнадцати — уже нет.
Где наивная сортировка теряет пагинацию
Первая ошибка, которую вы поймаете сразу: пользователь на третьей странице кликает по заголовку — и параметр page либо теряется, либо остаётся, но указывает на несуществующую страницу нового порядка. В примере выше ссылки заголовков не тащат page, и это правильно: при смене сортировки логично вернуться на первую страницу. А вот ссылки самой пагинации обязаны сохранять sort и dir, иначе перелистывание сбросит порядок.
<!-- ссылка пагинации сохраняет сортировку -->
{% if page.has_next %}
<a href="?page={{ page.next_page_number }}&sort={{ sort_key }}&dir={{ direction }}">Далее</a>
{% endif %}
Подходы: чем платит каждый
Ручной белый список — не единственный вариант. Вот три подхода и цена каждого.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| Ручной словарь + order_by() | Полный контроль, ноль зависимостей, понятно новичку | Повтор логики в шаблоне, ручная сборка URL |
| django-tables2 | Готовые сортируемые колонки, пагинация, рендер из коробки | Внешняя зависимость, свой синтаксис классов таблиц, меньше контроля над вёрсткой |
| DataTables на клиенте (JS) | Мгновенная сортировка без запроса к серверу | Сортирует только загруженную страницу, а не всю выборку — ломается на больших объёмах |
Когда что выбирать
- Ручной словарь — одна-две таблицы на проекте, нестандартная вёрстка, не хочется тянуть пакет.
- django-tables2 — много однотипных таблиц, устраивает его разметка, нужна сортировка и пагинация без ручной сборки. Проверяйте совместимость версии пакета с вашей версией Django в его документации.
- Клиентский JS — данных мало и они целиком помещаются на одну страницу. Как только выборка перестаёт влезать в один экран и появляется серверная пагинация, клиентская сортировка вводит пользователя в заблуждение: сортируется видимое, а не всё.
Что ломается на практике
- Нестабильный порядок при равных значениях. Если сортируете по полю с повторами (например, по статусу), строки с одинаковым значением могут прыгать между запросами и на границах страниц. Добавьте вторичный ключ:
order_by(field, 'pk')— так порядок детерминирован. - Сортировка по вычисляемому полю. Нельзя сортировать по свойству-
@propertyмодели — ORM про него не знает. Нуженannotate()с выражением, и уже по аннотированному полю строитьorder_by(). - NULL в конце или начале. По умолчанию положение
NULLзависит от СУБД. В PostgreSQL управляйте им черезF('field').asc(nulls_last=True), иначе пустые значения окажутся не там, где ждёте. - Регистр в текстовых полях. Сортировка строк учитывает регистр и зависит от collation базы. Для человекочитаемого порядка оборачивайте в
Lower('field')изdjango.db.models.functions.
Что настроить дальше
Как только таблиц становится больше одной, вынесите разбор sort/dir и генерацию заголовков в кастомный тег шаблона или миксин для class-based view — повторять {% if %} в каждом <th> не нужно. Если строк тысячи, проверьте, что по сортируемым колонкам есть индексы в базе: без индекса ORDER BY заставит СУБД сортировать весь набор в памяти или во временном файле. Мерить стоит план запроса через EXPLAIN — под нагрузкой именно сортировка без индекса становится узким местом раньше остального.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Django QuerySet API: order_by(), Django: Pagination
Частые вопросы
А если у меня Django 4.2, а не 5.x — код будет работать?
Что делать, если после клика по заголовку страница падает с FieldError?
Можно ли сортировать по полю связанной модели?
Почему строки с одинаковым значением прыгают между страницами?
Стоит ли переходить на django-tables2 вместо ручного решения?
Опасна ли подстановка параметра сортировки с точки зрения безопасности?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.