One dependency. No WASM engine. The Postgres ORM that ships light and locks tight.
npm install turbine-orm
Full docs: turbineorm.dev — Quick Start · API Reference · Relations · Transactions & Pipelines · Serverless & Edge · Typed Errors · Benchmarks
Prisma ships a 1.6 MB WASM query engine. Drizzle ships zero runtime but no Studio, no typed errors, no migration checksums. Turbine ships one dependency (pg) and no engine binary, and bundles six things no other TS ORM has together:
- One runtime dependency (
pg). No engine binary, no WASM adapter, no adapter packages to keep in lockstep. The main entry bundles to ~31 kB brotli (~109 KB minified); the edge entry to ~22 kB brotli. Prisma's WASM query engine alone is 1.6 MB. - Built-in read-only Studio.
npx turbine studiospins up a loopback-bound web UI with 192-bit auth tokens,BEGIN READ ONLYtransactions, and — since v0.19 — no raw-SQL surface at all: queries are composed in the ORM's own validated builder. The only TS ORM Studio that physically cannot mutate your database. DBA-approvable. - PII-safe error messages. Turbine errors show WHERE keys, not values. A
UniqueConstraintErrorsays which column violated the constraint — never the actual user data. Safe to log, safe to surface to monitoring, no scrubbing needed. - SQL-first migrations with drift detection. Write real SQL. SHA-256 checksums catch modified migration files.
pg_try_advisory_lock()prevents concurrent runs. Each migration in its own transaction. No shadow database, no magic DSL. - Edge-native — one import swap.
turbineHttp(pool, schema)— same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase. No WASM bundle, no adapter package, no separate serverless build. - Pipeline batching via wire protocol. Real Parse/Bind/Execute pipeline — not queries wrapped in a transaction. N independent queries in one round-trip.
Every ORM claims single-query nested loads now (Prisma 7 and Drizzle v2 both use json_agg). Turbine does too — see How It Works. The differentiator isn't the query strategy; it's the one-dependency, no-WASM footprint, the read-only Studio, and the error messages that never leak user data.
Tested against Prisma 7.6 (adapter-pg, relationJoins preview on) and Drizzle 0.45 (relational queries) on a Neon PostgreSQL database (pooled endpoint, US-East, PostgreSQL 17.8). 100 iterations, 20 warmup, Node v22. Same schema, same data (1K users, 10K posts, 50K comments), same connection pool config. Measured April 2026 on turbine-orm 0.7.1; the core read path these scenarios exercise is unchanged through 0.17.0 — see benchmarks/RESULTS.md to reproduce.
| Scenario | Turbine | Prisma 7 | Drizzle v2 |
|---|---|---|---|
| findMany — 100 users (flat) | 51.97 ms | 52.90 ms | 53.51 ms |
| findMany — 50 users + posts (L2) | 55.84 ms | 56.10 ms | 88.80 ms |
| findMany — 10 users → posts → comments (L3) | 52.77 ms | 59.35 ms | 52.38 ms |
| findUnique — single user by PK | 47.66 ms | 52.15 ms | 47.78 ms |
| findUnique — user + posts + comments (L3) | 51.71 ms | 54.42 ms | 52.47 ms |
| count — all users | 44.57 ms | 47.54 ms | 46.75 ms |
| stream — iterate 50K rows (batch 1000) | 3,207 ms | 3,099 ms | 4,620 ms |
atomic increment — view_count + 1 |
49.76 ms | 49.09 ms | 46.25 ms |
| pipeline — 5-query batch | 318 ms | 327 ms | 316 ms |
Against a real pooled database, most single-query scenarios are within noise — network round-trip to Neon is ~33–40 ms, which swamps per-query CPU overhead. But a few results stand out:
- L2 nested reads. Turbine and Prisma are neck-and-neck (~56 ms), while Drizzle is 1.59× slower (89 ms) on the 50-user + posts scenario. Turbine's
json_aggapproach and SQL template caching pay off here. - Streaming 50K rows. Turbine's optimized streaming (speculative first fetch + batch size 1000) matches Prisma at ~3.1–3.2 s. Drizzle's keyset pagination is 1.49× slower at 4.6 s. Turbine's cursor still gives you correctness on any
orderByand clean early-breaksemantics. - Pipeline batching puts 5 independent queries through a single round-trip using the Postgres extended-query pipeline protocol — all three ORMs are tied here since each runs 5 queries sequentially in a transaction.
Performance is at parity with Prisma and Drizzle — the real reasons to choose Turbine are elsewhere: one dependency and no WASM engine (vs Prisma's 1.6 MB WASM query engine), the only read-only Studio in the TS ORM ecosystem, PII-safe error messages that never leak user data, and SQL-first migrations with SHA-256 drift detection. Deep type inference through with clauses works end-to-end: write db.users.findMany({ with: { posts: { with: { comments: true } } } }) and users[0].posts[0].comments[0].body autocompletes — no manual assertion, no helper annotation.
Full analysis with p50/p95/p99 and methodology notes:
benchmarks/RESULTS.md. Reproduce:cd benchmarks && npm install && npx prisma generate && DATABASE_URL=... npx tsx bench.ts
# 1. Install
npm install turbine-orm
# 2. Initialize project
npx turbine init --url postgres://user:pass@localhost:5432/mydb
# 3. Generate typed client from your database
npx turbine generateWorks with both ESM and CommonJS:
// ESM
import { turbine } from './generated/turbine';
// CommonJS
const { turbine } = require('./generated/turbine');This introspects your database and generates a fully-typed client at ./generated/turbine/.
import { turbine } from './generated/turbine';
const db = turbine({ connectionString: process.env.DATABASE_URL });
// Type-safe queries with autocompletion
const users = await db.users.findMany({
where: { role: 'admin' },
orderBy: { createdAt: 'desc' },
limit: 10,
});
await db.disconnect();// Single query -- returns users with their posts and each post's comments
const users = await db.users.findMany({
where: { orgId: 1 },
with: {
posts: {
with: { comments: true },
orderBy: { createdAt: 'desc' },
limit: 5,
},
},
});
// users[0].posts[0].comments -- fully typed, single round-tripconst user = await db.users.findUnique({
where: { id: 42 },
with: { posts: true },
});
// user.posts is Post[] -- resolved in the same queryTurbine auto-detects pure junction tables during generate — a table whose primary key is exactly two single-column foreign keys and which carries no other columns (e.g. posts_tags(post_id, tag_id)). Both endpoints gain a many-to-many relation you can load like any other:
const posts = await db.posts.findMany({
with: { tags: true }, // each post comes back with its tags array
});
// Nested where / orderBy / limit work on the m2m target too
const post = await db.posts.findFirst({
where: { id: 1 },
with: { tags: { where: { name: 'sql' }, orderBy: { name: 'asc' }, limit: 5 } },
});A junction table that carries extra columns (a "payload") is treated as a first-class entity, so it stays an ordinary hasMany — that's by design. For those, or for any junction you want to wire up by hand, declare the relation explicitly in your code-first schema:
import { defineSchema } from 'turbine-orm';
export default defineSchema({
posts: {
id: { type: 'serial', primaryKey: true },
title: { type: 'text', notNull: true },
manyToMany: [
{ name: 'tags', target: 'tags', through: 'postsTags',
sourceKey: 'postId', targetKey: 'tagId' },
],
},
// ...tags and postsTags table definitions
});sourceKey/targetKey are the junction columns referencing each side's primary key; add references if the source side is keyed on something other than id.
A self-referencing foreign key (e.g. categories.parent_id → categories.id) introspects to a belongsTo and a hasMany on the same table, so parent and child queries just work — including nested trees:
// A category with its parent and its children
const category = await db.categories.findFirst({
where: { id: 2 },
with: { parent: true, children: true },
});
// Walk a level deeper
const tree = await db.categories.findFirst({
where: { id: 1 },
with: { children: { with: { children: true } } },
});When a table has a single self-referencing FK, Turbine auto-names the relations after the table: the belongsTo is named for the singular (category) and the hasMany for the table (categories). Rename them in your code-first schema if you prefer parent/children.
const newUser = await db.users.create({
data: {
email: 'alice@example.com',
name: 'Alice',
orgId: 1,
},
});
// Returns the full row with generated id, createdAt, etc.const users = await db.users.createMany({
data: [
{ email: 'a@b.com', name: 'A', orgId: 1 },
{ email: 'b@b.com', name: 'B', orgId: 1 },
{ email: 'c@b.com', name: 'C', orgId: 1 },
],
});
// Single INSERT with UNNEST -- not 3 separate insertsconst updated = await db.users.update({
where: { id: 42 },
data: { name: 'Alice Updated' },
});
const deleted = await db.users.delete({
where: { id: 42 },
});For race-free counter updates, pass an operator object instead of a literal. Turbine generates col = col + $n style SQL so concurrent updates are safe.
// Atomic increment — no read-modify-write race
await db.posts.update({
where: { id: 1 },
data: { viewCount: { increment: 1 } },
});
// Other supported operators on numeric columns
await db.posts.update({
where: { id: 1 },
data: {
viewCount: { increment: 5 },
likesCount: { decrement: 1 },
score: { multiply: 2 },
rank: { divide: 2 },
title: { set: 'New title' }, // explicit set, equivalent to a literal
},
});await db.$transaction(async (tx) => {
const user = await tx.users.create({
data: { email: 'new@example.com', name: 'New', orgId: 1 },
});
await tx.posts.create({
data: { userId: user.id, orgId: 1, title: 'First Post', content: '...' },
});
});
// Fully typed -- tx.users and tx.posts have the same API as db.users and db.postsconst [user, postCount, recentPosts] = await db.pipeline(
db.users.buildFindUnique({ where: { id: 1 } }),
db.posts.buildCount({ where: { orgId: 1 } }),
db.posts.buildFindMany({ where: { userId: 1 }, limit: 5 }),
);
// 3 queries, 1 database round-tripconst stats = await db.raw<{ day: Date; count: number }>`
SELECT DATE_TRUNC('day', created_at) AS day, COUNT(*)::int AS count
FROM posts WHERE org_id = ${orgId}
GROUP BY day ORDER BY day
`;db.sql<T> is the typed escape hatch: you supply the row shape and get a thenable query with .one() and .scalar() helpers. Every ${value} is bound as a $N parameter — never interpolated — so injection isn't possible even with hostile input.
// Awaiting the query returns T[]
const users = await db.sql<{ id: number; name: string }>`
SELECT id, name FROM users WHERE org_id = ${orgId}
`;
// .one() returns T | null
const user = await db.sql<{ id: number; name: string }>`
SELECT id, name FROM users WHERE id = ${42}
`.one();
// .scalar() returns the first column of the first row, or null
const total = await db.sql<{ count: number }>`
SELECT COUNT(*)::int AS count FROM users
`.scalar();Reach for db.sql<T> when you want a hand-written query with a known return type; use db.raw when you don't need the typing or the helpers.
const users = await db.users.findMany({
where: {
email: { contains: 'alice', mode: 'insensitive' },
},
});
// Generates: WHERE email ILIKE '%alice%'// Stream rows using PostgreSQL cursors — constant memory, no matter how many rows
for await (const user of db.users.findManyStream({
where: { orgId: 1 },
batchSize: 500, // internal FETCH batch size (default: 1000)
orderBy: { id: 'asc' },
with: { posts: true }, // nested relations work too
})) {
process.stdout.write(`${user.email}\n`);
}Uses DECLARE CURSOR under the hood — rows are fetched in batches on a dedicated connection, parsed individually, and yielded via AsyncGenerator. Safe to break early; the cursor and connection are cleaned up automatically.
const users = await db.users.findMany({
where: { orgId: 1 },
timeout: 5000, // 5 second timeout
});// Set a default limit for all queries on a model
const db = turbine({
connectionString: process.env.DATABASE_URL,
defaultLimit: 100,
});Middleware wraps every query. It runs after SQL generation, so it can observe what's about to execute (params.model, params.action, params.args), measure timing, and transform the result returned by next() — but it cannot change the query itself.
// Query timing
db.$use(async (params, next) => {
const start = Date.now();
const result = await next(params);
console.log(`${params.model}.${params.action} took ${Date.now() - start}ms`);
return result;
});
// Result transformation — redact a field on the way out
db.$use(async (params, next) => {
const result = await next(params);
if (params.model === 'users' && Array.isArray(result)) {
for (const row of result as { email?: string }[]) row.email = '[redacted]';
}
return result;
});Warning:
params.argsis a read-only snapshot — mutating it does not change the executed SQL. The query is fully built and parameterized before middleware runs.
Because middleware can't rewrite queries, cross-cutting filters like soft deletes belong in the query itself — either explicitly or via a small scoped helper:
import type { WhereClause } from 'turbine-orm';
// Explicit filter
const users = await db.users.findMany({ where: { deletedAt: null } });
// Scoped helper that always applies the filter
const activeUsers = (where: WhereClause<User> = {}) =>
db.users.findMany({ where: { ...where, deletedAt: null } });
const rows = await activeUsers({ orgId: 1 });Turbine throws typed errors you can catch programmatically:
import { NotFoundError, ValidationError, TimeoutError } from 'turbine-orm';
try {
const user = await db.users.findUniqueOrThrow({ where: { id: 999 } });
} catch (err) {
if (err instanceof NotFoundError) {
// err.code === 'TURBINE_E001'
console.log('User not found');
} else if (err instanceof TimeoutError) {
// err.code === 'TURBINE_E002'
console.log('Query timed out');
} else if (err instanceof ValidationError) {
// err.code === 'TURBINE_E003'
console.log('Invalid query:', err.message);
}
}Error codes: TURBINE_E001 (NotFound), TURBINE_E002 (Timeout), TURBINE_E003 (Validation), TURBINE_E004 (Connection), TURBINE_E005 (Relation), TURBINE_E006 (Migration), TURBINE_E007 (CircularRelation), TURBINE_E008 (UniqueConstraint), TURBINE_E009 (ForeignKey), TURBINE_E010 (NotNullViolation), TURBINE_E011 (CheckConstraint), TURBINE_E012 (Deadlock), TURBINE_E013 (SerializationFailure), TURBINE_E014 (Pipeline), TURBINE_E015 (OptimisticLock), TURBINE_E016 (ExclusionConstraint).
Full reference with wrapPgError() translation, retry patterns for DeadlockError / SerializationFailureError, and safe vs verbose message modes: turbineorm.dev/errors.
groupBy aggregates rows by one or more columns. Add a having clause to filter the resulting groups by their aggregates. Every comparison value is parameterized.
// Users with more than one post
const prolific = await db.posts.groupBy({
by: ['userId'],
_count: true,
having: { _count: { gt: 1 } },
});
// Groups whose summed view count clears a threshold
const popular = await db.posts.groupBy({
by: ['published'],
_sum: { viewCount: true },
having: { viewCount: { _sum: { gte: 100 } } },
});Filter on the group count with _count, or on a column aggregate with { column: { _sum | _avg | _min | _max: { ... } } }. Operators are gt, gte, lt, lte, in, and notIn (a bare number is shorthand for equality). having predicates combine with AND, and where filters rows before grouping while having filters groups after.
Set transaction-local Postgres settings (GUCs) so PostgreSQL Row-Level Security policies that call current_setting() filter rows for you. Pass sessionContext to $transaction, or use the $withSession shorthand.
// Postgres policy: USING (tenant_id = current_setting('app.current_tenant')::int)
const rows = await db.$transaction(
async (tx) => tx.documents.findMany(),
{ sessionContext: { 'app.current_tenant': tenantId } },
);
// Shorthand for a single-purpose session
const rows2 = await db.$withSession(
{ 'app.current_tenant': tenantId },
async (tx) => tx.documents.findMany(),
);Each entry is applied as SELECT set_config(name, value, true) right after BEGIN, so the setting is scoped to the transaction and resets automatically on commit. Values may be strings, numbers, or booleans (coerced to strings). Invalid setting names throw ValidationError and roll the transaction back before any query runs.
Subscribe to a Postgres channel with $listen and publish to it with $notify. The handler receives the notification payload as a string.
const sub = await db.$listen('order_created', (payload) => {
console.log('new order:', payload);
});
await db.$notify('order_created', JSON.stringify({ id: 1 }));
// Later, when you're done
await sub.unsubscribe();$listen holds a dedicated connection open for the lifetime of the subscription, so it requires a real persistent pool — it is not available over serverless HTTP drivers. $notify is a single round-trip and works everywhere. Channel names are validated as plain identifiers; the payload is always bound as a parameter.
Query a vector column for nearest neighbors. Requires the pgvector extension and a vector column on your table.
KNN ranking — order by distance to a query vector and take the closest rows:
const similar = await db.items.findMany({
orderBy: { embedding: { distance: { to: queryVector, metric: 'cosine' } } },
limit: 5,
});
// queryVector is a number[]; nearest-first by default (direction: 'desc' to invert)Distance filter — keep only rows within a distance threshold:
const close = await db.items.findMany({
where: { embedding: { distance: { to: queryVector, metric: 'l2', lt: 0.3 } } },
});metric selects the pgvector operator: 'l2' → <-> (Euclidean), 'cosine' → <=> (cosine distance), 'ip' → <#> (negative inner product). Distance filters accept lt, lte, gt, and gte. The query vector is always bound as $n::vector — never interpolated.
Note: pg has no built-in parser for the
vectortype, so a fetchedvectorcolumn comes back as a string literal like'[1,2,3]'unless you register a parser (e.g. via pgvector's own client helpers). Querying by distance works regardless.
Every operator supported by the where clause. Operators compose freely with AND, OR, NOT, and the relation filters some / every / none.
| Operator | Description | Example |
|---|---|---|
| literal | Implicit equality | where: { email: 'a@b.com' } |
equals |
Explicit equality | where: { email: { equals: 'a@b.com' } } |
not |
Inequality (or not: null for IS NOT NULL) |
where: { role: { not: 'admin' } } |
| Operator | Description | Example |
|---|---|---|
in |
Match any value in the array | where: { id: { in: [1, 2, 3] } } |
notIn |
Match none of the values in the array | where: { role: { notIn: ['banned', 'spam'] } } |
| Operator | Description | Example |
|---|---|---|
gt |
Greater than | where: { score: { gt: 100 } } |
gte |
Greater than or equal | where: { score: { gte: 100 } } |
lt |
Less than | where: { score: { lt: 100 } } |
lte |
Less than or equal | where: { score: { lte: 100 } } |
| Operator | Description | Example |
|---|---|---|
contains |
Substring match (LIKE %v%) |
where: { title: { contains: 'sql' } } |
startsWith |
Prefix match (LIKE v%) |
where: { email: { startsWith: 'admin@' } } |
endsWith |
Suffix match (LIKE %v) |
where: { email: { endsWith: '@acme.com' } } |
mode: 'insensitive' |
Switch any string operator to ILIKE |
where: { title: { contains: 'SQL', mode: 'insensitive' } } |
LIKE wildcards in user input are escaped automatically — %, _, and \ are treated as literals.
Filter parent rows by predicates against their related child rows. Available on hasMany and hasOne relations.
| Operator | Description | Example |
|---|---|---|
some |
At least one related row matches | where: { posts: { some: { published: true } } } |
every |
Every related row matches | where: { posts: { every: { published: true } } } |
none |
No related row matches | where: { posts: { none: { published: false } } } |
Operators for Postgres array columns (text[], int[], etc.).
| Operator | Description | Example |
|---|---|---|
has |
Array contains the given element | where: { tags: { has: 'sql' } } |
hasEvery |
Array contains every element in the list | where: { tags: { hasEvery: ['sql', 'postgres'] } } |
hasSome |
Array contains at least one element from the list | where: { tags: { hasSome: ['sql', 'mysql'] } } |
| Operator | Description | Example |
|---|---|---|
AND |
All sub-clauses must match | where: { AND: [{ orgId: 1 }, { role: 'admin' }] } |
OR |
Any sub-clause matches | where: { OR: [{ role: 'admin' }, { role: 'owner' }] } |
NOT |
Negate a sub-clause | where: { NOT: { role: 'banned' } } |
npx turbine <command> [options]
Commands:
init Initialize a Turbine project (creates config, dirs, templates)
generate | pull Introspect database and generate TypeScript types + client
push Apply schema-builder definitions to database
migrate create <name> Create a new SQL migration file
migrate create <name> --auto Auto-generate from schema diff
migrate up Apply pending migrations
migrate down Rollback last migration
migrate status Show applied/pending migrations
seed Run seed file
status Show database schema summary
studio Launch local read-only Studio web UI
observe Launch local metrics dashboard (requires TURBINE_OBSERVE_URL)
Options:
--url, -u <url> Postgres connection string
--out, -o <dir> Output directory (default: ./generated/turbine)
--schema, -s <name> Postgres schema (default: public)
--auto Auto-generate migration from schema diff
--dry-run Show SQL without executing
--verbose, -v Detailed output
Define your schema in TypeScript and push it to the database:
// turbine/schema.ts
import { defineSchema } from 'turbine-orm';
export default defineSchema({
users: {
id: { type: 'serial', primaryKey: true },
email: { type: 'text', unique: true, notNull: true },
name: { type: 'text', notNull: true },
orgId: { type: 'bigint', notNull: true, references: 'organizations.id' },
createdAt: { type: 'timestamp', default: 'now()' },
},
});npx turbine push --dry-run # Preview SQL
npx turbine push # Apply to database
npx turbine generate # Regenerate typed client# Create a blank migration (write SQL manually)
npx turbine migrate create add_users_table
# Auto-generate migration from schema diff (compares defineSchema() vs live DB)
npx turbine migrate create add_email_index --auto
# -> Generates UP (ALTER/CREATE) and DOWN (reverse) SQL automatically
# Apply all pending migrations
npx turbine migrate up
# Rollback the last applied migration
npx turbine migrate down
# Check migration status (applied vs pending)
npx turbine migrate statusThe only Postgres ORM with a Studio your DBA will approve. turbine studio launches a local, read-only web UI for exploring your database — no mutations, no writes, and since v0.19 no raw-SQL surface at all: every query is composed visually in the ORM and compiled by the same validated query builder your application uses.
DATABASE_URL=postgres://user:pass@localhost:5432/mydb npx turbine studio
# With flags
npx turbine studio --port 5173 --host 127.0.0.1 --no-openFeatures
- Query / Data / Schema tabs. Compose queries visually, browse rows, and inspect tables and relations.
- ORM-native query composer. The Query tab builds a real
findMany— drill into relations (with) to any depth, pick fields (select/omit), add filters (where),orderBy, andlimitat every level — with a live TypeScript preview of the exact call to copy into your codebase. - Saved queries. Named builder queries persisted to
.turbine/studio-queries.json— share them across runs without committing them. - Cmd+K command palette. Jump to any table, tab, or saved query in one keystroke.
- Full-text search across rows. The Data tab supports substring search across every text column of the current table.
Security posture (read-only by design)
- No SQL input surface. There is nothing to inject into — builder requests are validated identifier-by-identifier against the introspected schema, and every value is bound as a
$Nparameter. - Loopback by default (
127.0.0.1) with a loud warning if you bind to a non-loopback address. - Per-process auth token — 24 random bytes of hex, stored in a
SameSite=StrictHttpOnlycookie. - Every query runs inside
BEGIN READ ONLYwith a 30s transaction-local statement timeout (parameterizedset_config). Writes are physically impossible at the transaction level. - Security headers on every response — CSP,
X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy: no-referrer— plus per-session rate limiting and cross-origin refusal.
Built-in query metrics with zero new dependencies. $observe buffers per-query timings in memory and flushes per-minute aggregates — count, avg, p50, p95, p99, and error count per model:action — to a _turbine_metrics table in a separate database, over its own 1-connection pool so metrics writes never contend with your application pool.
const handle = await db.$observe({
connectionString: process.env.TURBINE_OBSERVE_URL!, // metrics DB (not your app DB)
flushIntervalMs: 60_000, // default: 60s
retentionDays: 30, // default: 30 — older buckets are pruned on flush
});
// Later, to flush remaining metrics and close the metrics pool
await handle.stop();$observe creates the _turbine_metrics table if it doesn't exist. Flushes are fire-and-forget (INSERT ... ON CONFLICT additive merge) and never throw into your application. If the TURBINE_OBSERVE_URL environment variable is set, the client starts observing automatically on construction — no code needed.
For your own instrumentation, subscribe to query events with $on('query') — each event carries sql, params, duration (ms), model, action, rows, timestamp, and error (if the query failed):
db.$on('query', (e) => {
if (e.duration > 200) {
console.warn(`slow query: ${e.model}.${e.action} (${e.duration.toFixed(1)}ms, ${e.rows} rows)`);
}
});View the collected metrics in a local dashboard:
TURBINE_OBSERVE_URL=postgres://... npx turbine observe
# Flags: --port (default 4984), --host (default 127.0.0.1), --no-openSame security model as Studio: loopback binding by default, per-process random auth token in an HttpOnly cookie, CSP headers, and read-only access to the metrics table.
Turbine's core is driver-agnostic: pass any pg-compatible pool to TurbineConfig.pool (or use the turbineHttp() factory) and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, Netlify Edge, or any other environment where a direct TCP connection is unavailable. No new dependencies — install whichever driver you already use.
// app/api/users/route.ts
import { Pool } from '@neondatabase/serverless';
import { turbineHttp } from 'turbine-orm/serverless';
import { schema } from '@/generated/turbine/metadata';
export const runtime = 'edge';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const db = turbineHttp(pool, schema);
export async function GET() {
const users = await db.table('users').findMany({
with: { posts: { with: { comments: true } } },
limit: 10,
});
return Response.json(users);
}import { createPool } from '@vercel/postgres';
import { turbineHttp } from 'turbine-orm/serverless';
import { schema } from './generated/turbine/metadata.js';
const pool = createPool({ connectionString: process.env.POSTGRES_URL });
const db = turbineHttp(pool, schema);import { TurbineClient } from 'turbine-orm';
import { schema } from './generated/turbine/metadata.js';
const db = new TurbineClient({
connectionString: process.env.SUPABASE_DB_URL,
ssl: { rejectUnauthorized: false },
}, schema);import { Pool } from '@neondatabase/serverless';
import { turbineHttp } from 'turbine-orm/serverless';
import { schema } from './generated/turbine/metadata';
export default {
async fetch(req: Request, env: Env) {
const pool = new Pool({ connectionString: env.DATABASE_URL });
const db = turbineHttp(pool, schema);
const users = await db.table('users').findMany({ limit: 10 });
return Response.json(users);
},
};- Streaming cursors (
findManyStream) requireDECLARE CURSOR, which most HTTP drivers don't support. UsefindManywithlimit+ pagination instead. - LISTEN/NOTIFY is not available over HTTP.
- Transactions work but hold an HTTP connection for their duration — keep them short.
When Turbine receives an external pool, db.disconnect() is a no-op: the caller owns the pool's lifecycle.
Create turbine.config.ts in your project root (or run npx turbine init):
import type { TurbineCliConfig } from 'turbine-orm/cli';
const config: TurbineCliConfig = {
url: process.env.DATABASE_URL,
out: './generated/turbine',
schema: 'public',
migrationsDir: './turbine/migrations',
seedFile: './turbine/seed.ts',
schemaFile: './turbine/schema.ts',
};
export default config;Priority order: CLI flags > environment variables (DATABASE_URL) > config file > defaults.
Turbine resolves nested relations the same way Prisma 7 and Drizzle v2 do: correlated subqueries with json_agg + json_build_object, evaluated by PostgreSQL in a single round-trip. No N+1, no client-side stitching, no separate queries per relation. The with clause is fully type-inferred end-to-end — write db.users.findMany({ with: { posts: { with: { comments: { with: { author: true } } } } } }) and users[0].posts[0].comments[0].author.name autocompletes with zero manual annotation.
The query strategy is table stakes now. What isn't table stakes: the one-dependency, no-WASM footprint, the read-only Studio your DBA will approve, the error messages that never leak PII, and the SQL-first migrations with SHA-256 drift detection. See Why Turbine? for the full breakdown.
Turbine maps Postgres types to TypeScript:
| Postgres | TypeScript | Notes |
|---|---|---|
int2, int4, float4, float8 |
number |
Standard numeric types |
int8 / bigint |
number |
Values > Number.MAX_SAFE_INTEGER (2^53 - 1) are returned as string at runtime to avoid precision loss. This affects < 0.01% of use cases (auto-increment IDs, counts, etc. are all safe). |
numeric, money |
string |
Arbitrary precision — kept as string to avoid JS float issues |
text, varchar, uuid, citext |
string |
|
timestamptz, timestamp, date |
Date |
|
boolean |
boolean |
|
json, jsonb |
unknown |
|
bytea |
Buffer |
|
| Array types | T[] |
e.g. _text → string[] |
| Turbine | Prisma | Drizzle | Kysely | |
|---|---|---|---|---|
| Engine / runtime | No engine binary (pg only) |
Client + 1.6 MB WASM engine | No engine | No engine |
| Runtime deps | 1 (pg) |
@prisma/client + adapter |
0 | 0 |
| Main bundle (brotli) | ~31 kB | dominated by 1.6 MB WASM | ~7 KB core | small |
| Studio | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Paid tier | None |
| Error PII safety | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
| Migrations | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
| Edge runtime | One import swap, ~22 kB brotli | 1.6 MB WASM adapter | Native | Native |
| Pipeline batching | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
| Typed errors | isRetryable discriminant |
Error codes only | None | None |
| Nested relations | 1 query, deep type inference | 1 query, shallow inference | 1 query, relations() re-declaration |
Manual (jsonArrayFrom) |
| Many-to-many | Auto-detected from junctions | Implicit/explicit | Explicit relations() |
Manual joins |
| Vector search | Built-in distance / KNN |
Preview / raw | Extension API | Manual |
| LISTEN/NOTIFY | $listen / $notify |
None | None | None |
| Multi-DB | PostgreSQL only | PG, MySQL, SQLite, MSSQL | PG, MySQL, SQLite | PG, MySQL, SQLite |
All three ORMs now do single-query nested loads — that's table stakes. Turbine's real differentiators: no engine binary or WASM — just one dependency (pg), vs Prisma's 1.6 MB WASM query engine; the only read-only Studio in the ecosystem; error messages that never leak PII; and SQL-first migrations with SHA-256 drift detection. See Benchmarks for performance numbers — most scenarios are within noise over a real pooled database.
Turbine is focused and opinionated. Here's what it doesn't do:
- PostgreSQL only. No MySQL, SQLite, or MSSQL. By design — going deep on one database enables the performance advantage and the edge-runtime story.
- Full-text search is available via a
searchfilter —where: { title: { search: 'hello & world', config: 'english' } }compiles to a parameterizedto_tsvector(...) @@ to_tsquery(...). For advanced ranking (ts_rank, weighted vectors) usedb.raw. - Large nested result sets. Nested results are materialized server-side in PostgreSQL memory. For relations with 10K+ rows, always use
limitin yourwithclause — or stream the parents withfindManyStreamand resolve children per-row.
Feature demos
- Thread Machine — HN clone rendered from a single
findMany. 4-level object graph (stories → comments → replies → author), every property autocompletes through the chain - Streaming CSV — Export 100K orders + line items to CSV with constant memory. PostgreSQL cursors, live heap meter, nested
withinsidefindManyStream - Clickstorm — Side-by-side atomic-increment vs read-modify-write load test. 10K concurrent clicks. The atomic path wins every time
Runtime targets
- Next.js — Server-rendered app with nested relations, streaming, and live code demos
- Neon Edge — Vercel Edge route handler talking to Neon over HTTP via
@neondatabase/serverless - Vercel Postgres — Next.js app router route handler on
@vercel/postgres - Cloudflare Worker — Worker
fetchhandler withpgover Cloudflare Hyperdrive - Supabase — Standalone script over the standard
pgdriver against Supabase
- Quick Start — zero-to-first-query in five minutes
- API Reference — every
findMany/findUnique/create/update/deleteoption, the full operator table, andpipeline()semantics - Relations — deep
withclause, nested options, relation filters (some/every/none), payload-size guidance - Transactions & Pipelines — isolation levels, nested SAVEPOINTs, retry loops for
DeadlockErrorandSerializationFailureError - Schema & Migrations —
defineSchema(), auto-diff migrations, checksum validation - Serverless & Edge — Neon, Vercel Postgres, Cloudflare Hyperdrive, Supabase walkthroughs
- CLI — every command, flag, and config option
- Typed Errors — error code reference,
wrapPgError()translation, retry patterns - Migrating from Prisma — API mapping table, side-by-side
findMany, and notes on the differences
- Node.js >= 18.0.0
- PostgreSQL >= 14
- Works with both ESM (
import) and CommonJS (require)
Contributions are welcome. See CONTRIBUTING.md for development setup, the test strategy, and the PR checklist. The unit suite runs without a database:
npm install
npm run test:unitIntegration tests need a PostgreSQL instance via DATABASE_URL (see CONTRIBUTING.md for a one-command seeded setup).
MIT