Переход с Flask на FastAPI: что реально меняется (и где поджидает боль) 😸
Flask — это как старый добрый инструмент: простой, понятный, без лишней магии. Ты сам решаешь, как всё устроить. Хочешь — так, хочешь — иначе.
FastAPI — это уже другая история. Он даёт много «из коробки»: валидацию, OpenAPI, асинхронность, dependency injection. И выглядит это очень привлекательно.
Но переход — это не просто «переписал пару декораторов». Это смена подхода. И если к этому не подготовиться, можно легко получить проект, который стал сложнее, но не стал лучше.
Давай разберёмся спокойно и по-человечески, что именно меняется и где обычно возникает боль.
Философия и поведение фреймворка
Flask даёт тебе свободу. Почти любую. Хочешь — пишешь всё сам, хочешь — собираешь свой стек из расширений. Это удобно, пока проект небольшой. Но чем больше система, тем больше разъезжается архитектура: в одном месте так, в другом — иначе.
FastAPI, наоборот, сразу задаёт рамки. Он опирается на типизацию, на декларативные модели, на строгие контракты входа и выхода. Это кажется «магией», но на практике это просто другой стиль: ты сначала описываешь данные и поведение, а уже потом пишешь логику.
Из-за этого при миграции возникает ощущение, что тебя «ограничивают». На самом деле — тебя заставляют быть более явным. И это сильно влияет на читаемость и поддержку кода в будущем. В долгой перспективе это снижает стоимость изменений: меньше «сюрпризов» при рефакторинге, меньше скрытых зависимостей.
Роуты, валидация и Pydantic — всё вместе
На уровне синтаксиса роуты выглядят почти одинаково. Но поведение — разное.
В Flask ты получаешь строку и дальше сам решаешь, что с ней делать. Проверяешь, парсишь, обрабатываешь ошибки. Где-то аккуратно, где-то не очень.
В FastAPI ты сразу описываешь типы. И это автоматически включает:
То же самое с телом запроса. Вместо «взял JSON и пошёл дальше» ты описываешь модель. Это сначала раздражает (много кода), но потом резко уменьшает количество багов.
Дальше начинается самое интересное — как именно работает валидация и где можно наступить на грабли.
Что реально происходит при запросе
Когда приходит HTTP-запрос, FastAPI делает несколько шагов:
- Парсит вход (path, query, headers, body)
- Прогоняет через Pydantic
- Преобразует типы
- Валидирует ограничения
- Только потом вызывает твой 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}
Если придёт 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)
👉 Здесь ты описываешь не только типы, но и правила.
Автоприведение типов (и где это опасно)
Pydantic умеет приводить типы:
{ "age": "30" }
Это удобно, но иногда скрывает проблемы.
Если тебе нужна строгая проверка:
from pydantic import StrictInt age: StrictInt
Когда простых ограничений мало:
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.
- Слишком сложные модели
- Смешивание ORM и Pydantic
- Неожиданное приведение типов
- Логика валидации превращается в бизнес-логику
👉 Тогда валидация становится не проблемой, а инструментом
Главная ловушка здесь — не 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. Это не просто «прикольно», это реально экономит время:
Плюс — нормальные ошибки из коробки. Вместо «500 где-то внутри» ты получаешь понятный ответ клиенту. Важно не ломать это своими кастомными обработчиками без необходимости.
Где реально возникает боль при миграции
Самая большая проблема — не синтаксис, а инфраструктура вокруг.
Тебе почти наверняка придётся:
И самое неприятное — это нельзя сделать частично. Если у тебя async-хендлер, всё, что он вызывает, должно быть либо async, либо явно вынесено в отдельный поток/процесс.
Отдельная боль — глобальное состояние. Во Flask это нормально. В FastAPI — быстро приводит к проблемам при нагрузке. Добавь к этому конфигурацию пулов и таймаутов — и становится ясно, почему «просто переписать» не работает.
Продакшн: как это реально запускают
Здесь важно понять одну вещь, которую часто упускают: Flask и FastAPI — это не «серверы». Это просто приложения. А вот как они реально обслуживают HTTP — зависит от того, через что ты их запускаешь.
Во Flask (WSGI) классический стек выглядит так:
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
Но в проде почти всегда добавляют gunicorn:
gunicorn -k uvicorn.workers.UvicornWorker app:app -w 4
- gunicorn создаёт несколько процессов
- в каждом процессе запускается uvicorn
- внутри uvicorn работает event loop (обычно uvloop)
Это даёт баланс между изоляцией (процессы) и эффективностью I/O (event loop). Но требует аккуратной настройки лимитов и таймаутов.
👉 Это не просто «другая библиотека». Это другая модель выполнения.
Почему это важно для производительности
👉 если внутри async есть блокирующий код — ты теряешь всё преимущество
uvicorn без gunicorn — когда можно
Иногда можно обойтись без gunicorn:
uvicorn app:app --workers 4
👉 В проде обычно всё-таки используют gunicorn.
Почему uWSGI почти не используют с FastAPI
uWSGI — это мощный, но тяжёлый 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
👉 сериализация может занимать значительное время.
from fastapi.responses import ORJSONResponse app = FastAPI(default_response_class=ORJSONResponse)
Иногда это даёт больше, чем весь переход на async. Ещё один трюк — уменьшить объём ответа (пагинация, поля по требованию).
И ещё один практический инсайт
Очень часто после миграции становится «чуть лучше», но не драматически.
Потому что реальная производительность системы определяется:
👉 FastAPI даёт инструменты. Но он не убирает узкие места сам. Оптимизация почти всегда идёт по цепочке зависимостей.
Итог по стеку
И здесь же всплывает следующая проблема: база данных.
Если оставить синхронный драйвер — ты убьёшь весь смысл async. Если не настроить пул соединений — под нагрузкой всё развалится. Это один из самых частых реальных bottleneck’ов после миграции.
Как мигрировать без боли
Самая частая ошибка — попытка переписать всё сразу. Это почти всегда заканчивается плохо.
Рабочий вариант выглядит скучно, но эффективно:
- сначала выделяешь API-слой
- оборачиваешь его в FastAPI без изменения логики
- постепенно переводишь на async
- потом меняешь инфраструктуру
- и только после этого оптимизируешь
Обязательно прогоняй нагрузку. Без этого ты не увидишь половину проблем. И фиксируй метрики «до/после» — это помогает не обманывать себя.
Частые продакшн-проблемы
На практике после миграции чаще всего всплывает:
- блокирующий код внутри async
- неправильный размер пула соединений
- слишком много воркеров
- утечки соединений
- отсутствие таймаутов
И всё это может не проявляться локально, но вылезти под нагрузкой. Добавь к этому неправильные retry — и можно легко устроить каскадные фейлы.
История из жизни: как мы «просто» переехали и словили проблемы
Был сервис на Flask: API + немного фоновых задач. Всё работало нормально, но под нагрузкой начинал расти latency. Решили: «переедем на FastAPI, добавим async — станет быстрее».
Сделали первый шаг — переписали роуты.
@app.route("/users/<int:user_id>")
def get_user(user_id):
user = db.get_user(user_id)
return jsonify(user)
@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 — мощный инструмент. Но он не «делает быстрее сам по себе» и не исправляет плохую архитектуру.
Он просто делает требования к коду более строгими. И если ты к этому готов — получаешь:
👉 более чистый код
👉 меньше неявных багов
👉 нормальную документацию из коробки
Если нет — получаешь проект, который сложнее, чем был.
И да — миграция почти всегда сложнее, чем кажется в начале 😅