Comparing Database Access Approaches in TypeScript
When building backends with PostgreSQL and TypeScript, developers typically choose between three main approaches:
- Raw SQL / Query Builders (pg, Knex.js): Writing pure SQL delivers maximum performance and provides 100% control over the queries sent to the database. However, manually mapping TypeScript types is error-prone—a simple typo in a column name can easily bring down an API in production.
- Heavy ORMs (TypeORM, Prisma): Prisma provides an exceptional Developer Experience (DX) with auto-generated types. However, a major drawback is its heavy Rust query engine (15–30MB). On AWS Lambda or Vercel Serverless, this engine can inflate cold starts to 300ms–1s and occasionally generate unpredictable, deeply nested queries.
- Lightweight Type-Safe ORMs (Drizzle ORM): Embracing the philosophy “If you know SQL, you know Drizzle.” No bulky engine. No runtime magic. Drizzle weighs only ~30KB, maps TypeScript syntax 1-to-1 to SQL, and ensures complete end-to-end type safety from schema definitions to query results.
Real-World Pros and Cons of Drizzle ORM
In a recent project handling over 2,000 req/s, our team migrated from Prisma to Drizzle ORM on a PostgreSQL cluster (RDS db.t4g.medium). Here are our key takeaways after several months in production:
Key Advantages
- Zero Engine Overhead: The ultra-light bundle size slashes cold starts on Serverless and Cloudflare Workers down to under 15ms.
- SQL-Like Syntax: No need to memorize dozens of proprietary abstraction methods. If you know SQL, you already know how to write Drizzle queries.
- Accurate Automatic Type Inference: Query result types automatically infer directly from your schema without requiring code-generation build steps whenever the database changes.
- Convenient Drizzle Kit Tooling: Features automated schema diffing to generate migration files, along with Drizzle Studio for visual data inspection in the browser without needing DBeaver or pgAdmin.
Trade-offs to Consider
- The ecosystem and StackOverflow community are not yet as extensive as Prisma or TypeORM. Troubleshooting rare edge cases may require diving directly into the GitHub source code.
- For complex many-to-many relationships, you still need strong SQL fundamentals to construct optimal joins rather than relying entirely on ORM abstractions.
When Should You Choose Drizzle ORM?
Drizzle shines brightest in the following scenarios:
- Applications deployed on Edge Functions, Cloudflare Workers, or AWS Lambda where minimal cold start latency is critical.
- Teams that prefer explicit SQL and require granular control over every query dispatched to the database.
- Projects aiming to optimize server RAM and CPU utilization while maintaining bulletproof TypeScript type safety.
Step-by-Step Guide: Implementing Drizzle ORM with PostgreSQL
Step 1: Initialize the Project and Install Dependencies
First, initialize the project and install Drizzle along with the postgres driver (postgres.js):
mkdir drizzle-pg-demo && cd drizzle-pg-demo
npm init -y
npm install drizzle-orm postgres dotenv
npm install -D typescript @types/node drizzle-kit tsx
npx tsc --init
Step 2: Configure the Database Connection
Create a .env file to store the connection string:
DATABASE_URL="postgres://postgres:password@localhost:5432/my_db"
Next, initialize the database client in src/db/index.ts:
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
import 'dotenv/config';
const connectionString = process.env.DATABASE_URL!;
// Initialize postgres-js client with connection pooling
const client = postgres(connectionString, { max: 10 });
export const db = drizzle(client, { schema });
Step 3: Define Type-Safe Schemas
Create src/db/schema.ts. Here, we declare the users and posts tables with a one-to-many relationship:
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
fullName: text('full_name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
authorId: integer('author_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Define relations for nested relational queries
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
// Extract TypeScript types directly from the schema
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
Step 4: Configure Drizzle Kit and Sync Schema
Create the drizzle.config.ts configuration file in the project root:
import { defineConfig } from 'drizzle-kit';
import 'dotenv/config';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
Push the schema directly to your local database:
# Sync schema directly to PostgreSQL (ideal for local development)
npx drizzle-kit push
# Launch the visual data management GUI on port 4983
npx drizzle-kit studio
Note: For production environments, run
npx drizzle-kit generateto create SQL migration files and apply them usingmigrate()within your CI/CD pipeline to track schema changes.
Step 5: Practical CRUD Operations
Create src/index.ts to test insert, select, and relational query operations:
import { db } from './db';
import { users, posts } from './db/schema';
import { eq, desc } from 'drizzle-orm';
async function main() {
// 1. Insert a new User and immediately return the created record
const [newUser] = await db.insert(users).values({
fullName: 'Nguyen Van A',
email: `vana_${Date.now()}@example.com`,
}).returning();
console.log('Inserted User:', newUser);
// 2. Insert a Post linked to the User
await db.insert(posts).values({
title: 'Learn Drizzle ORM in 10 Minutes',
content: 'Drizzle ORM delivers an exceptionally smooth raw SQL experience.',
authorId: newUser.id,
});
// 3. Query using standard SQL syntax
const allUsers = await db.select().from(users).orderBy(desc(users.createdAt)).limit(5);
console.log('Top 5 Users:', allUsers);
// 4. Relational Query (Fetch User along with all associated Posts)
const usersWithPosts = await db.query.users.findMany({
where: eq(users.id, newUser.id),
with: {
posts: true,
},
});
console.log('User with posts:', JSON.stringify(usersWithPosts, null, 2));
}
main().catch(console.error);
Run the script:
npx tsx src/index.ts
3 Production Best Practices for Drizzle ORM
- Manage Connection Pooling: On serverless architectures, each instance can spawn its own pool, quickly exhausting PostgreSQL connections (
too many clients alreadyerror). Implement pooling solutions such as PgBouncer, Supabase Pooler, or the Neon Serverless Driver. - Leverage
.returning(): Avoid issuing redundant SELECT statements after an INSERT or UPDATE. PostgreSQL’sRETURNING *clause allows fetching updated data in a single network round-trip. - Always Export
$inferSelectand$inferInsert: Use these inferred types as standard models for DTOs and API responses. Whenever the schema evolves, the TypeScript compiler will immediately flag breaking changes across related codebases.

