Configuring env.py¶
Alembic's env.py is the bridge between your application and the migration engine.
alembic-gauntlet injects a connection and a target_schema into
alembic_config.attributes before running each test — your env.py must read
those values so the library can drive migrations into isolated schemas.
How alembic-gauntlet communicates with env.py¶
Before calling alembic upgrade / alembic downgrade, the library sets:
| Key | Type | Description |
|---|---|---|
config.attributes["connection"] |
sqlalchemy.engine.Connection |
Ready-to-use sync connection. If present, skip engine creation and use this directly. |
config.attributes["target_schema"] |
str |
Schema to migrate into (e.g. test_mig_a1b2c3d4). Fall back to MIGRATION_SCHEMA env var or "public". |
Both are cleaned up automatically after each test run.
Minimal env.py¶
import asyncio
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy.engine import Connection
config = context.config
# Support injected schema (alembic-gauntlet) or env var override
target_schema = config.attributes.get("target_schema") or os.getenv("MIGRATION_SCHEMA", "public")
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Import your ORM metadata here
from myapp.db import Base
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table_schema=target_schema,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
if target_schema != "public":
from sqlalchemy import text
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"'))
# SET LOCAL — scoped to the current transaction only.
# Never use plain SET here: it would persist on the connection after
# it is returned to the pool and corrupt search_path for other callers.
connection.execute(text(f'SET LOCAL search_path TO "{target_schema}"'))
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_schema=target_schema,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
url = config.get_main_option("sqlalchemy.url")
engine = create_async_engine(url, poolclass=NullPool)
async with engine.connect() as conn:
await conn.run_sync(do_run_migrations)
await engine.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
# alembic-gauntlet injects a ready connection — use it directly
if config.attributes.get("connection") is not None:
do_run_migrations(config.attributes["connection"])
else:
asyncio.run(run_migrations_online())
Critical: SET LOCAL, not SET¶
Never use plain SET search_path in migrations
# ❌ WRONG — persists on the connection after it returns to the pool
connection.execute(text(f'SET search_path TO "{target_schema}"'))
# ✅ CORRECT — scoped to the current transaction only
connection.execute(text(f'SET LOCAL search_path TO "{target_schema}"'))
Plain SET changes the session-level search_path. When the connection is
returned to a connection pooler (PgBouncer, pgpool, SQLAlchemy's own pool),
it carries the altered search_path into the next caller's query — silently
routing their queries to the wrong schema.
SET LOCAL is automatically rolled back when the transaction ends, so the
connection is always returned to the pool in a clean state.
Handling non-public schemas¶
The do_run_migrations function above creates the schema if it doesn't exist and
sets search_path locally. This is exactly what alembic-gauntlet relies on when
it creates an isolated test_mig_* schema for each test.
For the public schema you can skip both steps — PostgreSQL's default
search_path already includes public.
Advisory locks¶
For services with multiple replicas, take a PostgreSQL advisory lock before running migrations to prevent concurrent execution:
import hashlib
# Generate a stable 64-bit lock ID from your service name.
# Use SHA-256 (not CRC32) to minimise collision risk across services.
_hash = hashlib.sha256(b"myapp_migrations").digest()
MIGRATIONS_LOCK_ID = int.from_bytes(_hash[:8], "big", signed=True)
def do_run_migrations(connection: Connection) -> None:
if target_schema != "public":
from sqlalchemy import text
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"'))
connection.execute(text(f'SET LOCAL search_path TO "{target_schema}"'))
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_schema=target_schema,
)
with context.begin_transaction():
from sqlalchemy import text
connection.execute(text(f"SELECT pg_advisory_xact_lock({MIGRATIONS_LOCK_ID})"))
context.run_migrations()
pg_advisory_xact_lock is released automatically when the transaction ends —
no explicit unlock needed.
Filtering autogenerate output¶
If you use partitioned tables or extension-owned tables, filter them out of
alembic revision --autogenerate with include_object:
from typing import Any
def include_object(
obj: Any, name: str, type_: str, reflected: bool, compare_to: Any
) -> bool:
if type_ == "table":
if name == "alembic_version":
return False
# Exclude default partitions and auto-generated partition tables
if "_default" in name or "partitioned_default" in name:
return False
if type_ == "index":
if hasattr(obj, "table") and obj.table is not None:
if "_default" in obj.table.name or "partitioned_default" in obj.table.name:
return False
return True
Pass it to context.configure:
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_schema=target_schema,
include_object=include_object,
include_schemas=False,
)
Loading database credentials¶
env.py is responsible for building the database URL. A common pattern is to
load settings from environment variables via a Pydantic settings model:
import os
from pydantic import BaseModel
from pydantic_settings import BaseSettings
class DBSettings(BaseSettings):
host: str = "localhost"
port: int = 5432
user: str
password: str
name: str
model_config = {"env_prefix": "DB_"}
def to_async_dsn(self) -> str:
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.name}"
db_settings = DBSettings()
Then in run_migrations_online:
async def run_migrations_online() -> None:
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
engine = create_async_engine(db_settings.to_async_dsn(), poolclass=NullPool)
async with engine.connect() as conn:
await conn.run_sync(do_run_migrations)
await engine.dispose()
Use NullPool in migrations — you never want migration connections to be reused
or kept alive after the migration completes.