SQLModel и “реальный” прод: ограничения, индексы и миграции
Покажем, как не ограничиться учебными моделями: добавить constraints, планировать индексы и связать модели с миграциями в процессе разработки.
Содержание
SQLModel и “реальный” прод: ограничения, индексы и миграции
SQLModel часто воспринимают как “облегчённый Pydantic + ORM”. В учебных примерах это действительно так: модель превращается в таблицу, типы становятся валидаторами, а в коде почти не видно боли. Но продовая реальность быстро возвращает к базам: нам нужны ограничения целостности на уровне БД, управляемые изменения схемы через миграции, аккуратное проектирование индексов и понимание, как эти вещи связаны с SQLModel.
Ниже — практический разбор того, как не останавливаться на “учебных моделях” и доводить SQLModel до уровня, на котором его комфортно использовать в реальном проекте: с constraints, индексами и миграциями, согласованными с жизненным циклом приложения.
Что SQLModel делает “из коробки”, и где заканчивается иллюзия простоты
SQLModel строится вокруг идеи: описать модель на Python и получить:
- схему данных (типизацию и валидацию),
- сопоставление с таблицами в SQLAlchemy,
- иногда — авто-генерацию CREATE TABLE через метаданные.
В учебных демо обычно упираются в три сценария:
- простые поля;
- связи (has-many / belongs-to);
- базовая CRUD-логика.
Но реальные системы требуют:
- ограничений (UNIQUE, CHECK, NOT NULL, FOREIGN KEY с правилами ON DELETE / ON UPDATE),
- индексов (композитных, частичных, уникальных, по выражениям — если поддерживается),
- миграций (версирование схемы, откаты, совместимость со старым приложением),
- контроля того, как SQLModel генерирует schema и как это влияет на производительность и целостность.
SQLModel не “магический генератор идеальной схемы”. Это инструмент, который стоит использовать осознанно: часть решений оставлять модели, часть — явно переносить в слой миграций и администрирования БД.
Constraints: целостность — на уровне БД, а не только в приложении
Почему одних Pydantic-валидаторов недостаточно
SQLModel наследует валидационную логику от Pydantic. Это даёт ранний feedback на стороне Python: типы, минимумы/максимумы, регулярки, обязательность полей и т. п.
Но на практике данные в БД могут попадать не только через приложение:
- прямые запросы админов,
- интеграции,
- фоновые джобы,
- “старый” код, который работает по старым правилам,
- операции на уровне SQL.
Поэтому ключевые правила должны жить в БД, а Python-валидация — дополнять их, но не заменять.
NOT NULL и CHECK: как и когда их задавать
В SQLModel поле можно определить так, чтобы оно отражалось в схеме. Например, ограничим длину и формат email не только на уровне Python, но и на уровне БД (через CHECK, если СУБД поддерживает).
Пример модели пользователя с ограничением длины:
from typing import Optional
from sqlmodel import SQLModel, Field
from sqlalchemy import CheckConstraint
class User(SQLModel, table=True):
__tablename__ = "user"
id: Optional[int] = Field(default=None, primary_key=True)
# Валидируем в приложении: min/max
username: str = Field(index=False, max_length=50)
# Индивидуальные constraints на уровне модели можно описывать
# через __table_args__ (SQLAlchemy-friendly).
__table_args__ = (
CheckConstraint("length(username) <= 50", name="ck_user_username_length"),
)
Важный момент: max_length в SQLModel/ Pydantic сам по себе не гарантирует CHECK на уровне БД. Поэтому для строгой целостности — либо используйте constraints через __table_args__, либо добавляйте соответствующие ограничения в миграции.
UNIQUE и “не дублируйся”: где часто ошибаются
Самая частая ошибка — думать, что index=True и unique=True “оба про индексы”. В большинстве БД UNIQUE — это и индекс, и constraint, то есть гарантируется на уровне схемы, а индекс обеспечивает быстрый поиск.
SQLModel поддерживает unique на уровне поля, но стоит проверять, как это отразится в вашей генерации схемы и миграциях. Пример:
from typing import Optional
from sqlmodel import SQLModel, Field
class Customer(SQLModel, table=True):
__tablename__ = "customer"
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(unique=True, index=True)
Подводный камень: если у вас уже есть данные и вы меняете constraint (например, делаете unique), то миграция может упасть из‑за существующих дублей. Планирование миграций должно включать проверку/очистку данных, а не только добавление constraint.
Foreign Key: ON DELETE/ON UPDATE и поведение каскадов
Ссылочная целостность обычно описывается связями (relationships). Но продовая схема требует уточнений:
- Что делать при удалении родителя?
CASCADE/RESTRICT/SET NULL. - Должна ли колонка быть
NOT NULL, если связь обязательная. - Как обрабатывать изменение ключей (редко, но важно).
В SQLModel связи выражаются через Relationship (на верхнем уровне) и поля внешнего ключа. В реальной схеме лучше явно управлять поведением в миграциях или через foreign_key/табличные аргументы, поскольку автоматические каскады не всегда совпадают с бизнес-логикой.
Индексы: не “поставить любой index”, а спроектировать под запросы
Индекс — это не ускоритель “в целом”, а оптимизация конкретных паттернов
Индекс ускоряет:
- фильтрацию (WHERE),
- сортировки (ORDER BY),
- объединения (JOIN),
- иногда — выборку по диапазонам.
Но индекс замедляет:
- вставки,
- обновления ключевых колонок,
- занимает место,
- усложняет оптимизатору выбор плана.
В SQLModel часто начинают с index=True “на всякий случай”. В проде это быстро превращается в набор индексов, которые не используются, но стоимость поддержания растёт.
Композитные индексы: когда один index не помогает
Если у вас запрос вида:
SELECT * FROM orders
WHERE customer_id = :id
ORDER BY created_at DESC
LIMIT 50;
то обычно помогает композитный индекс (customer_id, created_at) в правильном порядке. Один индекс на customer_id может ускорить фильтр, но сортировка может остаться дорогой.
SQLAlchemy позволяет задать композитные индексы через __table_args__ или индексы на уровне таблицы. В SQLModel это делается через табличные аргументы. Пример:
from typing import Optional
from sqlmodel import SQLModel, Field
from sqlalchemy import Index
class Order(SQLModel, table=True):
__tablename__ = "order"
id: Optional[int] = Field(default=None, primary_key=True)
customer_id: int = Field(index=False)
created_at: str # для примера, обычно DateTime
__table_args__ = (
Index("ix_order_customer_created_at", "customer_id", "created_at"),
)
Практика: сначала сформулируйте 5–10 основных запросов (самые частые и самые дорогие), затем пройдитесь по EXPLAIN/EXPLAIN ANALYZE, и только после этого фиксируйте индексы. SQLModel здесь выступает как способ хранить описание схемы рядом с типами, но решение всё равно должно быть основано на наблюдаемой нагрузке.
Уникальные индексы и “частичная уникальность”
Иногда бизнес-правило звучит так:
- email уникален среди всех пользователей,
- но “черновики” не должны мешать,
- или уникальность действует только для активных записей.
Во многих СУБД это делается частичными индексами (например, PostgreSQL). SQLModel напрямую не всегда удобно выразить частичные индексы на уровне поля — чаще это делается через миграции.
Идея выглядит так:
CREATE UNIQUE INDEX uniq_active_email
ON user(email)
WHERE status = 'active';
В SQLModel можно оставить поле обычным, а индексы — планировать в миграциях. Это нормальный подход: модель отвечает за форму данных, а “тонкие” оптимизации — за сценарии конкретной СУБД.
Слишком много индексов: как понять, что вы переборщили
Сигналы:
- рост времени на INSERT/UPDATE,
- увеличение размера базы,
- медленные миграции (ALTER TABLE с пересозданием индексов),
- EXPLAIN показывает, что индекс не используется.
На уровне процесса разработке помогает привычка: индекс добавляйте вместе с задачей/обоснованием (“ускоряем запрос X”). Если в течение 2–3 спринтов он не подтверждается метриками — индекс стоит пересмотреть или удалить.
Миграции: как связать SQLModel и изменение схемы без “разъезда версий”
Почему миграции — обязательны даже в небольших проектах
Даже если вы запускаете приложение на одной БД и “все поднимается заново”, миграции нужны для:
- развертываний по этапам,
- отката,
- совместимости версий приложения и схемы,
- работы нескольких экземпляров приложения параллельно.
Схема — это контракт между кодом и данными. Поэтому её изменения должны быть управляемыми.
Рекомендуемый принцип: миграции первичны, модель — источник намерения
SQLModel может генерировать DDL из метаданных, но в проде DDL “в один клик” почти всегда хуже, чем контролируемый процесс миграций:
- трудно предсказать порядок операций,
- сложно учитывать существующие данные,
- не всегда корректно обрабатываются большие таблицы,
- важно думать о lock’ах и времени выполнения.
Поэтому обычно делают так:
- меняете модель (и добавляете constraints/индексы там, где это безопасно и переносимо),
- создаёте миграцию,
- проверяете её на тестовой БД,
- при необходимости — дополняете миграцию ручными SQL-операциями.
Как выглядит связка на практике (идея пайплайна)
Типичный процесс изменения схемы:
- Добавили поле в SQLModel или поменяли constraint.
- Создали миграцию (например, через Alembic, который хорошо дружит с SQLAlchemy).
- В миграции:
- если добавляем NOT NULL-колонку — сначала добавляем её как nullable, заполняем, затем меняем на NOT NULL;
- если добавляем FK — учитываем порядок создания таблиц/полей;
- если делаем UNIQUE — сначала очищаем дубль, потом добавляем constraint.
Пример миграции (условно для PostgreSQL) — добавить status с default:
# alembic revision file example (упрощено)
def upgrade():
op.add_column('order', sa.Column('status', sa.String(), nullable=True))
op.execute("UPDATE \"order\" SET status = 'new' WHERE status IS NULL")
op.alter_column('order', 'status', nullable=False, server_default='new')
def downgrade():
op.drop_column('order', 'status')
Хотя это Alembic-код, логика полезна независимо от инструмента: изменение схемы должно быть “с учётом реальных данных”, иначе вы получите миграции, которые падают в проде.
Переименование и изменения типов: аккуратность важнее “удобства”
Поменять тип колонки — часто дороже, чем кажется. Например:
VARCHAR→TEXT(обычно ок),INT→BIGINT(обычно ок),TIMESTAMP WITH TIME ZONE→ другой формат (может требовать преобразований),UUIDи обратно (нужно преобразование формата).
С SQLModel это ощущается как “просто поменяли аннотацию”, но на БД это полноценная операция. Миграции должны содержать преобразования и тесты на данных приближенных к реальным (объём, распределение, индексы).
Синхронизация модели и миграций: где именно нужна дисциплина
Частые провалы:
- Модель обновили, миграции забыли.
- Миграцию написали, но модель уже ушла дальше и не совпадает с реальным состоянием.
- В индекс/constraint добавили логическую ошибку (например, забыли композитность).
- В проде выполнили миграцию в “неправильном порядке” (порядок создания/удаления FK).
Практика, которая реально снижает риск:
- у каждой миграции должен быть “фактический результат” (что стало в БД после выполнения),
- и “цель” (что это даёт приложению).
Комбинируем подходы: как держать модель чистой и всё же продовой
Где писать constraints в SQLModel, а где — в миграциях
Пишите в SQLModel, когда:
- constraint переносим между СУБД (например, NOT NULL, базовые UNIQUE),
- вы уверены, что DDL будет корректен и предсказуем,
- вы хотите, чтобы схема была самодокументируемой “рядом с типами”.
Пишите в миграциях, когда:
- нужен частичный индекс или индекс по выражению (PostgreSQL/Oracle),
- нужна сложная логика проверки (CHECK с бизнес-условиями),
- схема сильно зависит от существующих данных,
- важен контроль над lock’ами и временем выполнения (особенно на больших таблицах).
В результате модель остаётся “каркасом” доменной структуры, а миграции — инструментом безопасной эволюции схемы.
Пример “правильной” структуры проекта
Один из жизнеспособных вариантов:
models/— SQLModel классы (типы, отношения, базовые constraints),migrations/— история изменений (Alembic),db/— создание engine, настройки сессий, базовые health-check запросы,tests/— интеграционные тесты, проверяющие, что миграции создают схему ожидаемо.
Так вы минимизируете ситуацию “модель выглядит правильно, но в БД другое”.
Практический чек-лист перед выпуском: ограничения, индексы, миграции
Ниже — список, который стоит прогонять перед релизом изменения схемы.
Constraints
- Все ключевые бизнес-ограничения гарантируются в БД (UNIQUE, NOT NULL, FK).
- Есть план, что делать с существующими данными при добавлении constraints.
- Понимаете, какие ошибки будут возвращены приложению (например, нарушение UNIQUE).
- У FK задано поведение при удалении/обновлении родителя и оно соответствует требованиям.
Индексы
- Индекс добавлен под конкретные запросы, а не “на всякий случай”.
- Для композитных индексов — порядок колонок соответствует ORDER BY/WHERE.
- Уникальность осмыслена (обычный UNIQUE ≠ partial unique).
- Понимаете стоимость индекса на запись и на миграции.
Миграции
- Миграция безопасна по данным (переходы nullable → not null, заполнение before constraint).
- Миграция протестирована на БД с приближённым объёмом.
- Учтены lock’и/тайминги (особенно для больших таблиц).
- Миграции версионированы и соответствуют текущей версии приложения.
- В стратегии релиза (rolling deploy / blue-green) есть совместимость “старый код → новая схема”.
Вывод: SQLModel как язык схемы, а не как замена инженерной дисциплины
SQLModel хорошо подходит для описания данных и ускоряет старт: типы, связи и часть ограничений можно выразить рядом с доменной моделью. Но “настоящий прод” начинается там, где вы:
- переносите критичные правила целостности в БД через constraints,
- планируете индексы под запросы, а не по ощущениям,
- и делаете миграции управляемым процессом, учитывающим реальные данные и ограничения инфраструктуры.
Если вы хотите углубиться именно в механику SQLModel и то, как он сочетается с SQLAlchemy и миграционными практиками, полезно пройти материалы по теме в курсе SQLModel. Это не отменяет инженеринга и анализа запросов, но помогает быстрее выстроить правильный фундамент — чтобы “учебные модели” не превращались в архитектурный долг.
Комментарии
Пока нет комментариев