Skip to content

Migration System Guide

This guide covers creating, managing, and executing database migrations with surql's migration system.

Table of Contents

Overview

surql's migration system provides:

  • Version control - Track database schema changes over time
  • Reversibility - Roll back changes using down migrations
  • Automation - Generate migrations from schema definitions
  • Safety - Validate migrations before execution
  • History tracking - Store migration history in the database

Migration Lifecycle

1. Create migration file  2. Write SQL statements  3. Validate migration
                          4. Apply to database  5. Track in history  6. Execute statements

Configuration

Migration Path Configuration

The migration path can be configured through multiple sources (in priority order):

  1. CLI flag (--directory / -d) - highest priority
  2. Environment variable (SURQL_MIGRATION_PATH)
  3. .env file (SURQL_MIGRATION_PATH=...)
  4. pyproject.toml ([tool.surql] section)
  5. Default value (./migrations) - lowest priority

pyproject.toml Configuration

Add a [tool.surql] section to your pyproject.toml:

[tool.surql]
migration_path = "db/migrations"

Environment Variable

Set the SURQL_MIGRATION_PATH environment variable:

# Unix/macOS
export SURQL_MIGRATION_PATH=db/migrations

# Windows (PowerShell)
$env:SURQL_MIGRATION_PATH = "db/migrations"

# Windows (CMD)
set SURQL_MIGRATION_PATH=db/migrations

.env File

Create a .env file in your project root:

SURQL_MIGRATION_PATH=db/migrations

CLI Override

Override the configured path for a single command:

surql migrate up --directory custom/migrations
surql migrate status -d ./other/migrations

Configuration Priority Example

If you have: - pyproject.toml: migration_path = "toml/migrations" - .env: SURQL_MIGRATION_PATH=env/migrations - Environment: SURQL_MIGRATION_PATH=shell/migrations - CLI: --directory cli/migrations

The resolved path would be cli/migrations (CLI has highest priority).

Without the CLI flag, it would use shell/migrations (environment variable).

Accessing Configuration Programmatically

from surql.settings import get_migration_path, get_settings

# Get just the migration path
migration_dir = get_migration_path()
print(f"Migrations directory: {migration_dir}")

# Get all settings
settings = get_settings()
print(f"Migration path: {settings.migration_path}")
print(f"Environment: {settings.environment}")

Migration Structure

File Structure

Migration files are Python modules with a specific structure:

# migrations/20260102_120000_create_user_table.py

def up() -> list[str]:
  """Apply forward migration."""
  return [
    'DEFINE TABLE user SCHEMAFULL;',
    'DEFINE FIELD name ON TABLE user TYPE string;',
    'DEFINE FIELD email ON TABLE user TYPE string;',
  ]

def down() -> list[str]:
  """Rollback migration."""
  return [
    'REMOVE TABLE user;',
  ]

metadata = {
  'version': '20260102_120000',
  'description': 'Create user table',
  'author': 'surql',
  'depends_on': [],
}

Required Components

  1. up() function - Returns list of SQL statements to apply
  2. down() function - Returns list of SQL statements to rollback
  3. metadata dict - Contains migration information

Metadata Fields

metadata = {
  'version': '20260102_120000',        # Unique version (timestamp)
  'description': 'Create user table',   # Human-readable description
  'author': 'surql',                 # Migration author
  'depends_on': [],                     # List of required migrations
}

Creating Migrations

Manual Creation

Create a blank migration file:

surql migrate create "Add user table"

This generates:

migrations/20260102_120000_add_user_table.py

Edit the file to add your SQL statements.

Common Migration Patterns

Create Table

def up() -> list[str]:
  return [
    'DEFINE TABLE user SCHEMAFULL;',
    'DEFINE FIELD username ON TABLE user TYPE string;',
    'DEFINE FIELD email ON TABLE user TYPE string;',
    'DEFINE FIELD created_at ON TABLE user TYPE datetime DEFAULT time::now();',
  ]

def down() -> list[str]:
  return [
    'REMOVE TABLE user;',
  ]

Add Fields

def up() -> list[str]:
  return [
    'DEFINE FIELD bio ON TABLE user TYPE string DEFAULT "";',
    'DEFINE FIELD avatar_url ON TABLE user TYPE string;',
  ]

def down() -> list[str]:
  return [
    'REMOVE FIELD avatar_url ON TABLE user;',
    'REMOVE FIELD bio ON TABLE user;',
  ]

Create Index

def up() -> list[str]:
  return [
    'DEFINE INDEX email_idx ON TABLE user COLUMNS email UNIQUE;',
    'DEFINE INDEX username_idx ON TABLE user COLUMNS username UNIQUE;',
  ]

def down() -> list[str]:
  return [
    'REMOVE INDEX username_idx ON TABLE user;',
    'REMOVE INDEX email_idx ON TABLE user;',
  ]

Create Edge Table

def up() -> list[str]:
  return [
    'DEFINE TABLE follows SCHEMAFULL;',
    'DEFINE FIELD in ON TABLE follows TYPE record<user>;',
    'DEFINE FIELD out ON TABLE follows TYPE record<user>;',
    'DEFINE FIELD followed_at ON TABLE follows TYPE datetime DEFAULT time::now();',
  ]

def down() -> list[str]:
  return [
    'REMOVE TABLE follows;',
  ]

Modify Fields

def up() -> list[str]:
  return [
    # Remove old field
    'REMOVE FIELD name ON TABLE user;',
    # Add new fields
    'DEFINE FIELD name.first ON TABLE user TYPE string;',
    'DEFINE FIELD name.last ON TABLE user TYPE string;',
  ]

def down() -> list[str]:
  return [
    'REMOVE FIELD name.last ON TABLE user;',
    'REMOVE FIELD name.first ON TABLE user;',
    'DEFINE FIELD name ON TABLE user TYPE string;',
  ]

Add Event/Trigger

def up() -> list[str]:
  return [
    '''
    DEFINE EVENT email_changed ON TABLE user WHEN $before.email != $after.email THEN (
      CREATE audit_log SET
        table = 'user',
        record = $value.id,
        field = 'email',
        old_value = $before.email,
        new_value = $after.email,
        changed_at = time::now()
    )
    ''',
  ]

def down() -> list[str]:
  return [
    'REMOVE EVENT email_changed ON TABLE user;',
  ]

Add Permissions

def up() -> list[str]:
  return [
    '''
    DEFINE FIELD email ON TABLE user
      PERMISSIONS
        FOR select WHERE $auth.id = $parent.id OR $auth.admin = true
        FOR update WHERE $auth.id = $parent.id
    ''',
  ]

def down() -> list[str]:
  return [
    'REMOVE FIELD email ON TABLE user;',
    'DEFINE FIELD email ON TABLE user TYPE string;',
  ]

Migration Naming

Naming Convention

Files must follow this format:

YYYYMMDD_HHMMSS_description.py

Examples:

  • 20260102_120000_create_user_table.py
  • 20260102_120530_add_user_indexes.py
  • 20260103_093000_create_post_table.py

Best Practices for Names

# Good - Clear and descriptive
surql migrate create "Create user and post tables"
surql migrate create "Add email verification fields"
surql migrate create "Create follows edge table"

# Avoid - Vague or generic
surql migrate create "Update database"
surql migrate create "Changes"
surql migrate create "Fix"

Running Migrations

Apply All Pending Migrations

surql migrate up

Output:

Discovering migrations in migrations
Found 2 pending migration(s):
   20260102_120000: Create user table
   20260102_130000: Create post table
Successfully applied 2 migration(s)

Apply Specific Number

# Apply only the next migration
surql migrate up --steps 1

# Apply next 3 migrations
surql migrate up --steps 3

Dry Run (Preview)

# See what will be executed without applying
surql migrate up --dry-run

Output shows SQL that would be executed:

Found 1 pending migration(s):
   20260102_120000: Create user table

Dry run mode - no changes will be made

┌─────────────────────────────────────────┐
 20260102_120000: Create user table      ├─────────────────────────────────────────┤
 DEFINE TABLE user SCHEMAFULL;            DEFINE FIELD name ON TABLE user ...     └─────────────────────────────────────────┘

Check Migration Status

surql migrate status

Output:

Migration Status
┌───────────────────────────┬─────────┐
 Version                    Status  ├───────────────────────────┼─────────┤
 20260102_120000            APPLIED  20260102_130000            PENDING └───────────────────────────┴─────────┘
Total: 2 | Applied: 1 | Pending: 1

View Migration History

surql migrate history

Output:

Migration History
┌───────────────────────────┬─────────────┬────────────────────┐
 Version                    Description  Applied At         ├───────────────────────────┼─────────────┼────────────────────┤
 20260102_120000            Create user  2026-01-02 12:00   └───────────────────────────┴─────────────┴────────────────────┘

Migration History

History Storage

surql stores migration history in a special table:

CREATE TABLE _migration_history SCHEMAFULL;
DEFINE FIELD version ON TABLE _migration_history TYPE string;
DEFINE FIELD description ON TABLE _migration_history TYPE string;
DEFINE FIELD applied_at ON TABLE _migration_history TYPE datetime;
DEFINE FIELD execution_time_ms ON TABLE _migration_history TYPE int;
DEFINE FIELD checksum ON TABLE _migration_history TYPE string;
DEFINE INDEX version_idx ON TABLE _migration_history COLUMNS version UNIQUE;

Querying History

from surql.migration.history import get_applied_migrations
from surql.connection.client import get_client
from surql.settings import get_db_config

async def view_history():
  config = get_db_config()
  async with get_client(config) as client:
    history = await get_applied_migrations(client)

    for migration in history:
      print(f"{migration.version}: {migration.description}")
      print(f"  Applied: {migration.applied_at}")
      print(f"  Execution time: {migration.execution_time_ms}ms")

Rollback Strategies

Rollback Last Migration

surql migrate down

Rollback Multiple Migrations

# Rollback last 3 migrations
surql migrate down --steps 3

Preview Rollback

surql migrate down --dry-run

Writing Reversible Migrations

Always ensure down() properly reverses up():

def up() -> list[str]:
  return [
    'DEFINE TABLE user SCHEMAFULL;',
    'DEFINE FIELD name ON TABLE user TYPE string;',
    'DEFINE INDEX name_idx ON TABLE user COLUMNS name UNIQUE;',
  ]

def down() -> list[str]:
  # Reverse in opposite order
  return [
    'REMOVE INDEX name_idx ON TABLE user;',  # Remove index first
    'REMOVE FIELD name ON TABLE user;',       # Then field
    'REMOVE TABLE user;',                     # Finally table
  ]

Handling Data Migrations

For migrations that modify data:

def up() -> list[str]:
  return [
    # Add new field with default
    'DEFINE FIELD status ON TABLE user TYPE string DEFAULT "active";',
    # Migrate existing data
    'UPDATE user SET status = "active" WHERE status = NONE;',
  ]

def down() -> list[str]:
  return [
    'REMOVE FIELD status ON TABLE user;',
  ]

Non-Reversible Migrations

Some operations can't be reversed (e.g., dropping data):

def up() -> list[str]:
  return [
    'DELETE user WHERE created_at < time::now() - 1y;',
  ]

def down() -> list[str]:
  # Cannot restore deleted data
  return [
    '-- WARNING: This migration is not reversible',
    '-- Deleted data cannot be restored',
  ]

Best Practices

1. One Purpose Per Migration

# Good - Focused migrations
surql migrate create "Create user table"
surql migrate create "Create post table"
surql migrate create "Add user indexes"

# Avoid - Multiple unrelated changes
surql migrate create "Create all tables and indexes"

2. Test Migrations Locally

# Apply migration
surql migrate up

# Verify it works
surql schema show user

# Test rollback
surql migrate down

# Verify rollback worked
surql schema show user  # Should not exist

3. Use Transactions for Safety

Migrations are executed in transactions automatically. If any statement fails, all changes are rolled back.

4. Include Descriptive Comments

def up() -> list[str]:
  return [
    # Create user table with authentication fields
    'DEFINE TABLE user SCHEMAFULL;',

    # Username: 3-20 characters, alphanumeric
    'DEFINE FIELD username ON TABLE user TYPE string ASSERT string::len($value) >= 3;',

    # Email: must be valid email format
    'DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);',
  ]

5. Version Schema Changes

# Add schema version field
def up() -> list[str]:
  return [
    'DEFINE FIELD schema_version ON TABLE user TYPE int DEFAULT 2;',
    'UPDATE user SET schema_version = 2;',
  ]

6. Preserve Data During Schema Changes

def up() -> list[str]:
  return [
    # Create temporary field
    'DEFINE FIELD email_temp ON TABLE user TYPE string;',

    # Copy data
    'UPDATE user SET email_temp = email;',

    # Remove old field
    'REMOVE FIELD email ON TABLE user;',

    # Create new field with validation
    'DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is::email($value);',

    # Restore data
    'UPDATE user SET email = email_temp;',

    # Clean up
    'REMOVE FIELD email_temp ON TABLE user;',
  ]

7. Document Breaking Changes

"""
Migration: Rename 'name' to 'full_name'

BREAKING CHANGE: Applications using field 'name' must be updated
to use 'full_name' before applying this migration.

Update code:
  OLD: user.name
  NEW: user.full_name
"""

def up() -> list[str]:
  return [
    'DEFINE FIELD full_name ON TABLE user TYPE string;',
    'UPDATE user SET full_name = name;',
    'REMOVE FIELD name ON TABLE user;',
  ]

8. Validate Migration Files

# Validate before committing
surql migrate validate

9. Use Dependencies

metadata = {
  'version': '20260102_130000',
  'description': 'Add user foreign key to posts',
  'depends_on': ['20260102_120000'],  # Requires user table
}

10. Keep Migration Files in Version Control

# Add migrations to git
git add migrations/
git commit -m "Add user table migration"

Advanced Topics

Conditional Migrations

def up() -> list[str]:
  """Migrate only if table doesn't exist."""
  statements = []

  # Check if table exists
  statements.append('''
    IF (SELECT * FROM information_schema.tables WHERE table_name = 'user') = [] THEN
      DEFINE TABLE user SCHEMAFULL;
      DEFINE FIELD name ON TABLE user TYPE string;
    END;
  ''')

  return statements

Data Backups

def up() -> list[str]:
  return [
    # Create backup table
    'DEFINE TABLE user_backup SCHEMAFULL;',

    # Copy data
    'INSERT INTO user_backup (SELECT * FROM user);',

    # Perform migration
    'REMOVE FIELD old_field ON TABLE user;',
    'DEFINE FIELD new_field ON TABLE user TYPE string;',
  ]

def down() -> list[str]:
  return [
    # Restore from backup
    'DELETE user;',
    'INSERT INTO user (SELECT * FROM user_backup);',

    # Remove backup
    'REMOVE TABLE user_backup;',
  ]

Large Data Migrations

For large datasets, use batch processing:

def up() -> list[str]:
  """Migrate data in batches."""
  return [
    # Add new field
    'DEFINE FIELD computed_field ON TABLE user TYPE string;',

    # Process in batches (SurrealDB handles this efficiently)
    '''
    UPDATE user SET
      computed_field = string::concat(name.first, " ", name.last)
    WHERE computed_field IS NONE
    ''',
  ]

Migration Dependencies

# Migration 1: Create user table
metadata = {
  'version': '20260102_120000',
  'description': 'Create user table',
  'depends_on': [],
}

# Migration 2: Create post table (depends on user)
metadata = {
  'version': '20260102_130000',
  'description': 'Create post table',
  'depends_on': ['20260102_120000'],  # Requires user table
}

Custom Migration Scripts

# migrations/20260102_140000_custom_script.py

async def up_async(client):
  """Async migration with custom logic."""
  # Complex data transformation
  users = await client.execute('SELECT * FROM user')

  for user in users:
    # Custom processing
    processed = custom_transform(user)
    await client.update(user['id'], processed)

def up() -> list[str]:
  """Use this for async migrations."""
  return []  # Handled by up_async

def down() -> list[str]:
  return []

Auto-Generation from Schema

# Future feature - not yet implemented
from schemas.user import user_schema
from surql.migration.generator import generate_migration

# Generate migration from schema
migration = generate_migration(
  user_schema,
  description='Update user schema',
)

Troubleshooting

Migration Fails Mid-Execution

Migrations run in transactions. If a statement fails, all changes are rolled back:

# Check what went wrong
surql migrate status --verbose

# Fix the migration file
# Then try again
surql migrate up

Migration Already Applied

# If you need to re-run a migration:
# 1. Rollback first
surql migrate down

# 2. Re-apply
surql migrate up

Duplicate Version Numbers

# Check for duplicates
surql migrate validate

# Rename file with new timestamp
mv migrations/20260102_120000_old.py \
   migrations/20260102_120100_old.py

Corrupted Migration History

# Manually check history
from surql.connection.client import get_client
from surql.settings import get_db_config

async def check_history():
  async with get_client(get_db_config()) as client:
    result = await client.execute('SELECT * FROM _migration_history')
    print(result)

Schema Versioning and Rollback

surql provides advanced versioning and rollback capabilities for managing schema evolution safely.

Version Tracking

Track schema state at each migration:

from surql.migration.versioning import create_snapshot, store_snapshot

async def track_schema_version():
  async with get_client(config) as client:
    # Create snapshot after migration
    snapshot = await create_snapshot(
      client=client,
      version='20260109_120000',
      migration_count=5,
    )

    # Store for later comparison
    await store_snapshot(client, snapshot)

Safe Rollbacks

Plan and execute rollbacks with safety analysis:

from surql.migration.rollback import create_rollback_plan, execute_rollback

async def safe_rollback():
  async with get_client(config) as client:
    # Create rollback plan
    plan = await create_rollback_plan(
      client=client,
      migrations=all_migrations,
      target_version='20260108_120000',
    )

    # Check safety level
    print(f'Safety: {plan.overall_safety.value}')

    # Review issues
    for issue in plan.issues:
      print(f'{issue.safety.value}: {issue.description}')

    # Execute if safe
    if plan.is_safe:
      result = await execute_rollback(client, plan)

CLI Commands

# Create snapshot
surql migrate snapshot

# List snapshots
surql migrate list-snapshots

# Plan rollback
surql migrate plan-rollback 20260108_120000

# Execute rollback
surql migrate rollback 20260108_120000

# Compare snapshots
surql migrate compare 20260108_120000 20260109_120000

For comprehensive versioning and rollback documentation, see: - Schema Versioning and Rollback Guide

Next Steps