ZenoZENO

Migrations

Configure Drizzle Kit and generate, apply, and seed SQL migrations

Configure Drizzle Kit

Create a drizzle.config.ts next to the package or app that owns the schema:

{project}-db/drizzle.config.ts
import "dotenv/config"
import { defineDrizzleConfig } from "@zeno-lib/db/config"

export default defineDrizzleConfig()

Keep import "dotenv/config" here. Next.js loads .env* files for code running inside the Next runtime, and Vercel provides environment variables during builds and function execution. Drizzle Kit, though, runs this config as its own CLI process. The import makes local .env values available before defineDrizzleConfig() reads process.env.SUPABASE_DATABASE_URL.

The preset:

  • Reads schema from ./src/schema.ts.
  • Writes migrations to ./supabase/migrations.
  • Uses the postgresql dialect.
  • Reads SUPABASE_DATABASE_URL.
  • Keeps Supabase's built-in roles marked as existing, including the 18 that drizzle-kit's own provider: "supabase" does not cover.
  • Limits the diff to the public schema.

Those last two matter to drizzle-kit push and drizzle-kit pull, which read the live database. Drizzle Kit manages every role it finds there, so it treats a role your schema doesn't declare as one to drop. A stock Supabase project has 26 roles and provider: "supabase" excludes only 8, so a push would offer to DROP ROLE the other 18. The preset exports that list as supabaseManagedRoles and adds it to entities.roles.exclude, keeping any excludes of your own.

schemaFilter does the same for schemas. Without it, push and pull diff auth, storage and realtime, which Supabase owns and changes on upgrades. If you add your own non-public schema with schema(...), list it here rather than dropping the filter:

{project}-db/drizzle.config.ts
export default defineDrizzleConfig({
  schemaFilter: ["public", "billing"],
})

drizzle-kit generate accepts neither option. It diffs your schema files against the previous snapshot rather than against a database, and its config schema has no entities or schemaFilter at all. So it never emits DROP ROLE, but it also never filters by schema. To keep a Supabase-owned table out of a generated migration, don't export it from the files your schema glob reads.

You can still pass Drizzle Kit overrides for package-specific credentials or paths, for example a separate migrations connection string:

{project}-db/drizzle.config.ts
import "dotenv/config"
import { defineDrizzleConfig } from "@zeno-lib/db/config"

export default defineDrizzleConfig({
  dbCredentials: {
    url: process.env.MIGRATIONS_DATABASE_URL ?? "",
  },
})

Generate migrations

With the package scripts from installation in place, db:generate reads your TypeScript schema and writes a migration under supabase/migrations:

pnpm db:generate --name=posts
supabase/migrations/
├── 20260915144041_posts.sql
└── 20260915144041_posts/
    └── snapshot.json

The .sql file is the migration. snapshot.json is Drizzle Kit's record of your schema after that migration. The next db:generate compares your schema files with the newest snapshot and writes only the difference. Commit the snapshot together with the SQL, and do not edit it by hand.

Why the SQL is next to the folder

Drizzle Kit writes both files into the folder, the SQL as migration.sql. The Supabase CLI only reads .sql files directly in supabase/migrations. So defineDrizzleConfig() moves migration.sql up one level and names it after the folder. Set moveGeneratedSql: false to keep Drizzle Kit's layout for drizzle-kit migrate.

Apply migrations

The Supabase CLI applies migrations. It runs the .sql files in supabase/migrations in name order, which is the order you created them in, and records each one in supabase_migrations.schema_migrations.

  • supabase db reset rebuilds your local database from every file.
  • supabase db push runs the files a deployed project does not have yet.

Do not use drizzle-kit migrate or drizzle-orm's migrate(). The Supabase CLI applies migrations.

Custom SQL migrations

Some objects have no Drizzle representation. Triggers are the common case, because Drizzle Kit emits no trigger DDL and they are not part of its snapshot format. drizzle-kit generate --custom writes an empty but tracked migration for you to fill in; its snapshot links into the chain like any generated one.

pnpm db:generate --custom --name=posts_updated_at_trigger

Put your SQL in the generated file. For the updated_at case, @zeno-lib/db/triggers prints what goes in it. Write re-runnable SQL (if not exists, drop ... if exists) so re-applying a migration against an existing database is safe.

Verify against a live database

drizzle-kit push --explain introspects a database, prints the statements it would run to make it match your schema, and returns before running any of them. On a database your migrations built, it should print [i] No changes detected. Run it when your schema describes a database that already exists; see Existing schema.

Generate migrations, day to day

Author tables and RLS policies in your Drizzle schema, run db:generate (drizzle-kit generate), review the generated SQL, and commit it together with its snapshot. To apply it, run supabase db reset for your local database or supabase db push for a deployed project.