Версионирование API в Django: архитектурные решения и их цена
Смена контракта API ломает мобильные приложения, которые обновляются месяцами. Разбираем, где хранить версию, чем платит каждый подход и что рвётся первым при поддержке двух версий разом.

Вы выкатили API v1, на нём сидят три мобильных приложения и десяток интеграций партнёров. Через полгода поле full_name надо разбить на first_name и last_name. Просто переименовать нельзя: старые клиенты обновляются неделями, а часть пользователей не обновится никогда. Это точка, где абстрактный вопрос «как версионировать» превращается в решение с ценой в человеко-часах и в риске уронить прод чужого приложения.
Здесь разбираем именно архитектурную развилку: где живёт номер версии, как Django REST Framework это поддерживает и чем платит команда за каждый вариант при реальной поддержке двух версий одновременно. Стратегию депрекации и генерацию клиентского SDK не трогаем — это отдельные темы. Всё, что ниже, проверялось на Django 5.x и DRF 3.15.x.
Где хранить версию: четыре точки контракта
DRF из коробки поддерживает несколько схем версионирования через настройку DEFAULT_VERSIONING_CLASS. Различаются они только тем, откуда фреймворк достаёт строку версии и кладёт её в request.version. Всё остальное — ваша логика.
- URL-путь —
/api/v1/orders/. КлассURLPathVersioning. Версия видна в логах, в закладках, в конфиге Nginx. Самый явный вариант. - Query-параметр —
/api/orders/?version=1. КлассQueryParameterVersioning. Клиент может забыть параметр, и вы упрётесь в дефолт. - Заголовок Accept —
Accept: application/json; version=1. КлассAcceptHeaderVersioning. URL остаётся чистым, но версия невидима в браузере и требует дисциплины на стороне клиента. - Кастомный заголовок / поддомен —
X-API-Version: 1илиv1.api.example.com. КлассыHostNameVersioningи собственный на базеBaseVersioning.
Точные имена классов и параметры настройки сверяйте в актуальном разделе документации DRF по версионированию — API между минорными версиями менялось мало, но проверить перед копированием стоит.
Минимальная настройка
# settings.py, Django 5.x + DRF 3.15.x
REST_FRAMEWORK = {
# схема с версией в пути: /api/v1/...
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_VERSION": "1",
"ALLOWED_VERSIONS": ["1", "2"],
"VERSION_PARAM": "version",
}# urls.py
from django.urls import path
from orders.views import OrderListView
urlpatterns = [
# <version> попадёт в request.version
path("api/<version>/orders/", OrderListView.as_view()),
]После этого в любом view доступен request.version — строка "1" или "2". Если клиент передаст "3", DRF вернёт 404 или 400 в зависимости от схемы, потому что версии нет в ALLOWED_VERSIONS. Это уже страховка от опечаток.
Что именно версионируется
Номер в URL — это только адрес. Реальная работа начинается там, где расходятся сериализаторы, вьюхи и модель ответа. Есть три уровня, на которых можно развести версии, и цена растёт слева направо.
| Подход | Что дублируется | Цена поддержки | Когда оправдан |
|---|---|---|---|
| Один сериализатор, ветвление внутри | Ничего, но растут if request.version | Низкая на старте, высокая через год: логика превращается в кашу | Одно-два отличия между версиями |
| Отдельный сериализатор на версию | Классы сериализаторов | Средняя: дублирование полей, но версии изолированы | Разошёлся формат ответа, общая бизнес-логика |
| Отдельное приложение/пакет на версию | Вьюхи, сериализаторы, роуты | Высокая: две кодовые базы, но полная свобода | Версии живут годами и развиваются независимо |
Выбор сериализатора по версии
Самый частый рабочий вариант — держать сериализаторы раздельно и выбирать нужный в методе get_serializer_class. Логика запроса и права остаются общими, а формат ответа изолирован.
# orders/serializers.py
from rest_framework import serializers
from orders.models import Order
class OrderSerializerV1(serializers.ModelSerializer):
# старый контракт: одно поле с полным именем
full_name = serializers.CharField(source="customer_full_name")
class Meta:
model = Order
fields = ["id", "full_name", "total"]
class OrderSerializerV2(serializers.ModelSerializer):
# новый контракт: имя разбито на два поля
first_name = serializers.CharField(source="customer_first_name")
last_name = serializers.CharField(source="customer_last_name")
class Meta:
model = Order
fields = ["id", "first_name", "last_name", "total"]# orders/views.py
from rest_framework.generics import ListAPIView
from orders.models import Order
from orders.serializers import OrderSerializerV1, OrderSerializerV2
class OrderListView(ListAPIView):
queryset = Order.objects.all()
def get_serializer_class(self):
# request.version уже проставлен схемой версионирования
if self.request.version == "2":
return OrderSerializerV2
return OrderSerializerV1Обратите внимание: обе версии читают одну модель. Пока схема БД поддерживает оба представления (есть и customer_full_name, и разбитые поля, либо одно вычисляется из другого) — вы платите только дублированием сериализатора. Как только версии требуют несовместимой схемы данных, цена резко растёт: об этом ниже.
Версионируется не URL и не код, а контракт. Клиент полагается на форму ответа и поведение ошибок. Всё, что он видит, нельзя менять внутри одной версии — даже добавление обязательного поля в запрос ломает старых клиентов.
Где подход ломается
Развести сериализаторы легко. Проблемы приходят на уровне данных и совместимости, и они плохо видны на этапе проектирования.
Расхождение схемы БД
Пока v1 отдаёт full_name, а v2 — два поля, база должна отдавать оба. Пути тут два: хранить все поля физически и синхронизировать их, либо хранить одно и вычислять второе на лету. Первое — дублирование данных и риск рассинхрона при записи из разных версий. Второе — full_name как property или аннотация, что дёшево на чтение, но ломается на записи: разбить произвольную строку на имя и фамилию надёжно нельзя.
Запись, а не только чтение
Версионирование ответов обсуждают чаще, чем версионирование ввода. А именно ввод рвётся первым. Если v1 принимает full_name на создание заказа, а модель уже перешла на два поля, вам нужен слой преобразования на входе каждой версии. Ветвление в get_serializer_class это решает, но валидация усложняется: правила для full_name и для пары полей разные.
Общие фильтры и пагинация
Если v2 добавила фильтр по last_name, а v1 его не знает, легко получить ситуацию, когда параметр запроса молча игнорируется в старой версии. Клиент думает, что фильтрует, а получает всё. Такие тихие расхождения не ловятся тестами формата ответа — нужны отдельные тесты на каждую версию.
Взрыв матрицы тестов
Две версии — это не x2 тестов, а ближе к x2 по каждому эндпоинту, каждому статусу ошибки и каждому набору прав. Через три версии матрица становится дороже самой фичи. Это главный аргумент против того, чтобы плодить версии по любому мелкому изменению.
Когда что выбирать
- Не создавайте новую версию вообще, если изменение аддитивное: новое необязательное поле в ответе, новый эндпоинт, новый необязательный параметр. Старые клиенты просто игнорируют то, чего не знают. Это самое дешёвое решение и оно закрывает большинство случаев.
- Ветвление в одном сериализаторе — когда отличий одно-два и вы уверены, что версий будет ровно две и вторая быстро вытеснит первую.
- Раздельные сериализаторы — дефолт для реального продакшена, где формат разошёлся, но модель и бизнес-логика общие.
- Раздельные приложения — только когда версии живут годами и развиваются независимо, и вы сознательно готовы содержать две кодовые базы. Обычно это признак того, что стоило выпустить отдельный продукт, а не версию.
Как понять, что версия больше не нужна
Версия — это долг, а не актив. Считать её живой стоит по метрике, а не по ощущению. Логируйте request.version на каждом запросе и держите дашборд с долей трафика по версиям. Пока на v1 идёт хоть сколько-то запросов, вы платите за её поддержку каждым релизом.
Что делать дальше
Начните с малого: добавьте DEFAULT_VERSIONING_CLASS и логирование версии, даже если версия пока одна. Это бесплатная страховка на будущее. Перед выпуском v2 напишите тест, который дёргает оба эндпоинта на одинаковых данных и сравнивает форму ответа — так вы поймаете момент, когда изменение в общей модели тихо сломает старый контракт. И сразу заведите правило: аддитивные изменения не порождают новую версию. Это сэкономит вам большую часть матрицы тестов.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Django REST Framework — Versioning, Django REST Framework — Serializers
Частые вопросы
А если у меня старая версия DRF, эти классы версионирования есть?
Обязательно ли выносить версию в URL?
Что делать, если версий стало три и поддержка стала неподъёмной?
Ломает ли обратную совместимость добавление нового поля в ответ?
Как версионировать не ответ, а входные данные?
Не сработало: request.version всегда None. В чём дело?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.