Prisma: type-safe queries with real migration costs

March 14, 2026

Prisma's schema-first approach and generated client are genuinely good. The migration workflow, connection model, and production gotchas deserve more attention before you commit.


What makes it compelling

Prisma's core value proposition: define your schema in one place, get type-safe database access everywhere in your codebase. No mismatches between what's in the database and what TypeScript thinks is there.

model Post {
    id        Int      @id @default(autoincrement())
    title     String
    published Boolean  @default(false)
    author    User     @relation(fields: [authorId], references: [id])
    authorId  Int
    createdAt DateTime @default(now())

    @@index([published, createdAt])
}
const posts = await prisma.post.findMany({
    where: { published: true },
    include: { author: { select: { name: true, email: true } } },
    orderBy: { createdAt: "desc" },
    take: 20,
})
// posts: Array<Post & { author: { name: string; email: string } }>

The return type is inferred from the query — not declared, not cast. Add a field to the schema, run prisma generate, and TypeScript immediately shows you every query that doesn't handle the new field. This is the sell, and it's real.

The migration workflow

Development is clean. Edit schema.prisma, run prisma migrate dev, and a SQL migration file is generated and applied:

npx prisma migrate dev --name add-post-index

Prisma tracks migration history in a _prisma_migrations table and uses a shadow database during development to validate the migration before applying it.

The shadow database is the first gotcha: Prisma creates and destroys a temporary database to compare state. On local Postgres this is automatic. In CI with a managed database or a restricted user, you need to configure it explicitly or grant CREATE DATABASE permissions:

DATABASE_URL="postgresql://user:pass@host/mydb"
SHADOW_DATABASE_URL="postgresql://user:pass@host/mydb_shadow"

Teams discover this when CI passes locally (using local Postgres) and fails in the pipeline. It's documented, but it's easy to miss until it bites you.

Production deployment

prisma migrate deploy applies pending migrations without interactivity:

npx prisma migrate deploy

The deployment concern is coordination: migrations must run before the new application code is deployed. If code expecting a new column deploys before the migration runs, you get errors. If the migration fails partway through, the lock table needs manual clearing before future runs.

For zero-downtime migrations on large tables, Prisma's migration files are plain SQL — you can edit them before running. Add CONCURRENTLY to index creation, split column additions from constraints. Prisma generates correct SQL; it doesn't generate migration-safe SQL for all cases.

The connection model

Prisma doesn't use a traditional connection pool directly. It runs a query engine process (a Rust binary) that manages connections. This has implications for serverless environments:

Every Lambda invocation that instantiates PrismaClient can open a new connection to the database. Under load, this exhausts Postgres connection limits fast.

// serverless environments need connection pooling external to Prisma
// or use Prisma Accelerate / PgBouncer in front of the database

declare global {
    var prisma: PrismaClient | undefined
}

export const prisma = global.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== "production") global.prisma = prisma

The global singleton pattern helps in development but doesn't solve the serverless problem — every cold Lambda start still opens connections. For serverless workloads on Postgres, add PgBouncer or Prisma Accelerate.

N+1 is still your responsibility

Prisma's query builder doesn't prevent N+1 queries. You can write:

const users = await prisma.user.findMany()
for (const user of users) {
    const posts = await prisma.post.findMany({ where: { authorId: user.id } })
    // N queries for N users
}

The correct version uses include:

const users = await prisma.user.findMany({
    include: { posts: true }
})

Enable query logging in development so you see what's being generated:

const prisma = new PrismaClient({
    log: ["query", "warn", "error"]
})

The unexpected query count is visible immediately and catches N+1s before they reach production.

Raw queries when the builder falls short

Prisma exposes $queryRaw for cases the builder doesn't express well. The template literal syntax prevents SQL injection:

const result = await prisma.$queryRaw<{ count: bigint }[]>`
    SELECT COUNT(*) as count
    FROM "Post"
    WHERE "createdAt" > NOW() - INTERVAL '7 days'
    AND "published" = true
`
// count is bigint — convert for JSON serialization
const count = Number(result[0].count)

The return type is manual here — you lose the generated types when you drop to raw SQL. Worth containing to specific files and documenting the expected shape.

The verdict

Prisma is a good fit for TypeScript applications with moderately complex data models where keeping the schema and application code in sync is a real pain point. The DX in development is excellent.

Know the migration workflow before you commit to it in production. Understand the connection model before deploying to serverless. Neither is a disqualifier — they're operational requirements that need handling, and handling them is documented.

References

Hi, I'm Martin Duchev. You can find more about my projects on my GitHub.