Alembic vs. the Async ORM
Context
This project (fosterclub-talent-api-ai-ats-service) uses: - SQLAlchemy (async) with DeclarativeBase models in app/core/database.py - MariaDB as the database, accessed via mysql+aiomysql:// (see app/core/config.py:177) - alembic listed as a dependency in pyproject.toml, but not yet initialized — there is no alembic.ini or alembic/versions/ directory in the project, so no migration history currently exists.
Note: asyncpg also appears in pyproject.toml but is unused — the project runs on MariaDB/aiomysql, not Postgres. Worth confirming if it’s dead weight and removing.
Alembic vs. SQLAlchemy ORM — not alternatives
- SQLAlchemy ORM defines what the schema should look like in Python (
ResumeParseJob,JDSession,RankingCriteria, etc.) and lets the app read/write rows at runtime. It does not track schema history or apply changes to an existing database safely. - Alembic is the migration tool that turns a diff between your models and the live database into versioned, ordered, reviewable migration scripts, and applies/reverts them (
alembic upgrade head/alembic downgrade).
Why Alembic is needed even with the ORM
Base.metadata.create_all()only creates tables that don’t exist yet — it never alters existing tables. Adding a column to a model does nothing to a table that’s already in the database.- Alembic generates the diff (
alembic revision --autogenerate), lets you review/edit it, then applies it consistently across dev/staging/prod.
What Alembic gives you
- Repeatable deploys — same migrations run in every environment, no schema drift.
- Team coordination — parallel schema changes become separate migration files instead of manual
ALTER TABLEreconciliation. - Safe evolution of existing data — renames, backfills, adding
NOT NULLto populated tables — thingscreate_all()can’t do. - Auditable history —
git logonalembic/versions/is the schema changelog. - Rollback —
alembic downgrade -1if a migration goes wrong in production.
When Alembic doesn’t apply / can’t help
- Non-relational stores — Alembic only manages SQLAlchemy-mapped relational schema. It has no concept of Qdrant collections, Redis cache keys/TTLs, or Celery/TaskIQ job/queue state. Those need their own versioning approach.
- Multi-tenant / sharded databases — Alembic doesn’t fan a migration out across multiple databases/tenants on its own; you’d script that orchestration yourself (e.g. loop
alembic upgrade headper connection string). - Large-table structural changes on live traffic — Alembic will run whatever SQL you give it, including a lock-heavy
ALTER TABLE, without warning you. Safe multi-step patterns (add nullable column → backfill in batches → add constraint → drop old column) have to be hand-written. - Changes autogenerate can’t detect reliably:
- Renames (seen as drop + add, loses data unless manually fixed with
op.alter_column(..., new_column_name=...)) - Data backfills/transformations (no “old value” to diff against — write custom Python in the migration)
- Some dialect-specific objects: enum types, certain index/collation nuances, default-value comparisons
- Renames (seen as drop + add, loses data unless manually fixed with
- Non-DDL runtime config — feature flags, cache TTLs, queue definitions — outside Alembic’s scope by design.
MariaDB/MySQL-specific caveats (this project’s actual DB)
Since this project runs on MariaDB via aiomysql, not Postgres, these matter more than the generic caveats above:
- DDL is not transactional. Most DDL statements (
ALTER TABLE,CREATE TABLE, etc.) cause an implicit commit in MariaDB/MySQL and can’t be rolled back as part of a transaction. If a multi-statement migration fails partway through,alembic downgrademay not cleanly undo already-applied DDL — you can be left in a partial state requiring manual cleanup. Postgres, by contrast, has transactional DDL and is safer here. ALTER TABLElocks the whole table on most MariaDB versions/storage engines (unless the specific version/change qualifies forALGORITHM=INSTANT/INPLACE). Schema changes on large, live tables can block reads/writes for the duration. For anything beyond a small table, use online-schema-change tooling (pt-online-schema-change,gh-ost) invoked from the migration, or split the change into safer steps — Alembic won’t do this automatically.- Weaker autogenerate support on the MySQL/MariaDB dialect compared to Postgres — enum types, some index/collation nuances, and default-value comparisons are more likely to produce a no-op or slightly wrong diff that needs manual correction.
- Connection pool pressure —
app/core/config.py:179-181notes that MariaDB gets flooded under concurrent load from pool sizing (DB_POOL_SIZE+DB_MAX_OVERFLOWper worker process). Any migration step that opens its own connection (e.g. autogenerate comparing live schema) should be mindful of this same constraint, though it’s a minor operational note rather than a blocker.