«На чём писать API на Python» — вопрос не моды, а задачи. FastAPI даёт типизацию и async, Django — всё из коробки для полноценных приложений, Flask — минимализм и контроль. Разберём три подхода и то, за что платишь на каждом.
Разберём и главный вопрос, который обычно задают первым: насколько они различаются по скорости. Ответ измерен на стенде, и он неудобный — вопрос поставлен неверно.
digital-cookbook/python/web
В статье
- Сначала о том, как выбирать
- FastAPI: типизация как основной инструмент
- Django: батарейки в комплекте
- Flask: минимализм и контроль
- Сколько стоит сам фреймворк
- Почему «кто быстрее» — неправильный вопрос
- Что действительно решает
Сначала о том, как выбирать
Короткий ответ, до всяких чисел.
Нужно полноценное приложение с админкой, аутентификацией, миграциями и формами — берите Django. Всё это в нём уже есть, написано и протестировано, и собирать эквивалент из отдельных библиотек вы будете долго.
Нужен API с описанным контрактом, особенно если его потребляют чужие команды, — берите FastAPI. Типизированные модели дают валидацию и схему OpenAPI из одного описания, и это его главная ценность.
Нужен маленький сервис или полный контроль над устройством — берите Flask. Он не навязывает структуру и не тащит того, что вам не нужно.
Дальше — про то, чем за каждый вариант платят, и про то, почему скорость в этом списке не упоминается.
FastAPI: типизация как основной инструмент
Ценность FastAPI не в слове «fast» и не в async. Она в том, что описание данных, валидация и документация — это одна и та же запись.
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class OrderIn(BaseModel):
model_config = {"extra": "forbid"} # лишние поля — ошибка, а не сюрприз
id: int = Field(ge=1)
name: str = Field(min_length=1, max_length=64)
tags: list[str] = Field(max_length=16)
@app.post("/orders")
async def create(order: OrderIn) -> dict:
return {"id": order.id, "tags": len(order.tags)}
Здесь одновременно: разбор тела, проверка типов и границ, отказ с внятной ошибкой при нарушении, и схема OpenAPI, по которой клиент сгенерирует себе типы. В Flask и Django эти четыре вещи пишутся отдельно и расходятся между собой при первом же изменении.
Платят за это тем, что pydantic — не бесплатная библиотека, а полноценный слой со своей моделью данных. Асинхронность при этом не обязательна. FastAPI принимает и обычные обработчики def — их он выполняет в пуле потоков, — и синхронные зависимости; синхронный драйвер базы в таком обработчике работает как обычно. Ловушка в другом: если обработчик объявлен async def, всё блокирующее внутри него — синхронный драйвер, time.sleep, тяжёлое вычисление — выполняется прямо в цикле событий и останавливает его целиком, вместе со всеми остальными запросами. Почему именно так, разобрано в статье про asyncio и GIL. Правило простое: async def — только если всё, что обработчик ждёт, асинхронное; иначе def.
Django: батарейки в комплекте
Django выигрывает там, где задача — не «отдать JSON», а «сделать приложение». ORM с миграциями, админка, аутентификация, права, формы, шаблоны — всё связано между собой и работает вместе.
Отсюда же его цена: этот набор работает всегда, даже когда вам нужен один эндпоинт. Каждый запрос проходит через цепочку middleware — сессии, CSRF, аутентификация, — и всё это делает работу, которую вы не заказывали.
Для API-задач у Django есть async-обработчики и DRF, но он остаётся фреймворком, чьи сильные стороны лежат в другой плоскости. Выбирать Django ради REST-эндпоинтов — значит платить за инфраструктуру, которой не пользуешься.
Flask: минимализм и контроль
Flask не решает за вас почти ничего, и это его свойство, а не недостаток.
from flask import Flask, request
app = Flask(__name__)
@app.post("/orders")
def create():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return {"detail": "invalid"}, 422
# валидацию пишете сами — или подключаете библиотеку по своему выбору
return {"id": payload["id"]}
Плата очевидна из примера: валидация, сериализация, структура проекта — ваша забота. На маленьком сервисе это выигрыш, на большом — накопленный технический долг, потому что каждый разработчик решит эти вопросы по-своему.
И главное ограничение, о котором вспоминают поздно: Flask — WSGI-приложение, синхронное по своей природе. Что это значит на практике, видно из замеров ниже, и последствия там серьёзнее, чем кажется.
Сколько стоит сам фреймворк
Теперь числа. Стенд гоняет три приложения, отдающие побайтово одинаковые ответы, на трёх ручках с разным объёмом полезной работы: пустой ответ, разбор JSON с валидацией, и то же плюс ожидание 20 мс вместо похода в базу.
Одинаковость ответов — не формальность, а условие осмысленности. Без неё сравнивался бы объём работы: у Django по умолчанию включены middleware, у FastAPI ответ проходит через pydantic-сериализацию, у Flask сортируются ключи JSON. Проверка на стенде блокирующая и сразу поймала три таких расхождения.
Ещё одна тонкость: FastAPI и Django работают под uvicorn, Flask под gunicorn. Разницу серверов из результата не вычесть, поэтому в стенде есть контрольные плечи — голое ASGI- и голое WSGI-приложение с тем же контрактом. Каждый фреймворк сравнивается со своим полом, а не с чужим сервером.
Доля от своего пола при 64 одновременных запросах. В скобках — размах по одиннадцати кругам, и он здесь важнее самой медианы:
| фреймворк | пустой ответ | валидация | с ожиданием 20 мс |
|---|---|---|---|
| FastAPI | 74% (66–85) | 70% (61–76) | 102% (94–107) |
| Flask | 71% (54–118) | 69% (56–74) | 99% (97–101) |
| Django | 14% (12–15) | 16% (13–18) | 27% (25–30) |
Читается так: 100% означает, что фреймворк не стоит ничего сверх своего сервера.
Про размах отдельно, потому что без него таблица врёт точностью. На быстрых ручках стенд даёт от круга к кругу расхождение в десятки процентов: у Flask на пустой ручке доля гуляла от 54% до 118%. Причина не в целях, а в машине: на пяти тысячах запросов в секунду замер начинает мерить её состояние. Я пробовал это лечить — удлинял окно измерения, добавлял двадцатисекундный разогрев перед каждым плечом; разброс у одних клеток падал, у других рос, и устойчивого числа не вышло.
Поэтому читать таблицу нужно так: стенд различает эффекты, у которых размахи не пересекаются, и разрешающая способность у него разная на разных ручках. Общего «различает десятки процентов» тут не выйдет — на ручке с ожиданием размахи узкие (94–107% против 25–30%), и вывод «пропускная способность Django относительно своего пола вчетверо ниже, чем у соседей» держится с запасом; на пустой ручке размах Flask 54–118% перекрывает почти всё, и оттуда нельзя вытащить даже порядок различия между ним и FastAPI.
Что стенд выдерживает: «доля пропускной способности, которую Django сохраняет от своего пола, кратно ниже, чем у соседей». Это доля RPS, а не сами накладные расходы: падение пропускной способности вчетверо не означает, что расходы фреймворка вчетверо больше. Чего не выдерживает: «FastAPI на три процента дороже Flask» — такого утверждения в статье нет и быть не может.
Почему «кто быстрее» — неправильный вопрос
Посмотрите на правый столбец — но только на две первые строки. У FastAPI и Flask треть накладных расходов перестаёт измеряться, как только в обработчике появляется поход в базу. Не уменьшается — пропадает из виду: 102% и 99% от голого приложения, то есть от контрольного плеча они неотличимы.
И размах на этой ручке узкий — 94–107% у FastAPI, 97–101% у Flask, — в отличие от пустой. Это не совпадение: двадцать миллисекунд ожидания задают темп, машина перестаёт быть узким местом, и замер становится устойчивым. Тот самый случай, когда разброс говорит о замере больше, чем медиана.
У Django этого не происходит. Он остаётся на 27% от своего пола — заметно выше собственных 14–16% на пустой ручке, но всё ещё вчетверо ниже соседей. Вывод «разница тонет в работе» верен для двух фреймворков из трёх, и это существенная оговорка, а не мелочь.
Причина арифметическая. На пустой ручке фреймворк — это вся работа, и его цена видна целиком. Добавьте 20 миллисекунд ожидания базы, и доля фреймворка в общем времени падает до долей процента. В реальной службе, где запрос ходит в базу, кэш и соседний сервис, разница между FastAPI и Flask измеряется величиной, которую нельзя увидеть за шумом.
Это рассуждение работает, пока собственная цена фреймворка мала по сравнению с ожиданием. У Django она не мала: даже с двадцатью миллисекундами ожидания он отдаёт 746 запросов в секунду против 2809 у FastAPI. То есть его накладные расходы сопоставимы с самим ожиданием и потому не тонут.
Отсюда практический вывод: выбирая фреймворк по бенчмаркам на пустых ручках, вы оптимизируете то, чего в вашей нагрузке нет. Бенчмарки такого рода честно измеряют реальную величину — просто эта величина не имеет отношения к вашему решению.
Про Django надо сказать отдельно и честно. Его 14–16% — впятеро хуже соседей, и это при том, что на стенде с него сняты middleware и ORM ради равенства объёма работы. То есть число характеризует не Django, как его разворачивают, а маршрутизацию и сериализацию, приведённые к общему знаменателю. Почему разрыв такой большой — стенд не выяснял: он единственный из трёх отдаёт ответы с Transfer-Encoding: chunked, и какая доля отставания приходится на это, я не измерял. Гипотеза, а не вывод.
Что действительно решает
А вот число, которое решает по-настоящему, — и оно не про фреймворки.
Пропускная способность на ручке с ожиданием, 64 одновременных запроса:
| цель | запросов в секунду |
|---|---|
| FastAPI под uvicorn | 2808,7 |
| голое ASGI-приложение | 2806,5 |
| Flask под gunicorn | 47,9 |
| голое WSGI-приложение | 48,2 |
Разрыв почти шестидесятикратный, и фреймворки тут ни при чём: голые приложения показывают ровно то же самое. Дело в модели.
Синхронный воркер занят всё время ожидания целиком. Пока он ждёт ответа базы 20 миллисекунд, он не делает ничего другого. Один воркер — около пятидесяти запросов в секунду, и никакая одновременность этого не меняет: на стенде синхронное плечо даёт 48,1 при одном одновременном запросе и 48,2 при шестидесяти четырёх. Асинхронный обработчик на время ожидания освобождает цикл событий, и тот занимается остальными запросами.
Лечится это воркерами — синхронная модель масштабируется числом процессов почти линейно, — но воркеры стоят памяти, и потолок задаёт формула «воркеров ÷ время ответа». Подробно про выбор числа воркеров, а заодно про то, как одно и то же приложение теряет от способа запуска — втрое на ручке с ожиданием и на порядок на пустой, — в статье про эксплуатацию.
Итог. Между FastAPI, Django и Flask выбирают по тому, что фреймворк даёт: типизированный контракт, готовое приложение или свободу.
Разница в скорости между FastAPI и Flask тонет в первом же походе в базу — по ней выбирать нечего. С Django осторожнее: его собственная цена на этом стенде велика настолько, что не растворяется даже при двадцати миллисекундах ожидания. Если он вам нужен ради админки, ORM и форм — берите, но считайте эту цену частью сделки, а не пренебрежимой величиной.
А вот выбор между синхронной и асинхронной моделью — решение, которое видно в числах невооружённым глазом, и принимать его надо осознанно.
Как это устроено в других языках
Web-фреймворки в Rust и net/http в Go — там компромиссы другие, потому что синхронной модели в нашем смысле у них нет. Reactive против virtual threads на JVM — самый близкий сюжет: тот же выбор между «занять поток на время ожидания» и «освободить его», решённый третьим способом.
Внутри серии соседние части: типизация, на которой стоит FastAPI, и доступ к данным — там же выясняется, асинхронный ли у вас драйвер.
Комментарии