[BUG]: `drizzle-kit generate` silently drops a newly created unique index when another table’s column type changes

Author: sukalovCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbug

Report hasn't been filed before.

  • I have verified that the bug I'm about to report hasn't been filed before.

What version of drizzle-orm are you using?

0.45.2

What version of drizzle-kit are you using?

0.31.10

Other packages

[email protected], [email protected]

Describe the Bug

NB: bug discovered and verified by me, then verified by gpt-6-astra. text of this description is generated by astra, and verified by me carefully except the last section where it supposes the root-cause for the bug

describe the bug

Adding an indexed table and changing an existing table's column from integer to real in the same drizzle-kit generate produces a migration that:

  1. creates the new table and its unique index;
  2. drops that index while changing the unrelated existing table's column;
  3. never recreates it.

migrate succeeds, but duplicate values forbidden by the declared unique index can then be inserted. The generated snapshot still contains the index, so another generate reports no schema changes.

This appears related to #5564 and PR #5703, but the failure here is with generated migration files and silently missing uniqueness, rather than push failing with no such index. Please advise whether this should be tracked there instead.

minimal reproduction

Start in an empty directory and pin the versions below:

bash
npm init -y
npm install --save-exact [email protected] @libsql/[email protected]
npm install --save-dev --save-exact [email protected]

Create drizzle.config.ts:

typescript
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'turso',
  schema: './schema.ts',
  out: './drizzle',
});

Create schema.ts:

typescript
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

export const player = sqliteTable('player', {
  id: text('id').primaryKey(),
  rd: integer('rd').notNull().default(350),
});

Generate the initial migration:

bash
node node_modules/drizzle-kit/bin.cjs generate --name=initial

Replace only schema.ts with:

typescript
import { real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';

export const player = sqliteTable('player', {
  id: text('id').primaryKey(),
  rd: real('rd').notNull().default(350),
});

export const ratingEvent = sqliteTable('rating_event', {
  id: text('id').primaryKey(),
  playerId: text('player_id').notNull().references(() => player.id),
}, (table) => [uniqueIndex('rating_event_player_unique').on(table.playerId)]);

Generate the change migration:

bash
node node_modules/drizzle-kit/bin.cjs generate --name=change

The generated drizzle/0001_change.sql, without manual modifications, is:

sql
CREATE TABLE `rating_event` (
  `id` text PRIMARY KEY NOT NULL,
  `player_id` text NOT NULL,
  FOREIGN KEY (`player_id`) REFERENCES `player`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `rating_event_player_unique` ON `rating_event` (`player_id`);--> statement-breakpoint
DROP INDEX "rating_event_player_unique";--> statement-breakpoint
ALTER TABLE `player` ALTER COLUMN "rd" TO "rd" real NOT NULL DEFAULT 350;

Create verify.mjs:

javascript
import { readFileSync } from 'node:fs';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';

const client = createClient({ url: 'file::memory:' });
try {
  await migrate(drizzle(client), { migrationsFolder: './drizzle' });
  const snapshot = JSON.parse(readFileSync('./drizzle/meta/0001_snapshot.json', 'utf8'));
  console.log('snapshot indexes:', Object.keys(snapshot.tables.rating_event.indexes));
  const indexes = await client.execute("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'rating_event_player_unique'");
  console.log('actual named indexes:', indexes.rows.map(row => row.name));
  await client.execute("INSERT INTO player (id) VALUES ('p1')");
  await client.execute("INSERT INTO rating_event (id, player_id) VALUES ('e1', 'p1')");
  let duplicateAccepted = false;
  try {
    await client.execute("INSERT INTO rating_event (id, player_id) VALUES ('e2', 'p1')");
    duplicateAccepted = true;
  } catch (error) {
    console.log('duplicate rejected:', error.code);
  }
  console.log('duplicate accepted:', duplicateAccepted);
  console.log('foreign-key violations:', (await client.execute('PRAGMA foreign_key_check')).rows.length);
} finally {
  client.close();
}

Run:

bash
node verify.mjs
node node_modules/drizzle-kit/bin.cjs generate --name=check-drift

Observed output:

snapshot indexes: [ 'rating_event_player_unique' ]
actual named indexes: []
duplicate accepted: true
foreign-key violations: 0

The subsequent generation reports:

No schema changes, nothing to migrate

expected behaviour

  • After applying generated migrations, the unique index declared in the target schema and snapshot exists in the database.
  • The second event for p1 is rejected by that unique index.
  • A column alteration on player must not leave rating_event without its index.

root-cause evidence

In LibSQLModifyColumn.convert, indexes are collected from every table in the target schema, not just the table being modified:

typescript
for (const table of Object.values(json2.tables)) {
  for (const index of Object.values(table.indexes)) {
    const unsquashed = SQLiteSquasher.unsquashIdx(index);
    sqlStatements.push(`DROP INDEX "${unsquashed.name}";`);
    indexes.push({ ...unsquashed, tableName: table.name });
  }
}

The converter later emits recreations. However, the turso diff path deduplicates SQL strings across the entire plan:

typescript
const uniqueSqlStatements: string[] = [];
sqlStatements.forEach((ss) => {
  if (!uniqueSqlStatements.includes(ss)) {
    uniqueSqlStatements.push(ss);
  }
});

Therefore the second CREATE UNIQUE INDEX is discarded as a duplicate of the first, even though a DROP INDEX occurs between them. SQL statements with identical text are not redundant when earlier statements have changed database state.

Adding IF EXISTS to the drop alone cannot restore an omitted recreation. Scoping the converter to the modified table, as proposed in #5703, addresses the unrelated-table sweep in this example; broader statement deduplication should also be reviewed for valid create/drop/create lifecycles.

Source: drizzle-team/drizzle-orm