What you will learn:
How to set up better-drizzle with Lakebase Postgres
How to define schemas, seed, and query your database
How to use plugins, hooks, and transactions in better-drizzle
better-drizzle is a thin wrapper around Drizzle ORM that gives every table a consistent, type-safe API without replacing Drizzle itself. If you are already using Drizzle with Lakebase Postgres, better-drizzle removes the repetitive query glue you would otherwise rewrite in every service while staying close to the metal.
better-drizzle is sponsored by Neon, and is maintained by Hiago Almeida.
What better-drizzle adds
better-drizzle wraps your existing Drizzle client and generates one delegate per table. You get:
- Consistent CRUD:
findMany,findFirst,findUnique,create,update,delete,upsert, andupsertManyon every table - Typed relation loading:
includeandselectwith full type inference from your Drizzle schema - Nested relation filters:
some,every,none, andisfor filtering by related rows - Unified pagination: Offset and cursor pagination returning the same
{ data, pagination }shape - Lifecycle hooks: Cross-cutting concerns like audit trails, tracing, and metrics without sprinkling them through every call
- First-class plugins: Timestamps, soft delete, and custom behavior packaged as reusable plugins
- Transactions with savepoints: Nested transactions, rollback,
afterCommitcallbacks, and automatic retries
All of this compiles down to Drizzle queries. You still define your schema in Drizzle, choose your driver, and drop to raw SQL whenever you need to.
To connect a TypeScript/Node.js project to Lakebase Postgres using better-drizzle, follow these steps:
Create a project on Neon
If you do not have one already, create a project.
- Navigate to the Projects page in the console.
- Click New Project.
- Specify your project settings and click Create Project.
Get your connection string
Find your database connection string by clicking the Connect button on your Project Dashboard to open the Connect to your database modal. Select a branch, a user, and the database you want to connect to. A connection string is constructed for you.
The connection string includes the user name, password, hostname, and database name.Create a
.envfile in your project's root directory and add the connection string to it. Your.envfile should look like this:DATABASE_URL="postgresql://[user]:[password]@[neon_hostname]/[dbname]?sslmode=require&channel_binding=require"note
Neon supports both direct and pooled connection strings, which you can find by clicking the Connect button on your Project Dashboard. A pooled connection string (the hostname includes
-pooler) routes through a PgBouncer connection pool, which is ideal for your application at runtime. However, using a pooled connection string for migrations can lead to errors. Use a direct (non-pooled) connection when running Drizzle Kit migrations. For more information, see Connection pooling and Schema migration with Drizzle ORM.Configure Drizzle Kit
Drizzle Kit uses a configuration file to manage schema and migrations. Create a
drizzle.config.tsfile in your project root:drizzle.config.tsimport 'dotenv/config'; import { defineConfig } from 'drizzle-kit'; if (!process.env.DATABASE_URL) { throw new Error('DATABASE_URL is not set in the .env file'); } export default defineConfig({ schema: './src/schema.ts', out: './drizzle', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL, }, });Define your schema and relations
better-drizzle reads your Drizzle schema (including relations) to generate each table's typed API. The relations you define power
include,select, and nested relation filters.Create a
src/schema.tsfile with two tables:usersandposts. Each user can have many posts, and each post belongs to one user.src/schema.tsimport { relations } from 'drizzle-orm'; import { boolean, integer, pgTable, serial, text } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: serial('id').primaryKey(), email: text('email').notNull().unique(), name: text('name').notNull(), active: boolean('active').notNull().default(true), }); export const posts = pgTable('posts', { id: serial('id').primaryKey(), authorId: integer('author_id') .notNull() .references(() => users.id), title: text('title').notNull(), published: boolean('published').notNull().default(false), }); export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts), })); export const postsRelations = relations(posts, ({ one }) => ({ author: one(users, { fields: [posts.authorId], references: [users.id], }), })); export const schema = { users, usersRelations, posts, postsRelations, };Initialize the Better Drizzle client
Create a standard Drizzle client and wrap it with
better()to get the Better client. Create asrc/db.tsfile:src/db.tsimport { better } from 'better-drizzle'; import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import { schema } from './schema'; import 'dotenv/config'; const pool = new Pool({ connectionString: process.env.DATABASE_URL! }); const db = drizzle(pool, { schema }); export const client = better(db, { schema });Seed the database
Create a
src/seed.tsfile to populate the database with some users and posts:src/seed.tsimport { client } from './db'; async function seed() { await client.users.upsertMany({ data: [ { email: 'alice@example.com', name: 'Alice', active: true }, { email: 'bob@example.com', name: 'Bob', active: false }, { email: 'charlie@example.com', name: 'Charlie', active: true }, ], target: ['email'], update: ['name'], }); const alice = await client.users.findUnique({ where: { email: 'alice@example.com' }, }); const bob = await client.users.findUnique({ where: { email: 'bob@example.com' }, }); await client.posts.createMany({ data: [ { authorId: alice!.id, title: 'Hello World', published: true }, { authorId: alice!.id, title: 'better-drizzle is great', published: true }, { authorId: alice!.id, title: 'Neon Postgres rocks', published: true }, { authorId: bob!.id, title: 'Secret draft', published: false }, ], }); console.log('Seeded 3 users and 4 posts.'); } seed();Run the seed script:
npx tsx src/seed.ts
Query with better-drizzle
Reads with typed relations
Each table gets a delegate (client.users, client.posts) with methods that are fully typed against your schema.
List active users with their three most recent published posts:
const users = await client.users.findMany({
where: { active: true },
include: {
posts: {
where: { published: true },
select: { id: true, title: true },
orderBy: [{ id: 'desc' }],
take: 3,
},
},
orderBy: [{ id: 'desc' }],
take: 20,
});The query returns active users with their published posts nested under each user:
[
{
"id": 3,
"email": "charlie@example.com",
"name": "Charlie",
"active": true,
"posts": []
},
{
"id": 1,
"email": "alice@example.com",
"name": "Alice",
"active": true,
"posts": [
{ "id": 3, "title": "Neon Postgres rocks" },
{ "id": 2, "title": "better-drizzle is great" },
{ "id": 1, "title": "Hello World" }
]
}
]select vs include
By default, queries return all table columns.
- Use
includeto return all columns plus nested relations (e.g.,include: { posts: true }). - Use
selectto return only the specified columns and drop everything else (e.g.,select: { id: true, title: true }).
select and include are mutually exclusive at the same query level, but you can nest a select inside an include to narrow the fields of a related table (as shown in the active users query above).
Find a single user by a unique field:
const alice = await client.users.findUnique({
where: { email: 'alice@example.com' },
});Filter posts by conditions on their related author with nested relation filters:
const posts = await client.posts.findMany({
where: {
published: true,
author: { is: { active: true } },
},
select: {
id: true,
title: true,
author: { select: { id: true, name: true } },
},
orderBy: [{ id: 'desc' }],
take: 20,
});The
author: { is: { active: true } }filter keeps only posts where the author is active, excluding Bob's posts since he is inactive:
Writes with skipDuplicates and upsertMany
Create a new user, skipping if the email already exists
const maybeCreated = await client.users.create({
data: {
email: 'better@example.com',
name: 'better',
active: true,
},
skipDuplicates: ['email'],
});
if (!maybeCreated) {
console.log('User already existed');
}Bulk upsert multiple users in a single batch operation:
await client.users.upsertMany({
data: [
{ email: 'alice@example.com', name: 'Alice Updated', active: true },
{ email: 'bob@example.com', name: 'Bob Updated', active: true },
],
target: ['email'],
update: ['name', 'active'],
select: { id: true, name: true },
});Pagination: one shape for offset and cursor
import { PaginationType } from 'better-drizzle';
// Offset pagination
const page1 = await client.users.paginate({
type: PaginationType.Offset,
where: { active: true },
select: { id: true, name: true },
orderBy: [{ id: 'desc' }],
skip: 0,
limit: 10,
});Both offset and cursor pagination return the same { data, pagination } shape:
{
"data": [
{ "id": 3, "name": "Charlie" },
{ "id": 1, "name": "Alice" }
],
"pagination": { "count": 2, "hasNext": false, "hasPrevious": false }
}Cursor pagination uses the last item's cursor to fetch the next page:
const page2 = await client.users.paginate({
type: PaginationType.Cursor,
where: { active: true },
select: { id: true, name: true },
after: { id: page1.data[page1.data.length - 1]?.id },
limit: 10,
orderBy: [{ id: 'desc' }],
});Count and exists
const activeCount = await client.users.count({
where: { active: true },
});
const exists = await client.users.exists({
where: { id: 1 },
});Use plugins for cross-cutting behavior
Plugins can manage timestamps, soft deletes, and custom behavior.
Timestamps
Auto-manage createdAt and updatedAt columns on every write:
npm install @better-drizzle/timestampsimport { timestamps } from '@better-drizzle/timestamps';
const client = better(db, {
schema,
plugins: [
timestamps({
createdAt: 'createdAt',
updatedAt: 'updatedAt',
}),
],
});Timestamps plugin
Use the column names defined in your Drizzle schema rather than the raw PostgreSQL column names. For example, if your schema specifies createdAt and updatedAt, those are the names you should reference in the plugin configuration - not the database’s created_at and updated_at.
The plugin automatically sets createdAt on row creation and updates updatedAt on every update. Ensure your schema has the createdAt and updatedAt columns defined as timestamps. For example:
import { relations } from "drizzle-orm";
import { boolean, integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
});
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
authorId: integer("author_id")
.notNull()
.references(() => users.id),
title: text("title").notNull(),
published: boolean("published").notNull().default(false),
createdAt: timestamp("created_at"),
updatedAt: timestamp("updated_at"),
});Soft delete
To use soft deletes, add a deletedAt column to your schema:
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
authorId: integer('author_id')
.notNull()
.references(() => users.id),
title: text('title').notNull(),
published: boolean('published').notNull().default(false),
deletedAt: timestamp('deleted_at'),
});Install and configure the @better-drizzle/soft-delete plugin:
npm install @better-drizzle/soft-deleteimport { softDelete } from '@better-drizzle/soft-delete';
const client = better(db, {
schema,
plugins: [
softDelete({
column: 'deletedAt',
defaults: { mode: 'soft', visibility: 'without' },
}),
],
});
// Soft-deletes the row instead of removing it
await client.posts.delete({ where: { id: 1 } });
// Get only deleted posts
await client.posts.findMany({ deleted: 'only' });
// Restore a soft-deleted row
await client.posts.restore({ where: { id: 1 } });Add lifecycle hooks
Observe operations for auditing, metrics, or custom metadata:
const client = better(db, {
schema,
hooks: {
beforeCreate(ctx) {
console.log('beforeCreate', ctx.action, ctx.table);
},
afterCreate(ctx) {
console.log('afterCreate', ctx.row);
},
beforeQuery(ctx) {
console.log('beforeQuery', ctx.action, ctx.args.where);
},
afterQuery(ctx) {
console.log('afterQuery', ctx.action, ctx.result);
},
},
});The example above shows create and query hooks, but Better Drizzle also supports beforeUpdate / afterUpdate, beforeDelete / afterDelete, transaction hooks (beforeTransaction, afterTransactionCommit, afterTransactionRollback, onTransactionError), raw SQL hooks (beforeRaw, afterRaw, onRawError), and a catch-all onError. See the hooks docs for details.
Hook callbacks receive ctx.meta, ideal for logging request IDs, tenant IDs, or user context in a server environment.
Transactions with savepoints
Transactions run on the client, with the callback receiving a Better client bound to the transaction context:
const user = await client.transaction(async (tx) => {
const created = await tx.users.create({
data: { email: 'new@example.com', name: 'New User', active: true },
});
await tx.posts.create({
data: { authorId: created.id, title: 'Hello World', published: true },
});
tx.afterCommit(() => {
console.log('Committed user', created.email);
});
return created;
});Nested transactions use savepoints that roll back independently of the outer transaction:
await client.transaction(async (tx) => {
await tx.users.create({
data: { email: 'a@test.com', name: 'Alice', active: true },
});
try {
await tx.transaction(async (nested) => {
await nested.users.create({
data: { email: 'b@test.com', name: 'Bob', active: true },
});
nested.rollback('duplicate email, rolling back nested savepoint');
});
} catch {
// Only the nested savepoint was rolled back
}
await tx.users.create({
data: { email: 'c@test.com', name: 'Charlie', active: true },
});
});Automatic retries
Opt into transaction retries for transient failures like deadlocks:
await client.transaction(
async (tx) => {
await tx.users.create({
data: { email: 'retry@example.com', name: 'Retry', active: true },
});
},
{
retries: {
attempts: 3,
on: ['deadlock', 'serializationFailure'],
delayMs: (attempt) => attempt * 25,
},
},
);Summary
better-drizzle provides a type-safe, boilerplate-free API on top of Drizzle ORM, providing features like auto-pagination, soft deletes, lifecycle hooks, and nested transactions.
Other features
To explore more advanced capabilities of better-drizzle, refer to the following topics in the official documentation:
- Querying & Reads: Reads & Filters, Selecting fields, Relations, and Pagination
- Writing & CRUD: CRUD Operations and Throwing Results
- Core Concepts: Transactions, Hooks, and Error Handling
- Plugins: Plugins Overview and Writing Custom Plugins
- Advanced Patterns: Service-layer Patterns and Dynamic Repositories
Resources
- better-drizzle documentation
- better-drizzle GitHub repository
- Neon Drizzle setup guide
- Schema migration with Neon and Drizzle
Need help?
Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.








