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.
Evaluating against Prisma, Drizzle or Kysely? Why Turbine makes the case, and says what is not a reason to switch.
What you are actually installing
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.
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.
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.
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.
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.
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.
Point an agent at it
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.
The whole relation graph, or one table’s subtree: cardinality, keys, junction tables.
How to get from comments to orgs: the relation chain and the with clause to write.
Planner row estimate, on-disk size, indexes. Reports analyzed: false instead of guessing 0.
EXPLAIN for a schema-validated findMany plan. No free-form SQL input exists.
A Turbine error code mapped to cause, fix, and docs link.
Up to 50 rows, PII-tagged columns redacted before they reach the model.
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.
Designed for a database with real rows in it
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.
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.
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.
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.
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.
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.
Postgres-first underneath
KNN ranking and distance filters over vector columns, orderBy: { embedding: { distance: { to, metric: "cosine" } } }. l2 / cosine / inner-product, every value bound as a parameter.
Vector docs →Postgres pub/sub with db.$listen(channel, handler) and db.$notify(channel, payload). No broker, no extra service: your database is the message bus.
Realtime docs →Multi-tenant isolation the database enforces. $transaction(fn, { sessionContext }) sets transaction-local GUCs so Row-Level Security policies filter rows for you.
Transactions docs →where: { body: { search: 'postgres & orm' } } compiles to to_tsvector @@ to_tsquery with the query bound as a parameter. Pick any text search config.
Operator docs →Pure junction tables are detected at generate time, so db.posts.findMany({ with: { tags: true } }) needs no declaration. A self-referencing FK gives you parent and children.
Relations docs →db.pipeline(...) uses the extended-query protocol (Parse/Bind/Execute/Sync) to put N queries in one TCP flush. Wire pipelining, not a batch transaction. Write builders batch too.
Pipeline docs →And the rest of the box
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 →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 →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 →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 →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 →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 →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 →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 →How it works
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.
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" = $1Comparison
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.
One install, one generate, one query. A typed Postgres client in under two minutes.