Skip to content
BataDB

DocsGuides

Migrate from Prisma

Move a Prisma app to BataDB and Turbine ORM: data first, then code.

Leaving a Prisma app for BataDB is two separate moves. Each one checks its own work and fails loudly instead of guessing.

  1. Move the data with bata import. Any Postgres source works: Neon, RDS, a local database. The import streams pg_dump into psql, checks extensions first, and compares row counts and sequences on both sides before it reports success.
  2. Move the code with turbine migrate-from-prisma, part of Turbine ORM. It reads your schema.prisma, matches every model, field and relation against the live database, and writes a Turbine client plus a typed name map. Anything it cannot match is reported, never guessed.

Do the data first. migrate-from-prisma matches names against a live database, so pointing it at the newly imported BataDB project checks the map against the database your app will actually use.

Turbine has a Prisma-shaped API and a turbine-orm/prisma-compat adapter, but it is not Prisma. Read What changes for your code before you cut over.

Prerequisites

  • The bata CLI, signed in: npm install -g @batadata/cli, then bata login or BATA_API_KEY set.
  • Local pg_dump and psql at least as new as your source server. bata import checks this and names the version to install.
  • A current turbine-orm in the app you are migrating: npm install turbine-orm. This guide was checked against turbine-orm 0.81.

Step 1: move the data with bata import

Terminal
bata import --source "$DATABASE_URL" --name my-app
  • --source is your current connection string, the one in Prisma's datasource db { url = ... }. If your provider has pooled and direct strings, use the direct one.
  • --name creates a new BataDB project. Its PostgreSQL major matches the source, capped at 17 (BataDB runs 16 and 17). --pg 16 or --pg 17 overrides it.
  • Add --json for one machine-readable result, for agents and CI.

The import stops with EXTENSION_UNSUPPORTED before any data moves if your source uses an extension BataDB cannot provide. After the restore it compares every table's row count and every sequence's value. A mismatch is a non-zero exit, not a warning.

Two notes for Prisma apps:

  • _prisma_migrations comes across with the data. Turbine leaves it out of the generated client by default. Keep the table until the cutover is proven, then drop it.
  • Prisma's client-side defaults are not in the database. A column declared @default(uuid()), @default(cuid()) or @updatedAt often has no database default, because Prisma filled it in on the client. The data moves fine, but a plain insert on the new side can hit a NOT NULL violation. Step 2 records these fields as clientDefaults, and the compat adapter fills them. See What changes for your code.

Migrating from Neon is the full bata import guide: every phase, the manual pg_dump | psql fallback, the newer-source caveat and failure recovery. All of it applies to a Prisma app's database.

Step 2: move the code with turbine migrate-from-prisma

Point the command at your schema.prisma and at the new BataDB database. bata db url prints the direct connection string:

Terminal
export DATABASE_URL="$(bata db url --project <project-id>)"
npx turbine migrate-from-prisma --schema prisma/schema.prisma

--schema takes the schema file, or a directory of .prisma files for a multi-file schema. Without it, the command looks for prisma/schema.prisma, then prisma/schema/.

The connection string comes from --url, then DATABASE_URL, then turbine.config.ts, then the datasource block in your Prisma schema.

The command reads models, enums, views, @map and @@map, relations (including implicit many-to-many junction tables), @@unique (including named selectors) and @@id. It matches every name against the database's public schema and writes into the output directory (--out, default ./generated/turbine):

  • The Turbine client: types.ts, metadata.ts and index.ts, the same output turbine generate produces.
  • prisma-map.ts: a typed PRISMA_MAP with models, fields, relations and their cardinality, compound-unique selector names, and clientDefaults (the uuid(), cuid() and @updatedAt fields above, plus @default(now()) where the database has no default).
  • prisma-migration-report.md: a per-model report with an UNRESOLVED list and the reason for each item, ending with notes on Prisma and Turbine behavior differences.

Matching is conservative. A model that matches more than one table is reported unresolved; add an @@map to pick one. Several relations to the same model pair up by their shared @relation("Name"), and a last unnamed pair resolves by elimination, the same way Prisma resolves them.

The command exits non-zero when anything is unresolved, unless you pass --allow-partial. With it, you still get a working client; only the name map is partial.

FlagEffect
--schema <path>The Prisma schema file, or a directory of .prisma files
--url <url>The database to match against (else DATABASE_URL)
--out <dir>Output directory (default ./generated/turbine)
--no-dbParse only: write the report without a database, no matching
--allow-partialExit 0 with unresolved items; the client is still generated
--if-dbSkip quietly when no connection string is set, for a postinstall hook
--no-timestampOutput that diffs cleanly between runs

Two limits:

  • Only the public schema is read. Prisma's multi-schema @@schema is not supported: the parser notes it in the report.
  • To keep raw snake_case column names as field names, set keepColumnNames: true in turbine.config.ts. The client and the name map then agree. This command does not take a --keep-column-names flag.

Two ways to adopt the output

A. Keep your Prisma call sites with turbine-orm/prisma-compat. Wrap the Turbine client, and db.user.findMany({ include: ... }) keeps working:

TypeScript
import { turbine } from './generated/turbine';
import { createPrismaCompatClient } from 'turbine-orm/prisma-compat';
import { PRISMA_MAP } from './generated/turbine/prisma-map';

const core = turbine({ connectionString: process.env.DATABASE_URL });
const db = createPrismaCompatClient(core, PRISMA_MAP);

The adapter translates arguments both ways: include to with, take and skip to limit and offset, field and relation renames, and compound-unique selectors. It fills clientDefaults on create, supports both $transaction forms, $queryRaw and $executeRaw, and $extends with client and model components.

Some Prisma features throw a clear error instead of misbehaving: $extends with query or result components, $use with Prisma's middleware shape, fluent relation chaining (prisma.user.findUnique().posts()), Accelerate, Pulse and the Mongo API.

B. Rewrite to native Turbine. Move call sites onto the generated client directly. The changes are small: include: becomes with:, skip: becomes offset:, take: works at the top level, and inside a nested with the per-relation cap is limit:. The report lists every rename per model. This is the end state. The compat adapter lets you get there one model at a time.

For connecting the client to BataDB (direct TCP or edge HTTP through @batadata/serverless, and what works on each), see Turbine ORM.

Step 3: verify all of it

bata import already compared row counts and sequences. A cutover deserves a second look:

Terminal
# 1. Data: spot-check a table that matters, on both sides
psql "$OLD_DATABASE_URL" -Atc "SELECT count(*) FROM users"
bata db query "SELECT count(*) FROM users" --project <project-id>

# 2. Code: the generated client and your call sites must typecheck
npx tsc --noEmit

# 3. Runtime: exercise the paths the migration changes
#    - a create on a model with @default(uuid()) or @updatedAt (clientDefaults)
#    - an upsert whose where row already exists
#    - an include across a relation the report matched by @relation name
#    - a query through an implicit many-to-many relation

Before you compare performance, run npx turbine doctor --fix against the direct connection string. Postgres does not index foreign keys on its own, and Prisma does not add those indexes. doctor --fix writes a migration file that adds the missing ones. Review it, then run it.

Read prisma-migration-report.md end to end once. The UNRESOLVED list and the behavior notes are the reason it exists.

What changes for your code

AreaPrismaAfter the migration
UpsertFinds the row by where, updates it, else inserts createSame contract in both prisma-compat and core Turbine. When every where key has the same value in create, it is one atomic INSERT ... ON CONFLICT. Otherwise it looks the row up by where, then updates or inserts, in one transaction. Like Prisma's, that lookup is not atomic against a concurrent insert of the same key
Client-side defaults@default(uuid()), @default(cuid()) and @updatedAt are filled by the Prisma client; the columns often have no database defaultRecorded as clientDefaults in the name map. prisma-compat fills them on create and createMany, and sets @updatedAt on update, updateMany and upsert. A value you pass is never overwritten. Native Turbine call sites must pass these values, or you add real database defaults
time and timetz columnsA Date on 1970-01-01 UTCprisma-compat returns the same, so .getHours() call sites keep working. Core Turbine returns the raw HH:MM:SS string
$transaction([...])Lazy batch, atomic, in orderSupported. A batch with nested writes (connect, create, ...) runs its items one by one inside one transaction. A plain batch stays a single round trip
Cursor paginationcursor is inclusive; the idiom is cursor plus skip: 1Turbine cursors are exclusive: drop the skip: 1 when you rewrite to native. prisma-compat translates cursor plus skip for you. A bare cursor on a field that is not the sort key throws rather than return an off-by-one page
take and skip without orderByPrisma orders by primary keyprisma-compat keeps that order. Core Turbine does not: pass an explicit orderBy
_count and relation arrays_count keyed by relation name; array order unspecifiedSame _count shape through the adapter. Child array order is not guaranteed; set the compat option stablePkOrder: true for primary-key order

Migrations need the direct connection string

If you keep Prisma Migrate (Step 2 replaces the client, not your migration history), run it on the direct connection string. prisma migrate deploy takes a session-level advisory lock. The pooled string runs in transaction mode, so each transaction can land on a different server connection, and session state is reset after every transaction. A session lock taken there protects nothing.

Prisma schema
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")           // pooled: the app
  directUrl = env("DIRECT_URL")             // direct: migrations
}

directUrl has no fallback. Prisma fails validation when the variable is unset, so set it in local development and CI too. If a migration ever hangs on an advisory lock, see the recovery query in Moving a production app.

Troubleshooting

SymptomCauseFix
A model is UNRESOLVED: multiple tables matchMore than one table fits the model's nameAdd @@map("table_name") to the Prisma model and run again
A model is UNRESOLVED: no table matchedThe table has a name Turbine did not tryAdd @@map("table_name") and run again
Two relations to the same model are reported ambiguousNeither pair is namedAdd @relation("Name") to both sides of one pair, then run again
A relation that was a list is now object | nullA foreign key covered by a unique constraint is a one-to-one relation, as in PrismaRead it as an object or null
NOT NULL violation on id or updatedAt when creating rowsThe default lived in the Prisma client, not the databaseUse prisma-compat (it fills clientDefaults), pass the value, or add a database default (gen_random_uuid(), or a trigger for updatedAt)
Unknown flag for turbine migrate-from-prismaThe flag belongs to another command, such as --keep-column-namesSet it in turbine.config.ts instead, or check turbine migrate-from-prisma --help
bata import fails on extensions, versions or verificationThe import sideSee the import troubleshooting table

See also

  • Migrating from Neon: the full bata import guide, for any Postgres source.
  • Turbine ORM: Turbine on BataDB, direct TCP or edge HTTP.
  • Connecting: pooled and direct connection strings.
  • Turbine's Prisma migration guide: the API mapping and cursor semantics in more depth.
  • turbine migrate-from-prisma --help and bata import --help: the flag lists for your installed versions.