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 adapter | Native port | |
|---|---|---|
| What you run | turbine migrate-from-prisma, then wrap the client in createPrismaCompatClient | Rename include to with, re-point imports |
| Call sites | Unchanged. prisma.user.findMany({ include }) keeps working | Rewritten to db.users.findMany({ with }) |
| Time to first green build | Hours | Proportional to your call-site count |
| Ceiling | A documented set of Prisma features it will not translate | The 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.
| Strategy | Time |
|---|---|
| Correlated, FK unindexed | 17.8 s |
Batched (WHERE fk IN (…)) | 92 ms |
| Correlated, FK indexed | 62 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 ahasOneon aUNIQUEFK. 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:
| Deployment | Round trip | Correlated wins below roughly |
|---|---|---|
| Local socket / same host | ~0.03 ms | 43 parent rows |
| Loopback TCP | ~0.12 ms | 170 parent rows |
| Same-region managed Postgres | ~1 ms | 1,400 parent rows |
| Cross-region pooled connection | ~35 ms | 50,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 --fixdoctor 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:
relationLoadStrategy | What it does | When |
|---|---|---|
'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 underjson_aggwas never guaranteed to begin with. If any code relies on implicit child order, addorderByto thatwithblock, or enablestableRelationOrder. 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.
| What | Prisma returns | Turbine returns | Why it bites |
|---|---|---|---|
Decimal / numeric column | a Decimal instance (decimal.js) | a string | total.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 column | a JS bigint | a number, or a string above Number.MAX_SAFE_INTEGER | The 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 relations | the full row, no relations | The 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 cursor | applies them | ignores 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 equals | strict deep equality | containment (@>) | { 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.prismaIt 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 typedPRISMA_MAPthe 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 asturbine generatewrites it.
| Flag | Effect |
|---|---|
--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-db | Parse-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-partial | Exit 0 even when items are unresolved. By default an unresolved item exits 1 so CI fails loudly. |
--if-db | Exit 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-timestamp | Omit 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-prismaIt 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:
| Difference | Reported as drift? |
|---|---|
| CRLF or lone-CR line endings | No |
| A leading byte-order mark | No |
| Whitespace or blank lines at the end of the file | No |
| Trailing whitespace on an individual line | Yes |
| An edited comment | Yes |
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 site | Turbine relation | Junction table |
|---|---|---|
Post.tags | tags | _PostToTag |
and closes with a ready-to-run grep over the Prisma field names:
grep -rEn "\b(tags)\b" srcRun 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 spellingFour details are worth knowing:
- Both delegate spellings get the members. A
modelkey 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.$allModelsapplies to every delegate. thisis the extended delegate. Members are copied onto a shallow copy, sothisinside 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.$nameis 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).modelmembers survive$transaction. The delegates handed to a$transactioncallback are extended with the same members, which is the one place a naive implementation silently diverges.clientmembers are deliberately not on the transaction client. Such a member usually closes over the base client, so reaching it throughtxwould run its queries outside the transaction. Absent, it is aTypeErrorat 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:
| Component | Result |
|---|---|
client, model | Supported |
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
orderByfield (or is the single-column primary key with noorderBy) it compiles to agte/ltekeyset predicate. Otherwise it throws rather than emit an off-by-one page. Pairing the cursor withskip: 1, Prisma's usual idiom, translates exactly. - Negative
take(take-from-end). Turbine'slimithas no reverse form. skipinside a nested relationinclude. Turbine'swithclause 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 | nullIf 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-uniquesIt 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.
| Prisma | Turbine | Notes |
|---|---|---|
prisma.user.findMany | db.users.findMany | Table accessor uses the snake_case table name (camelCased). |
prisma.user.findUnique | db.users.findUnique | Same shape. |
prisma.user.findFirst | db.users.findFirst | Same. |
prisma.user.findFirstOrThrow | db.users.findFirstOrThrow | Throws NotFoundError (TURBINE_E001). |
prisma.user.findUniqueOrThrow | db.users.findUniqueOrThrow | Same. |
prisma.user.create | db.users.create | Same data shape. |
prisma.user.createMany | db.users.createMany | Single INSERT ... UNNEST under the hood. |
prisma.user.update | db.users.update | Supports atomic operators: { count: { increment: 1 } }. |
prisma.user.updateMany | db.users.updateMany | Empty where rejected unless allowFullTableScan: UNSAFE. |
prisma.user.delete | db.users.delete | Same. |
prisma.user.deleteMany | db.users.deleteMany | Empty where rejected unless allowFullTableScan: UNSAFE. |
prisma.user.upsert | db.users.upsert | Same where / create / update shape. |
prisma.user.count | db.users.count | Same. |
prisma.user.aggregate | db.users.aggregate | _sum / _avg / _min / _max / _count. _count: true returns a number; _count: { _all: true } returns { _all: n } (Prisma's shape). |
prisma.user.groupBy | db.users.groupBy | by, where, orderBy, plus _count / _sum / _avg / _min / _max. _count: true returns a number; _count: { _all: true } returns { _all: n }. |
prisma.$transaction | db.$transaction | Callback 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.$queryRaw | db.raw`...` | Typed form: db.sql<T>`...` returns T[] with .one() / .scalar(). |
take: 10 | take: 10 | Works 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: 20 | offset: 20 | Renamed. |
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-firstdefineSchema()in a TypeScript module.npx turbine push(fast path) ormigrate create --auto(generates SQL). No DSL, no separate parser. include→with,skip→offset. That's the full lexical diff.take,cursor, anddistinctwork as-is at the top level (takeis an alias forlimit); inside a nestedwith, the per-relation limit is spelledlimit.- Atomic update operators are first-class.
set,increment,decrement,multiply,divide. All compile to in-place SQL. - Typed errors with codes.
UniqueConstraintError/ForeignKeyError/NotNullViolationError/CheckConstraintErrorall carrycode(TURBINE_E008–E011) andcause.findUniqueOrThrowthrowsNotFoundError(TURBINE_E001) with thewhereattached.DeadlockError/SerializationFailureErrorhavereadonly 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.
pgonly. 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-textsearch, array-column filters,groupBy({ distinctOn })) stay Postgres-only and throw a typedUnsupportedFeatureError(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/connectOrCreateon a create, plusdisconnect/set/delete/update/upserton 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, andset, which covers the common Prisma implicit-m2m idiom. The rest (create,connectOrCreate,update,upsert,delete) throwValidationError(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 aconnect, 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 justcursor: { id }. Drop theskip. - Prisma
cursor: { id }with noskipis 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: ntranslates exactly, to an exclusive cursor plusoffset: n - 1.- A bare inclusive
cursoris translated exactly, as an inclusivegte/ltekeyset predicate merged into thewhere, when the cursor names a single field and that field is either the singleorderByfield or (with noorderByat 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 noorderBy. 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 withskip: 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#
timestampanddatecolumns are UTC on both sides. Turbine reads Postgrestimestamp(without time zone) anddatevalues as UTC, the same convention Prisma uses, so a migrated app gets identical instants, not values shifted by the server's local zone.datejoined 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: theDatevalues thatcreate/update/upsertandwhereclauses bind to zone-lessdate/timestampcolumns are written as UTC too. Opt out withutcTimestamps: falseif 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 setfalsewas 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, whichpg.types.setTypeParserinstalls once for the whole pg module, so the first client in the process settles it and a later client asking for the opposite value throwsValidationError(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 sameValidationError. 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> ASCto afindManywithtake/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 unorderedLIMIT, 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 explicitorderByalways 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 bareLIMITunless you setimplicitPkOrdering: true. - Bare to-one relation filters work without
is.where: { vendor: { name: { contains: 'x' } } }filters by a to-one relation with noiswrapper, exactly like Prisma.is: null/isNot: nullcompile toNOT EXISTS/EXISTS. timecolumns through the compat adapter. Postgrestimevalues surface fromturbine-orm/prisma-compatas aDateon 1970-01-01 UTC, Prisma's epoch-day convention, so.getHours()-style call sites keep working. Turbine core keeps the driver's rawHH:MM:SSstring (a documented core-vs-Prisma difference).- Client-side defaults are emulated. Prisma's
@default(uuid()),@default(cuid()), and@updatedAtare filled by the Prisma client, so those columns usually have no database default and Prisma call sites omit them.migrate-from-prismarecords them in the generated map (clientDefaults), and the compat adapter fills them oncreate/createManyand touches@updatedAtfields onupdate/updateMany/upsert, exactly like Prisma.@default(now())is carried only when introspection finds no database default. upsertfollows Prisma's lookup-first semantics. Turbine core'supsertcompiles a single atomicINSERT ... ON CONFLICTkeyed on the create data's unique values. When thewherekey 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 thewhererow exists, so the adapter emulates Prisma instead: look up bywhereinside a transaction, update the found row, else insertcreate. If you use coreupsertdirectly, know the ON CONFLICT semantics; awhere/createkey 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.
npm install turbine-orm && npm uninstall @prisma/client prisma.- Fix FK indexes before you benchmark. Run
npx turbine doctor --fixto add the indexes Prisma left off, thennpx turbine migrate up, or setrelationLoadStrategy: 'batched'on the client to reproduce Prisma's loading pattern until the migration lands (see above). - Write
schema.tsmirroring your.prismamodels (or runnpx turbine pullto introspect your live DB). npx turbine generate, writes./generated/turbine/{types,metadata,index}.ts.- 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.user→db.users) include:→with:skip:→offset:take:stays as-is at the top level; inside awithblock, rename it tolimit:cursor:stays as-is, but delete anyskip: 1next to it, and audit cursors that had noskip, since Turbine's cursor is exclusive (see Cursor pagination).
- Replace
import { PrismaClient } from '@prisma/client'withimport { turbine } from './generated/turbine'. - Port any raw SQL from
prisma.$queryRawtodb.raw`SELECT ...`; for a typed result usedb.sql<T>`SELECT ...`(the drop-in for Prisma's TypedSQL), it returnsT[]with.one()and.scalar(). - Update your error-handling to the typed classes (or keep catching by message during transition).
- Delete
schema.prismaand theprisma/directory once the build passes.
Connection URL note. If your Prisma
DATABASE_URLcarriessslmode=require,pgprints aSECURITY WARNINGat every startup, that mode currently aliases full verification but will change meaning inpgv9. Pick one:sslmode=verify-fullto keep full certificate verification (recommended when your provider's certs chain to a public CA, which covers most managed Postgres);uselibpqcompat=true&sslmode=requireto opt into the future libpq semantics now (encryption without certificate verification); or dropsslmodefrom the URL and passsslconfig through the pool onTurbineConfig. Turbine doesn't rewrite the warning, it belongs to the driver, and the fix is the URL.
See also#
- Schema & Migrations,
defineSchema, DDL, introspection. - API Reference, every method, operator, option.
- Relations, one-to-many, many-to-many, filters.
- Typed Errors, full hierarchy with SQLSTATE mapping.
- Serverless, Neon, Vercel, Cloudflare Hyperdrive.