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

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: true.
prisma.user.deletedb.users.deleteSame.
prisma.user.deleteManydb.users.deleteManyEmpty where rejected unless allowFullTableScan: true.
prisma.user.upsertdb.users.upsertSame where / create / update shape.
prisma.user.countdb.users.countSame.
prisma.user.aggregatedb.users.aggregate_sum / _avg / _min / _max / _count.
prisma.user.groupBydb.users.groupByby, where, orderBy supported.
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.
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 — no skip: 1 needed).
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 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):

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

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 an index yet, relationLoadStrategy: 'batched' reproduces Prisma's loading behavior in the meantime.)

Two behaviors that match Prisma

  • timestamp columns parse as UTC. Turbine reads Postgres timestamp (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 with utcTimestamps: false if 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 no is wrapper, exactly like Prisma. is: null / isNot: null compile to NOT EXISTS / EXISTS.

Migration checklist

  1. npm install turbine-orm && npm uninstall @prisma/client prisma
  2. Write schema.ts mirroring your .prisma models (or run npx turbine pull to introspect your live DB).
  3. npx turbine generate — writes ./generated/turbine/{types,metadata,index}.ts.
  4. npx turbine doctor --fix — add the FK indexes Prisma left off (see above), then npx turbine migrate up.
  5. 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:
  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 ...``.
  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.

See also