Database Migrations with Alembic
This guide covers how to use Alembic for database schema migrations with YDB SQLAlchemy.
Overview
Alembic is SQLAlchemy’s database migration tool that allows you to:
Track database schema changes over time
Apply incremental schema updates
Rollback to previous schema versions
Generate migration scripts automatically
Support Status
Everything marked supported below is covered by an integration test in
test/test_alembic.py and runs on every commit. Everything marked
not supported is likewise asserted by a test, so if a future YDB release
lifts a restriction the test starts failing and this table gets updated.
Alembic’s own suite for third-party dialects, alembic.testing.suite, runs
alongside it from test/test_alembic_suite.py, with the YDB feature flags in
test/alembic_requirements.py.
Commands
Command |
Status |
Notes |
|---|---|---|
|
Supported |
Re-running at head is a no-op |
|
Supported |
|
|
Supported |
Records the revision without running it |
|
Supported |
|
|
Supported |
Scope it with |
|
Not supported |
Schema statements render, version bookkeeping does not |
Operations inside a revision
Operation |
Status |
Notes |
|---|---|---|
|
Supported |
Needs a primary key; no foreign keys |
|
Supported |
|
|
Supported |
|
|
Supported |
The new column is always nullable |
|
Supported |
|
|
Supported |
But see the note on |
|
Supported |
|
|
Supported |
Raw DDL must be wrapped in |
|
Supported |
Maps onto YDB’s |
|
Not supported |
YDB: |
|
Not supported |
YDB’s |
Foreign key constraints |
Not supported |
YDB has no foreign keys |
Changing a primary key |
Not supported |
Create a new table, copy, drop, rename |
Autogenerate
Detects added and removed tables, added and removed columns, and added
indexes, and produces an empty diff when the model matches the database.
A changed column type has to be resolved by hand, since the alter_column
autogenerate would emit for it is not executable on YDB.
Autogenerate compares against every table in the database, so scope it with
include_name/include_object in env.py if the database holds tables
outside your model.
Other limits
Unique indexes are not actually unique.
create_index(unique=True)compiles withoutUNIQUE, so the resulting index does not enforce uniqueness, and reflection reportsunique: Falseeven for an index that is unique. YDB supports unique indexes; this is a gap in the dialect, and autogenerate cannot converge on a model that declares one. Do not rely onunique=Truein a migration until the dialect emits and reflects it.Offline mode does not produce runnable YQL.
upgrade --sqlrenders the schema statements correctly, but thealembic_versioninsert comes out with an unfilled placeholder for the surrogate key column, and the update that advances a revision would target a primary key column if that column were dropped. Apply migrations online.Branched history is not supported. The version table can only hold one row, so a second head fails with a constraint violation. Keep the history linear.
Migrations are not atomic. YDB cannot run schema operations in a transaction, so a revision that fails part way through leaves the schema partly migrated. Keep revisions small.
Installation
Install Alembic alongside YDB SQLAlchemy:
pip install alembic ydb-sqlalchemy
Initial Setup
Initialize Alembic in your project:
alembic init migrations
This creates an alembic.ini configuration file and a migrations/ directory.
Configure
alembic.ini:
# alembic.ini
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
# YDB connection string
sqlalchemy.url = yql+ydb://localhost:2136/local
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
YDB-Specific Configuration
Alembic dispatches on the SQLAlchemy dialect name and refuses to start unless an
implementation is registered for that name, so env.py has to import the one
shipped with this package. The import is the whole integration: YDBImpl
registers itself for the yql dialect, and it gives the alembic_version
table a layout YDB accepts.
# migrations/env.py
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from ydb_sqlalchemy.alembic import YDBImpl # noqa: F401
# Import your models
from myapp.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
The Version Table
YDBImpl creates alembic_version with an extra, always-NULL id
column that serves as its primary key, instead of Alembic’s usual primary key on
version_num. Two YDB rules force this: a primary key column cannot be
updated, and Alembic advances a revision with
UPDATE alembic_version SET version_num = ...; and the named primary key
constraint Alembic emits by default is rejected by the YDB parser.
A consequence is that branched migrations are not supported – more than one
head would need more than one row, and the rows would collide on a NULL
primary key. Keep the revision history linear.
Authentication
The engine_from_config form above builds the engine from sqlalchemy.url
alone, which only covers anonymous access. Every other method – an IAM token,
a service account key, static credentials – is a ydb.Credentials object
passed through connect_args, and an object cannot live in a URL. Such an
environment builds the engine itself:
# migrations/env.py
import os
import sqlalchemy as sa
import ydb
from sqlalchemy import pool
from alembic import context
from ydb_sqlalchemy.alembic import YDBImpl # noqa: F401
config = context.config
def credentials():
# ydb.AccessTokenCredentials, ydb.ServiceAccountCredentials,
# ydb.StaticCredentials, ydb.iam.MetadataUrlCredentials, ...
return ydb.AccessTokenCredentials(os.environ["YDB_TOKEN"])
def run_migrations_online() -> None:
engine = sa.create_engine(
config.get_main_option("sqlalchemy.url"),
poolclass=pool.NullPool,
connect_args={"credentials": credentials()},
)
try:
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
finally:
engine.dispose()
YDBImpl is selected from the connection’s dialect, so it does not care how
the engine was built; the import is still all that is YDB-specific about the
migration machinery itself.
Beyond credentials, the same applies to anything else that is an argument
rather than a string – ydb.DriverConfig, a custom
ydb.QueryClientSettings, or a shared session pool. env.py is where they
go.
Autogenerating Against a Throwaway Database
revision --autogenerate compares the model against a live database and
refuses to run unless that database is at head, so it needs one to talk to.
Pointing it at production to author a migration is usually not what you want.
A practical alternative is to start a throwaway YDB container, run
upgrade head against it to replay the existing history, and autogenerate
from there:
from alembic import command
# container: a locally started ydbplatform/local-ydb, with config pointed at it
command.upgrade(config, "head")
script = command.revision(config, message="...", autogenerate=True, rev_id=next_id)
The generated file can then be printed or moved into the project. This keeps autogenerate reproducible in CI and independent of any deployed database.
Creating Your First Migration
Define your models:
# models.py
from sqlalchemy import Column, String, Integer
from sqlalchemy.ext.declarative import declarative_base
from ydb_sqlalchemy.sqlalchemy.types import UInt64
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(UInt64, primary_key=True)
username = Column(String(50), nullable=False)
email = Column(String(100), nullable=False)
full_name = Column(String(200))
Generate the initial migration:
alembic revision --autogenerate -m "Create users table"
This creates a migration file like 001_create_users_table.py:
"""Create users table
Revision ID: 001
Revises:
Create Date: 2024-01-01 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from ydb_sqlalchemy.sqlalchemy.types import UInt64
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table('users',
sa.Column('id', UInt64(), nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('email', sa.String(length=100), nullable=False),
sa.Column('full_name', sa.String(length=200), nullable=True),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
op.drop_table('users')
Apply the migration:
alembic upgrade head
Common Migration Operations
Adding a Column
# Add a new column
def upgrade() -> None:
op.add_column('users', sa.Column('created_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'created_at')
Modifying a Column
op.alter_column() on YDB can only relax nullability, not change a type –
see What alter_column Can Change. Replace a column by adding the
replacement, copying the data and dropping the original:
def upgrade() -> None:
op.add_column('users', sa.Column('username_v2', sa.Unicode(100)))
op.execute('UPDATE `users` SET username_v2 = username')
op.drop_column('users', 'username')
def downgrade() -> None:
op.add_column('users', sa.Column('username', sa.Unicode(50)))
op.execute('UPDATE `users` SET username = username_v2')
op.drop_column('users', 'username_v2')
Creating Indexes
def upgrade() -> None:
op.create_index('ix_users_email', 'users', ['email'])
def downgrade() -> None:
op.drop_index('ix_users_email', table_name='users')
Adding a New Table
YDB has no foreign keys, so a revision can only declare the column and, if the lookup needs it, a secondary index:
def upgrade() -> None:
op.create_table('posts',
sa.Column('id', UInt64(), nullable=False),
sa.Column('user_id', UInt64(), nullable=False),
sa.Column('title', sa.String(200), nullable=False),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_posts_user_id', 'posts', ['user_id'])
def downgrade() -> None:
op.drop_table('posts')
YDB-Specific Considerations
Primary Key Limitations
YDB doesn’t support modifying primary key columns. Plan your primary keys carefully:
# Good: Use appropriate primary key from the start
class User(Base):
__tablename__ = 'users'
id = Column(UInt64, primary_key=True) # Can't be changed later
# If you need to change primary key structure, you'll need to:
# 1. Create new table with correct primary key
# 2. Migrate data
# 3. Drop old table
# 4. Rename new table
What alter_column Can Change
YDB’s ALTER COLUMN
changes column options – it can drop NOT NULL and set FAMILY,
DEFAULT or COMPRESSION. It cannot change a column’s type. Of the
changes Alembic can express, only relaxing nullability goes through.
Relaxing NOT NULL works, because it maps onto DROP NOT NULL:
op.alter_column('users', 'username',
existing_type=sa.Unicode(50),
nullable=True) # Works
Adding NOT NULL does not; YDB answers
SET NOT NULL is currently not supported:
op.alter_column('users', 'status', nullable=False) # Fails
Changing a type does not either. Alembic emits ALTER COLUMN ... TYPE ...,
which the YQL parser rejects with
no viable alternative at input 'ALTER COLUMN' – including for a widening
change that other databases accept:
op.alter_column('users', 'username',
existing_type=sa.Unicode(50),
type_=sa.Unicode(100)) # Fails
Use add-copy-drop, as shown in Modifying a Column, to change a type. For a primary key column even that is not enough, because the primary key of an existing table cannot be changed; create a new table, copy the data into it, drop the old one and rename.
The remaining ALTER COLUMN options have no Alembic operation, so reach them
with raw YQL. Wrap the statement in sa.schema.DDL: the dialect only sends a
statement to the YDB scheme service when SQLAlchemy marks it as DDL, and a
plain string is not marked, so it reaches the query service and is rejected
with Scheme operations cannot be executed inside transaction.
op.execute(sa.schema.DDL(
'ALTER TABLE `users` ALTER COLUMN `username` SET FAMILY default'
))
This applies to any raw DDL passed to op.execute. Raw DML – an UPDATE
in a data migration, say – is fine as a plain string.
Working with YDB Types
Use YDB-specific types in migrations:
from ydb_sqlalchemy.sqlalchemy.types import (
UInt64, UInt32, Decimal, YqlJSON, YqlDateTime
)
def upgrade() -> None:
op.create_table('financial_records',
sa.Column('id', UInt64(), nullable=False),
sa.Column('amount', Decimal(precision=15, scale=2), nullable=False),
sa.Column('metadata', YqlJSON(), nullable=True),
sa.Column('created_at', YqlDateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
Advanced Migration Patterns
Data Migrations
Sometimes you need to migrate data along with schema:
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
def upgrade() -> None:
# Add new column
op.add_column('users', sa.Column('status', sa.String(20), nullable=True))
# Create a temporary table representation for data migration
users_table = table('users',
column('id', UInt64),
column('status', sa.String)
)
# Update existing records
op.execute(
users_table.update().values(status='active')
)
# The column stays nullable: YDB cannot add NOT NULL to an existing
# column. Declare it NOT NULL at CREATE TABLE time, or enforce it in
# the application.
def downgrade() -> None:
op.drop_column('users', 'status')
Conditional Migrations
def upgrade() -> None:
# Check if column already exists
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('users')]
if 'new_column' not in columns:
op.add_column('users', sa.Column('new_column', sa.String(50)))
Migration Best Practices
Test Migrations: Always test migrations on a copy of production data
Backup Data: Backup your data before running migrations in production
Review Generated Migrations: Always review auto-generated migrations before applying
Do Not Rely on Atomicity: YDB cannot run schema operations inside a transaction, so a revision that fails part way through leaves the schema partly migrated. Keep revisions small so that re-running one after a manual fix is cheap.
Plan Primary Keys: Design primary keys carefully as they can’t be changed
Plan Nullability: A column can only be made
NOT NULLwhen the table is created
# Good migration practices
def upgrade() -> None:
# One schema change per revision, so a failure is easy to place
op.add_column('users', sa.Column('new_field', sa.String(100), nullable=True))
# Populate data
# ... data migration code ...
Common Commands
# Generate new migration
alembic revision --autogenerate -m "Description of changes"
# Apply all pending migrations
alembic upgrade head
# Apply specific migration
alembic upgrade revision_id
# Rollback one migration
alembic downgrade -1
# Rollback to specific revision
alembic downgrade revision_id
# Show current revision
alembic current
# Show migration history
alembic history
# Show pending migrations
alembic show head
Troubleshooting
- Migration Fails with “Table already exists”
Check if migration was partially applied
Use
alembic stamp headto mark current state without running migrations
- Primary Key Constraint Errors
YDB requires primary keys on all tables
Ensure all tables have appropriate primary keys
- Type Conversion Errors
Some type changes aren’t supported in YDB
Create new column, migrate data, drop old column instead
- Connection Issues
Verify YDB is running and accessible
Check connection string in
alembic.ini
Example Project Structure
myproject/
├── alembic.ini
├── migrations/
│ ├── env.py
│ ├── script.py.mako
│ └── versions/
│ ├── 001_create_users_table.py
│ ├── 002_add_posts_table.py
│ └── 003_add_user_status.py
├── models/
│ ├── __init__.py
│ ├── user.py
│ └── post.py
└── main.py
This setup provides a robust foundation for managing YDB schema changes over time using Alembic migrations.