Database Migration System#
Overview#
Dify uses Flask-Migrate (an Alembic wrapper) for schema versioning. 199+ migration files live under api/migrations/versions/ and are applied via the flask upgrade-db CLI command. The system targets PostgreSQL, MySQL, and OceanBase through dialect-aware SQLAlchemy type decorators and database-agnostic Alembic scripts .
In multi-instance deployments a Redis-backed distributed lock ensures only one pod runs the migration at a time; all others skip silently .
Startup Sequence & Entry Points#
The container entrypoint script runs flask upgrade-db when MIGRATION_ENABLED=true (the default). If MODE=migration, the container exits immediately after migration completes β useful for one-shot Kubernetes init containers .
| Entry point | When used |
|---|---|
flask upgrade-db | Container startup (API) and manual upgrades |
flask db upgrade | Development server (dev/start-api) |
MODE=migration container | Kubernetes migration init containers |
The upgrade-db command is the canonical path for all production upgrades; flask db upgrade is the lower-level Alembic command and does not use the distributed lock.
Redis Distributed Lock (DbMigrationAutoRenewLock)#
api/libs/db_migration_lock.py implements the lock. The upgrade-db command acquires it non-blocking (blocking=False); if another pod holds it, migration is skipped without error .
Key design details:
| Detail | Value / Behavior |
|---|---|
| Redis key | db_upgrade_lock |
| Lock TTL | 60 s (DB_UPGRADE_LOCK_TTL_SECONDS) |
| Heartbeat interval | TTL / 3 β 20 s |
thread_local=False | Heartbeat thread must call reacquire() with the main-thread token |
LockNotOwnedError | Stops heartbeat loop permanently |
RedisError | Logged at WARNING, heartbeat retries |
release_safely() | Never raises β lock errors don't mask migration failures |
| Stale lock on pod death | Expires naturally within 60 s; no manual cleanup needed |
The heartbeat runs on a daemon thread (_heartbeat_loop) that calls lock.reacquire() every ~20 s, keeping the lock alive for long-running DDL/DML operations. On release_safely(), the stop event is set and the thread is joined with a bounded timeout .
Warning:
DbMigrationAutoRenewLockis intentionally migration-only. Do not use it as a general-purpose lock primitive .
Migration File Conventions#
Migration files follow a timestamped naming scheme configured in api/migrations/alembic.ini:
YYYY_MM_DD_HHMM-{revhash}_{slug}.py
Examples: 2026_04_15_1726-227822d22895_add_workflow_comments_table.py, 2024_11_12_0925-01d6889832f7_add_created_at_index_for_messages.py . Older files use plain hash-only names (e.g., 64b051264f32_init.py) .
Branches are converged with merge migrations (e.g., 63f9175e515b_merge_branches.py) that list multiple down_revision values and contain empty upgrade()/downgrade() bodies. Alembic tracks the current schema version in the alembic_version table.
Dialect-specific behavior (e.g., GIN indexes on PostgreSQL) is handled by calling adjusted_json_index() inside migration files β it returns None on non-PostgreSQL backends so no conditional guards are needed in the migration code.
Database-Agnostic Types#
All models and migration scripts use custom TypeDecorator subclasses from api/models/types.py instead of dialect-specific SQLAlchemy types:
| Type | PostgreSQL | MySQL / OceanBase | Source |
|---|---|---|---|
StringUUID | Native UUID | CHAR(36) | |
LongText | TEXT | LONGTEXT | |
BinaryData | BYTEA | LONGBLOB | |
AdjustedJSON | JSONB | JSON | |
EnumText | VARCHAR(n) | VARCHAR(n) |
PR #28787 removed if conn.dialect.name == "postgresql" guards from 36 migration files, replacing per-dialect column definitions with these shared types. PR #28188 introduced the initial MySQL/OceanBase support, adding DB_TYPE configuration and a dialect-aware date-conversion helper.
The adjusted_json_index() helper returns a PostgreSQL GIN index when DB_TYPE=postgresql and None otherwise, so migration files don't need if dialect branches for index creation.
Application-Level UUID Generation#
UUID primary keys are generated in Python using default=lambda: str(uuid4()) in SQLAlchemy model column definitions β not by the database. This avoids reliance on uuid_generate_v4(), a PostgreSQL extension unavailable on MySQL and OceanBase .
The established pattern is used by most models (e.g., App, InstalledApp). Migration files are not retroactively updated when a model is refactored to remove a server_default; they reflect schema state at creation time only.
Key Source Files#
| File | Purpose |
|---|---|
api/libs/db_migration_lock.py | Redis distributed lock with heartbeat renewal |
api/commands/system.py | flask upgrade-db command implementation |
api/docker/entrypoint.sh | Container startup β MIGRATION_ENABLED check |
api/models/types.py | Dialect-agnostic SQLAlchemy type decorators |
api/migrations/versions/ | All migration revision files |
api/migrations/alembic.ini | Alembic config (file template, logging) |