Database Migrations Guide¶
FastKitty provides multi-tenancy-aware database migrations using Alembic and async SQLAlchemy.
The migration runner dynamically adapts to your configured tenancy strategy (row, schema, or database).
Quick Reference¶
| Task | Command |
|---|---|
| Apply all pending migrations | uv run alembic upgrade head |
| Migrate a single tenant | uv run alembic -x tenant=tenant_1 upgrade head |
| Generate raw SQL (offline preview) | uv run alembic upgrade head --sql |
| View current migration heads | uv run alembic heads |
| View migration history | uv run alembic history |
| Create a new migration revision | uv run alembic revision -m "description" |
Multi-Tenancy Strategy Behaviors¶
FastKitty's migration runner in db/migrations.py (invoked via alembic/env.py) inspects TENANCY_DB_STRATEGY:
1. Row Strategy (TENANCY_DB_STRATEGY=row)¶
- Connects to the shared database URL.
- Migrates shared tables in the
publicschema. - Tracks migration state in the shared
alembic_versiontable.
2. Schema Strategy (TENANCY_DB_STRATEGY=schema)¶
- Connects to the shared database engine.
- Discovers active tenants from
TenancyConfigService. - For each tenant:
- Validates the schema name against SQL injection and reserved PostgreSQL schemas (
public,pg_catalog,information_schema). - Runs
CREATE SCHEMA IF NOT EXISTS "<schema_name>". - Sets connection
search_path TO "<schema_name>", public. - Runs migrations with isolated version tracking (
version_table_schema="<schema_name>"). - Cleans up with
RESET search_path.
[!NOTE] FastKitty is primarily built and optimized for PostgreSQL. PostgreSQL natively supports isolated schemas within a database and dynamic
search_pathconnection switching. In MySQL/MariaDB,SCHEMAis an exact alias forDATABASE(there are no sub-schemas inside a database), so MySQL users should choose either thedatabaseorrowstrategy.
3. Database Strategy (TENANCY_DB_STRATEGY=database)¶
- Discovers active tenants from
TenancyConfigServiceand connection secrets fromTenancySecretsService. - Creates an independent async engine for each tenant database.
- Runs migrations and updates the
alembic_versiontable in each database independently.
Selective Tenant Migrations¶
To migrate or test a single tenant without running migrations across all tenants, pass the -x tenant=<tenant_id> argument:
Offline Mode / SQL Generation¶
If your production environment requires change reviews or database administrator (DBA) approval before executing DDL, generate raw SQL using --sql:
Alembic will emit the complete transactional DDL for your configured strategy without executing anything against the live databases.
Adding New Models¶
To register a new SQLAlchemy model for migrations:
-
Define your model inheriting from
Base,TenantScopedModel, orTimestampedModelinmodels/:# models/orders.py from sqlalchemy import Integer, String from sqlalchemy.orm import Mapped, mapped_column from models.base import Base, TenantScopedModel, TimestampedModel class Order(TenantScopedModel, TimestampedModel, Base): __tablename__ = "orders" id: Mapped[int] = mapped_column(Integer, primary_key=True) -
Re-export the model in
models/__init__.py: -
Create your migration revision:
Important Architectural Limitations: Raw SQL Bypass¶
[!WARNING] Raw SQL & Core Bulk Statements Bypass ORM Tenant Isolation and Timestamp Hooks
FastKitty's automatic multi-tenancy protections and timestamp management are powered by SQLAlchemy ORM event listeners: 1. Tenant Isolation: In
rowstrategy, the automaticWHERE tenant_id = :current_tenantfilter is injected via ORMwith_loader_criteria. 2.updated_atTimestamps: Automatic UTC timestamp updating is managed via the ORMonupdateattribute.If developers use raw SQL queries (
session.execute(text("..."))) or SQLAlchemy Core bulk statements (session.execute(update(Model)...)), these ORM hooks will NOT trigger: - Raw SQL statements will bypass tenant filtering unless an explicitWHERE tenant_id = :tenant_idclause is manually included. - Core bulk updates will NOT automatically update theupdated_atcolumn.Always prefer standard ORM operations (
session.scalars(),session.get(),session.add()) to preserve tenant isolation and audit timestamp guarantees.