v0.79 · Failed raw and batch statements reported as successes after minification

A Postgres ORM written from scratch.
One dependency.

Turbine compiles typed queries straight to SQL: no query engine, no WASM, nothing between your code and Postgres but pg. Nested relations resolve in one statement. Small enough for a Worker, close enough to hand-written SQL that the benchmarks publish the control arm, and built so a coding agent can explore your schema through read-only tools instead of guessing. MIT, with the engine seam documented if you want to fork it.

$npm install turbine-orm

Evaluating against Prisma, Drizzle or Kysely? Why Turbine makes the case, and says what is not a reason to switch.

query.ts
const users = await db.users.findMany({
  where: { orgId: 1 },
  with: {
    posts: {
      with: { comments: { with: { author: true } } },
      orderBy: { createdAt: 'desc' },
      limit: 5,
    },
  },
});

// One SQL statement, any depth, typed end to end:
users[0].posts[0].comments[0].author.name
//                                  ^ autocompletes

// Everything between your code and Postgres:
//   "dependencies": { "pg": "^8.13.1" }

Five claims, each checkable.

Every number here traces to a source you can run: the benchmark results file, the size-limit config in CI, or the package's own dependencies field. Nothing on this page is a mood.

1runtime dependency

Written from scratch, on pg alone

No query engine, no WASM compiler, no query-builder library underneath. Query compilation is plain string building into a bounded LRU of SQL templates, and dependencies is one line: pg. The optional engines (SQLite, MySQL, SQL Server, PowDB) are peer dependencies or Node builtins you install only if you use them.

1query, any depth

Nested relations, one statement

A with clause compiles to correlated json_agg subqueries, so users with posts with comments is one round trip. The result is typed end to end at any depth, with no manual annotation. Four load strategies produce identical rows: join, batched and auto are held to byte-identical output by a differential fuzz suite, flatten by its own parity suite.

1.08xvs raw pg (last run)

Close to hand-written SQL

In the last published run, Turbine ran at 1.08x a hand-written pg control by geometric mean, where Drizzle ran at 1.47x and Prisma at 1.81x. Losses are published with the wins: the row-at-a-time streaming API still trails the Drizzle 1.0 release candidate, and the benchmarks page states the noise floor next to the numbers.

91 kBCI-enforced ceiling

Small enough for the edge

The main entry is held under 91 kB brotli as an import graph with pg external, the edge entry under 72 kB, enforced by size-limit in CI. One import swap runs the same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, and Supabase. No separate serverless build, no WASM bundle in your cold start.

MITeverything in the box

MIT, no cloud tier

Studio, doctor, the MCP server, observability: everything named on this site is in the npm package. No paid tier, no telemetry, no account. All SQL generation routes through a documented Dialect contract, so if you need an engine Turbine does not ship, you extend the seam instead of forking the core.

Typed tools for agents, not a SQL prompt.

npx turbine mcp ships in the package: a read-only MCP server with eleven tools, every one running inside BEGIN READ ONLY with PII-tagged columns redacted before rows reach a model. An agent explores the schema, finds the join path, checks the plan, and reads the error catalog through typed tools. It cannot write, and there is no free-form SQL input to talk it into. The rest is structural: typed query args turn a wrong query into a compile error an agent can read, and stable error codes give it something to branch on.

relation_graph

The whole relation graph, or one table’s subtree: cardinality, keys, junction tables.

find_join_path

How to get from comments to orgs: the relation chain and the with clause to write.

table_stats

Planner row estimate, on-disk size, indexes. Reports analyzed: false instead of guessing 0.

explain_query

EXPLAIN for a schema-validated findMany plan. No free-form SQL input exists.

explain_error

A Turbine error code mapped to cause, fix, and docs link.

sample_rows

Up to 50 rows, PII-tagged columns redacted before they reach the model.

compile_query

The exact SQL a read query compiles to, without running it. Zero database writes, zero execution.

Plus schema_overview, table_detail, migrate_status, and doctor_report. Setup for Claude Code and Cursor, a drop-in instructions snippet, and llms.txt: Turbine for AI agents.

The dangerous operations ask first.

The same posture the agent tools inherit, applied everywhere: reads are safe by construction, and anything that can lose or leak data requires an explicit, unspoofable opt-in.

0write endpoints by default

The database UI is read-only by default

npx turbine studio binds loopback, authenticates with a per-process token, and runs every read inside BEGIN READ ONLY. Without --write, the write endpoints do not exist in the router, so there is nothing to bypass. There is no raw-SQL surface: queries are composed in a builder validated identifier by identifier. Try it with no database: npx turbine-orm@latest studio --demo.

pii: trueenforced in the projection

PII is enforced in the emitted SQL

Tag a column pii: true and it is excluded from every default projection at the SQL level: RETURNING "id", "name" instead of RETURNING *. It is also refused as a groupBy key and a _min / _max target. Reading it back takes includePii: UNSAFE, a symbol, so a request body spread into query args cannot unlock it: JSON.parse cannot produce a symbol.

keysnot values

Errors carry keys, never values

A NotFoundError says where: { id, email } without printing the email. A UniqueConstraintError names the column that conflicted. Errors are safe to forward to your tracker with no scrubbing rule, and the full where object stays available as err.where in code.

SHA-256checksums + refusal

Destructive migrations need consent

migrate up, migrate down and push scan for DROP TABLE, DROP COLUMN, TRUNCATE, unqualified DELETE and UPDATE, and ALTER COLUMN ... TYPE, print an itemized report, and refuse to run. Interactively you type "destroy my data", then yes; in CI you pass --allow-destructive. A refused batch applies nothing. Migrations are SQL, checksummed with SHA-256.

doctorno account required

Index advice, offline

Turbine loads relations as correlated subqueries, so an unindexed FK is a scan per parent row. npx turbine doctor reports every relation column set with no covering index, with a cost tier per finding, and --fix writes the migration. It reads your schema and your database’s own statistics: no cloud service, no account.

The parts other ORMs push to raw SQL are typed here.

Tooling your DBA will sign off on.

Streaming with a true cursor

findManyStream iterates any result set with constant memory over DECLARE CURSOR, on a dedicated connection. Any orderBy, safe early break, nested with per batch.

Streaming docs →

Global filters and the UNSAFE symbol

Soft delete and multi-tenancy as client config: a WhereClause AND-merged into every query on a table. Opting out takes a symbol JSON.parse cannot produce, so a spread request body cannot disable tenancy.

Global filters docs →

Multi-engine, one typed API

The same findMany / with / where surface runs on SQLite (node:sqlite, zero installs), MySQL 8, SQL Server, and PowDB through subpath exports. Postgres-only features throw a typed error instead of degrading silently.

Engines docs →

explain() without dropping to raw

Every table accessor has explain(args): it compiles the exact statement findMany(args) would run and returns the engine plan. Verify the query the ORM emits hits the index you expect.

explain() docs →

Observability, in the box

db.$on("query") taps every query with params redacted by default. db.$observe() flushes p50/p95/p99 aggregates to Postgres, and npx turbine observe is the dashboard. No agent, no SaaS.

Observability docs →

Typed errors with stable codes

Every error carries a code (TURBINE_E001..E018) and a docs link. Retryable failures expose isRetryable: true as a typed const, so a retry loop is compiler-checked.

Error reference →

Coming from Prisma? Keep your call sites.

turbine migrate-from-prisma reads schema.prisma and emits a typed mapping. createPrismaCompatClient then wraps Turbine in a PrismaClient-shaped surface, so prisma.user.findMany({ include }) keeps working while you port module by module.

Prisma migration guide →

Coming from Drizzle?

The API mapping, the schema translation, and the behavioural differences worth auditing before you cut over: the empty-where guard, relation declaration, and differing defaults.

Drizzle migration guide →

One query. Any depth.

Your code writes one call. Turbine writes one query.

A nested read is one statement, at any depth. Turbine compiles with into correlated json_agg + json_build_array subqueries, so ten users with their posts and each post's comments is a single round trip, not an N+1 cascade. The default auto strategy keeps that plan, falling back to one flat follow-up statement for a relation whose correlation column has no covering index. The rows are identical either way.

The part that takes the work is staying correct at depth: an empty relation returns [] and never null, per-relation limit and orderBy apply per parent rather than to the whole result, and every type survives the JSON round trip, dates included. The join, batched and auto strategies are held to byte-identical output by a differential fuzz suite, and flatten to the same parity by its own suite.

  • ✓Correlated subqueries with json_agg + json_build_array
  • ✓COALESCE ensures empty relations return [] not null
  • ✓Inner subquery wrapping for per-relation LIMIT/ORDER BY
  • ✓Pipeline batching via real Parse/Bind/Execute protocol
  • ✓SQL template caching with FNV-1a shape fingerprinting
Generated SQL
SELECT "users"."id", "users"."name", "users"."email",
  (SELECT COALESCE(json_agg(json_build_array(
    t0i."id"::text,
    t0i."title",
    COALESCE((SELECT COALESCE(json_agg(json_build_array(
      t1."id"::text,
      t1."body"
    )), '[]'::json) FROM "comments" t1
      WHERE t1."post_id" = "t0i"."id"), '[]'::json)
  )), '[]'::json)
  FROM (SELECT t0."id", t0."title" FROM "posts" t0
    WHERE t0."user_id" = "users"."id"
    ORDER BY t0."created_at" DESC
    LIMIT $2) t0i
  ) AS "posts"
FROM "users" WHERE "org_id" = $1

Turbine vs. Prisma vs. Drizzle

TurbinePrismaDrizzle
Engine / runtimeNo engine binary (pg only)Client + TS/WASM query compilerNo engine
Runtime deps1 (pg)@prisma/client + required driver adapter0
Main bundle (brotli)under 91 KB import graph, pg external~1.6 MB client (TS/WASM compiler)~7 KB core
StudioRead-only by defaultFull CRUD, cloud-hostedDrizzle Studio (free)
Index adviceturbine doctor, offline, --fixOptimize retired (cloud Query Insights)None
MCP server for agents11 read-only tools, PII-redactedOfficial MCP serverdrizzle-kit mcp
Error PII safetyKeys only by defaultValues in messagesRaw pg errors
MigrationsSQL-first, SHA-256 drift detectionDSL-generated, shadow DBSQL or Drizzle Kit
Edge runtimeOne import swap, under 72 KB brotliDriver adapter + WASM compilerNative
Pipeline batchingParse/Bind/Execute protocolSequential in txnSequential
Typed errorsisRetryable discriminantError codes onlyNone
Nested relations1 query, deep type inference1 query (relationJoins, Preview), shallow inference1 query (lateral + JSON agg), relations() re-declaration
Many-to-manyAuto-detected from junctionsImplicit/explicitExplicit relations()
Vector searchBuilt-in distance / KNNPreview / rawExtension API
LISTEN/NOTIFY$listen / $notifyNoneNone

Competitor columns last checked August 2026, against Prisma 7 and Drizzle 0.45. Features marked Preview may change, and bundle sizes move release to release. The longer version of this argument, including what is not a reason to switch, is on Why Turbine; performance claims are measured on the benchmarks page.

Start building

One install, one generate, one query. A typed Postgres client in under two minutes.