I switched to Prisma about two years ago and it immediately felt like the right choice. Type-safe queries, auto-generated types, easy migrations. Compared to writing raw SQL or fighting with other ORMs, it was a huge improvement.

But I also made a bunch of mistakes early on that cost me hours of debugging. Some were obvious in hindsight, some were things the docs don’t emphasize enough. Here’s everything I wish someone had told me when I started.


Don’t use the Prisma client globally without a singleton

This bit me in development. Every time your Next.js app hot-reloads, it creates a new Prisma client instance. Do that enough times and you’ll hit the database connection limit.

// don't do this in Next.js
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default prisma;

You’ll eventually see a “Too many connections” error and wonder what went wrong. The fix is a singleton:

import { PrismaClient } from "@prisma/client";

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

export const prisma = globalForPrisma.prisma || new PrismaClient();

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

In production, each serverless function gets one instance. In development, the singleton prevents connection leaks during hot reload. I put this in lib/db/prisma.ts and import it everywhere.


Always use select or include, never return everything

When you do prisma.user.findUnique() without a select, you get every column in the table. Passwords, tokens, internal fields, everything.

// returns everything including fields you shouldn't expose
const user = await prisma.user.findUnique({
  where: { id },
});

Always pick what you need:

const user = await prisma.user.findUnique({
  where: { id },
  select: {
    id: true,
    name: true,
    email: true,
    createdAt: true,
  },
});

This is a security issue and a performance issue. If your user table has 20 columns, you’re fetching 20 columns when you need 4. In a list endpoint returning hundreds of users, that difference adds up.


If you need to create a user and their profile in one operation, don’t do them separately. If the second query fails, you’re left with an orphaned user.

// dangerous - second query might fail
const user = await prisma.user.create({ data: { email, name } });
const profile = await prisma.profile.create({
  data: { userId: user.id, bio: "" },
});

Use a transaction instead:

const [user, profile] = await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({
    data: { email, name },
  });

  const profile = await tx.profile.create({
    data: { userId: user.id, bio: "" },
  });

  return [user, profile];
});

If anything inside the transaction throws, everything gets rolled back. Both records exist or neither does.


Nested creates are cleaner than you think

Instead of creating a user and then creating related records separately, you can do it all in one query:

const user = await prisma.user.create({
  data: {
    email: "hello@baransel.dev",
    name: "Baransel",
    profile: {
      create: {
        bio: "Web developer",
        website: "https://baransel.dev",
      },
    },
    posts: {
      create: [
        { title: "First post", content: "Hello world" },
        { title: "Second post", content: "Still here" },
      ],
    },
  },
  include: {
    profile: true,
    posts: true,
  },
});

One query creates the user, their profile, and two posts. All in a transaction automatically. I use this pattern for seed scripts and onboarding flows.


Learn the difference between include and select

This confused me early on. Both fetch related data, but they work differently.

include adds related records to the full parent object:

const user = await prisma.user.findUnique({
  where: { id },
  include: { posts: true }, // all user fields + posts
});

select only returns the fields you specify:

const user = await prisma.user.findUnique({
  where: { id },
  select: {
    name: true,
    posts: { select: { title: true } }, // only name + post titles
  },
});

You can’t use both at the same level. If you need fine-grained control over which fields to return, use select. If you just want to add a relation to the default fields, use include.


Soft deletes with middleware

I rarely want to actually delete data. Usually I want to mark it as deleted and filter it out of queries. Prisma middleware makes this easy:

prisma.$use(async (params, next) => {
  // intercept delete and turn it into an update
  if (params.action === "delete") {
    params.action = "update";
    params.args.data = { deletedAt: new Date() };
  }

  // intercept findMany and filter out soft-deleted records
  if (params.action === "findMany") {
    params.args.where = {
      ...params.args.where,
      deletedAt: null,
    };
  }

  return next(params);
});

Add a deletedAt DateTime? field to your models and this middleware handles the rest. When you call prisma.user.delete(), it sets deletedAt instead. When you query, it automatically excludes deleted records.


Raw queries when Prisma can’t do it

Prisma covers 90% of what I need. But sometimes you need a complex query that the query builder can’t express. Don’t fight it, just use raw SQL:

const results = await prisma.$queryRaw`
  SELECT u.name, COUNT(p.id) as post_count
  FROM "User" u
  LEFT JOIN "Post" p ON p."authorId" = u.id
  WHERE u."createdAt" > ${startDate}
  GROUP BY u.id
  HAVING COUNT(p.id) > 5
  ORDER BY post_count DESC
`;

The template literal syntax handles parameterization so you don’t have to worry about SQL injection. I use this for reports and analytics queries where I need full SQL control.


Migrations in production

Run npx prisma migrate dev in development. It creates and applies migrations and regenerates the client.

In production, use npx prisma migrate deploy. This only applies pending migrations without trying to create new ones. Put it in your deployment script:

{
  "scripts": {
    "postbuild": "prisma migrate deploy"
  }
}

I’ve seen people run migrate dev in production and it tried to reset the database. Don’t make that mistake.


Index your queries

If you’re querying by a field often, add an index. Prisma makes this simple in the schema:

model Post {
  id        String   @id @default(cuid())
  title     String
  authorId  String
  status    String
  createdAt DateTime @default(now())

  author User @relation(fields: [authorId], references: [id])

  @@index([authorId])
  @@index([status, createdAt])
}

That composite index on [status, createdAt] speeds up queries like “get all published posts ordered by date.” Without it, the database scans every row.


It’s an ORM, not magic

Prisma is the best ORM I’ve used, but it’s still an abstraction over SQL. Understanding what queries it generates helps you avoid performance issues. Turn on query logging in development:

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

You’ll see every SQL query Prisma runs. If you spot a query that looks inefficient, you’ll know exactly what to optimize.

The goal isn’t to avoid SQL. It’s to write less of it while keeping full control when you need it.