Tutorial: Managing Database Schema Evolution with Migrations¶
This tutorial demonstrates how to use pydapter's migrations module to manage database schema changes in a SQLAlchemy-based application. We'll create a simple user management system and evolve its schema over time using migrations.
Prerequisites¶
Before starting, ensure you have installed pydapter with the migrations-sql extension:
This will install the required dependencies, including SQLAlchemy and Alembic.
Step 1: Set Up the Project Structure¶
First, let's create a basic project structure:
user_management/
├── migrations/ # Will be created by the migration tool
├── models.py # SQLAlchemy models
├── database.py # Database connection setup
└── main.py # Application entry point
Step 2: Define the Initial Database Models¶
Let's create our initial database models in models.py
:
# models.py
from sqlalchemy import Column, Integer, String, DateTime, func
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), unique=True, nullable=False)
created_at = Column(DateTime, default=func.now())
Next, let's set up the database connection in database.py
:
# database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Use SQLite for simplicity in this tutorial
DATABASE_URL = "sqlite:///./user_management.db"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Step 3: Initialize Migrations¶
Now, let's initialize the migrations environment. Create a simple script in
main.py
:
# main.py
import os
from pydapter.migrations import AlembicAdapter
import models
def init_migrations():
"""Initialize the migrations environment."""
os.makedirs("migrations", exist_ok=True)
AlembicAdapter.init_migrations(
directory="migrations",
connection_string="sqlite:///./user_management.db",
models_module=models
)
print("Migrations initialized successfully!")
if __name__ == "__main__":
init_migrations()
Run this script to initialize the migrations environment:
This will create the necessary directory structure and configuration files for
Alembic in the migrations
directory.
Step 4: Create the Initial Migration¶
Now, let's create our first migration to set up the initial database schema:
# main.py (add this function)
def create_initial_migration():
"""Create the initial migration."""
revision = AlembicAdapter.create_migration(
message="Create users table",
autogenerate=True,
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Created initial migration: {revision}")
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
create_initial_migration()
Run the script again to create the initial migration:
This will create a new migration file in the migrations/versions/
directory
with a unique revision ID.
Step 5: Apply the Migration¶
Now, let's apply the migration to create the database schema:
# main.py (add this function)
def apply_migrations():
"""Apply all pending migrations."""
AlembicAdapter.upgrade(
revision="head",
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print("Migrations applied successfully!")
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
# create_initial_migration() # Comment this out after first run
apply_migrations()
Run the script to apply the migration:
This will create the users
table in the database according to our model
definition.
Step 6: Evolve the Schema¶
Now, let's evolve our schema by adding new fields to the User
model:
# models.py (updated)
from sqlalchemy import Column, Integer, String, DateTime, Boolean, func
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), unique=True, nullable=False)
full_name = Column(String(100)) # New field
is_active = Column(Boolean, default=True) # New field
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) # New field
Now, let's create a new migration to reflect these changes:
# main.py (add this function)
def create_schema_update_migration():
"""Create a migration for schema updates."""
revision = AlembicAdapter.create_migration(
message="Add user profile fields",
autogenerate=True,
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Created schema update migration: {revision}")
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
# create_initial_migration() # Comment this out after first run
# apply_migrations() # Comment this out after first run
create_schema_update_migration()
Run the script to create the new migration:
Then apply the migration:
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
# create_initial_migration() # Comment this out after first run
# create_schema_update_migration() # Comment this out after first run
apply_migrations()
Step 7: Add a New Model¶
Let's add a new model to our application for user roles:
# models.py (updated)
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, func
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), unique=True, nullable=False)
full_name = Column(String(100))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# Add relationship to roles
roles = relationship("UserRole", back_populates="user")
class UserRole(Base):
__tablename__ = "user_roles"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role_name = Column(String(50), nullable=False)
created_at = Column(DateTime, default=func.now())
# Add relationship to user
user = relationship("User", back_populates="roles")
Now, let's create a migration for this new model:
# main.py (add this function)
def create_roles_migration():
"""Create a migration for the roles model."""
revision = AlembicAdapter.create_migration(
message="Add user roles table",
autogenerate=True,
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Created roles migration: {revision}")
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
# create_initial_migration() # Comment this out after first run
# apply_migrations() # Comment this out after first run
# create_schema_update_migration() # Comment this out after first run
create_roles_migration()
Run the script to create the new migration:
Then apply the migration:
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
# create_initial_migration() # Comment this out after first run
# create_schema_update_migration() # Comment this out after first run
# create_roles_migration() # Comment this out after first run
apply_migrations()
Step 8: Check Migration Status¶
Let's add functionality to check the current migration status:
# main.py (add this function)
def check_migration_status():
"""Check the current migration status."""
current = AlembicAdapter.get_current_revision(
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Current migration revision: {current}")
history = AlembicAdapter.get_migration_history(
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print("\nMigration history:")
for migration in history:
print(f"- {migration['revision']}: {migration.get('description', 'No description')}")
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
# create_initial_migration() # Comment this out after first run
# apply_migrations() # Comment this out after first run
# create_schema_update_migration() # Comment this out after first run
# create_roles_migration() # Comment this out after first run
check_migration_status()
Run the script to check the migration status:
Step 9: Downgrade to a Previous Version¶
Sometimes you might need to revert to a previous version of your schema. Let's add functionality to downgrade:
# main.py (add this function)
def downgrade_migration(revision):
"""Downgrade to a specific revision."""
AlembicAdapter.downgrade(
revision=revision,
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Downgraded to revision: {revision}")
if __name__ == "__main__":
# Get the second-to-last revision from history
history = AlembicAdapter.get_migration_history(
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
if len(history) >= 2:
previous_revision = history[-2]['revision']
downgrade_migration(previous_revision)
else:
print("Not enough migrations to downgrade")
Run the script to downgrade to the previous migration:
Step 10: Create a Custom Migration¶
Sometimes you need to create custom migrations that aren't just schema changes. Let's create a data migration:
# main.py (add this function)
def create_custom_migration():
"""Create a custom data migration."""
revision = AlembicAdapter.create_migration(
message="Add default admin user",
autogenerate=False, # Don't auto-generate from models
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Created custom migration: {revision}")
print(f"Edit the migration file in migrations/versions/{revision}_add_default_admin_user.py")
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
# create_initial_migration() # Comment this out after first run
# apply_migrations() # Comment this out after first run
# create_schema_update_migration() # Comment this out after first run
# create_roles_migration() # Comment this out after first run
# check_migration_status() # Comment this out after first run
create_custom_migration()
Run the script to create the custom migration:
Now, edit the generated migration file in migrations/versions/
to add custom
SQL operations:
"""Add default admin user
Revision ID: <your_revision_id>
Revises: <previous_revision_id>
Create Date: 2025-05-16 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '<your_revision_id>'
down_revision = '<previous_revision_id>'
branch_labels = None
depends_on = None
def upgrade():
# Add a default admin user
op.execute("""
INSERT INTO users (username, email, full_name, is_active)
VALUES ('admin', 'admin@example.com', 'System Administrator', 1)
""")
# Get the user ID
conn = op.get_bind()
result = conn.execute("SELECT id FROM users WHERE username = 'admin'").fetchone()
if result:
user_id = result[0]
# Add admin role
op.execute(f"""
INSERT INTO user_roles (user_id, role_name)
VALUES ({user_id}, 'admin')
""")
def downgrade():
# Remove the admin role and user
op.execute("DELETE FROM user_roles WHERE role_name = 'admin'")
op.execute("DELETE FROM users WHERE username = 'admin'")
Then apply the migration:
if __name__ == "__main__":
# init_migrations() # Comment this out after first run
# create_initial_migration() # Comment this out after first run
# create_schema_update_migration() # Comment this out after first run
# create_roles_migration() # Comment this out after first run
# check_migration_status() # Comment this out after first run
# create_custom_migration() # Comment this out after first run
apply_migrations()
Step 11: Using Async Migrations¶
If your application uses asynchronous database connections, you can use the async migration adapter. Let's modify our code to use async migrations:
# database.py (updated for async)
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
# Use SQLite for simplicity in this tutorial
DATABASE_URL = "sqlite+aiosqlite:///./user_management_async.db"
engine = create_async_engine(DATABASE_URL)
AsyncSessionLocal = sessionmaker(
class_=AsyncSession,
autocommit=False,
autoflush=False,
bind=engine
)
async def get_db():
async with AsyncSessionLocal() as db:
yield db
# main_async.py
import asyncio
import os
from pydapter.migrations import AsyncAlembicAdapter
import models
async def init_migrations():
"""Initialize the migrations environment."""
os.makedirs("migrations_async", exist_ok=True)
await AsyncAlembicAdapter.init_migrations(
directory="migrations_async",
connection_string="sqlite+aiosqlite:///./user_management_async.db",
models_module=models
)
print("Async migrations initialized successfully!")
async def create_initial_migration():
"""Create the initial migration."""
revision = await AsyncAlembicAdapter.create_migration(
message="Create users table",
autogenerate=True,
directory="migrations_async",
connection_string="sqlite+aiosqlite:///./user_management_async.db"
)
print(f"Created initial async migration: {revision}")
async def apply_migrations():
"""Apply all pending migrations."""
await AsyncAlembicAdapter.upgrade(
revision="head",
directory="migrations_async",
connection_string="sqlite+aiosqlite:///./user_management_async.db"
)
print("Async migrations applied successfully!")
async def main():
await init_migrations()
await create_initial_migration()
await apply_migrations()
if __name__ == "__main__":
asyncio.run(main())
Run the async script:
Complete Example¶
Here's a complete example of the main.py file that includes all the migration operations:
# main.py
import os
from pydapter.migrations import AlembicAdapter
import models
def init_migrations():
"""Initialize the migrations environment."""
os.makedirs("migrations", exist_ok=True)
AlembicAdapter.init_migrations(
directory="migrations",
connection_string="sqlite:///./user_management.db",
models_module=models
)
print("Migrations initialized successfully!")
def create_initial_migration():
"""Create the initial migration."""
revision = AlembicAdapter.create_migration(
message="Create users table",
autogenerate=True,
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Created initial migration: {revision}")
def apply_migrations():
"""Apply all pending migrations."""
AlembicAdapter.upgrade(
revision="head",
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print("Migrations applied successfully!")
def create_schema_update_migration():
"""Create a migration for schema updates."""
revision = AlembicAdapter.create_migration(
message="Add user profile fields",
autogenerate=True,
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Created schema update migration: {revision}")
def create_roles_migration():
"""Create a migration for the roles model."""
revision = AlembicAdapter.create_migration(
message="Add user roles table",
autogenerate=True,
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Created roles migration: {revision}")
def check_migration_status():
"""Check the current migration status."""
current = AlembicAdapter.get_current_revision(
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Current migration revision: {current}")
history = AlembicAdapter.get_migration_history(
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print("\nMigration history:")
for migration in history:
print(f"- {migration['revision']}: {migration.get('description', 'No description')}")
def downgrade_migration(revision):
"""Downgrade to a specific revision."""
AlembicAdapter.downgrade(
revision=revision,
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Downgraded to revision: {revision}")
def create_custom_migration():
"""Create a custom data migration."""
revision = AlembicAdapter.create_migration(
message="Add default admin user",
autogenerate=False, # Don't auto-generate from models
directory="migrations",
connection_string="sqlite:///./user_management.db"
)
print(f"Created custom migration: {revision}")
print(f"Edit the migration file in migrations/versions/{revision}_add_default_admin_user.py")
if __name__ == "__main__":
# Uncomment the function you want to run
# init_migrations()
# create_initial_migration()
# apply_migrations()
# create_schema_update_migration()
# create_roles_migration()
# check_migration_status()
# Downgrade example
# history = AlembicAdapter.get_migration_history(
# directory="migrations",
# connection_string="sqlite:///./user_management.db"
# )
# if len(history) >= 2:
# previous_revision = history[-2]['revision']
# downgrade_migration(previous_revision)
# else:
# print("Not enough migrations to downgrade")
# create_custom_migration()
# Final upgrade to latest
apply_migrations()
Summary¶
In this tutorial, we've demonstrated how to use pydapter's migrations module to manage database schema evolution. We've covered:
- Setting up a migrations environment
- Creating and applying initial migrations
- Evolving the schema with new fields
- Adding new models
- Checking migration status
- Downgrading to previous versions
- Creating custom data migrations
- Using async migrations
The migrations module provides a powerful way to manage database schema changes in a controlled, versioned manner, making it easier to evolve your application's data model over time.
Best Practices¶
Here are some best practices to follow when working with migrations:
- Always back up your database before applying migrations in production
- Keep migrations small and focused on specific changes
- Test migrations thoroughly in development before applying to production
- Include descriptive messages for each migration
- Use version control for your migration files
- Consider using separate migration environments for different deployment stages
- Document complex migrations with comments
- Include both upgrade and downgrade operations when possible