Migrations¶
Every database recipe assumes the schema already exists. This one is about the step before that: how the schema comes into being, and why the hand-written version of that step is wrong in a way that stays invisible for weeks.
The SDK ships the whole path as one method — AlembicHelper.sync_schema() —
and the rest of this page explains what it decides, so you can recognise which
state your database is in.
The complete bootstrap¶
# src/db/schema.py
from tempest_fastapi_sdk import AlembicHelper, SchemaSyncOutcome
from src.core.settings import settings
async def sync_schema() -> SchemaSyncOutcome:
"""Bring the database schema in line with the migration tree."""
helper: AlembicHelper = AlembicHelper(
"alembic.ini",
db_url=settings.DATABASE_URL,
)
return await helper.sync_schema_async()
Call it from the lifespan and the service boots with the right schema from any starting state:
# src/api/app.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from src.db.schema import sync_schema
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Sync the schema before the first request is served."""
outcome = await sync_schema()
print(f"schema: {outcome.value}")
yield
app: FastAPI = FastAPI(lifespan=lifespan)
From async code, use the _async method¶
The lifespan runs inside uvicorn's event loop. Every AlembicHelper method
that executes alembic/env.py has an _async twin — upgrade_async,
safe_upgrade_async, downgrade_async, stamp_async, revision_async,
check_async, current_async, has_existing_schema_async, adopt_async,
sync_schema_async, squash_async, pending_destructive_ops_async — with the
same signature, and that is the one you call from async code.
The reason is the env.py the SDK generates: it starts the async engine with
asyncio.run(...), and asyncio.run cannot be called while a loop is already
running. Calling the sync method from the lifespan now fails immediately,
with the right instruction:
RuntimeError: AlembicHelper.upgrade() was called from a running event loop. It runs alembic/env.py, which drives migrations with asyncio.run() and cannot nest inside the loop; use `await helper.upgrade_async(...)` instead.
Before, the same error came from deep inside Alembic as asyncio.run() cannot
be called from a running event loop, together with a
RuntimeWarning: coroutine 'run_async_migrations' was never awaited — and
check() did not even raise: it swallowed the error and answered False, as
if the schema had drifted.
The _async method runs the sync one in a worker thread, which has no loop of
its own, so the asyncio.run in env.py works there and the service's loop
keeps serving while the migration runs. Since the thread needs nothing new in
env.py, the env.py already in your repository keeps working — no need
to regenerate it.
Sync current() still works on the loop
Reading the revision does not go through env.py: with a sync driver
installed (the stdlib sqlite3, or psycopg2 on PostgreSQL),
helper.current() works from async code as before. Only an async-only
install (asyncpg with no sync driver) takes the asyncio.run path — and
then it raises too, asking for current_async().
Technical details: sharing the connection instead of a thread
Alembic has an official recipe for running the migration on the loop
itself: open an AsyncConnection and hand the sync connection that
run_sync provides over as config.attributes["connection"]. The
env.py the SDK generates from this release on accepts it — given the
connection, it migrates on it, without creating an engine or calling
asyncio.run:
# src/db/shared_connection.py
from alembic import command
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from tempest_fastapi_sdk import AlembicHelper
from src.core.settings import settings
async def upgrade_on_shared_connection() -> None:
"""Run every pending migration on a connection this code owns."""
helper: AlembicHelper = AlembicHelper(
"alembic.ini",
db_url=settings.DATABASE_URL,
)
config = helper.config
def _upgrade(connection: Connection) -> None:
"""Hand the sync connection to env.py and upgrade on it."""
config.attributes["connection"] = connection
command.upgrade(config, "head")
engine: AsyncEngine = create_async_engine(settings.DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(_upgrade)
await engine.dispose()
The _async methods do not take that path, and the reason is
compatibility: an env.py generated before this release ignores
config.attributes and calls asyncio.run regardless — inside
run_sync the loop is running, and it fails with the same error as
before. The thread works with every env.py already generated. Use the
path above when the migration must run on your connection or
transaction, and regenerate env.py for it: run tempest db init in an
empty directory and copy the generated alembic/env.py over yours,
checking the metadata import (the default is
from src.db.models import BaseModel).
The new env.py also refuses, with its own message, to be executed from
inside a loop without a connection handed over — the case of calling
command.upgrade straight from async code.
The three states it tells apart¶
sync_schema() asks the question almost every hand-written bootstrap forgets:
does the database hold tables Alembic did not create?
| Starting state | What runs | Return |
|---|---|---|
| Empty database | safe_upgrade() — the base revision creates the tables |
SchemaSyncOutcome.SYNCED |
| Database that predates Alembic | stamps the base, then safe_upgrade() |
SchemaSyncOutcome.ADOPTED |
| Database already under Alembic | safe_upgrade() |
SchemaSyncOutcome.SYNCED |
| Project with no revisions yet | nothing | SchemaSyncOutcome.NO_MIGRATIONS |
The empty database is the easy case, and it is what makes the rest work: the schema the base revision builds is, by construction, the same one later revisions assume they are altering — because it came from them.
Why create_tables() is not the answer¶
The Database recipe says db.create_tables() is for tests and
local development only. It is worth also saying what happens if you use it,
because the prohibition without the consequence does not make anyone see the
defect in their own code:
create_all is CREATE TABLE IF NOT EXISTS
Against a table that already exists, create_tables() adds no column at
all. It does not fail, does not warn, and does not return anything
different. It is a silent no-op.
The real defect, which took a service down for a day:
# scripts/broken_bootstrap.py — the defect, reproduced; not a recipe.
from tempest_fastapi_sdk import AlembicHelper, AsyncDatabaseManager
from src.core.settings import settings
db: AsyncDatabaseManager = AsyncDatabaseManager(settings.DATABASE_URL)
async def broken_bootstrap() -> None:
"""Reach the worst possible state: old schema, Alembic reporting head."""
helper: AlembicHelper = AlembicHelper(
"alembic.ini",
db_url=settings.DATABASE_URL,
)
if await helper.current_async() is None:
await db.create_tables()
await helper.stamp_async("head")
return
await helper.safe_upgrade_async()
Every line is plausible. Together they produce the worst possible state: an old schema, and Alembic declaring itself up to date.
$ alembic current
a3f9c21e88b4 (head)
$ alembic upgrade head
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
Nothing to do. alembic history flags nothing. Weeks later, the first query
touching a new column blows up far from the cause:
On the user's side that reads as "sending messages stopped working": the history
GET returns 500, and nothing at boot said a word.
Adopting a database that predates Alembic¶
This is the case the code above was trying to handle. The right answer is not to
stamp head — it is to stamp the base revision:
# src/db/schema.py
from tempest_fastapi_sdk import AlembicHelper
from src.core.settings import settings
def adopt_existing_database() -> bool:
"""Bring a pre-Alembic schema under Alembic, without upgrading it."""
helper: AlembicHelper = AlembicHelper(
"alembic.ini",
db_url=settings.DATABASE_URL,
)
return helper.adopt()
Stamping the base says "the baseline is already applied" — which is true,
because the tables exist — and leaves every revision after it pending, which
is also true. The safe_upgrade() that follows runs exactly those.
Stamping head says "everything is already applied", which is false for all
but the first.
adopt() does nothing when that is not the case: a database already stamped has
nothing to adopt, and an empty one needs the baseline to run, not to be
skipped. That is why sync_schema() can call it unconditionally.
The question is answered by has_existing_schema()
It lists the tables and discounts alembic_version, which Alembic writes
itself — its presence says nothing about the application schema.
Repairing a database stamped wrong¶
If you are already in the bad state, the fix has two steps. The first clears the pointer; the second re-adopts properly:
# scripts/repair_schema.py
from tempest_fastapi_sdk import AlembicHelper, SchemaSyncOutcome
from src.core.settings import settings
def repair() -> SchemaSyncOutcome:
"""Clear a wrong stamp and re-adopt the schema from the base."""
helper: AlembicHelper = AlembicHelper(
"alembic.ini",
db_url=settings.DATABASE_URL,
)
helper.stamp("base")
return helper.sync_schema()
stamp("base") is not the same as stamp(helper.base_revision()): "base" is
Alembic's word for no revision applied, and it deletes the alembic_version
row. After that the database looks like what it is — an existing schema with no
pointer — and sync_schema() takes the adoption path.
Check what is pending before you upgrade
After stamp("base"), the revisions between base and head will run against
a schema that may already carry part of them. Run
helper.pending_destructive_ops() and read helper.history() first — and
have a backup. safe_upgrade refuses destructive migrations without
force=True, which helps, but does not replace looking.
SQLite: migrations run with foreign keys off¶
On SQLite, Alembic alters a table in batch mode (the generated env.py
sets render_as_batch=True): it creates a copy with the new shape, copies the
rows, drops the original and renames the copy. With
PRAGMA foreign_keys=ON, that DROP TABLE runs the foreign-key actions of
the dropped table — and a child with ON DELETE CASCADE goes with it.
Measured with Alembic 1.19.1 and SQLite 3.47.1, adding one nullable column to
the parent table with batch_alter_table("org", recreate="always"):
PRAGMA foreign_keys |
Rows in member before → after |
Error |
|---|---|---|
0 |
3 → 3 | none |
1 |
3 → 0 | none |
So the engines the SDK builds to migrate — the generated env.py's and the
AlembicHelper read engines (current(), has_existing_schema()) — keep
FK off, while AsyncDatabaseManager turns it on by default. If you use the
connection-sharing recipe (config.attributes["connection"], described
above), the generated env.py
checks the pragma of the connection it receives and refuses before
migrating:
RuntimeError: refusing to run migrations on a SQLite connection with PRAGMA foreign_keys=ON: Alembic batch mode recreates tables, and dropping the old copy of a parent table runs ON DELETE CASCADE on its children, deleting their rows without an error. ...
The way out is migrating on a connection from an engine without FK — a manager just for that:
# src/db/shared_connection.py
from alembic import command
from sqlalchemy.engine import Connection
from tempest_fastapi_sdk import AlembicHelper, AsyncDatabaseManager
from src.core.settings import settings
async def upgrade_on_shared_connection() -> None:
"""Migrate on a connection that does not enforce foreign keys."""
helper: AlembicHelper = AlembicHelper(
"alembic.ini",
db_url=settings.DATABASE_URL,
)
config = helper.config
def _upgrade(connection: Connection) -> None:
"""Hand the connection to env.py and migrate on it."""
config.attributes["connection"] = connection
command.upgrade(config, "head")
migrations_db: AsyncDatabaseManager = AsyncDatabaseManager(
settings.DATABASE_URL,
sqlite_foreign_keys=False,
)
await migrations_db.connect()
try:
async with migrations_db.engine.begin() as conn:
await conn.run_sync(_upgrade)
finally:
await migrations_db.disconnect()
PRAGMA foreign_keys=OFF cannot fix an open connection
SQLite ignores the pragma inside a transaction, and the connection that
reaches env.py is already in one. Turning it off has to happen when
the engine is built (sqlite_foreign_keys=False, or a plain
create_async_engine(url)), not on the connection.
An env.py generated before this version does not refuse
The check lives in env.py, and yours was copied into the repository
when you ran tempest db init. If it receives a connection from an
AsyncDatabaseManager — which now turns FK on by default —, switch to a
connection without FK as above and regenerate env.py (run
tempest db init in an empty directory and copy alembic/env.py over
yours, checking the metadata import). Migrating through AlembicHelper
or the CLI is not affected: those paths open their own engine, without
FK.
The generated env.py¶
tempest db init (or AlembicHelper.init) writes an alembic/env.py of
three statements — the docstring is the only prose, with no inline comment:
"""Alembic environment script generated by tempest-fastapi-sdk."""
from tempest_fastapi_sdk.db import run_alembic_env
from src.db.models import BaseModel
target_metadata = BaseModel.metadata
run_alembic_env(target_metadata)
Everything the file used to do now lives in run_alembic_env: resolving
DATABASE_URL, the revision hooks, render_enum_types, batch mode, the
offline and online paths, and the connection handed over in
config.attributes["connection"]. Two reasons, both seen on a real service:
- A copy drifts. A service rewrote
env.pyby hand to pass lint, ended up with its own copy of the SDK's logic — later fixes never reached it — and lost thesys.pathhandling along the way, souv run alembic downgrade -1stopped importingsrc. With a call into the SDK, the fix arrives when the package is upgraded. - A full script cannot be lint-clean under every configuration. The
alembic/directory next topyproject.tomlmakes ruff's default (src = [".", "src"]) treatalembicas first-party, and thetempest newscaffold (src = ["src", "tests"]) as third-party.from alembic import contextlands in a different import block under each, so one of the two always reportsI001. The generated file imports noalembic, and sorts the same under both.
The guard tests/test_alembic_env_lint_guard.py generates the file in a
temporary project and runs ruff check --select E,W,F,I under both
configurations (plus the scaffold's whole configuration): zero errors and zero
inline comments.
The bare Alembic CLI works too¶
The generated alembic.ini carries script_location = %(here)s/alembic and
prepend_sys_path = %(here)s, both anchored on the ini's own directory. So
uv run alembic upgrade head and uv run alembic downgrade -1 import your
package without the tempest CLI, and a lifespan that passes the ini's
absolute path (AlembicHelper("/app/alembic.ini")) finds the scripts whatever
directory the process started in. Up to 0.312.x the ini carried alembic and
., relative to the working directory, and only worked from the project
root. path_separator = os comes
with it: without it, Alembic 1.19.1 emits a DeprecationWarning about the
legacy splitting of prepend_sys_path, and anyone running with -W error (or
filterwarnings = ["error"]) sees the migration fail.
One more hook¶
The hooks every revision goes through are in DEFAULT_REVISION_HOOKS. To add
your own, compose and pass it as process_revision_directives= — the value
replaces the default, so include the SDK's:
"""Alembic environment script with one extra revision hook."""
from typing import Any
from alembic.operations.ops import MigrationScript
from alembic.runtime.migration import MigrationContext
from tempest_fastapi_sdk.db import (
DEFAULT_REVISION_HOOKS,
compose_hooks,
run_alembic_env,
)
from src.db.models import BaseModel
def skip_empty_revisions(
context: MigrationContext,
revision: Any,
directives: list[Any],
) -> None:
"""Write no file when autogenerate finds no change."""
script = directives[0]
if (
isinstance(script, MigrationScript)
and script.upgrade_ops is not None
and script.upgrade_ops.is_empty()
):
directives[:] = []
run_alembic_env(
BaseModel.metadata,
process_revision_directives=compose_hooks(
*DEFAULT_REVISION_HOOKS,
skip_empty_revisions,
),
)
Measured against PostgreSQL 16: after upgrade head, an
alembic revision --autogenerate with no model change writes no file.
Already have an env.py generated earlier?
It keeps working. To start receiving the SDK's fixes, replace its content
with the three statements above (keeping your metadata import) and add
prepend_sys_path = %(here)s and path_separator = os to the [alembic] section
of alembic.ini. Migrations already written do not change:
op.replace_enum and op.drop_enum_type are registered when env.py
imports tempest_fastapi_sdk.db.
Each revision file¶
tempest db revision -m "Cria tabela de anotações" (and
AlembicHelper.revision) writes:
- An ASCII name. The message is folded to ASCII only to build the file
name, so
alembic/versions/holds no accented module name; the migration's docstring starts with the message as you wrote it, accents included. - No comment line.
tempest db initwrites the SDK's ownscript.py.mako, without the# revision identifiersline of Alembic's template, and the revision drops the# ### commands auto generated by Alembic ###markers autogenerate puts around the operations.
With bare alembic revision
The new template applies (no # revision identifiers), but the name keeps
its accents and the markers stay: measured with
alembic revision --autogenerate -m "Cria anotações", the file came out
as ..._cria_anotações.py with all four markers. Use
tempest db revision.
Project initialized before this?
The ASCII name and the marker removal already apply, because they live in
tempest db revision. For the comment-free template, replace
alembic/script.py.mako with the SDK's:
Where create_tables() is legitimate¶
In tests and throwaway local development — where the database is born and dies in the same process, and there is no migration to drift from:
# tests/conftest.py
from collections.abc import AsyncIterator
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import AsyncDatabaseManager
@pytest.fixture
async def session() -> AsyncIterator[AsyncSession]:
"""Yield a session over a fresh in-memory schema."""
db: AsyncDatabaseManager = AsyncDatabaseManager(
"sqlite+aiosqlite:///:memory:"
)
await db.create_tables()
async with db.get_session_context() as opened:
yield opened
await db.drop_tables()
The technical reason it works here and not in production is the same in both
cases: create_all only knows how to create what is missing. In a database that
was just born, "what is missing" is everything — so it gets it right. In one
that has already run, "what is missing" is no table at all, but possibly several
columns — and columns it does not look at.
Recap
sync_schema()is the whole bootstrap: it tells an empty database, a pre-Alembic one and an already-migrated one apart, and reports which path it took.- From async code (lifespan, endpoint), call the
_asynctwin (await helper.sync_schema_async()); the sync method raises with a loop running, naming the_asyncone to use. create_tables()isCREATE TABLE IF NOT EXISTS— a silent no-op on an existing table. It is never the step that evolves a schema.- When adopting an existing schema, stamp the base revision
(
helper.base_revision(), or justhelper.adopt()), neverhead. - To repair a wrong
stamp("head"):stamp("base"), thensync_schema(). create_tables()stays legitimate where there is no migration to drift from: tests and in-memory SQLite.- On SQLite, migrations run on a connection without foreign keys:
batch mode rebuilds the table and the cascade would delete the children.
The generated
env.pyrefuses a handed-over connection with FK on. - The generated
env.pyis one call torun_alembic_env: SDK fixes arrive with the package upgrade, and the file passes ruff with no inline comment.prepend_sys_path = %(here)slets the bare Alembic CLI import your package.
See also¶
- Database » — session, repository,
AsyncDatabaseManager. - CLI » —
tempest db upgrade,revision,stamp,check. - Safe deploys » —
safe_upgradeand the CI drift gate.