Drizzle ORM with Neon DB -- migrations, relations, and query patterns
Drizzle gives you SQL-level control with TypeScript types. Neon gives you serverless Postgres that scales to zero. Together they are a solid production stack -- but there are a handful of patterns worth knowing before you hit your first migration conflict or relations query that silently returns nothing.
This post covers the full picture: schema definition, relations, migrations, and the query patterns that keep your repo layer clean.
Setup
Install the packages:
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kitCreate drizzle.config.ts at the repo root:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './db/schema.ts',
out: './db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});Create the db client in db/drizzle.ts:
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
import * as schema from './schema';
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });The { schema } option is required if you want to use db.query.* with relations. Without it, Drizzle does not know which tables exist.
db/schema.ts re-exports every table so Drizzle config and the db client have a single import:
export * from '../modules/user/user.schema';
export * from '../modules/post/post.schema';
export * from '../modules/comment/comment.schema';Schema definition
Tables live in modules/<name>/<name>.schema.ts:
import { pgTable, uuid, text, boolean, timestamp } from 'drizzle-orm/pg-core';
export const postTable = pgTable('posts', {
id: uuid('id').defaultRandom().primaryKey(),
slug: text('slug').notNull().unique(),
title: text('title').notNull(),
content: text('content').notNull(),
published: boolean('published').notNull().default(false),
authorId: uuid('author_id').notNull().references(() => userTable.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});Naming rules that matter:
- Table names are plural:
posts,users,comments - Column names are
snake_casein the DB:author_id,created_at - TypeScript export names are
camelCase+Tablesuffix:postTable,userTable - Never name exports
PostSchema-- that collides with Zod schema naming conventions
Relations
Relations live in a separate file: modules/<name>/<name>.relations.ts.
import { relations } from 'drizzle-orm';
import { postTable } from './post.schema';
import { userTable } from '../user/user.schema';
import { commentTable } from '../comment/comment.schema';
export const postRelations = relations(postTable, ({ one, many }) => ({
author: one(userTable, {
fields: [postTable.authorId],
references: [userTable.id],
}),
comments: many(commentTable),
}));Register both the schema and the relations in db/drizzle.ts:
import * as schema from './schema';
import * as postRelations from '../modules/post/post.relations';
import * as commentRelations from '../modules/comment/comment.relations';
export const db = drizzle(sql, {
schema: { ...schema, ...postRelations, ...commentRelations },
});If you forget to register a relations file, db.query.postTable.findMany({ with: { author: true } }) returns the post rows but author is undefined on every row -- no error, just missing data.
Migrations
Two commands, two use cases:
npm run db:generate # generates a SQL migration file from schema changes
npm run db:migrate # applies pending migrations to the databasedb:generate diffs your current schema against the last migration and writes a new .sql file to db/migrations/. Always review the generated SQL before running db:migrate -- Drizzle will drop columns it cannot reconcile.
db:push syncs the schema directly without generating a migration file. Use it in local dev when iterating fast. Never use it against a production database.
After adding a new module:
npm run db:generate
npm run db:migrateAfter renaming a column, Drizzle will generate a DROP COLUMN + ADD COLUMN. If the column has data, write the migration by hand instead.
Query patterns
All queries live in modules/<name>/<name>.repo.ts. No business logic, no HTTP concepts -- just typed DB access.
Select all:
import { db } from '@/db/drizzle';
import { postTable } from './post.schema';
import { eq, desc } from 'drizzle-orm';
export async function findAllPosts() {
return db.select().from(postTable).orderBy(desc(postTable.createdAt));
}Select by field:
export async function findPostBySlug(slug: string) {
const rows = await db
.select()
.from(postTable)
.where(eq(postTable.slug, slug))
.limit(1);
return rows[0] ?? null;
}With relations (using query API):
export async function findPostWithAuthor(id: string) {
return db.query.postTable.findFirst({
where: eq(postTable.id, id),
with: { author: true },
});
}This only works if postRelations is registered in the db client's schema option.
Insert:
import { type NewPost } from './post.types';
export async function insertPost(data: NewPost) {
const rows = await db.insert(postTable).values(data).returning();
return rows[0];
}NewPost is inferred from the schema:
// post.types.ts
import { type InferSelectModel, type InferInsertModel } from 'drizzle-orm';
import { postTable } from './post.schema';
export type Post = InferSelectModel<typeof postTable>;
export type NewPost = InferInsertModel<typeof postTable>;Update:
export async function updatePost(id: string, data: Partial<NewPost>) {
const rows = await db
.update(postTable)
.set({ ...data, updatedAt: new Date() })
.where(eq(postTable.id, id))
.returning();
return rows[0] ?? null;
}Delete:
export async function deletePost(id: string) {
await db.delete(postTable).where(eq(postTable.id, id));
}Common gotchas
Relations silently missing: If db.query.* returns rows without the with fields populated, the relations file is not registered in the db client. Add it to the schema spread in db/drizzle.ts.
returning() is Postgres-only: Drizzle supports returning() on insert, update, and delete for Postgres. Do not expect it on SQLite or MySQL.
updatedAt does not auto-update: Drizzle does not manage updatedAt automatically. Set it explicitly in every update() call: .set({ ...data, updatedAt: new Date() }).
Neon cold starts on first query: Neon serverless connections have a cold start on the first query after inactivity. This is normal -- subsequent queries in the same request are fast. Use connection pooling (the -pooler URL variant) in production to reduce cold start frequency.
Schema drift in preview environments: Each Vercel preview branch points at the same Neon database by default. Run db:migrate after deploying a branch with schema changes, or use Neon branching to give each preview its own DB branch.
Takeaway
The repo layer is thin by design: typed queries, no business logic, no HTTP imports. Keep it that way and migrations become the only complexity worth managing. When you do hit a migration conflict -- usually a rename or a NOT NULL column with existing rows -- write the SQL by hand rather than trusting the generated diff.