We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide13 min read

Prisma 7, or a data layer after its rebuild

Prisma 7 dropped the Rust engine for TypeScript. What that changes, what migrating from v6 looks like, the schema, queries, and how it compares.

Prisma 7, or a data layer after its rebuild

Prisma was for years the most convenient database access layer in the Node ecosystem, and it carried one trait that drew resistance: underneath ran an engine written in Rust, shipped as a separate binary per platform.

Version seven changed that. The engine is gone, its work taken over by TypeScript code with a portion compiled to a format that runs in browsers and on servers. The change became the default in late 2025 and pulled several things along with it that must be handled during an upgrade.

The benefits are measurable. The vendor reports roughly ninety percent smaller bundles, queries up to three times faster, and noticeably faster type checking. It also removes a problem that could be blocking in serverless and edge deployments: shipping the right binary for every target platform.

What changed in version seven

Four changes, each requiring action during an upgrade, so they deserve knowing before you start.

The first is mandatory driver adapters. The client no longer connects to the database itself but through a driver you supply explicitly, so every project needs the appropriate package for its database. That is an extra line of code and equally a gain, since the connection pool is now managed by a driver you know.

The second is where generated code lives. It no longer appears in the dependency directory but in a path you state explicitly, with imports pointing there instead of at a package name. That means changing the import in every file reaching for the client and is the most laborious part of the migration.

The third is the configuration file. The database address moves from the schema into a separate configuration file, and the generator changes its name. Skipping that step produces an error whose text does not name the cause directly.

The fourth is the removal of the middleware layer that allowed wrapping queries. Client extensions replace it, available earlier already, so code using that mechanism needs rewriting.

Schema and migrations

The heart of the project remains the file describing the data model, and that has not changed.

Code
Prisma
model User {
  id      String  @id @default(cuid())
  email   String  @unique
  name    String?
  orders  Order[]
  created DateTime @default(now())
}

model Order {
  id      String  @id @default(cuid())
  amount  Decimal @db.Decimal(10, 2)
  status  Status  @default(NEW)
  user    User    @relation(fields: [userId], references: [id])
  userId  String

  @@index([userId, status])
}

enum Status {
  NEW
  SHIPPED
  CANCELLED
}

Two things come out of that description: a migration changing the database structure, and TypeScript types for the whole codebase. The latter is why people come here, since a typo in a field name becomes a compile error rather than an empty value discovered by a user.

Code
Bash
npx prisma migrate dev --name add-order-status

Migrations are recorded as files holding queries and go into the repository, so they pass review and run in production in a controlled way. That is the right way to work, and the command pushing a schema without migrations suits prototypes only, since it leaves no trace and can drop a column without asking.

Plan indexes alongside the schema rather than after the first report of a slow page. Declaring an index on a pair of columns used together in a query is one line, and the difference at a million rows reaches seconds.

Queries and relations

The query interface is a strength here, since it reads without knowledge of database syntax.

Code
TypeScript
const orders = await prisma.order.findMany({
  where: {
    status: 'NEW',
    amount: { gte: 100 },
    user: { email: { endsWith: '@company.com' } }
  },
  include: { user: { select: { name: true, email: true } } },
  orderBy: { created: 'desc' },
  take: 20
})

A nested condition on a related model translates into a join, and selecting fields limits the columns fetched. The result type reflects exactly what you selected, so reaching for a field outside the list will not compile.

The most common performance problem in this layer is called a query in a loop. It arises when you fetch a list and then pull a relation separately for each element. A list of twenty orders then generates twenty one queries instead of one, and at a hundred the difference becomes visible to the naked eye.

The cure fits in one word: including relations in the same query, as in the example above. Enable query logging in the development environment, since a list of twenty identical queries in a row identifies this problem faster than any tool.

Transactions support both needed variants: the simple one, where several operations run together or not at all, and the interactive one, where you make decisions inside based on data read. The latter holds a connection open, so long operations inside a transaction are an antipattern here.

Migrations in production

This part decides whether the access layer is a convenience or a source of night time phone calls, and documentation usually gives it less space than it deserves.

There is one rule, following from deployment order. The schema migration goes before the code deployment, so a new column must exist before the application starts writing to it. A change removing a column runs in reverse: first the code stops using it, then the column disappears.

From that follows how to handle irreversible changes. Renaming a column outright creates a moment where old code writes to a field that no longer exists. The safe route is three deployments: adding the new column and writing to both, moving the data and switching reads, dropping the old column. Slower, and without an outage.

Separate structural migrations from data migrations too. The first are fast and predictable, the second can run for hours on a large table and block writes.

Code
Bash
npx prisma migrate diff \
  --from-config-datasource \
  --to-schema=prisma/schema.prisma \
  --script > migration.sql

npx prisma migrate deploy

The first command shows the SQL that will be produced before anything runs. That is the moment you see a statement dropping a column or adding a not null constraint to a table full of data. Moving data is better done by a separate script, in batches, outside a deployment.

The last thing is testing migrations against a copy of production data rather than an empty development database. A statement adding an index completes instantly on a thousand rows and takes a quarter of an hour on ten million, and that difference is better learnt a day earlier.

Performance in practice

A few things that make the biggest difference with this layer while requiring no rebuild of anything.

The first is selecting columns. By default all are fetched, and on a wide table with text fields that can be an order of magnitude difference in data transferred. An explicit field list is one line.

The second is cursor based pagination rather than offset based. Skipping a hundred thousand rows requires the database to walk them, so the tenth page of a list is fast and the thousandth does not work at all.

Code
TypeScript
const page = await prisma.order.findMany({
  take: 20,
  skip: cursor ? 1 : 0,
  cursor: cursor ? { id: cursor } : undefined,
  orderBy: { id: 'desc' },
  select: { id: true, number: true, amount: true, createdAt: true }
})

A cursor on an indexed column keeps the time constant regardless of depth. The explicit field list on the last line also handles the first item above, since without it every column is fetched, including text fields the list never displays.

The third is batched writes. Inserting a thousand rows one at a time is a thousand round trips to the database, while one batch call is one. The difference can be a hundredfold and shows up in every data import.

The fourth is counting. Counting every matching row on a large table can cost more than fetching the data itself, so with infinite scrolling it is better to check whether another page exists than to report an exact result count nobody reads anyway.

The fifth is query logging in development. Enabling it costs one option when creating the client and states plainly how many queries one page view generates.

Code
TypeScript
import { PrismaClient } from '../generated/prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })

export const prisma = new PrismaClient({
  adapter,
  log: process.env.NODE_ENV === 'development'
    ? [{ emit: 'event', level: 'query' }]
    : ['warn', 'error']
})

let counter = 0
prisma.$on('query', (e) => {
  counter += 1
  if (e.duration > 100) console.warn(counter, e.duration + 'ms', e.query)
})

It is the simplest diagnostic tool in this whole layer and the least used, because it only works once somebody looks at it. A counter with a duration threshold changes that: instead of reading hundreds of lines you see only the queries that actually cost something.

Prisma against the alternatives

OptionStrengthWeaknessPick it when
PrismaReadable schema, migrations, best typesA separate schema language, heavier than rivalsA team wanting one source of truth for the model
DrizzleCloser to queries, light, schema in TypeScriptFewer built in conveniencesControl over the generated query
Query buildersFull control, minimal abstractionTypes and migrations are yoursComplex analytical queries
Raw queriesNo layers, the database's full powerNo types, manual mappingUnusual queries and optimisation

Choosing between the first two rows comes down to whether you want to describe the model in a separate language or in TypeScript. The first gives one file readable even by non technical people and better tooling around migrations. The second gives a thinner abstraction and queries closer to what actually reaches the database.

Remember this is not an exclusive choice. The overwhelming majority of queries fit the access layer's interface, and the few hard analytical ones can be written directly while keeping types through the typed raw query mechanism.

The third row deserves attention for reporting. Queries with window functions, aggregations across several dimensions, and correlated subqueries come out awkwardly through an access layer interface, or not at all, while written directly they are shorter and clearer. Keeping them in a separate directory, next to the rest of the data access code, works better than trying to fit everything into one style.

The fourth row is not a failure of the project but a normal part of working with a database. An access layer is there to cover ninety percent of cases conveniently, not a hundred percent at any cost.

Working with a hosted database

The access layer works with every popular database service, with two things worth checking before you choose.

The first is the number of concurrent connections under serverless deployment. Every function instance opens its own, so the limit can be reached during a traffic spike. The answer is a pooler, offered by most providers, including those covered in the pieces on Neon and PlanetScale.

The second is foreign key handling, since it does not work the same everywhere. On databases that do not enforce them, the access layer can emulate the behaviour, so the code looks familiar while the database will not stop a write violating a relation. Know that distinction before resting assumptions about data consistency on it.

With a Next.js application there is one more thing: a single client instance. Creating a new client on every reload in development mode exhausts the connection pool within minutes, and the answer is holding the instance in a global object outside production.

Code
TypeScript
import { PrismaClient } from '../generated/prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

That snippet recurs in nearly every project and is worth adding immediately rather than after the first exhausted pool message. Note the import path pointing at the generated directory and the adapter handed to the constructor, since those two changes are what separate version seven from earlier ones and most often slip past during an upgrade. A call with no adapter is no longer supported, so the old form ends in an error rather than a quiet fallback to the previous behaviour.

Common mistakes

The first is a query in a loop, meaning pulling relations separately for each list element. Including them in one query removes the problem.

The second is pushing a schema without migrations beyond a prototype. That route leaves no trace in the repository and can drop a column without asking.

The third is a new client instance per request. The connection pool exhausts faster than intuition suggests, particularly in development mode.

The fourth is assuming the database will enforce referential integrity when the access layer does it. A write bypassing that layer will go through unchallenged.

The fifth is long operations inside an interactive transaction. The connection stays occupied and under heavier traffic blocks later requests.

The sixth is upgrading to version seven without reading the change list. Mandatory adapters, a new import path, and moving the database address into a configuration file all require action, and the errors do not always name the cause directly.

FAQ

What did Prisma 7 change?

The engine written in Rust was replaced by TypeScript code, giving noticeably smaller bundles, faster queries, and faster type checking. It also removed the need to ship a separate binary per platform, which eases serverless and edge deployments.

What does migrating from version six look like?

Four steps: adding a driver adapter for your database, setting the generated client path and replacing imports across the project, moving the database address into a configuration file, and replacing the former middleware layer with client extensions. Replacing the imports is the most laborious.

Prisma or Drizzle?

It depends on whether you want to describe the model in a separate schema file or in TypeScript. Prisma offers better tooling around migrations and a readable schema, Drizzle offers a thinner layer and queries closer to what reaches the database.

Does it work in an edge environment?

After the architecture change, yes, and that is one of version seven's main benefits. A driver communicating over a network protocol rather than a classic socket is required, since an ordinary database connection is unavailable in such an environment.

Can I write raw queries?

Yes, and for complex analytical queries that is the best route. The typed raw query mechanism preserves result types, so you do not lose this layer's main advantage merely because one query needed the database's full power.

Documentation sits on the project site, and the upgrade path is described in the official version seven migration guide.