Why Drizzle?
Type Safety
Full TypeScript inference - compile-time error checking
Zero Overhead
No runtime proxy, maps directly to SQL
SQL-Like API
Familiar syntax for SQL developers
Excellent DX
Auto-complete, inline docs, visual studio
Basic Queries
Select
Copy
import { db } from "@/lib/db";
import { task } from "@/lib/db/schema";
// Select all
const allTasks = await db.select().from(task);
// Select specific columns
const taskTitles = await db
.select({
id: task.id,
title: task.title,
})
.from(task);
Insert
Copy
// Insert single record
const [newTask] = await db
.insert(task)
.values({
displayId: 123,
title: "New task",
clientId: "client-123",
pmId: "user-456",
priority: 3,
})
.returning();
// Insert multiple records
const newTasks = await db
.insert(task)
.values([
{ displayId: 124, title: "Task 1", ... },
{ displayId: 125, title: "Task 2", ... },
])
.returning();
Update
Copy
import { eq } from "drizzle-orm";
// Update single record
await db
.update(task)
.set({
status: "complete",
updatedAt: new Date(),
})
.where(eq(task.id, taskId));
// Update multiple records
await db
.update(task)
.set({ priority: 1 })
.where(eq(task.clientId, clientId));
Delete
Copy
// Delete single record
await db
.delete(task)
.where(eq(task.id, taskId));
// Delete with condition
await db
.delete(task)
.where(
and(
eq(task.status, "cancelled"),
lt(task.createdAt, oneYearAgo)
)
);
Filtering
Basic Conditions
Copy
import { eq, ne, gt, gte, lt, lte } from "drizzle-orm";
// Equal
const task = await db
.select()
.from(task)
.where(eq(task.id, "task-123"));
// Not equal
const activeTasks = await db
.select()
.from(task)
.where(ne(task.status, "cancelled"));
// Greater than
const recentTasks = await db
.select()
.from(task)
.where(gt(task.createdAt, lastWeek));
Combining Conditions
Copy
import { and, or } from "drizzle-orm";
// AND - all conditions must be true
const filteredTasks = await db
.select()
.from(task)
.where(
and(
eq(task.clientId, clientId),
eq(task.status, "in_progress"),
gte(task.priority, 3)
)
);
// OR - any condition can be true
const tasks = await db
.select()
.from(task)
.where(
or(
eq(task.status, "complete"),
eq(task.status, "cancelled")
)
);
Pattern Matching
Copy
import { like, ilike } from "drizzle-orm";
// Case-sensitive LIKE
const tasks = await db
.select()
.from(task)
.where(like(task.title, "%website%"));
// Case-insensitive ILIKE (PostgreSQL)
const tasks = await db
.select()
.from(task)
.where(ilike(task.title, "%website%"));
IN / NOT IN
Copy
import { inArray, notInArray } from "drizzle-orm";
// IN - match any value
const tasks = await db
.select()
.from(task)
.where(inArray(task.status, ["in_progress", "awaiting_review"]));
// NOT IN - exclude values
const tasks = await db
.select()
.from(task)
.where(notInArray(task.status, ["cancelled", "complete"]));
NULL Checks
Copy
import { isNull, isNotNull } from "drizzle-orm";
// Is NULL
const unassignedTasks = await db
.select()
.from(task)
.where(isNull(task.pmId));
// Is NOT NULL
const assignedTasks = await db
.select()
.from(task)
.where(isNotNull(task.pmId));
Joins
Left Join
Copy
import { client } from "@/lib/db/schema";
// Task with client info
const tasksWithClients = await db
.select({
taskId: task.id,
taskTitle: task.title,
clientName: client.name,
clientSlug: client.slug,
})
.from(task)
.leftJoin(client, eq(task.clientId, client.id));
// Returns: { taskId, taskTitle, clientName, clientSlug | null }
Inner Join
Copy
// Only tasks that have a client
const tasksWithClients = await db
.select()
.from(task)
.innerJoin(client, eq(task.clientId, client.id));
Multiple Joins
Copy
import { user, subtask } from "@/lib/db/schema";
// Task with client, PM, and subtasks
const taskDetails = await db
.select({
task,
clientName: client.name,
pmName: user.name,
subtaskCount: count(subtask.id),
})
.from(task)
.leftJoin(client, eq(task.clientId, client.id))
.leftJoin(user, eq(task.pmId, user.id))
.leftJoin(subtask, eq(task.id, subtask.taskId))
.groupBy(task.id, client.name, user.name);
Aggregation
Count
Copy
import { count } from "drizzle-orm";
// Count all tasks
const [{ totalTasks }] = await db
.select({
totalTasks: count(task.id),
})
.from(task);
// Count with filter
const [{ activeTasks }] = await db
.select({
activeTasks: count(task.id),
})
.from(task)
.where(eq(task.status, "in_progress"));
Sum, Average, Min, Max
Copy
import { sum, avg, min, max } from "drizzle-orm";
// Aggregate time entries
const [stats] = await db
.select({
totalMinutes: sum(timeEntry.duration),
avgMinutes: avg(timeEntry.duration),
minMinutes: min(timeEntry.duration),
maxMinutes: max(timeEntry.duration),
})
.from(timeEntry)
.where(eq(timeEntry.clientId, clientId));
Group By
Copy
// Tasks per client
const clientStats = await db
.select({
clientId: task.clientId,
clientName: client.name,
taskCount: count(task.id),
totalMinutes: sum(task.estimatedMinutes),
})
.from(task)
.leftJoin(client, eq(task.clientId, client.id))
.groupBy(task.clientId, client.name);
Sorting
Order By
Copy
import { asc, desc } from "drizzle-orm";
// Sort ascending
const tasks = await db
.select()
.from(task)
.orderBy(asc(task.priority));
// Sort descending
const tasks = await db
.select()
.from(task)
.orderBy(desc(task.createdAt));
// Multiple sort columns
const tasks = await db
.select()
.from(task)
.orderBy(
asc(task.priority),
desc(task.createdAt)
);
Pagination
Limit & Offset
Copy
const PAGE_SIZE = 20;
const page = 1; // 0-indexed
const tasks = await db
.select()
.from(task)
.orderBy(desc(task.createdAt))
.limit(PAGE_SIZE)
.offset(page * PAGE_SIZE);
// Get total count for pagination
const [{ total }] = await db
.select({ total: count(task.id) })
.from(task)
.where(eq(task.status, "in_progress"));
Transactions
Basic Transaction
Copy
await db.transaction(async (tx) => {
// Create task
const [newTask] = await tx
.insert(task)
.values({ ... })
.returning();
// Create subtasks
await tx
.insert(subtask)
.values([
{ taskId: newTask.id, ... },
{ taskId: newTask.id, ... },
]);
// If any operation fails, entire transaction rolls back
});
Rollback on Error
Copy
try {
await db.transaction(async (tx) => {
await tx.insert(task).values({ ... });
// If this throws, everything rolls back
if (someCondition) {
throw new Error("Validation failed");
}
await tx.insert(subtask).values({ ... });
});
} catch (error) {
console.error("Transaction failed:", error);
}
Advanced Patterns
Conditional Queries
Build queries dynamically based on filters:Copy
import { SQL, and } from "drizzle-orm";
function buildTaskQuery(filters: {
clientId?: string;
status?: string;
searchTerm?: string;
}) {
const conditions: SQL[] = [];
if (filters.clientId) {
conditions.push(eq(task.clientId, filters.clientId));
}
if (filters.status) {
conditions.push(eq(task.status, filters.status));
}
if (filters.searchTerm) {
conditions.push(ilike(task.title, `%${filters.searchTerm}%`));
}
return db
.select()
.from(task)
.where(conditions.length > 0 ? and(...conditions) : undefined);
}
// Usage
const tasks = await buildTaskQuery({
clientId: "client-123",
status: "in_progress",
});
Subqueries
Copy
import { sql } from "drizzle-orm";
// Count subtasks per task
const tasksWithSubtaskCount = await db
.select({
task,
subtaskCount: sql<number>`(
SELECT COUNT(*)
FROM ${subtask}
WHERE ${subtask.taskId} = ${task.id}
)`,
})
.from(task);
Upsert (Insert or Update)
Copy
// PostgreSQL ON CONFLICT
await db
.insert(userPreference)
.values({
userId: "user-123",
key: "theme",
value: "dark",
})
.onConflictDoUpdate({
target: [userPreference.userId, userPreference.key],
set: { value: "dark", updatedAt: new Date() },
});
Batch Operations
Copy
// Insert in batches (more efficient for large datasets)
const batchSize = 100;
const records = [...]; // 1000s of records
for (let i = 0; i < records.length; i += batchSize) {
const batch = records.slice(i, i + batchSize);
await db.insert(timeEntry).values(batch);
}
Common CharleOS Patterns
Get Task with Full Details
Copy
export async function getTaskWithDetails(taskId: string) {
const [result] = await db
.select({
task,
client: client,
pm: user,
subtasks: sql`
json_agg(json_build_object(
'id', ${subtask.id},
'type', ${subtask.type},
'status', ${subtask.status}
))
`,
})
.from(task)
.leftJoin(client, eq(task.clientId, client.id))
.leftJoin(user, eq(task.pmId, user.id))
.leftJoin(subtask, eq(task.id, subtask.taskId))
.where(eq(task.id, taskId))
.groupBy(task.id, client.id, user.id);
return result;
}
Calculate Client Utilization
Copy
export async function getClientUtilization(
clientId: string,
startDate: Date,
endDate: Date
) {
const [result] = await db
.select({
allocatedMinutes: client.monthlyHours * 60, // Convert to minutes
usedMinutes: sum(timeEntry.duration),
utilization: sql<number>`
ROUND(
(${sum(timeEntry.duration)} * 100.0) / (${client.monthlyHours} * 60)
)
`,
})
.from(client)
.leftJoin(timeEntry, eq(client.id, timeEntry.clientId))
.where(
and(
eq(client.id, clientId),
gte(timeEntry.date, startDate),
lte(timeEntry.date, endDate)
)
)
.groupBy(client.id);
return result;
}
Performance Tips
Use Indexes for Frequently Queried Columns
Use Indexes for Frequently Queried Columns
Drizzle supports index definitions in schema:
Copy
export const task = pgTable("task", {
// ... columns
}, (table) => ({
clientIdIdx: index("task_client_id_idx").on(table.clientId),
statusIdx: index("task_status_idx").on(table.status),
}));
Select Only Needed Columns
Select Only Needed Columns
Don’t select all columns if you only need a few:
Copy
// ✅ Good: Select only what you need
const tasks = await db
.select({ id: task.id, title: task.title })
.from(task);
// ❌ Bad: Selecting everything
const tasks = await db.select().from(task);
Use Prepared Statements for Repeated Queries
Use Prepared Statements for Repeated Queries
Copy
const getTaskById = db
.select()
.from(task)
.where(eq(task.id, sql.placeholder("id")))
.prepare("get_task_by_id");
// Reuse prepared statement
const task1 = await getTaskById.execute({ id: "task-123" });
const task2 = await getTaskById.execute({ id: "task-456" });
Limit Large Result Sets
Limit Large Result Sets
Always use
.limit() when querying potentially large datasets:Copy
const recentTasks = await db
.select()
.from(task)
.orderBy(desc(task.createdAt))
.limit(100); // Prevent loading 10,000+ records
Type Safety
Drizzle provides full type inference:Copy
// Types are automatically inferred
const tasks = await db.select().from(task);
// tasks: Array<{
// id: string;
// title: string;
// clientId: string | null;
// ... all columns
// }>
// Custom selections
const taskTitles = await db
.select({
id: task.id,
title: task.title,
})
.from(task);
// taskTitles: Array<{ id: string; title: string }>
// Joins
const tasksWithClients = await db
.select({
taskId: task.id,
clientName: client.name,
})
.from(task)
.leftJoin(client, eq(task.clientId, client.id));
// tasksWithClients: Array<{ taskId: string; clientName: string | null }>
Error Handling
Copy
import { PostgresError } from "postgres";
try {
await db.insert(task).values({ ... });
} catch (error) {
if (error instanceof PostgresError) {
// Handle specific PostgreSQL errors
if (error.code === "23505") {
throw new Error("Duplicate record");
}
if (error.code === "23503") {
throw new Error("Foreign key violation");
}
}
throw error;
}
Debugging
Log Queries
Copy
// Enable query logging in development
const db = drizzle(pool, {
logger: process.env.NODE_ENV === "development",
});
Inspect Generated SQL
Copy
// Get SQL without executing
const query = db
.select()
.from(task)
.where(eq(task.clientId, clientId))
.toSQL();
console.log(query.sql); // SQL string
console.log(query.params); // Parameter values