Most query layers are designed for the shape of a laptop database: empty, disposable, nobody's. Turbine is designed for the same schema six months later. The database UI is read-only until you say otherwise. Columns you tag as personal data stay out of results, logs, and aggregates. Anything that can lose data makes you say so out loud. Underneath, it is Postgres-maximalist: typed pgvector, RLS sessions, and realtime are all first-class.
Evaluating against Prisma, Drizzle or Kysely? Why Turbine says what is genuinely different, and what is table stakes in 2026.
Designed for a database with real rows in it
Each of these is checkable, so here is the checkable version, as of July 2026. No other TypeScript ORM ships a studio that is read-only by default or that redacts PII: Prisma Studio is open source (@prisma/studio-core is Apache-2.0) but offers no read-only mode, its read-only request has been open since February 2021, Drizzle Studio is not open source and self-hosting runs through the paid Drizzle Gateway, and TypeORM, MikroORM, Kysely and Sequelize have no studio at all. No TypeScript ORM CLI offers missing-index advice, and Prisma Optimize was retired in March 2026 in favour of cloud-only Query Insights. Prior art exists outside TypeScript, notably Ruby's active_record_doctor, so the honest claim is "no TypeScript ORM", not "no ORM".
npx turbine studio binds loopback, authenticates with a 192-bit per-process token, and runs every read inside BEGIN READ ONLY. In the default mode the write endpoints do not exist in the router at all, so there is nothing to bypass. There has been no raw-SQL surface since v0.19: queries are composed in the ORM builder and validated identifier by identifier. --write opts one launch in to edits, each addressed by its full primary key rather than a predicate. Try it with no database: npx turbine-orm@latest studio --demo boots a seeded in-memory sample DB with a live Read-only / Show PII / Write switcher.
Tag a column pii: true and it is excluded from every default projection on every engine: top-level rows, with subqueries, batched loaders, write returns, and Studio. It is also refused as a groupBy key and as a _min / _max target, because both hand back a stored cell. includePii: UNSAFE unlocks it explicitly, per read, and the symbol is the only value that works: a request body spread into query args cannot escalate, because JSON.parse cannot produce a symbol. A schema with no tagged column emits byte-identical SQL.
A NotFoundError says where: { id, email }. A UniqueConstraintError names the column that conflicted. Neither prints the row. That means the error is safe to forward straight to Sentry or Datadog with no scrubbing rule in front of it, and the full where object is still 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" and then yes; in CI you pass --allow-destructive. A refused batch applies nothing. Migrations are real SQL, checksummed with SHA-256 and serialized behind pg_try_advisory_lock().
npx turbine doctor derives every column set the relation subqueries probe and reports the ones with no covering index, with a cost tier per finding. --fix writes the migration. In dev the first query over an unindexed FK logs the exact CREATE INDEX. No cloud service, no telemetry, no account: it reads your introspected schema.
And it is still Postgres-maximalist underneath
Safe defaults usually arrive as a lowest-common-denominator API that can only do what every database can do. Turbine goes the other way: Postgres is the primary target, and the parts of it that other query layers push you to raw SQL for are typed, first-class surface.
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. No extension, no extra service.
Operator docs→Pure junction tables are detected at generate time, db.posts.findMany({ with: { tags: true } }) just works. Self-relations too: a self-referencing FK gives you parent + children.
Relations docs→db.$on("query") taps every query with PII-redacted params. db.$observe() flushes p50/p95/p99 aggregates to Postgres, and npx turbine observe is the dashboard. No agent, no SaaS.
Observability docs→Ship it and sleep
Turbine ships pg and nothing else, no WASM at all. Prisma 7 dropped its Rust engine but its client still bundles a TS/WASM query compiler (~1.6 MB) plus a required driver adapter. The main entry is held under 77 KB brotli as an import graph with pg external, under 61 KB on the edge, enforced by size-limit in CI rather than quoted from a past measurement. That is the client footprint your bundler sees, not the size of the dual ESM+CJS build on disk, which is larger.
Benchmarks→Turbine loads relations as correlated subqueries, so an unindexed foreign key becomes a full scan per parent row. npx turbine doctor finds every missing FK index before it hits production, and turbine doctor --fix writes the add-index migration for you. In dev, the first query over an unindexed FK also logs the exact CREATE INDEX. The check your DBA would have asked for, run for you.
Doctor docs→Postgres is the default and the primary target, but the same findMany / with / where surface runs on SQLite, MySQL 8, SQL Server, and PowDB through subpath exports (turbine-orm/sqlite, /mysql, /mssql, /powdb). npm install turbine-orm still pulls exactly one runtime dependency; each engine driver is an optional peer you install only if you use it.
Engines docs→Every table accessor has explain(args): it compiles the exact statement findMany(args) would run and returns the engine plan as string[] lines. Verify the query the ORM actually emits hits the index you expect, mapped to EXPLAIN / EXPLAIN QUERY PLAN per engine.
explain() docs→turbineHttp(pool, SCHEMA) gives you the same API on Neon, Vercel Postgres, Cloudflare Hyperdrive, and Supabase. No WASM bundle to ship, no adapter package to install, no separate serverless build step. ~45 KB brotli as an import graph with the driver external.
Serverless docs→db.pipeline(...) uses the Postgres extended-query protocol (Parse/Bind/Execute/Sync) to put N independent queries in one TCP flush. node-postgres does not expose pipelining in its pure-JS core, and Drizzle db.batch() is an implicit transaction on specific drivers rather than independent-query pipelining. Write builders batch too, so a create + createMany + update can go out as one atomic $transaction([...]).
Pipeline docs→turbine migrate-from-prisma reads your schema.prisma and emits a typed mapping plus a migration report. Then createPrismaCompatClient wraps a TurbineClient in a PrismaClient-shaped surface: prisma.user.findMany({ include }) keeps working unchanged, so a port is measured in hours rather than in call sites. It is a runtime shim, not a codemod, so it never edits your source and you can move modules to the native API on your own schedule.
Prisma migration guide→The full API mapping, the schema translation, and the behavioural differences worth auditing before you cut over: the empty-where guard, relation declaration, and where the two query builders disagree about defaults.
Drizzle migration guide→turbine mcp exposes your database to Claude Code, Cursor, or any MCP client over JSON-RPC stdio. Read-only tools only, no free-form SQL: schema overview, table detail, migrate status, doctor report, EXPLAIN, and sample rows, all inside BEGIN READ ONLY. The same safety stance as Studio.
MCP docs→How it works
This is table stakes, and it is here for correctness rather than as a selling point. Drizzle has compiled relational queries to LEFT JOIN LATERAL plus JSON aggregation since 0.28, Prisma does the same under its relationJoins preview flag, and Kysely ships jsonArrayFrom / jsonObjectFrom helpers. Turbine uses correlated json_agg subqueries. What is worth reading below is how the nesting stays correct at depth: empty relations, per-relation limits, and types that survive the JSON round-trip.
SELECT "users".*,
(SELECT COALESCE(json_agg(json_build_object(
'id', t0."id",
'title', t0."title",
'comments', (SELECT COALESCE(json_agg(json_build_object(
'id', t1."id",
'body', t1."body"
)), '[]'::json) FROM "comments" t1
WHERE t1."post_id" = t0."id")
)), '[]'::json)
FROM (SELECT * FROM "posts"
WHERE "posts"."user_id" = "users"."id"
ORDER BY "posts"."created_at" DESC
LIMIT 5) t0
) AS "posts"
FROM "users"
WHERE "users"."org_id" = $1Comparison
The longer, more honest version of this table, including what is not a reason to switch, is on Why Turbine. Comparison as of July 2026, against Prisma 7 and Drizzle 0.45. Competitor features marked Preview or beta may change, and bundle sizes move release to release. Turbine's bundle-size and performance claims are measured on the benchmarks page.
Building nested reads by hand? Kysely's jsonArrayFrom recipe uses the same correlated-subquery-plus-JSON approach — proof the pattern is right. But once rows are aggregated into JSON the driver can no longer see their types, so a Date inside a jsonArrayFrom result is typed Dateyet arrives as a string, and the nesting isn't type-checked at depth. Turbine types the whole tree and re-applies date coercion to every nested row, so users[0].posts[0].createdAt is a real Date at any depth — no plugin to wire up.
One install, one generate, one query. Get a typed Postgres client in under two minutes.