Python
Yesterday

Переход с Flask на FastAPI: что реально меняется (и где поджидает боль) 😸

Flask — это как старый добрый инструмент: простой, понятный, без лишней магии. Ты сам решаешь, как всё устроить. Хочешь — так, хочешь — иначе.

FastAPI — это уже другая история. Он даёт много «из коробки»: валидацию, OpenAPI, асинхронность, dependency injection. И выглядит это очень привлекательно.

Но переход — это не просто «переписал пару декораторов». Это смена подхода. И если к этому не подготовиться, можно легко получить проект, который стал сложнее, но не стал лучше.

Давай разберёмся спокойно и по-человечески, что именно меняется и где обычно возникает боль.


Философия и поведение фреймворка

Flask даёт тебе свободу. Почти любую. Хочешь — пишешь всё сам, хочешь — собираешь свой стек из расширений. Это удобно, пока проект небольшой. Но чем больше система, тем больше разъезжается архитектура: в одном месте так, в другом — иначе.

FastAPI, наоборот, сразу задаёт рамки. Он опирается на типизацию, на декларативные модели, на строгие контракты входа и выхода. Это кажется «магией», но на практике это просто другой стиль: ты сначала описываешь данные и поведение, а уже потом пишешь логику.

Из-за этого при миграции возникает ощущение, что тебя «ограничивают». На самом деле — тебя заставляют быть более явным. И это сильно влияет на читаемость и поддержку кода в будущем. В долгой перспективе это снижает стоимость изменений: меньше «сюрпризов» при рефакторинге, меньше скрытых зависимостей.


Роуты, валидация и Pydantic — всё вместе

На уровне синтаксиса роуты выглядят почти одинаково. Но поведение — разное.

В Flask ты получаешь строку и дальше сам решаешь, что с ней делать. Проверяешь, парсишь, обрабатываешь ошибки. Где-то аккуратно, где-то не очень.

В FastAPI ты сразу описываешь типы. И это автоматически включает:

  • валидацию
  • парсинг
  • генерацию схемы
  • понятные ошибки клиенту

То же самое с телом запроса. Вместо «взял JSON и пошёл дальше» ты описываешь модель. Это сначала раздражает (много кода), но потом резко уменьшает количество багов.

Дальше начинается самое интересное — как именно работает валидация и где можно наступить на грабли.


Что реально происходит при запросе

Когда приходит HTTP-запрос, FastAPI делает несколько шагов:

  1. Парсит вход (path, query, headers, body)
  2. Прогоняет через Pydantic
  3. Преобразует типы
  4. Валидирует ограничения
  5. Только потом вызывает твой handler

👉 Важно: твой код вызывается уже с «чистыми» данными

Это сильно меняет стиль разработки. Ты меньше пишешь defensive-кода внутри handler’ов.


Валидация query и path параметров

from fastapi import Query

@app.get("/items")
def get_items(limit: int = Query(10, ge=1, le=100)):
    return {"limit": limit}

👉 Здесь сразу:

  • default значение
  • минимум и максимум

Если придёт limit=1000 → FastAPI сам вернёт ошибку.

Это убирает кучу ручных проверок.


Валидация тела запроса

from pydantic import BaseModel, Field

class User(BaseModel):
    name: str = Field(min_length=3, max_length=50)
    age: int = Field(ge=0, le=120)

👉 Здесь ты описываешь не только типы, но и правила.

FastAPI:

  • проверит
  • преобразует
  • вернёт ошибку, если не ок

Автоприведение типов (и где это опасно)

Pydantic умеет приводить типы:

{ "age": "30" }

👉 станет age = 30

Это удобно, но иногда скрывает проблемы.

Если тебе нужна строгая проверка:

from pydantic import StrictInt

age: StrictInt

👉 тогда "30" уже не пройдёт


Кастомная валидация

Когда простых ограничений мало:

from pydantic import validator

class User(BaseModel):
    name: str

    @validator("name")
    def no_admin(cls, v):
        if v.lower() == "admin":
            raise ValueError("forbidden name")
        return v

👉 Это уже бизнес-логика валидации

Но тут есть ловушка: не превращай модели в «комбайн» из всей логики системы.


Вложенные модели

class Address(BaseModel):
    city: str

class User(BaseModel):
    name: str
    address: Address

👉 FastAPI рекурсивно валидирует всё

Но:

  • ошибки становятся длинными
  • дебаг сложнее

Поэтому вложенность лучше держать под контролем.


Ошибки валидации: как они выглядят

FastAPI возвращает structured response:

{
  "detail": [
    {
      "loc": ["body", "age"],
      "msg": "value is not a valid integer",
      "type": "type_error.integer"
    }
  ]
}

👉 Это очень удобно для клиентов API

Но иногда хочется кастомизировать формат.

Можно через exception handler.


Где чаще всего ломаются

  1. Слишком сложные модели
  2. Смешивание ORM и Pydantic
  3. Неожиданное приведение типов
  4. Логика валидации превращается в бизнес-логику

Практический совет

Делай так:

  • простые модели
  • отдельные модели для input/output
  • минимум магии
  • явные ограничения

👉 Тогда валидация становится не проблемой, а инструментом


Главная ловушка здесь — не Pydantic как таковой, а сложные модели. Как только появляются вложенные структуры, optional-поля, кастомная логика — магия начинает мешать. Поэтому важно не превращать модели в «всё и сразу», а держать их простыми. Хорошая практика — иметь отдельные модели для входа/выхода и не смешивать их с ORM-сущностями.


Асинхронность без иллюзий

Самое опасное место миграции — async.

Очень легко подумать: «добавлю async — станет быстрее». Нет. Async — это не ускорение, это способ по-другому работать с I/O.

Если внутри async-хендлера у тебя остаётся sync-код (requests, psycopg2, любые блокирующие вызовы) — ты просто блокируешь event loop. И получаешь худшую производительность, чем было.

Правильный переход обычно выглядит так:

  • сначала оставить всё sync, просто на FastAPI
  • потом точечно переводить на async
  • параллельно менять инфраструктуру (httpx, async DB драйверы)

И только после этого ты начинаешь получать выгоду. Плюс, не забывай про лимиты: слишком много одновременных задач без ограничений (semaphore, pool) легко «положат» внешние сервисы.


Dependency Injection и структура кода

В Flask зависимости обычно прокидываются руками или через глобальные объекты. Это просто, но плохо масштабируется.

В FastAPI есть Depends, и это меняет подход. Ты начинаешь явно описывать зависимости: база, конфиг, авторизация, клиенты.

Сначала это кажется лишним уровнем абстракции. Но как только проект растёт, становится понятно, зачем это нужно:

  • тестировать проще
  • подменять зависимости проще
  • код становится предсказуемее

Главное — не перегнуть палку и не строить «DI ради DI». Держи функции маленькими и зависимости явными — это окупается.


Документация и ошибки — приятный бонус

FastAPI автоматически генерирует OpenAPI и даёт Swagger UI. Это не просто «прикольно», это реально экономит время:

  • фронтенд сразу видит API
  • тестирование проще
  • меньше расхождений между кодом и документацией

Плюс — нормальные ошибки из коробки. Вместо «500 где-то внутри» ты получаешь понятный ответ клиенту. Важно не ломать это своими кастомными обработчиками без необходимости.


Где реально возникает боль при миграции

Самая большая проблема — не синтаксис, а инфраструктура вокруг.

Тебе почти наверняка придётся:

  • заменить HTTP-клиенты
  • заменить драйвер базы
  • переписать часть middleware
  • поменять тесты (async)

И самое неприятное — это нельзя сделать частично. Если у тебя async-хендлер, всё, что он вызывает, должно быть либо async, либо явно вынесено в отдельный поток/процесс.

Отдельная боль — глобальное состояние. Во Flask это нормально. В FastAPI — быстро приводит к проблемам при нагрузке. Добавь к этому конфигурацию пулов и таймаутов — и становится ясно, почему «просто переписать» не работает.


Продакшн: как это реально запускают

Здесь важно понять одну вещь, которую часто упускают: Flask и FastAPI — это не «серверы». Это просто приложения. А вот как они реально обслуживают HTTP — зависит от того, через что ты их запускаешь.

Во Flask (WSGI) классический стек выглядит так:

  • gunicorn — менеджер процессов
  • worker (sync, gevent, eventlet) — модель выполнения
gunicorn app:app -w 4

Gunicorn сам по себе не знает ничего про async. Он просто форкает процессы и даёт им принимать запросы.

А дальше всё зависит от worker’а:

  • sync worker — каждый запрос блокирует процесс
  • gevent/eventlet — зелёные потоки (кооперативная модель)

👉 Поэтому Flask под нагрузкой часто масштабируют количеством процессов.


С FastAPI появляется другой мир — ASGI.

Здесь ключевые игроки:

  • uvicorn — ASGI сервер (event loop + HTTP)
  • hypercorn — альтернатива uvicorn
  • gunicorn — менеджер процессов (опционально)

Минимальный запуск:

uvicorn app:app --host 0.0.0.0 --port 8000

Здесь uvicorn делает всё:

  • принимает соединения
  • управляет event loop
  • исполняет coroutine

Но в проде почти всегда добавляют gunicorn:

gunicorn -k uvicorn.workers.UvicornWorker app:app -w 4

👉 Что происходит внутри:

  • gunicorn создаёт несколько процессов
  • в каждом процессе запускается uvicorn
  • внутри uvicorn работает event loop (обычно uvloop)

Это даёт баланс между изоляцией (процессы) и эффективностью I/O (event loop). Но требует аккуратной настройки лимитов и таймаутов.


WSGI vs ASGI

WSGI (Flask):

  • запрос → функция → ответ
  • строго синхронная модель

ASGI (FastAPI):

  • event loop
  • coroutine
  • неблокирующий I/O

👉 Это не просто «другая библиотека». Это другая модель выполнения.


Почему это важно для производительности

В Flask:

  • один запрос = один worker занят
  • масштабирование = больше процессов

В FastAPI:

  • один worker может обрабатывать много запросов
  • если они I/O-bound

Но есть ловушка:

👉 если внутри async есть блокирующий код — ты теряешь всё преимущество


uvicorn без gunicorn — когда можно

Иногда можно обойтись без gunicorn:

uvicorn app:app --workers 4

Но:

  • нет сложного менеджмента процессов
  • меньше контроля

👉 В проде обычно всё-таки используют gunicorn.


Почему uWSGI почти не используют с FastAPI

uWSGI — это мощный, но тяжёлый WSGI-сервер.

Он:

  • сложный в конфиге
  • заточен под WSGI

ASGI он поддерживает, но через костыли.

👉 Поэтому для FastAPI его почти не берут.


Наблюдаемость: без неё миграция слепая

Отдельный момент, который почти всегда недооценивают — наблюдаемость.

Пока сервис маленький, кажется, что «и так всё понятно». Но как только появляется нагрузка, пара внешних зависимостей и асинхронность — без нормальной телеметрии ты буквально ничего не видишь.

Минимальный набор, который реально помогает:

  • логирование с request_id
  • метрики (RPS, latency, ошибки)
  • количество активных соединений к БД
  • количество задач в очередях

Пример простого middleware для request id:

import uuid
from fastapi import Request

@app.middleware("http")
async def add_request_id(request: Request, call_next):
    request_id = str(uuid.uuid4())
    request.state.request_id = request_id

    response = await call_next(request)
    response.headers["X-Request-ID"] = request_id
    return response

И дальше этот id пробрасывается в логи.

👉 Без этого любой дебаг превращается в «угадай, какой запрос это был». Добавь ещё correlation с внешними вызовами — и жизнь станет заметно проще.


Немного про latency и где он прячется

После миграции часто возникает вопрос: «почему стало не быстрее?»

Ответ обычно в деталях:

  • DNS lookup (в http-клиентах)
  • TLS handshake
  • создание соединений без пула
  • сериализация JSON
  • блокировки внутри кода

Async убирает блокировку на I/O, но не убирает сами задержки.

👉 Поэтому важно измерять, а не гадать.

Простейший способ — тайминг внутри кода:

import time

start = time.time()
# вызов
print("took", time.time() - start)

Да, это примитивно. Но иногда этого достаточно, чтобы увидеть, где реально теряется время. На следующем уровне — добавляй метрики и распределения (p95/p99), а не только среднее.


Ещё одна ловушка: JSON и CPU

Многие думают, что FastAPI «всегда быстрее». Но если у тебя тяжёлые ответы (большие JSON), всё упирается в CPU.

return large_dict

👉 сериализация может занимать значительное время.

В таких случаях:

  • async не помогает
  • нужен более быстрый JSON (orjson)

Пример:

from fastapi.responses import ORJSONResponse

app = FastAPI(default_response_class=ORJSONResponse)

Иногда это даёт больше, чем весь переход на async. Ещё один трюк — уменьшить объём ответа (пагинация, поля по требованию).


И ещё один практический инсайт

Очень часто после миграции становится «чуть лучше», но не драматически.

И это нормально.

Потому что реальная производительность системы определяется:

  • базой данных
  • сетью
  • внешними сервисами

А не только фреймворком.

👉 FastAPI даёт инструменты. Но он не убирает узкие места сам. Оптимизация почти всегда идёт по цепочке зависимостей.


Итог по стеку

Flask:

  • gunicorn + sync worker
  • или gunicorn + gevent

FastAPI:

  • uvicorn (локально)
  • gunicorn + uvicorn worker (прод)

И здесь же всплывает следующая проблема: база данных.

Если оставить синхронный драйвер — ты убьёшь весь смысл async. Если не настроить пул соединений — под нагрузкой всё развалится. Это один из самых частых реальных bottleneck’ов после миграции.


Как мигрировать без боли

Самая частая ошибка — попытка переписать всё сразу. Это почти всегда заканчивается плохо.

Рабочий вариант выглядит скучно, но эффективно:

  • сначала выделяешь API-слой
  • оборачиваешь его в FastAPI без изменения логики
  • постепенно переводишь на async
  • потом меняешь инфраструктуру
  • и только после этого оптимизируешь

Обязательно прогоняй нагрузку. Без этого ты не увидишь половину проблем. И фиксируй метрики «до/после» — это помогает не обманывать себя.


Частые продакшн-проблемы

На практике после миграции чаще всего всплывает:

  • блокирующий код внутри async
  • неправильный размер пула соединений
  • слишком много воркеров
  • утечки соединений
  • отсутствие таймаутов

И всё это может не проявляться локально, но вылезти под нагрузкой. Добавь к этому неправильные retry — и можно легко устроить каскадные фейлы.


История из жизни: как мы «просто» переехали и словили проблемы

Был сервис на Flask: API + немного фоновых задач. Всё работало нормально, но под нагрузкой начинал расти latency. Решили: «переедем на FastAPI, добавим async — станет быстрее».

Сделали первый шаг — переписали роуты.

Flask было так:

@app.route("/users/<int:user_id>")
def get_user(user_id):
    user = db.get_user(user_id)
    return jsonify(user)

Стало на FastAPI:

@app.get("/users/{user_id}")
def get_user(user_id: int):
    return db.get_user(user_id)

Всё ок. Даже стало приятнее: типы, схема, ошибки — из коробки.

Дальше решили «ускорить» — добавили async.

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    return db.get_user(user_id)  # всё ещё sync

👉 И вот здесь первая ловушка: ничего не ускорилось. Наоборот, под нагрузкой стало хуже. Почему? Потому что db.get_user — синхронный вызов, он блокирует event loop.

Исправили:

from sqlalchemy.ext.asyncio import AsyncSession

@app.get("/users/{user_id}")
async def get_user(user_id: int, session: AsyncSession = Depends(get_session)):
    result = await session.execute(
        select(User).where(User.id == user_id)
    )
    return result.scalar_one()

Стало лучше. Но тут вылезла следующая проблема.


Второй удар: HTTP-клиенты и «невидимая» блокировка

В сервисе был внешний вызов:

def get_profile(user_id):
    return requests.get(f"https://api/.../{user_id}").json()

После «async-миграции» код выглядел так:

@app.get("/profile/{user_id}")
async def profile(user_id: int):
    return get_profile(user_id)  # requests внутри

👉 Под нагрузкой latency вырос. Причина та же — блокирующий requests.

Правильный вариант:

import httpx

async def get_profile(user_id):
    async with httpx.AsyncClient(timeout=2.0) as client:
        r = await client.get(f"https://api/.../{user_id}")
        return r.json()

И только после этого async начал реально работать.


Третий удар: пул соединений

После перехода на async БД всё стало быстрее… до первой нагрузки.

Проблема оказалась в том, что пул соединений был по умолчанию.

engine = create_async_engine(DATABASE_URL)

👉 Под нагрузкой соединения заканчивались, начинались таймауты.

Исправление:

engine = create_async_engine(
    DATABASE_URL,
    pool_size=10,
    max_overflow=20,
    pool_timeout=30,
)

После этого сервис перестал «сыпаться» на пике.


Четвёртый удар: фоновые задачи и shutdown

Во Flask у нас был простой воркер:

threading.Thread(target=worker).start()

При переходе на FastAPI это превратилось в:

@app.on_event("startup")
async def startup():
    asyncio.create_task(worker())

И всё работало… пока не начали делать graceful shutdown.

Сервис перестал корректно останавливаться.

👉 Причина: воркер не слушал контекст.

Исправление:

async def worker(stop_event: asyncio.Event):
    while not stop_event.is_set():
        await do_work()

stop_event = asyncio.Event()

@app.on_event("startup")
async def startup():
    app.state.worker = asyncio.create_task(worker(stop_event))

@app.on_event("shutdown")
async def shutdown():
    stop_event.set()
    await app.state.worker

После этого shutdown стал нормальным.


Маленькие, но важные детали, которые всплыли по пути

  • Без таймаутов внешние запросы подвешивали весь сервис
  • Логи без request id сделали дебаг почти невозможным
  • Слишком много воркеров в gunicorn только ухудшили ситуацию
  • Метрика runtime.NumGoroutine() показала утечку воркеров

Это те вещи, которые не видно на локалке, но сразу видно в проде.


Что в итоге поменялось

После нормальной миграции мы получили:

  • стабильный latency под нагрузкой
  • понятные ошибки API
  • автогенерируемую документацию
  • меньше «скрытых» багов

Но цена — это переработка инфраструктуры и более строгая дисциплина в коде.


Итог

FastAPI — мощный инструмент. Но он не «делает быстрее сам по себе» и не исправляет плохую архитектуру.

Он просто делает требования к коду более строгими. И если ты к этому готов — получаешь:

👉 более чистый код
👉 меньше неявных багов
👉 нормальную документацию из коробки

Если нет — получаешь проект, который сложнее, чем был.

И да — миграция почти всегда сложнее, чем кажется в начале 😅


Если хочется продолжения, то мы скоро разберем:

  • реальные кейсы миграции (что ломалось)
  • FastAPI под нагрузкой
  • async в Python без иллюзий