What is the best way to manage database schema migrations in 2026?
Since this sort of thing is getting easier with AI tooling, I spent some time doing a survey across a bunch of recognizable multi-contributor open source projects to see how they do database schema change management.

Biggest takeaway: the framework provided by your programming language is the most common pattern. After that seems to be custom project-specific code. Even while Pramod Sadalage and Martin Fowler’s twenty-year-old general evolutionary pattern is followed, I was surprised to see very few occurrences of the specific tools they listed in their 2016 article about Evolutionary Database Design. Those tools might be used behind some corporate firewalls, but they aren’t showing up in collaborative open source projects.
Second takeaway: it should be obvious that we still have schema migrations with document databases and distributed NoSQL databases; but lots of interesting illustrations here of what it looks like in practice to deal with document models and NoSQL schemas as they change over time. My recent comment on an Adam Jacob LinkedIn post:“life is great as long as changing your schema can remain avoidable (ie. requiring some kind of migration).”
What about the method of triggering the schema migrations? The most common pattern is that the application process itself triggers schema migration. After that we have kubernetes jobs.
The rest of this blog post is the supporting data I generated with some AI tooling. I made sure to include links to source code, for verifying accuracy. I spot checked a few and they were all accurate – but I didn’t go through every single project.
If you spot errors, please let me know!! I’ll update the blog.
A survey of how major open-source projects handle database schema migrations. Each project includes a real code example and how migrations are triggered during upgrades.
Kubernetes Migration Trigger Methods
Projects with no official Helm chart or k8s support (Mastodon, Discourse, Sentry, Zulip, NetBox, Metabase, Lemmy, MediaWiki, Matrix Synapse†, CHT Core, Signal Server, Firefox, Chromium, Signal Desktop, FDB Record Layer, RxDB) are omitted.
| Trigger Method | Projects |
|---|---|
| Dedicated k8s Job (Helm hook) | GitLab (post-deploy), Airflow (post-install/upgrade), Superset (post-install/upgrade), Temporal (pre-deploy), Kong (pre-install), Jaeger (pre-deploy), ThingsBoard (install only; upgrades require a separate manual pod) |
| Init container in pod spec | Gitea (official chart runs gitea migrate in init container before main container starts) |
| App process migrates on pod startup | Ghost, Backstage, Keycloak, Grafana, Mattermost, Odoo, Parse Server, Appsmith, Rocket.Chat, Graylog |
| Triggered by action against running process | WordPress (first admin HTTP request), Kubernetes (StorageVersionMigration CRD triggers in-cluster controller), Dgraph (POST /admin API call; async index rebuild) |
| Manual operator action | Calico (calico-upgrade CLI), Neo4j-Migrations (neo4j-migrations migrate CLI), Nextcloud (occ upgrade via exec or Job), Zipkin (SQL DDL applied before deploy), APISIX (no tooling; manual etcd data transformation) |
| No migration needed | Cortex (schema versioned in YAML config; new period appended and deployed, old data untouched) |
† Matrix Synapse has no official Helm chart from Element; the widely-used community chart (ananace/matrix-synapse) relies on in-process startup migration.
Part 1: Relational
1A. External Frameworks
| Projects | Language | Framework | Trigger |
|---|---|---|---|
| GitLab, Mastodon, Discourse | Ruby | Rails ActiveRecord | GitLab: dedicated k8s Job (Helm). Mastodon: manual two-phase CLI; no official Helm chart. Discourse: launcher script runs rake db:migrate during rebuild; no official Helm chart. |
| Sentry, Zulip, NetBox | Python | Django Migrations | Sentry: sentry upgrade CLI (acquires distributed lock; post-deployment migrations must be run separately); official self-hosted is docker-compose only, no official Helm chart. Zulip: scripts/upgrade-zulip script; no official Helm chart, typically deployed on VMs. NetBox: container entrypoint script runs manage.py migrate on container start (netbox-docker); no official Helm chart. |
| Airflow, Superset | Python | Alembic | Both: dedicated k8s Job as Helm post-install/post-upgrade hook. |
| Ghost, Backstage | JavaScript, TypeScript | Knex.js | Both: app code calls migration runner on startup. Both have official Helm charts (Bitnami for Ghost, backstage/charts for Backstage); migrations run in-process at pod startup, no separate job. |
| Keycloak, Metabase | Java, Clojure | Liquibase | Both: app code calls Liquibase on startup. Keycloak: DefaultJpaConnectionProviderFactory; official Helm chart (Bitnami) and k8s Operator exist, auto-migrates at pod startup. Metabase: setup-db! (custom Clojure macros wrap Liquibase changesets); no official Helm chart. |
| Lemmy | Rust | Diesel | App code calls run_pending_migrations() on startup (before pool is returned). No official Helm chart; typically deployed via docker-compose. |
| Gitea | Go | XORM | Official Helm chart exists; init container explicitly runs gitea migrate before the main container starts (not relying on auto-migration). AUTO_MIGRATION=false can disable the in-process fallback. |
| Nextcloud | PHP | Doctrine DBAL | occ upgrade CLI or web-based updater; not automatic. Official Helm chart exists (nextcloud/helm); init containers only wait for DB readiness. occ upgrade must be run manually (e.g., exec into pod). |
1B. Custom Systems
| Projects | Language | Approach | Trigger |
|---|---|---|---|
| Grafana, Mattermost | Go | Custom Go | Both: app code calls migration runner on startup. Both have official Helm charts (grafana-community/helm-charts, mattermost/mattermost-helm); migrations run in-process at pod startup, no separate job. Mattermost also has an offline mattermost db migrate CLI with --dry-run. |
| WordPress, MediaWiki | PHP | Custom PHP | WordPress: app code runs on first admin page HTTP request after update; Bitnami Helm chart exists, auto-migration works in k8s. MediaWiki: manual php maintenance/update.php; no official Helm chart (Wikimedia uses an internal helmfile). |
| Odoo, Parse Server | Python, JavaScript | Declarative + scripts | Odoo: odoo -u <module> CLI; Bitnami Helm chart exists, migration triggered at pod startup via env var. Parse Server: app code runs schema reconciliation on startup; Bitnami chart available, auto-migrates at pod startup. |
| Matrix Synapse | Python | Custom Python | App code applies delta scripts on startup (main process only; worker processes refuse to start if schema is behind). No official Helm chart from Element; the widely-used community chart (ananace/matrix-synapse) also relies on in-process startup migration. |
| Temporal, Kong | Go, Lua | Custom multi-DB | Both: dedicated CLI tool (temporal-sql-tool update-schema, kong migrations up) + dedicated k8s Job in Helm chart. |
| Zipkin, ThingsBoard | Java | Custom multi-DB | Zipkin: manual SQL file application before starting the server; Bitnami Helm chart exists but provides no migration automation. ThingsBoard: official Helm chart with a dedicated initializedb k8s Job for fresh installs; upgrades require running a separate pod with UPGRADE_TB=true. |
| Firefox, Chromium, Signal Desktop | C++, C++, TypeScript | Desktop SQLite | App code runs sequential version chain on startup. Desktop applications; Kubernetes not applicable. |
Part 2: Non-Relational Only
2A. External Frameworks
| Projects | Language | Framework | Trigger |
|---|---|---|---|
| Appsmith (server-side) | Java | Mongock (MongoDB) | App code runs migrations on startup via Spring Boot auto-configuration (MongockInitializingBeanRunner). Official Helm chart exists; init containers only wait for dependencies (MongoDB, Redis) to be ready — migrations run in the main application process. |
| FDB Record Layer | Java | (framework itself, powers iCloud) | Programmatic — FDBRecordStore.open() checks stored metadata version on each store open. A library, not a deployable service; Kubernetes not applicable. |
| Neo4j-Migrations | Java | (tool itself) | neo4j-migrations migrate CLI, or app code on startup via Spring Boot InitializingBean. Neo4j has an official Helm chart (neo4j/helm-charts); this tool is not bundled in it and must be run separately (e.g., as a k8s Job). |
2B. Custom Systems
| Projects | Language | Approach | Trigger |
|---|---|---|---|
| Kubernetes, Calico, Vitess, APISIX | Go, Go, Go, Lua | etcd / protobuf | K8s: StorageVersionMigration CRD triggers an in-cluster controller. Calico: operator-run calico-upgrade CLI tool. Vitess: topology schema evolves via additive protobuf field changes — no migration tooling needed. APISIX: fully manual, no tooling provided. |
| Jaeger | Go | Cassandra CQL | Dedicated k8s Job using jaeger-cassandra-schema Docker image, run before deploying Jaeger. |
| Rocket.Chat, Appsmith (DSL), Graylog | TypeScript, TypeScript, Java | MongoDB | Rocket.Chat/Graylog: app code runs migrations on startup; both have official Helm charts (RocketChat/helm-charts, Graylog2/graylog-helm), migrations run in-process at pod startup. Appsmith DSL: browser-side on every page load (migrations never written back to server); Kubernetes not applicable. |
| RxDB, CHT Core | TypeScript, JavaScript | CouchDB / offline-first | RxDB: client-side library; runs migrations per-device when a collection is opened; Kubernetes not applicable. CHT: app-level migrations run as part of API server startup; cluster-level migration requires a separate manual Docker tool; no official k8s support, deployed via docker-compose. |
| Cortex, Signal Server | Go, Java | DynamoDB | Cortex: time-partitioned schema config — no data rewrite, old tables coexist; official Helm chart exists (cortex-helm-chart), schema changes are config-file updates with no migration job. Signal Server: no explicit migration; new fields written into JSON blob on next update; tables provisioned via IaC; not publicly self-hosted, no Helm chart. |
| Dgraph | Go | Graph DB | POST /admin endpoint or dgraph live --schema; async background goroutine reindexes affected predicates. Official Helm chart exists (dgraph-io/charts); schema updates are pushed to running pods via Admin API. |
1A. Relational Database Support: Using External Migration Frameworks
Rails ActiveRecord Migrations
GitLab
Background migrations, batched migrations, migration helpers, extensive written policies for safe migrations at scale. Uses a custom Gitlab::Database::Migration base class rather than stock ActiveRecord.
Trigger: Dedicated Kubernetes Job in the official Helm chart (charts/gitlab/charts/migrations/). The job runs /scripts/db-migrate and must complete before web/Sidekiq pods roll out. Migrations never run automatically on app startup. (Helm chart)
Example — creating a partitioned table with sparse indexes (source):
class CreateWorkItemTransitions < Gitlab::Database::Migration[2.3]
milestone '18.3'
def up
create_table :work_item_transitions, id: false do |t|
t.bigint :work_item_id, primary_key: true, default: nil
t.bigint :namespace_id, null: false
t.bigint :moved_to_id, null: true
t.index :moved_to_id, where: 'moved_to_id IS NOT NULL',
name: 'index_work_item_transitions_on_moved_to_id'
end
end
end
Mastodon
Federated: thousands of independently-upgraded instances, so migrations must be safe across version skew.
Trigger: NOT automatic. Admins run migrations manually in two phases — SKIP_POST_DEPLOYMENT_MIGRATIONS=true rails db:migrate (before restart) then rails db:migrate (after). No official Helm chart.
Example — data migration translating theme settings to new key/value pairs (source):
class MigrateUserTheme < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
class User < ApplicationRecord; end
def up
User.where.not(settings: nil).find_each do |user|
settings = JSON.parse(user.attributes_before_type_cast['settings'])
case settings['theme']
when 'default'
settings['web.color_scheme'] = 'dark'
when 'mastodon-light'
settings['web.color_scheme'] = 'light'
end
user.update_column('settings', JSON.generate(settings))
end
end
end
Discourse
Mature self-hosted forum with a disciplined migration history over 10+ years.
Trigger: discourse_docker launcher runs bundle exec rake db:migrate during ./launcher rebuild app. Auto-migration on every boot is off by default, controlled by MIGRATE_ON_BOOT env var.
Example — DDL + data backfill in one migration (source):
class CreateCategoryApprovalGroups < ActiveRecord::Migration[8.0]
def up
create_table :category_posting_review_groups do |t|
t.integer :post_type, null: false
t.integer :category_id, null: false
t.integer :group_id, null: false
t.timestamps null: false
end
# Backfill from existing category_settings
execute(<<~SQL)
INSERT INTO category_posting_review_groups (post_type, permission, category_id, group_id, created_at, updated_at)
SELECT 0, 1, cs.category_id, 0, NOW(), NOW()
FROM category_settings cs WHERE cs.require_topic_approval = true
SQL
end
end
Django Migrations
Sentry
Operates at massive scale; has written extensively about the pain of running Django migrations on huge tables.
Trigger: sentry upgrade CLI (called via docker compose run --rm web upgrade in self-hosted). Acquires a distributed lock. Migrations marked is_post_deployment = True are skipped and must be run manually in a separate step.
Example — post-deployment data backfill with Redis progress checkpointing (source):
class Migration(CheckedMigration):
is_post_deployment = True # won't auto-run during deploy
operations = [
migrations.RunPython(
backfill_group_open_periods,
migrations.RunPython.noop,
hints={"tables": ["sentry_groupopenperiod"]},
),
]
Zulip
Well-engineered chat server, known for thoughtful code quality and contributor documentation.
Trigger: scripts/upgrade-zulip → upgrade-zulip-stage-3 stops the server, runs manage.py migrate --noinput, then restarts. Not automatic on startup.
Example — data-repair migration using raw SQL lateral join across JSONB audit log (source):
class Migration(migrations.Migration):
atomic = False # outside a transaction for large repair work
operations = [
migrations.RunPython(
recreate_missing_realmemoji,
elidable=True,
),
]
NetBox
Network infrastructure management tool widely used by network teams. Large plugin ecosystem extends the schema.
Trigger: Auto on container startup. netbox-docker entrypoint checks manage.py migrate --check and runs manage.py migrate --no-input if unapplied migrations exist.
Example — AddField + RunPython backfill + RemoveField in one migration (source):
operations = [
migrations.AddField(
model_name='vminterface', name='primary_mac_address',
field=models.OneToOneField(null=True, on_delete=SET_NULL, to='dcim.macaddress'),
),
migrations.RunPython(code=populate_mac_addresses, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(model_name='vminterface', name='mac_address'),
]
Alembic (SQLAlchemy)
Airflow
Apache project, widely deployed in very different operator environments.
Trigger: Dedicated Kubernetes Job (migrate-database-job.yaml) as a Helm post-install/post-upgrade hook running airflow db migrate. All other pods have a wait-for-airflow-migrations init container that blocks until the job completes. (Helm chart)
Example (source):
revision = "53ff648b8a26"
down_revision = "a5a3e5eb9b8d"
def upgrade():
op.create_table(
"revoked_token",
sa.Column("jti", sa.String(32), primary_key=True, nullable=False),
sa.Column("exp", UtcDateTime, nullable=False, index=True),
)
def downgrade():
op.drop_table("revoked_token")
Apache Superset
Apache data visualization platform, large contributor base.
Trigger: Dedicated Kubernetes Job (init-job.yaml) as a Helm post-install/post-upgrade hook running superset db upgrade. (Helm chart)
Example (source):
def upgrade():
op.add_column("dbs", sa.Column("password", sa.LargeBinary(), nullable=True))
def downgrade():
op.drop_column("dbs", "password")
Knex.js Migrations
Ghost
Popular blogging platform. Uses knex-migrator (a wrapper around Knex).
Trigger: Auto on every startup. boot.js → DatabaseStateManager checks knexMigrator.isDatabaseOK() and calls knexMigrator.migrate() if needed. (source)
Example (source):
const {addTable} = require('../../utils');
module.exports = addTable('members_created_events', {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
created_at: {type: 'dateTime', nullable: false},
member_id: {type: 'string', maxlength: 24, nullable: false,
references: 'members.id', cascadeDelete: true},
attribution_id: {type: 'string', maxlength: 24, nullable: true},
source: {type: 'string', maxlength: 50, nullable: false}
});
Backstage
Spotify-created developer portal. Plugin architecture means migrations come from many independent teams.
Trigger: Auto on startup. Each plugin’s CatalogBuilder.build() calls applyDatabaseMigrations(dbClient) → knex.migrate.latest() before any database objects are constructed. (source)
Example (source):
exports.up = async function up(knex) {
await knex.schema.createTable('entities_relations', table => {
table.comment('All relations between entities in the catalog');
table.uuid('originating_entity_id')
.references('id').inTable('entities').onDelete('CASCADE').notNullable();
table.string('type').notNullable();
table.string('target_full_name').notNullable();
table.primary(['source_full_name', 'type', 'target_full_name']);
});
};
Liquibase (XML/YAML Changelogs)
Keycloak
Red Hat-backed identity server. Liquibase changelogs declared in XML.
Trigger: Auto on startup. DefaultJpaConnectionProviderFactory calls LiquibaseJpaUpdaterProvider.update() → liquibase.update(). No special Helm/Operator handling — pods auto-migrate. (source)
Example (source):
<changeSet author="keycloak" id="25.0.0-28265-tables">
<addColumn tableName="OFFLINE_USER_SESSION">
<column name="BROKER_SESSION_ID" type="VARCHAR(1024)" />
<column name="VERSION" type="INT" defaultValueNumeric="0" />
</addColumn>
<addColumn tableName="OFFLINE_CLIENT_SESSION">
<column name="VERSION" type="INT" defaultValueNumeric="0" />
</addColumn>
</changeSet>
Metabase
BI tool written in Clojure. Uses Liquibase under the hood with custom Clojure macros (define-migration, define-reversible-migration) layered on top.
Trigger: Auto on startup. setup-db! → run-schema-migrations! → Liquibase’s migrate-up-if-needed!. (source)
Example — custom Clojure migration macro wrapping Liquibase (source):
(define-migration DeleteAbandonmentEmailTask
(custom-migrations.util/with-temp-schedule! [scheduler]
(qs/delete-trigger scheduler
(triggers/key "metabase.task.abandonment-emails.trigger"))
(qs/delete-job scheduler
(jobs/key "metabase.task.abandonment-emails.job"))))
Diesel ORM Migrations (Rust)
Lemmy
Federated Reddit alternative. Diesel generates migration SQL files.
Trigger: Auto on every startup. build_db_pool() calls run_pending_migrations() synchronously before the pool is returned. A standalone lemmy_diesel_utils binary also allows running migrations offline. (source)
Example (source):
CREATE TABLE private_message (
id serial PRIMARY KEY,
creator_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
recipient_id int REFERENCES user_ ON UPDATE CASCADE ON DELETE CASCADE NOT NULL,
content text NOT NULL,
deleted boolean DEFAULT FALSE NOT NULL,
read boolean DEFAULT FALSE NOT NULL,
published timestamp NOT NULL DEFAULT now(),
updated timestamp
);
XORM-Based Migrations (Go)
Gitea
Git hosting platform. Migrations are Go functions using XORM for cross-database compatibility.
Trigger: Auto on startup (unless AUTO_MIGRATION=false in app.ini). InitDBEngine() → Migrate() compares the Version table against ExpectedDBVersion(). (source)
Example — typical pattern: define minimal struct, call SyncWithOptions (source):
func AddExclusiveOrderColumnToLabelTable(x *xorm.Engine) error {
type Label struct {
ExclusiveOrder int `xorm:"DEFAULT 0"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreConstrains: true,
IgnoreIndices: true,
}, new(Label))
return err
}
Doctrine DBAL-Based Migrations (PHP)
Nextcloud
Huge self-hosted user base. Plugin ecosystem means third-party apps also run their own migrations.
Trigger: occ upgrade CLI command or web-based updater. Updater::doUpgrade() instantiates MigrationService and calls migrate(). Not automatic on every page load. (source)
Example — using Doctrine’s schema abstraction to change a column type (source):
class Version34000Date20260318095645 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();
if ($schema->hasTable('jobs')) {
$table = $schema->getTable('jobs');
$argumentColumn = $table->getColumn('argument');
if ($argumentColumn->getType() !== Type::getType(Types::TEXT)) {
$argumentColumn->setType(Type::getType(Types::TEXT));
return $schema;
}
}
return null; // idempotency guard — no change needed
}
}
1B. Relational Database Support: Custom / In-House Migration Systems
Custom Go Migration Systems
Grafana
Must keep migrations working across 3 DB backends (SQLite, PostgreSQL, MySQL). Uses a Go DSL with per-dialect SQL dispatch.
Trigger: Auto on every startup. ProvideService() calls s.Migrate() synchronously — the server won’t start if migration fails. Official Helm chart has no migration job; relies entirely on auto-migration. (source)
Example — per-dialect SQL in one migration (source):
mg.AddMigration("Update uid column values in alert_notification", new(RawSQLMigration).
SQLite("UPDATE alert_notification SET uid=printf('%09d',id) WHERE uid IS NULL;").
Postgres("UPDATE alert_notification SET uid=lpad('' || id::text,9,'0') WHERE uid IS NULL;").
Mysql("UPDATE alert_notification SET uid=lpad(id,9,'0') WHERE uid IS NULL;"))
Mattermost
Enterprise messaging. Switched from a custom Go DSL to morph (their own migration engine) with embedded .up.sql/.down.sql files.
Trigger: Auto on startup by default. sqlstore.New() calls store.migrate(). Also has an offline mattermost db migrate CLI with --dry-run and --save-plan flags for zero-downtime deploys. Helm chart has no migration job. (source)
Example (source):
UPDATE AccessControlPolicies AS p
SET Name = LEFT(p.Name, 128 - LENGTH(' (' || p.ID || ')')) || ' (' || p.ID || ')'
FROM (
SELECT ID, Name, ROW_NUMBER() OVER (PARTITION BY Name ORDER BY CreateAt ASC) AS rn
FROM AccessControlPolicies WHERE Type = 'parent'
) AS dupes
WHERE p.ID = dupes.ID AND dupes.rn > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_accesscontrolpolicies_name_type
ON AccessControlPolicies (Name, Type) WHERE Type = 'parent';
Custom PHP Systems
WordPress
Famously has no migration framework. Uses dbDelta() for schema and version-numbered upgrade functions for data. 40%+ of the web runs on this.
Trigger: Auto on first admin page load after update. wp-admin/admin.php checks get_option('db_version') vs $wp_db_version; if they differ, redirects to upgrade.php which calls each version-specific upgrade_NNN() function.
Example — version-specific data migration (source):
function upgrade_700() {
global $wp_current_db_version, $wpdb;
if ( $wp_current_db_version < 61644 ) {
$wpdb->update(
$wpdb->usermeta,
array( 'meta_value' => 'modern' ),
array( 'meta_key' => 'admin_color', 'meta_value' => 'fresh' )
);
}
}
MediaWiki
Powers Wikipedia. Has its own maintenance script system with separate SQL files per database engine.
Trigger: php maintenance/update.php must be run manually after deploying new code. Wikimedia runs this as a k8s Job in their deployment pipeline via the scap tool. Never auto-runs on web requests.
Example — batch data migration merging a temp table into the main table (source):
protected function doDBUpdates() {
$dbw = $this->getDB( DB_PRIMARY );
if ( !$dbw->tableExists( 'revision_comment_temp', __METHOD__ ) ) {
$this->output( "revision_comment_temp does not exist, nothing to do.\n" );
return true;
}
// batch-copies revcomment_comment_id → rev_comment_id
$dbw->newUpdateQueryBuilder()
->update( 'revision' )
->set( [ 'rev_comment_id' => $row->revcomment_comment_id ] )
->where( [ 'rev_id' => $row->rev_id ] )
->caller( __METHOD__ )->execute();
}
Declarative / ORM-Diffing + Explicit Migration Scripts
Odoo
ERP with 10,000+ modules. ORM handles additive changes declaratively; renames, transforms, and restructuring require explicit pre/post/end migration scripts. Major version upgrades use Odoo SA’s proprietary upgrade service or the community OpenUpgrade project (~120 scripts per major version).
Trigger: odoo -u <module> or -u all. MigrationManager in loading.py discovers migrations/<version>/pre-*.py and post-*.py files via glob and exec_module()s each script’s migrate(cr, version) function.
Example — pre-migrate script changing FK constraints (source):
def migrate(cr, version):
cr.execute("""
SELECT value::int FROM ir_config_parameter WHERE key = 'analytic.project_plan'
""")
[project_plan_id] = cr.fetchone()
cr.execute("SELECT id FROM account_analytic_plan WHERE id != %s AND parent_id IS NULL",
[project_plan_id])
plan_ids = [r[0] for r in cr.fetchall()]
for column in [f"x_plan{id_}_id" for id_ in plan_ids]:
sql.drop_constraint(cr, 'account_analytic_line', f'account_analytic_line_{column}_fkey')
sql.add_foreign_key(cr, 'account_analytic_line', column,
'account_analytic_account', 'id', 'restrict')
Parse Server
Backend-as-a-Service (originally Facebook, 21k stars). No numbered migration scripts — declarative schema reconciliation at startup.
Trigger: Auto on startup. ParseServer.start() adds new DefinedSchemas(schema, config).execute() to startupPromises. Server won’t accept traffic until reconciliation completes (or process.exit(1) in production on failure). (source)
Example — schema reconciliation engine (source):
async executeMigrations() {
await this.createDeleteSession();
const schemaController = await this.config.database.loadSchema();
this.allCloudSchemas = await schemaController.getAllClasses();
await Promise.all(
this.localSchemas.map(async localSchema => this.saveOrUpdate(localSchema))
);
this.checkForMissingSchemas();
await this.enforceCLPForNonProvidedClass();
}
Custom Python (Delta Scripts)
Matrix Synapse
Federated messaging server. Numbered SQL/Python delta scripts. Federation means different homeservers run different versions simultaneously.
Trigger: Auto on startup (main process only). prepare_database() reads schema_version and applies all pending delta scripts. Worker processes refuse to start if schema is unmigrated — only the main process is permitted to apply changes. (source)
Example (source):
CREATE TABLE sliding_sync_connection_lazy_members (
connection_key BIGINT NOT NULL
REFERENCES sliding_sync_connections(connection_key) ON DELETE CASCADE,
room_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_seen_ts BIGINT NOT NULL
);
CREATE UNIQUE INDEX sliding_sync_connection_lazy_members_idx
ON sliding_sync_connection_lazy_members (connection_key, room_id, user_id);
Custom Versioned Scripts (Multi-DB, including non-relational)
Temporal
Workflow orchestration engine. Versioned SQL scripts per database backend.
Trigger: temporal-sql-tool update-schema CLI. In k8s, runs as a dedicated Kubernetes Job in the official Helm chart (charts/temporal/templates/server-job.yaml). (Helm chart)
Example (source):
CREATE TABLE visibility_tasks(
shard_id INTEGER NOT NULL,
task_id BIGINT NOT NULL,
data BYTEA NOT NULL,
data_encoding VARCHAR(16) NOT NULL,
PRIMARY KEY (shard_id, task_id)
);
Kong
Popular API gateway (43k stars). Custom Lua migration framework. Deprecated Cassandra in 2.7 and removed it in 3.4. Now PostgreSQL-only.
Trigger: kong migrations bootstrap (fresh install) / kong migrations up + kong migrations finish (upgrades). In k8s, runs as a dedicated Kubernetes Job in the official Helm chart. (Helm chart)
Example (source):
return {
postgres = {
up = [[
DO $$
BEGIN
ALTER TABLE IF EXISTS ONLY "plugins" ADD "protocols" TEXT[];
EXCEPTION WHEN DUPLICATE_COLUMN THEN
-- Do nothing, accept existing state
END;
$$;
CREATE TABLE IF NOT EXISTS "tags" (
entity_id UUID PRIMARY KEY,
entity_name TEXT,
tags TEXT[]
);
]],
},
}
Zipkin
The original distributed tracing system (17k stars, since 2012). Bundles versioned CQL and SQL schema files.
Trigger: Schema must be applied manually before running Zipkin — mysql < mysql.sql. Zipkin does not auto-apply schema on startup; it introspects existing tables but does not create or alter them. (docs)
Example (source):
CREATE TABLE IF NOT EXISTS zipkin_spans (
`trace_id_high` BIGINT NOT NULL DEFAULT 0,
`trace_id` BIGINT NOT NULL,
`id` BIGINT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`start_ts` BIGINT,
`duration` BIGINT,
PRIMARY KEY (`trace_id_high`, `trace_id`, `id`)
) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8;
ThingsBoard
IoT platform (21k stars). Uses Cassandra for time-series telemetry, PostgreSQL for relational data.
Trigger: upgrade.sh script invokes ThingsboardInstallApplication (a separate Spring Boot entry point, not the normal server) with --fromVersion flag. Docker: docker compose run --rm -e UPGRADE_TB=true. (source)
Example (source):
ALTER TABLE calculated_field
ADD COLUMN IF NOT EXISTS additional_info varchar;
Desktop SQLite Migrations
These projects run migrations on end-user machines — across hundreds of millions of installations, with no DBA watching, no rollback capability, and users who may skip many versions between upgrades.
Firefox
Migrates bookmarks, history, cookies, permissions databases in C++/Rust.
Trigger: Auto on startup. InitSchema() reads GetSchemaVersion() and runs sequential MigrateVNUp() functions inside a transaction. Failure prevents Places from loading.
Example — adding a column and backfilling it (source):
nsresult Database::MigrateV54Up() {
nsCOMPtr<mozIStorageStatement> stmt;
nsresult rv = mMainConn->CreateStatement(
"SELECT expire_ms FROM moz_icons_to_pages"_ns, getter_AddRefs(stmt));
if (NS_FAILED(rv)) {
rv = mMainConn->ExecuteSimpleSQL(
"ALTER TABLE moz_icons_to_pages "
"ADD COLUMN expire_ms INTEGER NOT NULL DEFAULT 0 "_ns);
NS_ENSURE_SUCCESS(rv, rv);
}
rv = mMainConn->ExecuteSimpleSQL(
"UPDATE moz_icons_to_pages SET expire_ms = "
"strftime('%s','now','localtime','start of day','utc') * 1000 "
"WHERE expire_ms = 0 "_ns);
return NS_OK;
}
Chromium
Same problem as Firefox, different implementation. Sequential if (cur_version == N) blocks.
Trigger: Auto on startup. HistoryDatabase::Init() → EnsureCurrentVersion() runs each version block up to the current version (70+). Version too new → INIT_TOO_NEW; migration failure → INIT_FAILURE.
Example (source):
if (cur_version == 15) {
if (!db_.Execute("DROP TABLE starred") || !DropStarredIDFromURLs())
return LogMigrationFailure(15);
++cur_version;
std::ignore = meta_table_.SetVersionNumber(cur_version);
std::ignore = meta_table_.SetCompatibleVersionNumber(
std::min(cur_version, kCompatibleVersionNumber));
}
Signal Desktop
Encrypted SQLite database (SQLCipher). Migrations in TypeScript.
Trigger: Auto on startup. ts/sql/Server.node.ts opens the encrypted DB, then calls updateSchema(db, logger) which iterates SCHEMA_VERSIONS and applies each pending migration in a transaction. Only the primary worker runs migrations. (source)
Example (source):
import type { Database } from '@signalapp/sqlcipher';
export default function updateToSchemaVersion1090(db: Database): void {
db.exec(`
CREATE INDEX reactions_messageId ON reactions (messageId);
CREATE INDEX storyReads_storyId ON storyReads (storyId);
`);
}
2A. No Relational Database Support: Using External Migration Frameworks
Very few non-relational projects use an external migration framework — the ecosystem of reusable tooling is much thinner than in the relational world.
Mongock (MongoDB Migration Framework for Java)
Appsmith (server-side)
Low-code platform. Server-side uses Mongock with @ChangeUnit annotations. Also has a separate client-side DSL migration system (see 2B).
Trigger: Auto on Spring Boot startup. Mongock runs as a MongockInitializingBeanRunner bean, scanning for @ChangeUnit classes and executing them in order. Helm chart has no init container for migrations — they run inside the main app container. (source)
Example — converting a policies array to a keyed policyMap across 22 collections (source):
@ChangeUnit(order = "059", id = "policy-set-to-policy-map")
public class Migration059PolicySetToPolicyMap {
private final ReactiveMongoTemplate mongoTemplate;
@Execution
public void execute() {
Mono.whenDelayError(CE_COLLECTION_NAMES.stream()
.map(c -> executeForCollection(mongoTemplate, c))
.toList())
.block();
}
// Uses ArrayToObject aggregation to transform policies[] → policyMap{}
}
FoundationDB Record Layer (Protobuf Schema Evolution)
FoundationDB Record Layer
Apple’s Java library powering iCloud/CloudKit — billions of independent databases sharing thousands of schemas. SIGMOD 2019 paper.
Trigger: Programmatic. Library consumers call FDBRecordStore.Builder#open() or #checkVersion(). A UserVersionChecker callback compares the stored metadata version in the database header against the current code’s metadata version and decides how to proceed. (source)
Example — adding a field to a record type via MetaDataProtoEditor (source):
public static void addField(@Nonnull RecordMetaDataProto.MetaData.Builder metaDataBuilder,
@Nonnull String recordType,
@Nonnull DescriptorProtos.FieldDescriptorProto field) {
DescriptorProtos.DescriptorProto.Builder messageType =
findMessageTypeByName(metaDataBuilder.getRecordsBuilder(), recordType);
if (messageType == null) {
throw new MetaDataException("Record type " + recordType + " does not exist");
}
messageType.addField(field);
}
And the evolution validator (source):
public void validate(@Nonnull RecordMetaData oldMetaData, @Nonnull RecordMetaData newMetaData) {
if (oldMetaData.getVersion() > newMetaData.getVersion()) {
throw new MetaDataException("new meta-data does not have newer version");
}
validateUnion(oldMetaData.getUnionDescriptor(), newMetaData.getUnionDescriptor());
validateRecordTypes(oldMetaData, newMetaData, getTypeRenames(...));
validateCurrentAndFormerIndexes(oldMetaData, newMetaData, typeRenames);
}
Neo4j-Migrations (Flyway-inspired for Graph DBs)
Neo4j-Migrations
Canonical migration tool for the Neo4j ecosystem. Migrations are Cypher scripts or Java classes.
Trigger: Two paths: (1) neo4j-migrations migrate CLI, (2) Spring Boot auto-configuration — MigrationsInitializer implements InitializingBean and calls migrations.apply(true) in afterPropertiesSet(). (source)
Example — Cypher migration file, Flyway naming convention (source):
MATCH (n:BrokenData) DETACH DELETE n;
The migration runner (source):
private void apply0(List<Migration> migrations) {
MigrationChain chain = this.chainBuilder.buildChain(this.context, migrations);
for (Migration migration : IterableMigrations.of(this.config, migrations, optionalStop)) {
migration.apply(this.context);
recordApplication(chain.getUsername(), previousVersion, migration, executionTime);
}
}
2B. No Relational Database Support: Custom / In-House Migration Systems
Almost every non-relational project has built its own migration infrastructure.
KV Store / Protobuf Schema Evolution (etcd)
Kubernetes
Objects stored as protobufs in etcd. When the storage version for a resource type changes, existing objects need re-encoding.
Trigger: Create a StorageVersionMigration CRD. The kube-storage-version-migrator controller watches for these CRDs and does a paginated no-op PUT on every object, causing the API server to re-serialize in the new storage version. Deployed as a standalone in-cluster controller.
Example — API version conversion function for Deployments (source):
func Convert_v1_Deployment_To_apps_Deployment(in *appsv1.Deployment, out *apps.Deployment, s conversion.Scope) error {
if err := autoConvert_v1_Deployment_To_apps_Deployment(in, out, s); err != nil {
return err
}
// Deprecated rollbackTo field → annotation for roundtrip
if revision := in.Annotations[appsv1.DeprecatedRollbackTo]; revision != "" {
revision64, _ := strconv.ParseInt(revision, 10, 64)
out.Spec.RollbackTo = &apps.RollbackConfig{Revision: revision64}
delete(out.Annotations, appsv1.DeprecatedRollbackTo)
}
return nil
}
Calico
Underwent a major data model overhaul from v2 to v3. Built a dedicated calico-upgrade migration tool.
Trigger: calico-upgrade start CLI. Operator-initiated one-time migration with four phases: dry-run, start (pauses networking, converts all v1 objects to v3), complete, abort. (source)
Example — policy name conversion for etcd storage (source):
func convertPolicyNameForStorage(name string) string {
if strings.HasPrefix(name, "knp.") {
return name // Kubernetes-native policies keep their prefix
}
return "default." + name // Calico policies stored under "default" tier
}
Vitess
CNCF Graduated MySQL clustering system (powers PlanetScale, Slack, GitHub). Stores topology metadata (keyspaces, shards, tablets, routing rules) as proto3 binary blobs in etcd. Schema evolution happens via standard protobuf rules — fields are only added, never removed or reordered — so stored objects remain readable across versions without any migration step. The topo2topo tool exists to copy topology between different backends (e.g., ZooKeeper → etcd) but this is a backend replacement, not a schema migration.
Trigger: No migration tooling needed. The protobuf encoding is forward- and backward-compatible by construction.
Example — protobuf-encoded topology object read from etcd (source):
func CopyKeyspaces(ctx context.Context, fromTS, toTS *topo.Server, parser *sqlparser.Parser) error {
keyspaces, err := fromTS.GetKeyspaces(ctx)
for _, keyspace := range keyspaces {
ki, err := fromTS.GetKeyspace(ctx, keyspace)
if err := toTS.CreateKeyspace(ctx, keyspace, ki.Keyspace); err != nil {
if topo.IsErrType(err, topo.NodeExists) {
log.Warn(fmt.Sprintf("keyspace %v already exists", keyspace))
}
}
}
return nil
}
Apache APISIX
Cloud-native API gateway. All dynamic runtime config (routes, upstreams, plugins, SSL certs) stored in etcd; static node config (listen ports, worker processes) remains in config.yaml on disk. The 2.x → 3.0 upgrade had incompatible etcd data structure changes with no automated migration.
Trigger: Entirely manual. etcdctl snapshot save, then either write custom scripts to transform JSON values in-place, or reconfigure from scratch via the 3.0 Admin API. No migration tooling provided. (docs)
Example — the breaking disable field relocation:
// 2.15.x — "disable" is top-level in each plugin
{ "plugins": { "limit-count": { "count": 2, "disable": true } } }
// 3.0.0 — "disable" must be nested under "_meta"
{ "plugins": { "limit-count": { "count": 2, "_meta": { "disable": true } } } }
Cassandra Schema Migrations (Cassandra-only projects)
Jaeger
CNCF Graduated distributed tracing. Versioned CQL templates parameterized by environment variables.
Trigger: create.sh shell script performs variable substitution and pipes CQL to cqlsh. In k8s, runs as a one-time Kubernetes Job using the jaegertracing/jaeger-cassandra-schema Docker image before deploying Jaeger. (k8s manifest)
Example (source):
CREATE TYPE IF NOT EXISTS ${keyspace}.keyvalue (
key text,
value_type text,
value_string text,
value_bool boolean,
value_long bigint,
value_double double,
value_binary blob
);
CREATE TABLE IF NOT EXISTS ${keyspace}.traces (
trace_id blob,
span_id bigint,
span_hash bigint,
operation_name text,
start_time bigint,
duration bigint,
PRIMARY KEY (trace_id, span_id, span_hash)
);
MongoDB Document Migrations
Rocket.Chat
Team chat platform (45k stars). 300+ migrations. Control document tracks version + lock state.
Trigger: Auto on every startup. xrun.ts calls performMigrationProcedure() → migrateDatabase('latest'). All versioned migration modules (v293–v335) are imported at startup. (source)
Example (source):
import { Settings } from '@rocket.chat/models';
import { addMigration } from '../../lib/migrations';
addMigration({
version: 309,
name: 'Remove unused UI_Click_Direct_Message setting',
async up() {
await Settings.removeById('UI_Click_Direct_Message');
},
});
Appsmith (client-side DSL)
Per-document version stamps. 94 sequential migration functions for widget DSL. Runs in the browser, not the server.
Trigger: On every page load. extractCurrentDSL() calls migrateDSL(currentDSL), which runs every if (version === N) block from the stored version up through 94. The upgraded DSL is never written back — migrations re-execute on every load. (source)
Example — migrating legacy styling enums to CSS tokens (source):
enum ButtonBorderRadiusTypes { SHARP = "SHARP", ROUNDED = "ROUNDED", CIRCLE = "CIRCLE" }
const THEMING_BORDER_RADIUS = { none: "0px", rounded: "0.375rem", circle: "9999px" };
export const migrateStylingPropertiesForTheming = (currentDSL: DSLWidget) => {
// walks every widget, rewrites legacy enum-style borderRadius / boxShadow
// to CSS token strings used by the theming system
};
Graylog
Log management (since 2010). 91 timestamped Java migration classes. Leader-gated.
Trigger: Auto on startup via ServerBootstrap.runMigrations(). Only runs on the leader node (checked via configuration.isLeader()). Three phases: PREFLIGHT, STANDARD, and ENFORCED_ON_ALL_NODES. No separate k8s job. (source)
Example (source):
public class V20190705071400_AddEventIndexSetsMigration extends Migration {
@Override
public ZonedDateTime createdAt() {
return ZonedDateTime.parse("2019-07-05T07:14:00Z");
}
@Override
public void upgrade() {
ensureEventsStreamAndIndexSet("Events",
"Stores events created by event definitions.",
elasticsearchConfiguration.getDefaultEventsIndexPrefix(),
Stream.DEFAULT_EVENTS_STREAM_ID, "All events");
}
}
CouchDB / Offline-First Migrations
RxDB
Reactive JavaScript database for client-side apps. Each collection carries a schema version with migrationStrategies functions.
Trigger: Auto when a collection is opened (if autoMigrate: true, the default). createRxCollection() detects a lower stored schema version and calls migratePromise(). Runs in the browser per-device; awaits leader election in multi-instance databases. (source)
Example — migration strategies and the core iteration loop (source):
// Defining strategies at collection creation
migrationStrategies: {
1: function(oldDoc) {
oldDoc.time = new Date(oldDoc.time).getTime(); // string → unix
return oldDoc;
},
2: function(oldDoc) {
if (oldDoc.time < 1486940585) return null; // deletes document
return oldDoc;
}
}
// Core iteration in migration-helpers.ts
let nextVersion = docSchemaVersion + 1;
while (nextVersion <= collection.schema.version) {
currentPromise = currentPromise.then(docOrNull =>
runStrategyIfNotNull(collection, nextVersion, docOrNull));
nextVersion++;
}
CHT Core (Community Health Toolkit)
CouchDB-based offline-first health apps used by tens of thousands of health workers in dozens of countries.
Trigger: Two-track. App-level migrations (in api/src/migrations/) auto-run on API startup — the server is unavailable (502) until complete. Cluster-level migrations (3.x → 4.x) require manually running the couchdb-migration Docker tool before upgrading. (docs)
Example — removing a field from CouchDB documents via bulkDocs (source):
module.exports = {
name: 'remove-enabled-from-translation-docs',
created: new Date('2025-09-01'),
run: async () => {
const translationDocs = await translations.getTranslationDocs();
translationDocs.forEach(doc => delete doc.enabled);
await db.medic.bulkDocs(translationDocs);
}
};
DynamoDB Schema Evolution
Cortex
CNCF Prometheus long-term storage. Time-partitioned schema versioning — you never migrate old data.
Trigger: No data migration. Append a new PeriodConfig block to the YAML config with a future from: date and new schema: version. At runtime, SchemaForTime(timestamp) selects the correct config for each chunk. Old and new schema tables coexist indefinitely. (original PR)
Example — the schema dispatch function (now maintained in Grafana Loki, same code) (source):
type PeriodConfig struct {
From DayTime `yaml:"from"`
Schema string `yaml:"schema"` // e.g. "v10", "v11"
}
func (cfg SchemaConfig) SchemaForTime(t model.Time) (PeriodConfig, error) {
for i := range cfg.Configs {
if t >= cfg.Configs[i].From.Time &&
(i+1 == len(cfg.Configs) || t < cfg.Configs[i+1].From.Time) {
return cfg.Configs[i], nil
}
}
return PeriodConfig{}, fmt.Errorf("no schema config found for time %v", t)
}
Signal Server
Backend for Signal Private Messenger. Uses DynamoDB as primary store. Schema evolution is implicit — most data lives inside a JSON blob attribute.
Trigger: No schema migration. New fields are added to the Account POJO and written into the D (data) attribute on next update. A per-item V (version) attribute provides optimistic locking. Table/GSI changes are provisioned externally via infrastructure-as-code, not application code. (source)
Example — optimistic locking on DynamoDB writes (source):
static final String ATTR_VERSION = "V";
// Every update atomically increments version and checks the condition
updateExpressionBuilder.append(" ADD #version :version_increment");
return new UpdateAccountSpec(accountTableName,
Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())),
attrNames, attrValues,
updateExpressionBuilder.toString(),
"attribute_exists(#number) AND #version = :version"); // conditional write
Graph Database Schema Evolution
Dgraph
When deploying a new GraphQL schema, Dgraph updates the schema in memory immediately but does not alter existing data — index rebuilds run asynchronously in the background.
Trigger: POST /admin with an updateGQLSchema mutation, or dgraph live --schema. The change propagates to all cluster nodes via Raft. If a predicate’s tokenizer changed, a background goroutine iterates all existing postings in Badger and writes new index entries. (source)
Example — schema mutation with conditional async index rebuild (source):
rebuild := posting.IndexRebuild{
Attr: su.Predicate, StartTs: startTs,
OldSchema: &old, CurrentSchema: su,
}
// Write new schema to memory immediately (queries see it now)
schema.State().Set(su.Predicate, rebuild.GetQuerySchema())
if rebuild.NeedIndexRebuild() {
go buildIndexes(su, rebuild, closer) // async background reindex
} else {
updateSchema(su, rebuild.StartTs) // write to Badger, done
}



Discussion
Trackbacks/Pingbacks
Pingback: 2026年数据库架构迁移调查 - 偏执的码农 - March 26, 2026