# 8. Concurrent startup and locking [← 7. Why there is no undo](07-why-there-is-no-undo.md) · [Index](../README.md) · Next: [9. Liquibase: anatomy of an update →](09-liquibase-anatomy-of-an-update.md) A rolling deploy starts several instances of the same application at close to the same moment, all pointed at the same database, all carrying the same migrations. If two of them both decide "I need to run V1" at once, what stops the second from either duplicating work or corrupting the history table? [`FlywayConcurrentMigrateTest`](../src/test/java/com/ankurm/dbmigrations/flyway/FlywayConcurrentMigrateTest.java) answers this empirically rather than by reading about it: it starts two Flyway instances on two real threads, pointed at the same H2 file (opened with `AUTO_SERVER=TRUE` so genuinely separate JDBC connections can share one file), both migrating a deliberately slow, 800ms Java-based migration ([`V1__SlowMigration.java`](../src/test/java/com/ankurm/dbmigrations/flyway/V1__SlowMigration.java)). ``` instance A migrate() took 905ms instance B migrate() took 911ms wall-clock time for both, run concurrently: 915ms ``` ``` flyway_schema_history: installed_rank | version | description | success -1 | NULL | ... | true 1 | 1 | SlowMigration| true ``` (from [`docs/output/08-flyway-concurrent-lock.txt`](output/08-flyway-concurrent-lock.txt)) The migration ran exactly once — the test asserts that directly by counting rows matching `SlowMigration`. What's more interesting is the timing: both instances took **almost the same, almost-full 800+ms**, and the wall clock for both together is close to *that one duration*, not their sum. That shape is the real proof of what's happening underneath: Flyway takes a row-level lock on its own schema history table before checking what needs to run. The winner holds it for the whole migration; the loser blocks on that same lock for nearly the full window, then — once it finally acquires it — finds the migration already recorded and returns almost immediately. No external coordinator, no separate lock table: the schema history table *is* the lock. instance A holds lock — runs migration (≈800ms) instance B blocked, waiting for the same row lock lock acquired → nothing to do → returns Both calls "took" roughly 800-900ms — the loser's time is almost entirely the wait, not the (nonexistent, for it) work. Wall clock ≈ one migration's length, not the sum of both. ## Going deeper - **A naive assertion here would compare wall-clock time against the sum of both individual durations**, expecting serialization to look like "one after the other, end to end". That's the wrong model for two threads submitted at the same instant with one blocking on the other's lock — the right check is that *both* individual durations are long, proving the loser genuinely waited rather than racing ahead. This module's test was rewritten once to fix exactly that reasoning error. - [Flyway's locking strategy](https://documentation.red-gate.com/flyway/flyway-cli-and-api/concepts/migrations#concurrent-migration) (`rel="nofollow"`) documents the row-lock approach and which databases support it natively versus via a fallback. - Liquibase solves the same problem with a dedicated, separate lock table rather than a row lock on the history table itself — chapter [11](11-liquibase-locking.md) measures how differently that behaves under contention.