Версионирование в FastAPI: что учесть до запуска в прод
FastAPI не навязывает схему версионирования API. Это свобода, за которую платят ломающимися интеграциями у клиентов. Разбираем четыре подхода — от префикса в URL до заголовков — и показываем, чем каждый оборачивается на практике.

Зачем вообще версионировать
Вы выкатили первую версию эндпоинта, мобильное приложение уже в сторе, три внешних партнёра интегрировались. Через месяц бизнес просит поменять формат ответа — убрать поле fullname и вернуть first_name + last_name. Без версионирования вы ломаете всех, кто парсит fullname. С версионированием — добавляете v2, а v1 живёт, пока партнёры не мигрируют.
FastAPI (рассматриваем ветку 0.100+, совместимую с Pydantic v2) не даёт встроенного механизма версионирования. Фреймворк предоставляет роутеры, зависимости и middleware — из них можно собрать любую схему. Вопрос в том, какую выбрать и сколько она будет стоить в поддержке.
Четыре подхода
1. Префикс в URL
Самый распространённый вариант: /api/v1/users, /api/v2/users. Каждая версия — отдельный APIRouter, подключённый с нужным префиксом.
# routers/v1/users.py
from fastapi import APIRouter
router = APIRouter(prefix="/api/v1/users", tags=["users-v1"])
@router.get("/{user_id}")
async def get_user_v1(user_id: int):
# Возвращаем старый формат с fullname
return {"id": user_id, "fullname": "Иван Петров"}
# routers/v2/users.py
from fastapi import APIRouter
router = APIRouter(prefix="/api/v2/users", tags=["users-v2"])
@router.get("/{user_id}")
async def get_user_v2(user_id: int):
# Новый формат: имя и фамилия отдельно
return {"id": user_id, "first_name": "Иван", "last_name": "Петров"}
# main.py
from fastapi import FastAPI
from routers.v1 import users as users_v1
from routers.v2 import users as users_v2
app = FastAPI()
app.include_router(users_v1.router)
app.include_router(users_v2.router)
Проверка: запустите uvicorn main:app --reload и откройте /docs — оба роутера покажутся в Swagger, разделённые тегами.
2. Заголовок (Accept или кастомный)
Клиент шлёт Accept: application/vnd.myapi.v2+json или X-API-Version: 2. Роутер один, но внутри хендлера или зависимости решается, какую схему ответа вернуть.
from fastapi import APIRouter, Request, HTTPException
router = APIRouter(prefix="/api/users", tags=["users"])
def get_api_version(request: Request) -> int:
"""Извлекаем версию из заголовка X-API-Version."""
raw = request.headers.get("X-API-Version", "1")
try:
return int(raw)
except ValueError:
raise HTTPException(status_code=400, detail="Некорректная версия API")
@router.get("/{user_id}")
async def get_user(user_id: int, request: Request):
version = get_api_version(request)
if version == 1:
return {"id": user_id, "fullname": "Иван Петров"}
elif version == 2:
return {"id": user_id, "first_name": "Иван", "last_name": "Петров"}
raise HTTPException(status_code=400, detail="Версия не поддерживается")
URL чистый, но Swagger не покажет обе схемы одновременно, и клиентам приходится помнить про заголовок.
3. Query-параметр
/api/users/1?version=2. Реализуется аналогично заголовку, только версия берётся из Query. Легко тестировать в браузере, но URL перестаёт быть каноничным — кеширование и аналитика усложняются.
4. Отдельные приложения (субмаунт)
Каждая версия — собственный экземпляр FastAPI, смонтированный через app.mount. Подход жёсткий: полная изоляция middleware, обработчиков ошибок, зависимостей. Подходит, когда версии отличаются не парой полей, а логикой целиком.
from fastapi import FastAPI
app = FastAPI()
v1_app = FastAPI(title="My API v1")
v2_app = FastAPI(title="My API v2")
# Регистрируем роутеры внутри v1_app и v2_app...
app.mount("/api/v1", v1_app)
app.mount("/api/v2", v2_app)
Swagger для каждой версии будет на своём адресе: /api/v1/docs и /api/v2/docs.
Сравнение подходов
| Критерий | URL-префикс | Заголовок | Query-параметр | Субмаунт |
|---|---|---|---|---|
| Видимость в Swagger | обе версии в одном UI | одна схема, без разделения | одна схема | раздельные Swagger |
| Кеширование (CDN, прокси) | работает по URL | нужен Vary-заголовок | по URL + query | работает по URL |
| Дублирование кода | среднее | низкое | низкое | высокое |
| Изоляция middleware | общая | общая | общая | полная |
| Удобство для клиента | очевидный URL | нужно знать заголовок | не каноничный URL | очевидный URL |
| Сложность поддержки при 3+ версиях | растёт линейно | растёт нелинейно (ветвления) | растёт нелинейно | растёт линейно, но дорого |
Pydantic-схемы: где дублировать, а где наследовать
Главная боль — не роутинг, а модели. Если v2 отличается от v1 одним полем, соблазн велик: наследовать UserV2(UserV1) и переопределить поле. Это работает ровно до момента, когда v1 нужно поменять независимо от v2 — и наследование превращается в ловушку.
Наследование Pydantic-моделей между версиями — это бомба с таймером. Версии должны расходиться, а наследование заставляет их слипаться. Дублируйте модели, пока версий меньше пяти — стоимость копирования ниже стоимости неожиданного регресса.
Практичный подход: каждая версия хранит свои схемы в отдельном пакете (schemas/v1/user.py, schemas/v2/user.py). Общая бизнес-логика и слой доступа к данным — одни на всех, а маппинг из внутренней модели в нужную схему делается в хендлере или тонкой сервисной функции.
Что ломается чаще всего
- Забытые зависимости. Вы создали
v2-роутер, но зависимостьget_current_userпо-прежнему возвращает модельv1. Ответ уезжает в непредсказуемый формат. Проверяйте, какие Pydantic-модели проходят черезDepends, а не только черезresponse_model. - Swagger-конфликты. Если две версии используют модели с одинаковым именем класса, OpenAPI-схема склеит их в одну. Решение: либо давайте классам уникальные имена (
UserResponseV1), либо используйте параметрmodel_configс уникальнымjson_schema_extraили задавайтеtitleвmodel_config. - Миграция без дедлайна. Версия
v1остаётся «на всякий случай» и живёт годами. В неё ходят три клиента, но она тянет за собой старую логику. Фиксируйте дату заката версии в заголовкеSunset(RFC 8594) и в документации. - Тесты только для последней версии. Старая версия деградирует незаметно. Включайте все живые версии в CI-пайплайн.
Когда подход выбирать
Выбор зависит не от вкуса, а от трёх переменных:
- Кто клиент? Мобильные приложения и внешние партнёры — URL-префикс или субмаунт: им нужна явная, самодокументированная точка входа. Внутренний фронтенд — заголовок допустим, потому что вы контролируете клиентский код.
- Масштаб различий. Одно-два поля — URL-префикс с отдельными схемами. Разная авторизация, другой набор middleware, существенно другая бизнес-логика — субмаунт.
- Число одновременных версий. Больше двух живых версий — URL-префикс управляется проще. Заголовки и query-параметры при трёх и более версиях превращают хендлеры в лапшу из
if.
Сноска: Sunset-заголовок — HTTP-заголовок, описанный в RFC 8594. Сервер возвращает его с датой, после которой ресурс перестанет работать. Клиент может логировать предупреждение и заранее мигрировать на новую версию.
Что делать дальше
Определитесь с подходом до первого публичного релиза — менять схему версионирования на ходу больнее, чем менять сам API. Зафиксируйте политику: сколько версий поддерживаете одновременно, за какой срок предупреждаете о закате. Добавьте в CI контрактные тесты (Schemathesis хорошо работает с OpenAPI-схемой FastAPI — актуальные возможности уточняйте в документации инструмента). И посмотрите в сторону feature flags для мелких изменений, которые не стоят отдельной версии, — это тема для отдельного разговора.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: FastAPI — официальная документация (Bigger Applications, Multiple Routers), RFC 8594 — The Sunset HTTP Header Field
Частые вопросы
Какой способ версионирования в FastAPI выбрать по умолчанию?
Можно ли наследовать Pydantic-модели между версиями API?
Как показать обе версии в Swagger одновременно?
Что если у меня другая версия FastAPI или Pydantic v1?
Сколько версий стоит поддерживать одновременно?
Есть ли готовая библиотека для версионирования в FastAPI?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.