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

К концу этой инструкции у вас будет страница со списком записей из базы, где клик по заголовку колонки меняет порядок сортировки, а направление переключается между по возрастанию и по убыванию. Работает на чистом Django без сторонних пакетов, GET-параметры видны в адресной строке, сортировка сохраняется при перезагрузке. На всё уйдёт минут двадцать, если модель и представление уже есть.
Написано для Django 5.x (подойдёт и 4.2 LTS — различий в этой части нет). Код проверялся на функциональных представлениях; в конце покажу, как то же самое делает ListView.
Что нужно иметь до начала
- рабочий проект Django с настроенной базой и хотя бы одним приложением;
- модель с несколькими полями, по которым захочется сортировать. Для примера возьму модель заказа;
- маршрут и функцию-представление, которая уже отдаёт шаблон со списком;
- базовые знания шаблонов Django — тег
{% for %}и наследование через{% extends %}.
Модель, на которую я буду ссылаться дальше:
# orders/models.py
from django.db import models
class Order(models.Model):
number = models.CharField("Номер", max_length=32)
customer = models.CharField("Клиент", max_length=200)
total = models.DecimalField("Сумма", max_digits=10, decimal_places=2)
created_at = models.DateTimeField("Создан", auto_now_add=True)
def __str__(self):
return self.numberШаг 1. Читаем параметр сортировки во view
Первый соблазн новичка — взять параметр из запроса и сунуть прямо в order_by(). Так делать нельзя: пользователь может подставить любое имя поля, в том числе служебное или несуществующее, и вы получите либо ошибку, либо утечку данных через связанные таблицы. Поэтому список разрешённых полей задаём явно.
# orders/views.py
from django.shortcuts import render
from .models import Order
# Только эти поля разрешено сортировать
ALLOWED_SORT = {"number", "customer", "total", "created_at"}
DEFAULT_SORT = "created_at"
def order_list(request):
sort = request.GET.get("sort", DEFAULT_SORT)
direction = request.GET.get("dir", "asc")
# Отсекаем всё, чего нет в белом списке
if sort not in ALLOWED_SORT:
sort = DEFAULT_SORT
# Формируем аргумент для order_by: минус означает по убыванию
order_field = sort if direction == "asc" else f"-{sort}"
orders = Order.objects.all().order_by(order_field)
context = {
"orders": orders,
"current_sort": sort,
"current_dir": direction,
}
return render(request, "orders/order_list.html", context)Проверка: откройте страницу с ?sort=total&dir=desc в адресе — записи должны выстроиться от большей суммы к меньшей. Подставьте ?sort=password — ничего не должно сломаться, список отсортируется по дате как по умолчанию.
Шаг 2. Выводим таблицу и ссылки в заголовках
Каждый заголовок колонки — ссылка, которая переключает сортировку. Логику переключения (если уже сортируем по этой колонке — меняем направление) удобнее держать не в шаблоне, а рядом. Но для наглядности новичку проще сначала собрать ссылки в шаблоне через условие.
<!-- orders/templates/orders/order_list.html -->
{% extends "base.html" %}
{% block content %}
<table>
<thead>
<tr>
<th>
<a href="?sort=number&dir={% if current_sort == 'number' and current_dir == 'asc' %}desc{% else %}asc{% endif %}">
Номер
</a>
</th>
<th>
<a href="?sort=customer&dir={% if current_sort == 'customer' and current_dir == 'asc' %}desc{% else %}asc{% endif %}">
Клиент
</a>
</th>
<th>
<a href="?sort=total&dir={% if current_sort == 'total' and current_dir == 'asc' %}desc{% else %}asc{% endif %}">
Сумма
</a>
</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td>{{ order.number }}</td>
<td>{{ order.customer }}</td>
<td>{{ order.total }}</td>
</tr>
{% empty %}
<tr><td colspan="3">Заказов пока нет</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}Проверка: кликните по «Сумма» — записи отсортируются по возрастанию. Кликните ещё раз — порядок должен перевернуться. Если строк нет, блок {% empty %} покажет заглушку вместо пустой таблицы.
Почему условие в шаблоне быстро надоедает
Три колонки — терпимо. На десяти этот {% if %} в каждой ячейке превращается в кашу, которую невозможно читать. Вынесите подготовку ссылок в представление: соберите список словарей вида «поле, подпись, готовый url, стрелка направления» и просто перебирайте его в шаблоне циклом. Это не обязательный шаг для запуска, но экономит нервы при росте таблицы.
Белый список сортируемых полей — не перестраховка, а единственный правильный способ. Всё, что приходит изrequest.GET, — данные пользователя, а не ваши. Прямая подстановка их вorder_by()— это дыра, а не удобство.
Шаг 3. Не теряем сортировку при пагинации
Как только добавите постраничный вывод, всплывёт классическая ошибка: ссылка «следующая страница» ведёт на ?page=2 и стирает sort и dir. Пользователь отсортировал по сумме, перешёл на вторую страницу — и снова видит сортировку по дате.
Решение — тащить текущие параметры в ссылки пагинации:
<a href="?page={{ page_obj.next_page_number }}&sort={{ current_sort }}&dir={{ current_dir }}">
Следующая
</a>Пагинацию как таковую здесь не разбираю — это отдельная тема, показываю только, что параметры сортировки нужно передавать вместе с номером страницы.
Если не получилось
| Что видите | Причина | Что делать |
|---|---|---|
FieldError: Cannot resolve keyword 'xxx' into field | В order_by() попало имя поля, которого нет в модели | Проверьте белый список ALLOWED_SORT — там должны быть только реальные поля модели |
| Сортировка не меняется при клике | Ссылка не передаёт dir или во view не читается direction | Откройте адресную строку и посмотрите, какие параметры реально уходят |
| Сортировка сбрасывается на второй странице | Ссылки пагинации не несут sort и dir | Добавьте параметры в href, как в шаге 3 |
| Стрелка направления показывает не туда | Перепутаны условия для asc/desc в шаблоне | Сверьте: если current_dir == 'asc', ссылка должна вести на desc |
| Порядок «плавает» между запросами | Сортировка по неуникальному полю без вторичного ключа | Добавьте вторым критерием id: .order_by(order_field, "id") |
То же самое на ListView
Если предпочитаете классовые представления, вся логика уходит в get_ordering():
# orders/views.py
from django.views.generic import ListView
from .models import Order
ALLOWED_SORT = {"number", "customer", "total", "created_at"}
class OrderListView(ListView):
model = Order
template_name = "orders/order_list.html"
context_object_name = "orders"
def get_ordering(self):
sort = self.request.GET.get("sort", "created_at")
direction = self.request.GET.get("dir", "asc")
if sort not in ALLOWED_SORT:
sort = "created_at"
return sort if direction == "asc" else f"-{sort}"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["current_sort"] = self.request.GET.get("sort", "created_at")
ctx["current_dir"] = self.request.GET.get("dir", "asc")
return ctxШаблон при этом остаётся тем же — ListView кладёт результат в переменную, указанную в context_object_name.
Что дальше
Когда базовая сортировка работает, посмотрите в сторону готовых решений, чтобы не изобретать заголовки-ссылки вручную. Пакет django-tables2 берёт на себя вывод таблицы, сортировку по клику и пагинацию — для больших таблиц это экономит много ручной вёрстки. Если таблица большая и запрос стал медленным, добавьте индексы на поля, по которым сортируете чаще всего (db_index=True в модели или составной индекс через Meta.indexes), и проверьте план запроса. Инвалидация и кеширование результатов — отдельная тема, к сортировке прямого отношения не имеющая.
Карточки для маркетплейсов: Таблица товаров и папка фотографий на входе — комплект слайдов и описаний на выходе, по требованиям Wildberries и Ozon.
Спросить за 5 ₽Источники: Django documentation: QuerySet API — order_by(), Django documentation: ListView и get_ordering()
Частые вопросы
А если у меня Django 4.2, а не 5.x?
Почему нельзя просто подставить request.GET.get('sort') в order_by()?
Сортировка сбрасывается при переходе на другую страницу — это нормально?
Строки с одинаковым значением поля скачут местами при перезагрузке. Почему?
Не проще ли сразу поставить django-tables2?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.