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.
- Move the data with
bata import. Any Postgres source works: Neon, RDS, a local database. The import streamspg_dumpintopsql, checks extensions first, and compares row counts and sequences on both sides before it reports success. - Move the code with
turbine migrate-from-prisma, part of Turbine ORM. It reads yourschema.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
bataCLI, signed in:npm install -g @batadata/cli, thenbata loginorBATA_API_KEYset. - Local
pg_dumpandpsqlat least as new as your source server.bata importchecks 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
bata import --source "$DATABASE_URL" --name my-app--sourceis your current connection string, the one in Prisma'sdatasource db { url = ... }. If your provider has pooled and direct strings, use the direct one.--namecreates a new BataDB project. Its PostgreSQL major matches the source, capped at 17 (BataDB runs 16 and 17).--pg 16or--pg 17overrides it.- Add
--jsonfor 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_migrationscomes 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@updatedAtoften 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 asclientDefaults, 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:
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.tsandindex.ts, the same outputturbine generateproduces. prisma-map.ts: a typedPRISMA_MAPwith models, fields, relations and their cardinality, compound-unique selector names, andclientDefaults(theuuid(),cuid()and@updatedAtfields 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.
| Flag | Effect |
|---|---|
--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-db | Parse only: write the report without a database, no matching |
--allow-partial | Exit 0 with unresolved items; the client is still generated |
--if-db | Skip quietly when no connection string is set, for a postinstall hook |
--no-timestamp | Output that diffs cleanly between runs |
Two limits:
- Only the
publicschema is read. Prisma's multi-schema@@schemais not supported: the parser notes it in the report. - To keep raw snake_case column names as field names, set
keepColumnNames: trueinturbine.config.ts. The client and the name map then agree. This command does not take a--keep-column-namesflag.
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:
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:
# 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 relationBefore 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
| Area | Prisma | After the migration |
|---|---|---|
| Upsert | Finds the row by where, updates it, else inserts create | Same 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 default | Recorded 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 columns | A Date on 1970-01-01 UTC | prisma-compat returns the same, so .getHours() call sites keep working. Core Turbine returns the raw HH:MM:SS string |
$transaction([...]) | Lazy batch, atomic, in order | Supported. 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 pagination | cursor is inclusive; the idiom is cursor plus skip: 1 | Turbine 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 orderBy | Prisma orders by primary key | prisma-compat keeps that order. Core Turbine does not: pass an explicit orderBy |
_count and relation arrays | _count keyed by relation name; array order unspecified | Same _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.
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
| Symptom | Cause | Fix |
|---|---|---|
| A model is UNRESOLVED: multiple tables match | More than one table fits the model's name | Add @@map("table_name") to the Prisma model and run again |
| A model is UNRESOLVED: no table matched | The table has a name Turbine did not try | Add @@map("table_name") and run again |
| Two relations to the same model are reported ambiguous | Neither pair is named | Add @relation("Name") to both sides of one pair, then run again |
A relation that was a list is now object | null | A foreign key covered by a unique constraint is a one-to-one relation, as in Prisma | Read it as an object or null |
NOT NULL violation on id or updatedAt when creating rows | The default lived in the Prisma client, not the database | Use 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-prisma | The flag belongs to another command, such as --keep-column-names | Set it in turbine.config.ts instead, or check turbine migrate-from-prisma --help |
bata import fails on extensions, versions or verification | The import side | See the import troubleshooting table |
See also
- Migrating from Neon: the full
bata importguide, 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 --helpandbata import --help: the flag lists for your installed versions.