Cheatsheet for the op API.
Imports
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
Tables
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("email"),
)
op.drop_table("users")
op.rename_table("old", "new")
Columns
op.add_column("users", sa.Column("status", sa.String(20), nullable=False, server_default="active"))
op.drop_column("users", "old_field")
op.alter_column("users", "name",
type_=sa.String(200),
nullable=False,
new_column_name="full_name",
server_default=sa.text("'unknown'"),
existing_type=sa.String(120),
existing_nullable=True,
)
existing_* args help Alembic understand the current state.
Indexes
op.create_index("ix_users_email", "users", ["email"], unique=True)
op.drop_index("ix_users_email", "users")
op.create_index("ix_users_active", "users", ["email"], postgresql_where=sa.text("active = true"))
op.create_index("ix_users_lower_email", "users", [sa.text("lower(email)")])
# Postgres GIN
op.create_index("ix_events_gin", "events", ["payload"],
postgresql_using="gin",
postgresql_ops={"payload": "jsonb_path_ops"},
)
# Concurrent (can't be in transaction)
op.execute("CREATE INDEX CONCURRENTLY ix_users_email ON users (email)")
Constraints
op.create_unique_constraint("uq_users_email", "users", ["email"])
op.create_check_constraint("ck_users_age", "users", "age > 0")
op.drop_constraint("uq_users_email", "users", type_="unique")
op.drop_constraint("ck_users_age", "users", type_="check")
Foreign keys
op.create_foreign_key(
"fk_posts_user", "posts", "users",
["user_id"], ["id"],
ondelete="CASCADE",
onupdate="NO ACTION",
deferrable=False,
initially="IMMEDIATE",
)
op.drop_constraint("fk_posts_user", "posts", type_="foreignkey")
Raw SQL
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
op.execute("UPDATE users SET status = 'active' WHERE status IS NULL")
op.execute(sa.text("DELETE FROM sessions WHERE expires_at < now()"))
For DB-specific or complex operations.
Bind for data manipulation
bind = op.get_bind()
# Lightweight tables (don't import ORM models)
users = sa.table("users",
sa.column("id", sa.Integer),
sa.column("status", sa.String),
)
bind.execute(users.update().values(status="active"))
Avoid importing your real models — they evolve.
ENUMs (Postgres native)
op.execute("CREATE TYPE user_role AS ENUM ('user', 'admin')")
op.add_column("users", sa.Column("role", postgresql.ENUM("user", "admin", name="user_role")))
# Add value
op.execute("ALTER TYPE user_role ADD VALUE 'editor'")
# Drop
op.execute("DROP TYPE user_role")
Schemas
op.execute("CREATE SCHEMA IF NOT EXISTS audit")
op.execute("DROP SCHEMA audit CASCADE")
op.create_table("logs", ..., schema="audit")
batch_alter_table (SQLite)
with op.batch_alter_table("users") as batch:
batch.add_column(sa.Column("status", sa.String(20)))
batch.alter_column("name", new_column_name="full_name")
SQLite doesn’t support most ALTER directly; batch emulates via copy.
Server defaults
op.add_column("users",
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.alter_column("users", "created_at",
server_default=sa.text("now()"),
existing_type=sa.DateTime(timezone=True),
)
Comments
op.create_table("users",
sa.Column(...),
comment="App users",
)
op.alter_column("users", "email", comment="User email address")
Postgres ranges / extensions / specific features
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
# Range column
op.add_column("bookings",
sa.Column("period", postgresql.TSTZRANGE),
)
# Exclusion constraint
op.execute("""
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, period WITH &&)
""")
Conditional via inspector
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
cols = [c["name"] for c in inspector.get_columns("users")]
if "old_field" in cols:
op.drop_column("users", "old_field")
For idempotent / partial-state-tolerant migrations.
Common mistakes
op.executewith f-string + user input — SQL injection. Always parameterize viasa.text("... :p")+ params.- Importing ORM models — they evolve; migration breaks later.
- Forgetting
existing_type/existing_nullableonalter_column— Alembic confused. - CONCURRENTLY index in transactional migration — fails.
Read this next
If you want my op-API quick-reference card, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .