Migrating from Prisma

Turbine is a Postgres-native TypeScript ORM with a Prisma-inspired API. If you're on Prisma but want a smaller dependency footprint, edge/serverless without an extra adapter, and a code-first schema (no .prisma DSL), Turbine is a near drop-in.

Two ways to migrate#

Pick one deliberately. They compose: you can start with the adapter and port modules to native calls over time.

Compat adapterNative port
What you runturbine migrate-from-prisma, then wrap the client in createPrismaCompatClientRename include to with, re-point imports
Call sitesUnchanged. prisma.user.findMany({ include }) keeps workingRewritten to db.users.findMany({ with })
Time to first green buildHoursProportional to your call-site count
CeilingA documented set of Prisma features it will not translateThe full Turbine API

The adapter is a runtime shim, not a codemod: it exposes a PrismaClient-shaped surface over a TurbineClient and translates arguments on the way in and results on the way out. It never edits your source. Nothing is rewritten for you, so a git diff after adopting it touches your client construction and nothing else.

One step is mandatory on either path before you benchmark: run npx turbine doctor, covered next. Then read Silent value differences, which applies to both paths.

Before anything else: run doctor#

This is the one migration step that bites people, so do it before you benchmark anything.

Prisma does not create an index on a relation's foreign key on Postgres, a @relation scalar field is unindexed unless you add @@index yourself. Prisma gets away with it because it loads relations by batching: one WHERE fk IN (ids) query per relation, which pays a missing FK index only once. So most Prisma schemas quietly ship without FK indexes and never notice.

Turbine's join plan probes the child table once per parent row with a correlated subquery. On an indexed FK that's an index seek per parent and it's fast. On an unindexed FK it's a full table scan per parent, which multiplies by the parent count. The same missing index that was invisible under Prisma becomes the whole query.

The fix is one command. Measured on a production-shaped dataset (659 parents, a 357K-row child table), adding the index took the correlated plan from 17.8 s to 62 ms, a ~290x difference, and by far the largest single number on this page.

StrategyTime
Correlated, FK unindexed17.8 s
Batched (WHERE fk IN (…))92 ms
Correlated, FK indexed62 ms

Do not read the last two rows as "correlated beats batched once indexed." An earlier version of this page made that argument, and it does not generalize: on a to-one relation over a local socket, the batched loader is measurably faster than the correlated plan from a few dozen parent rows upward (the arithmetic in The real tradeoff puts the crossover in the tens), and the gap widens with parent count. Turbine's own 'auto' default no longer follows the correlated-wins advice either: above autoToOneJoinMaxRows (default 1000) it switches a to-one relation to the batched loader precisely because the correlated plan loses at scale.

The real tradeoff#

The two plans trade a different cost, and neither wins everywhere:

  • Correlated ('join') is one round-trip, but the subquery is re-evaluated once per parent row. Its cost scales with parent-set size, even on a perfect index seek. Turbine's shipped constant for that per-parent cost is 0.0007 ms per parent row (AUTO_JOIN_PENALTY_MS_PER_ROW), measured on PostgreSQL 17 over a hasOne on a UNIQUE FK. The measurement's most useful property is that the per-row penalty came out effectively identical (0.000711 ms and 0.000717 ms) on two links whose round-trip times differ by 23x: it is a property of the plan, not of the wire.
  • Batched is two round-trips (base query, then WHERE fk = ANY($1)), but each is a single keyset lookup whose cost is essentially flat in parent count. It pays one extra round-trip and nothing more.

So the crossover is roundTripMs / 0.0007, which depends on both parent-set size and your round-trip time. Every row below is that division, not a separate measurement:

DeploymentRound tripCorrelated wins below roughly
Local socket / same host~0.03 ms43 parent rows
Loopback TCP~0.12 ms170 parent rows
Same-region managed Postgres~1 ms1,400 parent rows
Cross-region pooled connection~35 ms50,000 parent rows

The two rows that were measured rather than derived agree with the arithmetic: on loopback TCP (0.118 ms) the observed crossover sat between 200 and 400 parent rows, and over a link with 1 ms added per direction (2.683 ms) between 3,000 and 5,000.

The default threshold of 1000 corresponds to a database about 0.7 ms away (AUTO_ASSUMED_ROUND_TRIP_MS), a reasonable guess at same-region managed Postgres, and it is what the 'auto' strategy uses until the process has measured its own round-trip time. It is far too high for a local socket and too low for a cross-region pooled connection. If your database is on the same host or a very low-latency link, lower autoToOneJoinMaxRows substantially (into the tens) or pin relationLoadStrategy: 'batched' on wide-parent queries. If it is far away, raise it.

The numbers above are measured on a to-one relation with a perfectly indexed correlation column, on a local socket. A to-many relation, a wider child row, or a real network moves the crossover. Measure your own shape before pinning a strategy; the results are deep-equal whichever plan runs, so switching is free.

Right after you generate your client, run:

npx turbine doctor --fix

doctor introspects your database, finds every relation whose child-side FK (or many-to-many junction key) lacks a covering index, and --fix writes a migration that adds them. Review it and apply with npx turbine migrate up. See CLI → doctor for the full report format and the CREATE INDEX CONCURRENTLY note for large tables.

If you can't add the index yet: pick a load strategy#

Until the index migration lands you can reproduce Prisma's loading pattern with relationLoadStrategy: 'batched', one WHERE fk = ANY(...) follow-up per relation instead of a correlated probe. It pays a missing index only once, its output is deep-equal to the default, and it's the safe choice for a legacy schema mid-migration:

const db = new TurbineClient({
  connectionString: process.env.DATABASE_URL,
  relationLoadStrategy: 'batched',
});

Turbine picks a strategy per relation unless you pin one:

relationLoadStrategyWhat it doesWhen
'auto' (default)Correlated join per relation, but falls back to a batched follow-up for any relation whose foreign key it can see is unindexed, so a missing index degrades one relation, not the whole query. Logs a once-per-relation note in development when the fallback engages.Leave it. The best default for a schema you haven't fully indexed yet.
'join'Always the single-statement correlated json_agg: one round-trip for the whole with tree.Every FK is indexed and you want one round-trip.
'batched'Always one flat WHERE fk = ANY(...) per relation, stitched in memory, Prisma's loading pattern.Reproduce Prisma exactly, or a huge result set where flat rows beat nested JSON.
'flatten' (0.50)Compiles eligible to-one relations to a LEFT JOIN in the same statement: one round-trip, no correlated re-evaluation, no client-side stitching. Anything ineligible silently falls back to a correlated subquery.One round-trip is a hard requirement and the relations are to-one. See Load strategies for the eligibility rules and where it loses to 'batched'.

An explicit 'join', 'batched' or 'flatten' (at the client or on a single query) always wins over 'auto'.

'auto' only falls back where an index is provably missing. Endpoints that fan out to very large child sets (megabyte-scale JSON per request) can still profile faster on 'batched' even with healthy indexes; if measurement shows that, pin relationLoadStrategy: 'batched' on that query (results are deep-equal either way).

Ordering caveat. 'auto' can load some relations via the batched path, so an unordered child array's order can differ from the pure-join path. Relation-array order under json_agg was never guaranteed to begin with. If any code relies on implicit child order, add orderBy to that with block, or enable stableRelationOrder. See Relation array order below.

Silent value differences: check these first#

These are the differences that do not throw. Everything else in this page either errors loudly or changes a type your compiler catches; the five below hand back a value of a different shape or a different row set and let your code carry on. They apply to both migration paths, adapter and native port alike. Grep for them before you port, not after.

WhatPrisma returnsTurbine returnsWhy it bites
Decimal / numeric columna Decimal instance (decimal.js)a stringtotal.plus(x), .toNumber(), and Number(total) * qty all change meaning. Arithmetic on the string silently coerces through JS floats, which is exactly what the numeric type exists to prevent.
BigInt / int8 columna JS biginta number, or a string above Number.MAX_SAFE_INTEGERThe type is a union in practice. Code doing id + 1n breaks at the type level (loud), but code doing id > cutoff compares a string lexicographically past 2^53 (silent).
select / include on a write (create, update, upsert, delete)only the selected fields, plus the included relationsthe full row, no relationsThe option is dropped without error. Extra columns leak into anything that spreads the result into a response body. Re-read with findUnique if you need a projection.
aggregate() with orderBy, take, skip, or cursorapplies themignores them (they are honored on groupBy, not on aggregate)An aggregate over what you thought was a 10-row window is an aggregate over the whole matching set.
where on a JSON column with a pathless equalsstrict deep equalitycontainment (@>){ meta: { equals: { plan: 'pro' } } } also matches { plan: 'pro', seats: 5 }. Turbine's equals on JSON is the same operator as its contains. Add a path to get exact scalar equality, or filter in application code for strict whole-document equality.

The two column-type rows are the ones to take seriously if you have a billing, ledger, or metering schema. numeric as string is deliberate (it refuses to round-trip money through a JS float), and int8 as number is deliberate (auto -increment ids and counts should not force bigint on every call site), but both are divergences from Prisma that your test suite will not necessarily surface.

The migration toolkit#

Two pieces ship with the package: a CLI command that reads your schema.prisma and resolves it against your live database, and a runtime adapter that serves a PrismaClient-shaped API from the map that command emits.

turbine migrate-from-prisma#

DATABASE_URL=postgres://... npx turbine migrate-from-prisma --schema prisma/schema.prisma

It parses schema.prisma with a zero-dependency subset parser (no prisma CLI, no @prisma/client, neither needs to still be installed), introspects your database, and resolves every model, field, relation, enum, and compound unique against what is actually there. It writes three things into your generate output directory (default ./generated/turbine):

  • prisma-migration-report.md: per-model resolution, a Many-to-many relations (audit these call sites) section pairing each relation's Prisma field name with its Turbine relation name and junction table, detected junction tables for implicit many-to-many, enums, an Unresolved items section, and parser notes. Read this first: it is the list of things that will not translate.
  • prisma-map.ts: the typed PRISMA_MAP the adapter is driven by. Every name in it was proven to exist during introspection, so the adapter only ever translates names it can resolve.
  • The standard generated client (types.ts, metadata.ts, index.ts), exactly as turbine generate writes it.
FlagEffect
--schema <file>Path to schema.prisma (default prisma/schema.prisma). In this command only, --schema names the Prisma file, not the Postgres namespace the rest of the CLI means by it. The namespace is fixed to public here.
--url, -u <url>Connection string, unless --no-db. Falls back to DATABASE_URL.
--out, -o <dir>Output directory (default ./generated/turbine). Must resolve inside the directory you ran the command from, or the run exits 1 without writing anything. See The --out directory guard before scripting this into a temp dir.
--no-dbParse-only. Writes the report, skips introspection, and emits no prisma-map.ts and no client. Useful for auditing a schema before you have a database to point at.
--allow-partialExit 0 even when items are unresolved. By default an unresolved item exits 1 so CI fails loudly.
--if-dbExit 0 without doing anything when no connection string resolves, instead of failing. For the postinstall hook below, where a build image legitimately has no database.
--no-timestampOmit the generated-at lines, for byte-identical regeneration.

An unresolved item never blocks the client: it is generated from live introspected metadata, so a partial run still gives you a working db. Only the map entry is missing, which means the adapter will not know that one Prisma name.

Keeping the map current#

Nothing re-runs migrate-from-prisma for you. prisma-map.ts is a snapshot of your schema at the moment you last ran it, and once your schema moves past it, the adapter keeps translating the names it has: a model added last week is simply not on the compat client, and a renamed field is quietly absent from results. Both of those look like adapter bugs.

Two things address this, and you want both.

Regenerate where you already regenerate. The command belongs next to prisma generate:

{
  "scripts": {
    "postinstall": "prisma generate && turbine migrate-from-prisma --if-db"
  }
}

--if-db is what makes that safe to put in postinstall. An npm ci inside a build image has no DATABASE_URL, and without the flag that would turn a missing database into a failed install. With it, the run prints one line saying nothing was regenerated and exits 0, leaving the committed prisma-map.ts exactly as it was.

That does mean a build machine with no database silently uses the committed map, which is the right trade for an install hook but is not a guarantee. So keep the map in version control and regenerate it in the same commit as any schema.prisma change, the same way you would a lockfile. If you want CI to enforce that, run the command against a real database and fail on a dirty tree:

turbine migrate-from-prisma --no-timestamp
git diff --exit-code generated/turbine/prisma-map.ts

--no-timestamp is required for that check: without it every run rewrites the generated-at line and the diff is never clean.

The adapter tells you when the map is stale. Since 0.60, migrate-from-prisma records the path and a fingerprint of the schema.prisma it read into the map, and createPrismaCompatClient compares that fingerprint against the file on disk once per process at startup:

[turbine] prisma-compat: prisma/schema.prisma has changed since prisma-map.ts was generated,
so any model, field, relation or compound-unique added or renamed since then is missing
from the compat client. Re-run: turbine migrate-from-prisma

It is a development aid and behaves like one. It is skipped entirely when NODE_ENV=production; a missing schema.prisma is silent, because not shipping prisma/ to production is normal and is not evidence of anything; a bundled runtime with no filesystem is silent for the same reason; the read is asynchronous and unawaited, so client construction never waits on it; and it prints at most once per process. It cannot fail your app, and it cannot fail your build. A map generated before 0.60, or assembled by hand, carries no fingerprint and is skipped.

The fingerprint ignores exactly three differences a checkout can introduce on its own, and nothing else:

DifferenceReported as drift?
CRLF or lone-CR line endingsNo
A leading byte-order markNo
Whitespace or blank lines at the end of the fileNo
Trailing whitespace on an individual lineYes
An edited commentYes

The last two are deliberate, and the end-of-file rule is not a per-line one. Nothing in a checkout puts trailing spaces on a line or rewrites a comment, so both got there because someone edited the file. A regeneration you did not strictly need costs you nothing; a missed one is the whole failure this exists to end.

Connection string. The command resolves the URL in this order: --url, then DATABASE_URL, then url in turbine.config.ts, then the datasource block of the schema you pointed it at, including its env("...") indirection (url first, then directUrl). So a project whose schema declares url = env("DATABASE_URL_STAGING") needs no flag, as long as that variable is exported. The datasource is last on purpose: an explicit --url is never overridden by a value declared in a schema file. If nothing yields a URL, the error names the exact variable the datasource asked for.

Auditing your many-to-many call sites#

The many-to-many rules changed in 0.50 (see Notable differences), so a port needs to know which call sites are affected. The obvious recipe does not work.

Turbine relation names and Prisma field names are two different names for the same relation, related only through PRISMA_MAP. Application code written against the compat client uses the Prisma name. So an audit that greps the Turbine side (grep -rn "manyToMany" generated/, then searching the codebase for the relation names it prints) matches nothing in that code and reports a clean bill of health that is not real.

You do not need to get this right by hand. The report's "Many-to-many relations (audit these call sites)" section lists every resolved m2m relation with both names and the junction table:

Prisma call siteTurbine relationJunction table
Post.tagstags_PostToTag

and closes with a ready-to-run grep over the Prisma field names:

grep -rEn "\b(tags)\b" src

Run it, then review every write whose data nests one of those fields. In --no-db mode the section says the list needs a database run rather than printing an empty one, because m2m relations are recognized from the live database.

createPrismaCompatClient#

turbine-orm/prisma-compat wraps a TurbineClient in Prisma's surface. It is a pure TypeScript shim: zero new dependencies, never imported by Turbine core.

import { TurbineClient } from 'turbine-orm';
import { createPrismaCompatClient } from 'turbine-orm/prisma-compat';
import { SCHEMA } from './generated/turbine/metadata.js';
import { PRISMA_MAP } from './generated/turbine/prisma-map.js';
 
const db = new TurbineClient({ connectionString: process.env.DATABASE_URL }, SCHEMA);
export const prisma = createPrismaCompatClient(db, PRISMA_MAP);

Swap that in for new PrismaClient() and existing call sites keep working:

// unchanged from your Prisma codebase
const users = await prisma.user.findMany({
  where: { email: { contains: '@acme.com' } },
  include: { posts: { orderBy: { createdAt: 'desc' }, take: 5 } },
});

Model delegates are registered under both spellings, the Prisma model name (prisma.User) and Prisma's generated client property (prisma.user), so either style resolves to the same delegate.

Implicit junction tables get a delegate too (since 0.50). Prisma's schema has no model for an implicit many-to-many junction, so PRISMA_MAP has no entry for one and there used to be no way to touch link rows from the compat client at all. Every many-to-many junction in the Turbine metadata is now exposed under its raw table name, with identity field mapping (no renames, no relations), on the client and inside $transaction:

// The delegate is keyed by the junction's raw table name, and its fields are
// that table's own columns in Turbine's usual camelCase spelling.
await prisma.$transaction(async (tx) => {
  const junction = (tx as Record<string, any>)._PostToTag;
  await junction.createMany({ data: [{ postId: 1, tagId: 7 }] });
});

A junction name that collides with a real Prisma model, or with a table some model already maps to, is skipped: the model always wins its own key. These delegates are real at runtime but are not part of the generated PrismaCompatClient type (there is no Prisma model to type them from), so a cast is needed to reach them, as above. Check the junction's column names in the report's junction-tables section.

What it translates: include to with; select split into scalar selection plus relations; field and relation renames in both directions; take/skip to limit/offset; cursor pagination; compound-unique selectors including custom @@unique(name:) names; $transaction in both the callback and lazy-array-batching forms; $queryRaw / $executeRaw and their Unsafe variants with Prisma.sql-style fragment flattening; createMany({ skipDuplicates }) to ON CONFLICT DO NOTHING; _count objects keyed back to Prisma relation names; to-one relations surfaced as object | null. Prisma's client-side defaults (@default(uuid()), @default(cuid()), @updatedAt) are emulated on write, since Prisma fills those in the client and the columns usually have no database default.

Inside $transaction, the tx client carries the raw-SQL surface too. tx.$queryRaw, tx.$executeRaw and their Unsafe variants run on the transaction's own connection, so a migrated block that mixes ORM calls with raw statements stays atomic. If the underlying transaction client cannot execute raw SQL (it has no rawQuery method), the adapter throws ValidationError rather than quietly falling back to a pool connection, which would place the statement outside the transaction with no way for the caller to tell. The tx client also exposes model delegates under both spellings, tx.User and tx.user, matching the pool-level client; the lowercase alias is skipped when it would shadow a real model name.

Two options. prismaErrorCodes: true sets .code on thrown TurbineErrors to the nearest Prisma code (P2002 for a unique violation, P2025 for not-found, and so on) without pretending to be instanceof PrismaClientKnownRequestError; left off, Turbine's own TURBINE_E0NN codes are preserved. stablePkOrder: true loads every to-many with relation that has no explicit orderBy ordered by the target table's primary key ascending, which is closer to how Prisma's relation rows tend to arrive; an explicit per-relation orderBy always wins.

One Turbine-only argument passes straight through: includePii: UNSAFE on a read, a groupBy, or an aggregate. Prisma has no PII concept, so if your schema tags columns pii: true this is the only way a compat call site can ask for them (or run a groupBy keyed on one). Everything else about the tag behaves exactly as it does on the native API.

Client extensions: $extends#

$extends returns a new client. The one you called it on keeps working unchanged, and the returned client is itself extendable, so chains work as they do in Prisma:

const extended = prisma.$extends({
  name: 'helpers',
  client: {
    async $healthCheck() {
      return true;
    },
  },
  model: {
    User: {
      async findByEmail(email: string) {
        return prisma.User.findFirst({ where: { email } });
      },
    },
    $allModels: {
      async countAll() {
        return 0;
      },
    },
  },
});
 
await extended.$healthCheck();
await extended.User.findByEmail('a@b.com');
await extended.post.countAll();   // $allModels member, lowercase spelling

Four details are worth knowing:

  • Both delegate spellings get the members. A model key may be the Prisma model name (User) or its lowercased client property (user); either way the members land on the single delegate both names resolve to. $allModels applies to every delegate.
  • this is the extended delegate. Members are copied onto a shallow copy, so this inside an extension method is the delegate plus the extension's own members, and it carries $name (the Prisma model name) at runtime. Prisma.getExtensionContext(that) is exported and is the identity function, so migrated call sites keep working. $name is runtime-only: the delegate type does not declare it, so reading it inside a member needs a cast ((this as unknown as { $name: string }).$name).
  • model members survive $transaction. The delegates handed to a $transaction callback are extended with the same members, which is the one place a naive implementation silently diverges.
  • client members are deliberately not on the transaction client. Such a member usually closes over the base client, so reaching it through tx would run its queries outside the transaction. Absent, it is a TypeError at the call site instead of a silent correctness bug.

The callback form is Prisma's, unchanged: prisma.$extends(fn) is fn(prisma). Prisma.defineExtension is a type-preserving passthrough, so both spellings reach $extends intact.

What throws, and when. Everything the adapter cannot honour is refused at $extends time, not at the first query, so a wrong assumption fails at boot with a full explanation:

ComponentResult
client, modelSupported
query (interception)UnsupportedFeatureError (TURBINE_E017), pointing at client.$use on the underlying TurbineClient as the interception seam. $allOperations has no equivalent
result (computed fields)UnsupportedFeatureError (TURBINE_E017). Prisma implements it by rewriting the projection to satisfy needs and stripping the injected columns back out at every nesting level, which cannot be done safely on top of the PII projection rules: a needs field on a pii-tagged column would arrive undefined and the computed value would be silently wrong. Compute it in application code, or add a generated column
Anything else (Accelerate, Pulse, read-replica extensions)UnsupportedFeatureError naming the component

query and result are also declared never on the extension type, so passing one is a compile error before it is ever a runtime one.

Two more shapes throw ValidationError (TURBINE_E003): a client member whose name would shadow a delegate or a client-level method, and a model key that names no model on this client (the message lists the known models).

Turbine-native query options#

Prisma's argument shapes have no equivalent for a handful of Turbine-only query options, so the adapter forwards them verbatim when you pass them:

const rows = await compat.Order.findMany({
  where: { tenantId },
  orderBy: { id: 'asc' },
  take: 20,
  forceCustomPlan: true,   // Turbine-only, forwarded to the core client
});

The full set, by operation: forceCustomPlan, warnOnUnlimited, skipGlobalFilters, allowFullTableScan, timeout, includePii, stableRelationOrder, optimisticLock and groupBy's distinctOn. The two that name schema fields (optimisticLock.field, distinctOn.columns) are translated through the same name map as the rest of the call, so you write them in Prisma field names like everything else.

Unknown query options warn#

A key that is neither a Prisma argument nor a Turbine option used to disappear. Since 0.57.0 it logs one dev-only line per model, operation and key:

[turbine] prisma-compat: unknown option "customPlan" in User.findMany(), it is ignored. Did you mean "forceCustomPlan"?
[turbine] prisma-compat: "limit" is Turbine's spelling and is ignored here; prisma-compat takes Prisma's "take". (User.findMany)

The second form covers the specific confusion of writing Turbine's spelling into a Prisma-shaped call (limit/with/offset instead of take/include/skip), where a nearest-name guess would be unhelpful.

It is a warning, never a throw, for the same reason the client-config check added in 0.53 is: a stray key must not turn a working app into a failing one on upgrade. It is silent under NODE_ENV=production, fires once per key per process, and every legitimate Prisma argument is on the known set, including the ones Prisma itself accepts and ignores (count({ take })).

What the adapter does not do#

Read this before you commit to the adapter path. These are verified against the implementation, not aspirational.

Absent entirely. $use and $on are not implemented and are not present on the returned object. Calling one is a plain TypeError: prisma.$use is not a function, not a typed Turbine error. Turbine's own middleware is db.$use on the underlying TurbineClient, with different semantics: it runs after SQL generation and cannot rewrite a query. $extends is implemented for two of Prisma's four components, see Client extensions below.

Present but inert. $connect() and $disconnect() exist and resolve immediately without doing anything. The TurbineClient owns the pool, so shut down with db.disconnect(), not prisma.$disconnect(). A migration that relies on $disconnect() closing connections (tests, serverless teardown) will leak the pool.

count() silently ignores most of its Prisma options. Only where, plus the Turbine-native options above, are forwarded. Prisma's select, cursor, take, skip, orderBy, and distinct on count are dropped without error, so count({ take: 10 }) returns the count of all matching rows, not 10. If you count with any of those, rewrite the call.

Throws rather than guessing. Three shapes raise UnsupportedFeatureError instead of returning a subtly wrong result:

  • A bare inclusive cursor whose field is not the sort key. Prisma's bare cursor is inclusive; translating it exactly needs the anchor row's sort-key value. When the cursor field is the single orderBy field (or is the single-column primary key with no orderBy) it compiles to a gte/lte keyset predicate. Otherwise it throws rather than emit an off-by-one page. Pairing the cursor with skip: 1, Prisma's usual idiom, translates exactly.
  • Negative take (take-from-end). Turbine's limit has no reverse form.
  • skip inside a nested relation include. Turbine's with clause has no per-relation offset.

Not attempted. Fluent relation chaining (prisma.user.findUnique(...).posts()); instanceof PrismaClientKnownRequestError identity and byte-identical error messages; Prisma.join / Prisma.raw composition beyond plain fragment flattening; Accelerate, Pulse, and the driver-adapter preview features; the MongoDB API; the prisma migrate / prisma db CLI family (Turbine ships its own migrations). createMany({ skipDuplicates }) throws UnsupportedFeatureError on the SQL Server and PowDB engines.

Breaking change in 0.41: unique foreign keys introspect as hasOne#

If you generated a client before 0.41, this one changes types on you. When a child table's foreign-key columns are exactly covered by a unique constraint or unique index, the relation is genuinely one-to-one, and introspection now emits hasOne for the parent side instead of hasMany.

Two things change together, and it is easy to notice only the first. The generated type goes from Child[] to Child | null, and the relation is renamed from the plural child-table name to its singular (profiles becomes profile). A call site that only fixes the shape and keeps the old key gets a RelationError (TURBINE_E005) for an unknown relation.

// Before 0.41: plural key, array value
const user = await db.users.findUnique({ where: { id: 1 }, with: { profiles: true } });
user.profiles[0]?.bio;   // array, always length 0 or 1
 
// 0.41 and later: singular key, object-or-null value
const user = await db.users.findUnique({ where: { id: 1 }, with: { profile: true } });
user.profile?.bio;       // object | null

If the singular name would collide with an existing field, column, or relation, introspection keeps the legacy plural name instead, so a colliding schema sees only the shape change.

This matches Prisma's own shape for a @unique back-relation, so migrating call sites usually get more correct, not less. If you need the old shape while you port, regenerate with the escape hatch:

npx turbine generate --legacy-to-many-uniques

It is also settable as legacyToManyUniques: true in turbine.config.ts. It only affects introspection output, never runtime behavior.

Compound-unique where selectors#

Prisma addresses a multi-column unique constraint through a single synthetic key. Turbine accepts the same spelling, so these call sites port verbatim:

// Prisma and Turbine, identical
await db.members.findUnique({ where: { orgId_userId: { orgId: 1, userId: 7 } } });
 
// equivalent to
await db.members.findUnique({ where: { orgId: 1, userId: 7 } });

Selector names are derived from your metadata: a composite primary key, each composite unique constraint, and each composite unique index. Two spellings are registered for every column set, the underscore join of the camelCase field names (orgId_userId, Prisma's default) and, when it differs, the underscore join of the raw column names (org_id_user_id).

Two rules keep it unambiguous. A synthetic name is never registered when it collides with a real field, column, or relation name (the real member wins), and when two different column sets would produce the same name, that name is dropped entirely. A partial unique index never backs a selector, since it only guarantees uniqueness across the rows matching its predicate. Custom @@unique(name:) names from your Prisma schema are handled on top of this by the compat adapter, which reads them from PRISMA_MAP.

Selectors work on the whole findUnique family and in nested-write unique wheres (connect, connectOrCreate, and friends), on every engine including PowDB.

API mapping#

If you are doing a native port, this is the full mapping. Skip to Side-by-side if you want to see it working.

PrismaTurbineNotes
prisma.user.findManydb.users.findManyTable accessor uses the snake_case table name (camelCased).
prisma.user.findUniquedb.users.findUniqueSame shape.
prisma.user.findFirstdb.users.findFirstSame.
prisma.user.findFirstOrThrowdb.users.findFirstOrThrowThrows NotFoundError (TURBINE_E001).
prisma.user.findUniqueOrThrowdb.users.findUniqueOrThrowSame.
prisma.user.createdb.users.createSame data shape.
prisma.user.createManydb.users.createManySingle INSERT ... UNNEST under the hood.
prisma.user.updatedb.users.updateSupports atomic operators: { count: { increment: 1 } }.
prisma.user.updateManydb.users.updateManyEmpty where rejected unless allowFullTableScan: UNSAFE.
prisma.user.deletedb.users.deleteSame.
prisma.user.deleteManydb.users.deleteManyEmpty where rejected unless allowFullTableScan: UNSAFE.
prisma.user.upsertdb.users.upsertSame where / create / update shape.
prisma.user.countdb.users.countSame.
prisma.user.aggregatedb.users.aggregate_sum / _avg / _min / _max / _count. _count: true returns a number; _count: { _all: true } returns { _all: n } (Prisma's shape).
prisma.user.groupBydb.users.groupByby, where, orderBy, plus _count / _sum / _avg / _min / _max. _count: true returns a number; _count: { _all: true } returns { _all: n }.
prisma.$transactiondb.$transactionCallback form with nested SAVEPOINTs and isolation levels.
include: { posts: true }with: { posts: true }The only renamed key.
select: { id: true, name: true }select: { id: true, name: true }Same.
where: { name: { contains: 'a' } }where: { name: { contains: 'a' } }All operators ported.
where: { posts: { some: ... } }where: { posts: { some: ... } }Relation filters: some / every / none.
data: { posts: { create: [...] } }data: { posts: { create: [...] } }Nested writes map unchanged on to-one and one-to-many relations: create / connect / connectOrCreate on create, plus disconnect / set / delete / update / upsert on update. One transaction, depth cap 10. Many-to-many relations support the three junction-only operations since 0.50: connect, disconnect, set. The rest (create, connectOrCreate, update, upsert, delete) throw a ValidationError naming the supported set, because a junction's payload columns have no safe default. See Nested writes and Notable differences.
include: { _count: { select: { posts: true } } }with: { _count: { posts: true } }No select wrapper. _count: true counts every to-many relation.
prisma.$queryRawdb.raw`...` Typed form: db.sql<T>`...` returns T[] with .one() / .scalar().
take: 10take: 10Works as-is, take is an alias for limit.
cursor: { id: 99 }cursor: { id: 99 }Keyset pagination, same shape. Turbine's cursor is exclusive, drop any skip: 1, and audit cursors that had no skip (see Cursor pagination).
distinct: ['userId']distinct: ['userId']Same, compiles to DISTINCT ON.
skip: 20offset: 20Renamed.

Schema translation#

Prisma's .prisma schema translates to Turbine's defineSchema() call.

// schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  posts     Post[]
  createdAt DateTime @default(now())
}
 
model Post {
  id        Int      @id @default(autoincrement())
  userId    Int
  user      User     @relation(fields: [userId], references: [id])
  title     String
  published Boolean  @default(false)
  viewCount Int      @default(0)
  createdAt DateTime @default(now())
}
// 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 },
    createdAt: { type: 'timestamp', default: 'now()' },
  },
  posts: {
    id: { type: 'serial', primaryKey: true },
    userId: { type: 'bigint', notNull: true, references: 'users.id' },
    title: { type: 'text', notNull: true },
    published: { type: 'boolean', notNull: true, default: 'false' },
    viewCount: { type: 'integer', notNull: true, default: '0' },
    createdAt: { type: 'timestamp', default: 'now()' },
  },
});

Relations aren't declared in Turbine, they're inferred from foreign keys. posts.userId references 'users.id' automatically yields user on Post and posts on User.

Side-by-side#

findMany with nested relations#

// Prisma
const users = await prisma.user.findMany({
  where: { orgId: 1 },
  include: { posts: { orderBy: { createdAt: 'desc' }, take: 5 } },
  orderBy: { createdAt: 'desc' },
  take: 10,
});
// Turbine, top-level take works as-is; the nested take becomes limit
const users = await db.users.findMany({
  where: { orgId: 1 },
  with: { posts: { orderBy: { createdAt: 'desc' }, limit: 5 } },
  orderBy: { createdAt: 'desc' },
  take: 10,
});

Atomic update#

// Prisma
await prisma.post.update({
  where: { id: 42 },
  data: { viewCount: { increment: 1 } },
});
// Turbine, identical
await db.posts.update({
  where: { id: 42 },
  data: { viewCount: { increment: 1 } },
});

Both generate view_count = view_count + $1. No extra round-trip.

Transaction#

// Prisma
await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({ data: { email: 'a@b.c', name: 'A' } });
  await tx.post.create({ data: { userId: user.id, title: 'Hi' } });
});
// Turbine
await db.$transaction(async (tx) => {
  const user = await tx.users.create({ data: { email: 'a@b.c', name: 'A' } });
  await tx.posts.create({ data: { userId: user.id, title: 'Hi' } });
});

Upsert#

// Prisma
await prisma.user.upsert({
  where: { email: 'a@b.c' },
  create: { email: 'a@b.c', name: 'A' },
  update: { name: 'A' },
});
// Turbine, identical
await db.users.upsert({
  where: { email: 'a@b.c' },
  create: { email: 'a@b.c', name: 'A' },
  update: { name: 'A' },
});

Relation filter#

// Prisma
const active = await prisma.user.findMany({
  where: { posts: { some: { published: true } } },
});
// Turbine, identical
const active = await db.users.findMany({
  where: { posts: { some: { published: true } } },
});

Notable differences#

  • No schema.prisma. Code-first defineSchema() in a TypeScript module. npx turbine push (fast path) or migrate create --auto (generates SQL). No DSL, no separate parser.
  • includewith, skipoffset. That's the full lexical diff. take, cursor, and distinct work as-is at the top level (take is an alias for limit); inside a nested with, the per-relation limit is spelled limit.
  • Atomic update operators are first-class. set, increment, decrement, multiply, divide. All compile to in-place SQL.
  • Typed errors with codes. UniqueConstraintError / ForeignKeyError / NotNullViolationError / CheckConstraintError all carry code (TURBINE_E008E011) and cause. findUniqueOrThrow throws NotFoundError (TURBINE_E001) with the where attached. DeadlockError / SerializationFailureError have readonly isRetryable = true as const.
  • Driver-agnostic edge support. Pass any pg-compatible pool to turbineHttp(pool, SCHEMA) and the same API runs on Neon, Vercel, Cloudflare Hyperdrive, Supabase. No extra adapter package.
  • Single runtime dependency. pg only. No engine binary, no WASM, no @prisma/client.
  • Postgres-first, not Postgres-only. Postgres is the default and primary target; optional SQLite, MySQL, SQL Server, and PowDB engines ship behind subpath exports (turbine-orm/sqlite, /mysql, /mssql, /powdb) and share the same typed API. A few flagship features (pgvector, LISTEN/NOTIFY, RLS session context, full-text search, array-column filters, groupBy({ distinctOn })) stay Postgres-only and throw a typed UnsupportedFeatureError (TURBINE_E017) elsewhere, and the schema tooling (the CLI, schemaPush / schemaDiff, doctor) targets Postgres. See What is actually Postgres-only.
  • Nested writes, including many-to-many. create / connect / connectOrCreate on a create, plus disconnect / set / delete / update / upsert on an update, in one transaction with a depth cap of 10. Since 0.50 a many-to-many relation supports the three operations that only touch the junction table: connect, disconnect, and set, which covers the common Prisma implicit-m2m idiom. The rest (create, connectOrCreate, update, upsert, delete) throw ValidationError (TURBINE_E003) naming the supported set, because there is no safe default for a junction's extra payload columns; port those call sites to a target write plus a connect, or to explicit junction-table writes inside a $transaction. The compat client now exposes an accessor per implicit junction table (under its raw Prisma name, for example _PostToTag), on the client and inside $transaction, so those writes stay in one transaction. See Nested writes and Auditing your many-to-many call sites.

Cursor pagination#

Turbine's cursor is exclusive, the anchor row is not returned, which is the natural keyset semantics (WHERE pk > $cursor) and kills Prisma's skip: 1 boilerplate. This is a conscious divergence, not a gap: exclusive keyset pagination composes cleanly with orderBy and needs no offset math.

The two paths handle the difference differently, so read the one you are on.

On the native port

Two rules when rewriting call sites by hand:

  • Prisma cursor: { id } + skip: 1 (the common exclusive idiom) becomes just cursor: { id }. Drop the skip.
  • Prisma cursor: { id } with no skip is inclusive, Prisma returns the anchor row. Ported verbatim to Turbine, that call silently loses the anchor row. Nothing warns you. If you relied on inclusive behavior, fetch the anchor separately, or start the cursor at the previous row.

On turbine-orm/prisma-compat

The adapter does not silently drop the anchor row. It either reproduces Prisma's inclusive semantics exactly or refuses the call, because a silent off-by-one on a paginated endpoint is precisely the failure that is hardest to notice in production.

  • cursor + skip: n translates exactly, to an exclusive cursor plus offset: n - 1.
  • A bare inclusive cursor is translated exactly, as an inclusive gte / lte keyset predicate merged into the where, when the cursor names a single field and that field is either the single orderBy field or (with no orderBy at all) the single-column primary key. Those are the shapes where the anchor's sort-key value is known from the cursor itself.
  • Every other bare inclusive cursor throws UnsupportedFeatureError (TURBINE_E017): a multi-field cursor, a cursor field that is not the sort key, and a cursor on a non-PK field with no orderBy. Translating those needs the anchor row's sort-key value, which the adapter does not have and will not guess. The fix is in the message: order by the cursor field, or pair the cursor with skip: 1 (Prisma's own idiom) so it maps to the exact exclusive translation.

Relation array order#

Relation arrays have no guaranteed order unless you pass orderBy in the with block. Prisma makes no ordering guarantee either, but its batched loader usually surfaces insert/PK order, and apps quietly depend on it; under Turbine's json_agg loader (and the 'auto' strategy's batched fallback) the order can differ. If any code relies on implicit child order, add orderBy: { id: 'asc' } (or the real sort key) to that with clause.

If you want a stable default without annotating every relation, opt into stableRelationOrder, a client-wide or per-query flag that gives relation arrays a deterministic order. It is off by default, and a per-relation orderBy always wins over it.

Behaviors that match Prisma#

  • timestamp and date columns are UTC on both sides. Turbine reads Postgres timestamp (without time zone) and date values as UTC, the same convention Prisma uses, so a migrated app gets identical instants, not values shifted by the server's local zone. date joined the UTC read half in v0.54: before that it came back at the process's local midnight, so a port done on an earlier version and running east of UTC saw a different calendar day than Prisma did for the same row (see Zone-less columns, which also covers what changes in your API payloads on upgrade). Since v0.52 the same holds on the way in: the Date values that create / update / upsert and where clauses bind to zone-less date / timestamp columns are written as UTC too. Opt out with utcTimestamps: false if you relied on local-time handling, with two caveats. First, before v0.52 the flag reached the read path alone, so a client that had already set false was still binding UTC on writes and filters; on upgrade those statements change, and so does the text stored in zone-less columns, leaving pre-upgrade and post-upgrade rows on two conventions until you backfill. Second, the flag is per process, not per client: the read half is a set of pg type parsers on OIDs 1114 (timestamp), 1082 (date) and their array forms 1115 / 1182, which pg.types.setTypeParser installs once for the whole pg module, so the first client in the process settles it and a later client asking for the opposite value throws ValidationError (TURBINE_E003) at construction rather than reading and writing in different zones. Clients on an external pool never TRIGGER registration, but they are not exempt from the effect or from the check: registration is process-global, so once an owned client has installed the parsers an external-pool client reads through them too, and one asking for the opposite value throws the same ValidationError. A process whose only clients hold external pools registers nothing and keeps whatever parser configuration the caller set up.
  • Paginated reads are ordered by the primary key. Prisma appends an implicit ORDER BY <primary key> ASC to a findMany with take / skip; since 0.50 the adapter does the same (every column of a composite key, in declaration order). Without it, a ported paginated endpoint inherits an unordered LIMIT, which Postgres is free to answer with different rows as the heap changes, so a row can appear on two pages or on none. An explicit orderBy always wins, and a model with no primary key is left alone. There is no flag to turn this off: matching Prisma is the adapter's contract. Turbine core still emits a bare LIMIT unless you set implicitPkOrdering: true.
  • Bare to-one relation filters work without is. where: { vendor: { name: { contains: 'x' } } } filters by a to-one relation with no is wrapper, exactly like Prisma. is: null / isNot: null compile to NOT EXISTS / EXISTS.
  • time columns through the compat adapter. Postgres time values surface from turbine-orm/prisma-compat as a Date on 1970-01-01 UTC, Prisma's epoch-day convention, so .getHours()-style call sites keep working. Turbine core keeps the driver's raw HH:MM:SS string (a documented core-vs-Prisma difference).
  • Client-side defaults are emulated. Prisma's @default(uuid()), @default(cuid()), and @updatedAt are filled by the Prisma client, so those columns usually have no database default and Prisma call sites omit them. migrate-from-prisma records them in the generated map (clientDefaults), and the compat adapter fills them on create/createMany and touches @updatedAt fields on update/updateMany/upsert, exactly like Prisma. @default(now()) is carried only when introspection finds no database default.
  • upsert follows Prisma's lookup-first semantics. Turbine core's upsert compiles a single atomic INSERT ... ON CONFLICT keyed on the create data's unique values. When the where key values equal the create values (the common idiom) that is identical to Prisma and the adapter passes straight through. When they differ, native ON CONFLICT would insert the create row even though the where row exists, so the adapter emulates Prisma instead: look up by where inside a transaction, update the found row, else insert create. If you use core upsert directly, know the ON CONFLICT semantics; a where/create key mismatch silently inserts a second row.

Migration checklist#

The steps below are the native port. If you are taking the adapter path instead, the short version is: install turbine-orm, run npx turbine doctor --fix, run npx turbine migrate-from-prisma, read prisma-migration-report.md, replace new PrismaClient() with createPrismaCompatClient(db, PRISMA_MAP), and work through what the adapter does not do. Steps 1, 2, and 9 still apply.

  1. npm install turbine-orm && npm uninstall @prisma/client prisma.
  2. Fix FK indexes before you benchmark. Run npx turbine doctor --fix to add the indexes Prisma left off, then npx turbine migrate up, or set relationLoadStrategy: 'batched' on the client to reproduce Prisma's loading pattern until the migration lands (see above).
  3. Write schema.ts mirroring your .prisma models (or run npx turbine pull to introspect your live DB).
  4. npx turbine generate, writes ./generated/turbine/{types,metadata,index}.ts.
  5. Rewrite the call sites. Nothing rewrites them for you: the compat adapter serves Prisma's surface at runtime, it does not edit source. Find/replace:
    • prisma.db.
    • Singular model names → plural snake-camelCase table names (prisma.userdb.users)
    • include:with:
    • skip:offset:
    • take: stays as-is at the top level; inside a with block, rename it to limit:
    • cursor: stays as-is, but delete any skip: 1 next to it, and audit cursors that had no skip, since Turbine's cursor is exclusive (see Cursor pagination).
  6. Replace import { PrismaClient } from '@prisma/client' with import { turbine } from './generated/turbine'.
  7. Port any raw SQL from prisma.$queryRaw to db.raw`SELECT ...` ; for a typed result use db.sql<T>`SELECT ...` (the drop-in for Prisma's TypedSQL), it returns T[] with .one() and .scalar().
  8. Update your error-handling to the typed classes (or keep catching by message during transition).
  9. Delete schema.prisma and the prisma/ directory once the build passes.

Connection URL note. If your Prisma DATABASE_URL carries sslmode=require, pg prints a SECURITY WARNING at every startup, that mode currently aliases full verification but will change meaning in pg v9. Pick one: sslmode=verify-full to keep full certificate verification (recommended when your provider's certs chain to a public CA, which covers most managed Postgres); uselibpqcompat=true&sslmode=require to opt into the future libpq semantics now (encryption without certificate verification); or drop sslmode from the URL and pass ssl config through the pool on TurbineConfig. Turbine doesn't rewrite the warning, it belongs to the driver, and the fix is the URL.

See also#