Migrations¶
SQLSpec includes a built-in schema migration runner supporting sequential .sql and .py revisions across every sync and async adapter—without requiring SQLAlchemy or Alembic when your project is pure SQL.
Configure migration_config and pyproject.toml¶
Enable migrations on any adapter configuration by supplying migration_config:
"""Declarative SQLite migration configuration used by the CLI quickstart."""
from sqlspec.adapters.sqlite import SqliteConfig
__all__ = ("database_config",)
database_config = SqliteConfig(
bind_key="app",
connection_config={"database": "app.db"},
migration_config={"script_location": "migrations", "version_table_name": "schema_versions"},
)
Point [tool.sqlspec] in pyproject.toml at your module attribute or factory callable so the sqlspec CLI discovers configurations automatically:
Common MigrationConfig keys:
script_location: Directory containing migration files ("migrations"by default).version_table_name: Tracking table name ("ddl_migrations"by default).default_schema: Default target schema for unqualified DDL objects.version_table_schema: Dedicated admin schema holding the version-tracking table.include_extensions: Optional list of extension schemas to bundle (such as["litestar", "adk", "events"]).
Authoring SQL and Python Revisions¶
Revisions execute in lexical prefix order (0001_..., 0002_... or timestamp prefixes after sqlspec fix). You can mix .sql and .py files in the same migrations/ directory:
Multi-Schema Migration Tracking¶
Enterprise deployments frequently store application tables in app_schema while isolating migration tracking metadata in admin_schema. Configure default_schema and version_table_schema together—or invoke SyncMigrationCommands / AsyncMigrationCommands programmatically:
"""Run DuckDB migrations targeting separate application and version-tracking schemas."""
import tempfile
from pathlib import Path
from sqlspec.adapters.duckdb import DuckDBConfig
from sqlspec.migrations.commands import SyncMigrationCommands
__all__ = ("main",)
MIGRATION_SCRIPT = '''"""Create users."""
def up() -> list[str]:
"""Create an unqualified table in app_schema."""
return ["CREATE TABLE users (id INTEGER PRIMARY KEY, name VARCHAR NOT NULL)"]
def down() -> list[str]:
"""Drop the unqualified table from app_schema."""
return ["DROP TABLE IF EXISTS users"]
'''
def main() -> tuple[str, str]:
"""Apply a Python migration script across app_schema and admin_schema in DuckDB."""
with tempfile.TemporaryDirectory() as tmp_dir:
base_dir = Path(tmp_dir)
migration_dir = base_dir / "migrations"
db_path = base_dir / "app.duckdb"
config = DuckDBConfig(
connection_config={"database": str(db_path)},
migration_config={
"script_location": str(migration_dir),
"version_table_name": "schema_versions",
"default_schema": "app_schema",
"version_table_schema": "admin_schema",
},
)
try:
with config.provide_session() as session:
session.execute("CREATE SCHEMA app_schema")
session.execute("CREATE SCHEMA admin_schema")
commands = SyncMigrationCommands(config)
commands.init(str(migration_dir), package=True)
(migration_dir / "0001_create_users.py").write_text(MIGRATION_SCRIPT, encoding="utf-8")
commands.upgrade()
with config.provide_session() as session:
users_table = str(
session.select_value(
"SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_name = ?",
"app_schema",
"users",
)
)
tracker_table = str(
session.select_value(
"SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_name = ?",
"admin_schema",
"schema_versions",
)
)
assert users_table == "users"
assert tracker_table == "schema_versions"
return users_table, tracker_table
finally:
if config.connection_instance:
config.close_pool()
if __name__ == "__main__":
print(main())
When PostgreSQL adapters (AsyncpgConfig, PsycopgSyncConfig, PsycopgAsyncConfig, PsqlpyConfig) run behind transaction-mode PgBouncer (pooler_mode="transaction") and define direct_dsn, MigrationCommands automatically routes DDL execution through config.derive_direct_config() so session-level locks and DDL statements bypass the transaction pooler.
Upgrading, Stamping, and Squashing¶
| Action | CLI Command | Behavior |
|---|---|---|
| Apply all pending revisions | uv run sqlspec upgrade |
Runs every unapplied migration in lexical order and records each version in version_table_name. |
| Target a specific revision | uv run sqlspec upgrade 0003 |
Applies pending revisions up to and including 0003. |
| Roll back revisions | uv run sqlspec downgrade -1 |
Executes the down section of the most recently applied revision (downgrade base reverts all). |
| Baseline an existing database | uv run sqlspec stamp head |
Writes the target revision into version_table_name without executing DDL statements. |
| Consolidate history | uv run sqlspec squash --start 0001 --end 0010 |
Merges sequential revisions into one baseline file (pass --update-database to update tracker rows). |
Recap¶
- Declare
migration_configon your adapter config andconfig = "module:attr"under[tool.sqlspec]inpyproject.toml. - Prefer
.sqlmigration files for transparent, DBA-auditable DDL and.pyrevisions when statements depend on runtime environment inspection. - Use
version_table_schemaanddirect_dsnin pooled multi-schema production environments.