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:
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
postgresqldialect. - 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
publicschema.
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:
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:
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=postssupabase/migrations/
├── 20260915144041_posts.sql
└── 20260915144041_posts/
└── snapshot.jsonThe .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 resetrebuilds your local database from every file.supabase db pushruns 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_triggerPut 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.