Summary
drizzle-kit migrate (via PgDialect.migrate in drizzle-orm/pg-core/dialect.js, used by every Postgres driver — neon-serverless, neon-http, node-postgres, postgres-js, vercel-postgres) decides whether a journal entry has already been applied by selecting the row with the highest created_at from drizzle.__drizzle_migrations and skipping any pending journal entry whose folderMillis (i.e. _journal.json when) is ≤ that value. The skip is silent — no log, no warning, no non-zero exit. The CLI still prints [✓] migrations applied successfully! and exits 0.
This is a correctness bug in any case where a row in __drizzle_migrations has a created_at that doesn't come from drizzle-kit's own happy-path insert. That includes:
- The official "init from an existing schema" workflow.
- Any manual repair (e.g. recovering from
_journal.json ↔ table drift via INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (…, extract(epoch from now())*1000);, a pattern documented across multiple ops runbooks and Stack Overflow answers).
- Any project that bootstraps
__drizzle_migrations from an existing production schema before adopting drizzle-kit.
In all of these, created_at is wall-clock-derived rather than journal-derived, and the next drizzle-kit migrate invocation will silent-skip every pending migration whose _journal.json when is earlier than that wall-clock value — including migrations the operator has just generated and very much intends to apply.
Source
drizzle-orm/src/pg-core/dialect.ts (compiled to drizzle-orm/pg-core/dialect.js:56-71 in published builds), PgDialect.migrate. The relevant predicate is roughly:
const lastDbMigration = await tx.execute<{ id: number; hash: string; created_at: string }>(
sql`select id, hash, created_at from ${migrationsTable} order by created_at desc limit 1`,
);
// ...
if (!lastDbMigration[0] || Number(lastDbMigration[0].created_at) < migration.folderMillis) {
// apply migration
}
The single-row MAX(created_at) watermark is the root cause: a single poisoned created_at masks every later journal entry whose when ≤ that value, even if no row with that entry's hash exists in the table.
Repro (any Postgres-backed app using drizzle-kit migrate)
- Bootstrap a fresh Postgres database (any of the supported drivers).
- Generate two drizzle migrations a few minutes apart so
_journal.json has two entries with distinct when values, e.g. 1700000000000 and 1700000060000.
- Apply only the first migration via
drizzle-kit migrate (or psql + INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('<hash-of-0001>', 1700000000000);). Verify the table has one row with created_at = 1700000000000.
- Now insert a poisoned-watermark row to simulate the manual-repair / seed-from-existing pattern:
INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('manual-repair-marker', 9999999999999); (or use extract(epoch from now())*1000 for any wall-clock value > the second journal entry's when).
- Generate a third migration, e.g.
_journal.json when = 1700000120000.
- Run
drizzle-kit migrate.
Expected: the third migration applies, OR drizzle-kit errors out citing the poisoned row.
Actual: the third migration is silently skipped. CLI prints [✓] migrations applied successfully!. Exit 0. The third migration's hash is never inserted into __drizzle_migrations.
A standalone reproducer (Node + pg against a local Postgres) that toggles a single row to flip the outcome can be provided on request.
Why this matters
The silent-skip is hard to detect in CI because:
- The exit code is
0.
- No log line names the skipped migration.
- The next
drizzle-kit migrate call (e.g. on the next deploy) still exits 0.
- The schema drift only surfaces when a query at runtime hits a column or table the skipped migration was supposed to add — usually in production, not in CI.
Several real-world ops runbooks for "drizzle migration journal drift recovery" recommend the manual INSERT … (hash, created_at) VALUES (…, now()) pattern. Those recipes are silently broken under the current watermark logic.
Suggested fixes
Either of these resolves the correctness problem:
-
Apply-by-hash-absence. Replace the MAX(created_at) watermark with EXISTS (SELECT 1 FROM __drizzle_migrations WHERE hash = $1) per pending journal entry. To preserve the cheap single-query startup, fetch all hash values once into a Set<string> and check presence in a hash set. Cost: one extra column projected and a slightly larger result set; the migration table is small in practice.
-
Document the watermark invariant + add a runtime check. Keep the watermark for performance, but on every skipped journal entry, look up EXISTS (SELECT 1 FROM __drizzle_migrations WHERE hash = $expected_hash) and throw (or at minimum console.warn) if the hash isn't present. This preserves the cheap path and surfaces the bug loudly when it triggers.
Option 1 is the cleaner fix because it removes the "manual repair invalidates the watermark" foot-gun entirely. Option 2 is the smaller diff if backwards-compatibility for downstream consumers depending on the current behaviour is a concern.
Environment
drizzle-orm: tested up to current published version on npm (also reviewed main branch source — predicate unchanged).
drizzle-kit: any published version that uses PgDialect.migrate.
- Drivers: reproduces on
neon-serverless, neon-http, node-postgres, postgres-js (all delegate to the same PgDialect.migrate predicate).
Workaround we're using
Until this is fixed upstream, we run a small runner (~80 lines of tsx + pg) that reads drizzle/meta/_journal.json and applies any entry whose hash is NOT present in __drizzle_migrations, regardless of created_at. We'd happily retire it once the upstream predicate is fixed — hence this issue.
Happy to send a minimal reproducer repo or a draft PR if either would speed things along.
Summary
drizzle-kit migrate(viaPgDialect.migrateindrizzle-orm/pg-core/dialect.js, used by every Postgres driver —neon-serverless,neon-http,node-postgres,postgres-js,vercel-postgres) decides whether a journal entry has already been applied by selecting the row with the highestcreated_atfromdrizzle.__drizzle_migrationsand skipping any pending journal entry whosefolderMillis(i.e._journal.jsonwhen) is ≤ that value. The skip is silent — no log, no warning, no non-zero exit. The CLI still prints[✓] migrations applied successfully!and exits0.This is a correctness bug in any case where a row in
__drizzle_migrationshas acreated_atthat doesn't come from drizzle-kit's own happy-path insert. That includes:_journal.json↔ table drift viaINSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (…, extract(epoch from now())*1000);, a pattern documented across multiple ops runbooks and Stack Overflow answers).__drizzle_migrationsfrom an existing production schema before adopting drizzle-kit.In all of these,
created_atis wall-clock-derived rather than journal-derived, and the nextdrizzle-kit migrateinvocation will silent-skip every pending migration whose_journal.jsonwhenis earlier than that wall-clock value — including migrations the operator has just generated and very much intends to apply.Source
drizzle-orm/src/pg-core/dialect.ts(compiled todrizzle-orm/pg-core/dialect.js:56-71in published builds),PgDialect.migrate. The relevant predicate is roughly:The single-row
MAX(created_at)watermark is the root cause: a single poisonedcreated_atmasks every later journal entry whosewhen≤ that value, even if no row with that entry'shashexists in the table.Repro (any Postgres-backed app using
drizzle-kit migrate)_journal.jsonhas two entries with distinctwhenvalues, e.g.1700000000000and1700000060000.drizzle-kit migrate(orpsql+INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('<hash-of-0001>', 1700000000000);). Verify the table has one row withcreated_at = 1700000000000.INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('manual-repair-marker', 9999999999999);(or useextract(epoch from now())*1000for any wall-clock value > the second journal entry'swhen)._journal.jsonwhen = 1700000120000.drizzle-kit migrate.Expected: the third migration applies, OR drizzle-kit errors out citing the poisoned row.
Actual: the third migration is silently skipped. CLI prints
[✓] migrations applied successfully!. Exit0. The third migration's hash is never inserted into__drizzle_migrations.A standalone reproducer (Node +
pgagainst a local Postgres) that toggles a single row to flip the outcome can be provided on request.Why this matters
The silent-skip is hard to detect in CI because:
0.drizzle-kit migratecall (e.g. on the next deploy) still exits0.Several real-world ops runbooks for "drizzle migration journal drift recovery" recommend the manual
INSERT … (hash, created_at) VALUES (…, now())pattern. Those recipes are silently broken under the current watermark logic.Suggested fixes
Either of these resolves the correctness problem:
Apply-by-hash-absence. Replace the
MAX(created_at)watermark withEXISTS (SELECT 1 FROM __drizzle_migrations WHERE hash = $1)per pending journal entry. To preserve the cheap single-query startup, fetch allhashvalues once into aSet<string>and check presence in a hash set. Cost: one extra column projected and a slightly larger result set; the migration table is small in practice.Document the watermark invariant + add a runtime check. Keep the watermark for performance, but on every skipped journal entry, look up
EXISTS (SELECT 1 FROM __drizzle_migrations WHERE hash = $expected_hash)andthrow(or at minimumconsole.warn) if the hash isn't present. This preserves the cheap path and surfaces the bug loudly when it triggers.Option 1 is the cleaner fix because it removes the "manual repair invalidates the watermark" foot-gun entirely. Option 2 is the smaller diff if backwards-compatibility for downstream consumers depending on the current behaviour is a concern.
Environment
drizzle-orm: tested up to current published version on npm (also reviewedmainbranch source — predicate unchanged).drizzle-kit: any published version that usesPgDialect.migrate.neon-serverless,neon-http,node-postgres,postgres-js(all delegate to the samePgDialect.migratepredicate).Workaround we're using
Until this is fixed upstream, we run a small runner (~80 lines of
tsx+pg) that readsdrizzle/meta/_journal.jsonand applies any entry whosehashis NOT present in__drizzle_migrations, regardless ofcreated_at. We'd happily retire it once the upstream predicate is fixed — hence this issue.Happy to send a minimal reproducer repo or a draft PR if either would speed things along.