v0.65 · offset without limit was a syntax error on SQLite and MySQL

The Postgres ORM that assumes your database
has real data in it.

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.

$npm install turbine-orm

Evaluating against Prisma, Drizzle or Kysely? Why Turbine says what is genuinely different, and what is table stakes in 2026.

query.ts
export default defineSchema({
  users: {
    id:    { type: 'serial', primaryKey: true },
    name:  { type: 'text', notNull: true },
    email: { type: 'text', notNull: true, pii: true },
    //                                    ^^^^^^^^^
  },
});

// That one flag changes what the SQL is allowed to say:
//
//  1. the column is left out of every default projection,
//     on every engine: top-level rows, 'with' subqueries,
//     batched loaders, write returns. It is omitted from
//     the emitted SQL, not filtered out afterwards.
//  2. it is refused as a groupBy key and as a _min / _max
//     target, because both hand back a stored cell.
//  3. Studio renders it redacted, and refuses to filter,
//     sort or page on it.

import { UNSAFE } from 'turbine-orm';

await db.users.findMany();                       // no email
await db.users.findMany({ includePii: UNSAFE }); // email
// includePii: true throws. A privilege option cannot be
// enabled by a value JSON.parse can produce, or a spread
// request body would unlock it.

Five defaults, one assumption.

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".

0write endpoints by default

The database UI is read-only by default

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.

pii: trueenforced in the projection

PII is a schema contract the SQL enforces

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.

keysnot values

Errors carry keys, never values

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.

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" 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().

doctorno account required

The review a DBA would have given you, offline

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.

None of that costs you the database.

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.

Tooling your DBA will thank you for.

One dependency. No WASM.

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 doctor: the missing-index advisor

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

Multi-engine, one typed API

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

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 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

Edge-native, one import swap

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

Real pipelining, not a batch transaction

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

Coming from Prisma? Keep your call sites.

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

Coming from Drizzle?

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

MCP server for AI agents

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

One query. Any depth.

Your code writes one call. Turbine writes one query.

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.

  • Correlated subqueries with json_agg + json_build_object
  • 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".*,
  (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" = $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)~60 KB import graph, pg external~1.6 MB client (TS/WASM compiler)~7 KB core
StudioRead-only by default, 192-bit authFull CRUD, cloud-hostedDrizzle Studio (free; Gateway paid)
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, ~45 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

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.

Start building

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