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. Most of your query code moves over by renaming include to with and re-pointing your imports.
This is the full mapping. Skip to Side-by-side if you want to see it working.
API mapping
| 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: true. |
prisma.user.delete | db.users.delete | Same. |
prisma.user.deleteMany | db.users.deleteMany | Empty where rejected unless allowFullTableScan: true. |
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. |
prisma.user.groupBy | db.users.groupBy | by, where, orderBy supported. |
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. |
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 — no skip: 1 needed). |
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 only. Intentional — depth over breadth enables the edge and Studio stories.
Index your foreign keys first
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 loads relations as correlated subqueries — the child table is probed once per parent row. 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, and once the index exists correlated loading outperforms batching. Measured on a production-shaped dataset (659 parents, a 357K-row child table):
| Strategy | Time |
|---|---|
| Correlated, FK unindexed | 17.8 s |
Batched (WHERE fk IN (…)) | 92 ms |
| Correlated, FK indexed | 62 ms |
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 an index yet, relationLoadStrategy: 'batched' reproduces Prisma's loading behavior in the meantime.)
Two behaviors that match Prisma
timestampcolumns parse as UTC. Turbine reads Postgrestimestamp(without time zone) values as UTC — the same convention Prisma uses — so a migrated app gets identical instants, not values shifted by the server's local zone. (Opt out withutcTimestamps: falseif you relied on local-time parsing.)- 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.
Migration checklist
npm install turbine-orm && npm uninstall @prisma/client prisma- Write
schema.tsmirroring your.prismamodels (or runnpx turbine pullto introspect your live DB). npx turbine generate— writes./generated/turbine/{types,metadata,index}.ts.npx turbine doctor --fix— add the FK indexes Prisma left off (see above), thennpx turbine migrate up.- 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:
- Replace
import { PrismaClient } from '@prisma/client'withimport { turbine } from './generated/turbine'. - Port any raw SQL from
prisma.$queryRawtodb.raw\SELECT ...``. - Update your error-handling to the typed classes (or keep catching by message during transition).
- Delete
schema.prismaand theprisma/directory once the build passes.
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.