Web на Python: FastAPI vs Django vs Flask — выбор под задачу

Три подхода к web на Python: async-first FastAPI с типизацией, батарейки-в-комплекте Django и минималистичный Flask — что выбрать под задачу и чем платишь за каждый

«На чём писать API на Python» — вопрос не моды, а задачи. FastAPI даёт типизацию и async, Django — всё из коробки для полноценных приложений, Flask — минимализм и контроль. Разберём три подхода и то, за что платишь на каждом.

Разберём и главный вопрос, который обычно задают первым: насколько они различаются по скорости. Ответ измерен на стенде, и он неудобный — вопрос поставлен неверно.

digital-cookbook/python/web

Ретрофутуристичная схема-блюпринт: три разные по размеру установки стоят на одном общем основании с подписью «одна и та же работа» — компактная «FastAPI» с экраном типизированной схемы, крупная шкафная «Django» с выдвижными ящиками «админка», «ORM», «формы», и минималистичная открытая рама «Flask»; ниже во всю ширину листа тянется длинная труба с вентилем, подписанная «поход в базу: 20 мс», рядом с которой все три установки выглядят небольшими; справа две панели по три циферблата, подписанных именами фреймворков: верхняя «на пустой ручке», где стрелки FastAPI и Flask стоят высоко, а стрелка Django лежит внизу с табличкой «впятеро ниже», и нижняя «когда есть поход в базу», где FastAPI и Flask поднялись к верху с пометками «≈100%», а Django остался внизу с табличкой «вчетверо ниже»; между панелями табличка «разница тонет в работе»; внизу слева два циферблата «синхронный воркер: 48» и «цикл событий: 2800» с подписью «это уже не про фреймворк»

В статье

Сначала о том, как выбирать

Короткий ответ, до всяких чисел.

Нужно полноценное приложение с админкой, аутентификацией, миграциями и формами — берите 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, и доступ к данным — там же выясняется, асинхронный ли у вас драйвер.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии