Back to Blog

Prisma in Production: Schema Design, Migrations, Connection Management, and Query Optimization

Run Prisma in production correctly: design schemas that avoid N+1 queries, manage migrations safely with shadow databases, configure connection pooling for serverless environments, and optimize slow queries with query events and Prisma's explain.

Viprasol Tech Team
October 24, 2026
13 min read

Prisma's type safety and migration workflow are genuinely good. Its query performance is not automatically good โ€” you have to understand how include, select, and relations translate to SQL, and where the N+1 problem hides. Most teams hit a Prisma performance problem at some point and don't realize the ORM is generating twenty queries where one would do.


Schema Design Patterns

// prisma/schema.prisma

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["relationJoins"]  // Prisma 5.7+: use JOINs instead of separate queries
}

datasource db {
  provider          = "postgresql"
  url               = env("DATABASE_URL")
  shadowDatabaseUrl = env("SHADOW_DATABASE_URL")  // Required for migrations
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // One-to-many: a user has many projects
  projects  Project[]

  // One-to-one: a user has one profile
  profile   Profile?

  // Many-to-many via explicit join table (preferred โ€” allows extra fields on the relationship)
  teamMemberships TeamMember[]

  @@index([email])  // Explicit index โ€” Prisma doesn't add these for non-unique fields
  @@map("users")    // Map to lowercase table name (PostgreSQL convention)
}

model Project {
  id          String     @id @default(cuid())
  name        String
  slug        String     @unique
  visibility  Visibility @default(PRIVATE)
  ownerId     String
  tenantId    String
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
  deletedAt   DateTime?  // Soft delete pattern

  owner       User       @relation(fields: [ownerId], references: [id], onDelete: Cascade)

  // Composite index for common query pattern: get all projects for a tenant
  @@index([tenantId, deletedAt])
  @@index([ownerId])
  @@map("projects")
}

enum Visibility {
  PRIVATE
  TEAM
  PUBLIC
}

// Explicit join table โ€” more control than implicit many-to-many
model TeamMember {
  userId    String
  teamId    String
  role      TeamRole  @default(MEMBER)
  joinedAt  DateTime  @default(now())

  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  team      Team      @relation(fields: [teamId], references: [id], onDelete: Cascade)

  @@id([userId, teamId])  // Composite primary key
  @@map("team_members")
}

Avoiding N+1 Queries

// src/services/project.service.ts
import { prisma } from "@/lib/prisma";

// BAD: N+1 โ€” one query for projects, then one per project for owner
async function getProjectsBad(tenantId: string) {
  const projects = await prisma.project.findMany({
    where: { tenantId, deletedAt: null },
  });

  // N additional queries โ€” one per project
  const withOwners = await Promise.all(
    projects.map(async (p) => ({
      ...p,
      owner: await prisma.user.findUnique({ where: { id: p.ownerId } }),
    }))
  );

  return withOwners;
}

// GOOD: single query with JOIN (Prisma 5+ relationJoins preview)
async function getProjectsGood(tenantId: string) {
  return prisma.project.findMany({
    where: { tenantId, deletedAt: null },
    include: {
      owner: {
        select: { id: true, name: true, email: true }, // Only fetch needed fields
      },
    },
    orderBy: { createdAt: "desc" },
    take: 50,
  });
}

// BETTER: use select for maximum control over what's fetched
async function getProjectsOptimal(tenantId: string) {
  return prisma.project.findMany({
    where: { tenantId, deletedAt: null },
    select: {
      id: true,
      name: true,
      slug: true,
      visibility: true,
      createdAt: true,
      owner: {
        select: { id: true, name: true },
      },
      // Don't include heavy fields you don't need
      // _count: { select: { members: true } } โ€” add aggregates without fetching records
    },
    orderBy: { createdAt: "desc" },
    take: 50,
  });
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Pagination Patterns

// src/services/pagination.ts

// Cursor-based pagination (preferred for large datasets)
export async function getProjectsPage(params: {
  tenantId: string;
  cursor?: string;  // Last project ID from previous page
  pageSize?: number;
}) {
  const { tenantId, cursor, pageSize = 20 } = params;

  const projects = await prisma.project.findMany({
    where: { tenantId, deletedAt: null },
    cursor: cursor ? { id: cursor } : undefined,
    skip: cursor ? 1 : 0,  // Skip the cursor item itself
    take: pageSize + 1,     // Fetch one extra to determine if there's a next page
    orderBy: { createdAt: "desc" },
    select: {
      id: true,
      name: true,
      createdAt: true,
    },
  });

  const hasNextPage = projects.length > pageSize;
  const items = hasNextPage ? projects.slice(0, -1) : projects;
  const nextCursor = hasNextPage ? items[items.length - 1]?.id : null;

  return { items, nextCursor, hasNextPage };
}

// Offset pagination (for numbered pages in UI)
export async function getProjectsOffset(params: {
  tenantId: string;
  page: number;
  pageSize?: number;
}) {
  const { tenantId, page, pageSize = 20 } = params;

  const [total, items] = await prisma.$transaction([
    prisma.project.count({ where: { tenantId, deletedAt: null } }),
    prisma.project.findMany({
      where: { tenantId, deletedAt: null },
      skip: (page - 1) * pageSize,
      take: pageSize,
      orderBy: { createdAt: "desc" },
    }),
  ]);

  return {
    items,
    total,
    page,
    totalPages: Math.ceil(total / pageSize),
  };
}

Transactions

// src/services/project.service.ts

// Interactive transaction โ€” use for business logic that needs to read then write
export async function transferProjectOwnership(
  projectId: string,
  newOwnerId: string
): Promise<void> {
  await prisma.$transaction(async (tx) => {
    // Read current state
    const project = await tx.project.findUniqueOrThrow({
      where: { id: projectId },
      select: { ownerId: true, tenantId: true },
    });

    // Validate new owner is in the same tenant
    const newOwner = await tx.user.findFirst({
      where: {
        id: newOwnerId,
        teamMemberships: {
          some: { team: { tenantId: project.tenantId } },
        },
      },
    });

    if (!newOwner) {
      throw new Error("New owner must be a member of the same tenant");
    }

    // Write
    await tx.project.update({
      where: { id: projectId },
      data: { ownerId: newOwnerId },
    });

    await tx.auditLog.create({
      data: {
        action: "project.ownership_transferred",
        targetId: projectId,
        actorId: newOwnerId,
        metadata: { previousOwnerId: project.ownerId },
      },
    });
  }, {
    isolationLevel: "Serializable", // Prevent concurrent ownership transfers
    timeout: 10_000,
  });
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Prisma Client Setup for Production

// src/lib/prisma.ts

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

// Prevent multiple instances in development (hot reload)
declare global {
  // eslint-disable-next-line no-var
  var __prisma: PrismaClient | undefined;
}

function createPrismaClient() {
  return new PrismaClient({
    log:
      process.env.NODE_ENV === "development"
        ? ["query", "error", "warn"]
        : ["error"],

    // Log slow queries in production
    ...(process.env.NODE_ENV === "production" && {
      log: [
        { emit: "event", level: "query" },
        { emit: "stdout", level: "error" },
      ],
    }),
  });
}

export const prisma = globalThis.__prisma ?? createPrismaClient();

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

// Log slow queries to observability system
if (process.env.NODE_ENV === "production") {
  prisma.$on("query", (e) => {
    if (e.duration > 200) {
      console.warn("Slow Prisma query", {
        query: e.query,
        duration: e.duration,
        params: e.params,
      });
    }
  });
}

Migrations in Production

# Development: generate migration from schema changes
npx prisma migrate dev --name add_project_tags

# Production: apply pending migrations (never use migrate dev in prod)
npx prisma migrate deploy

# Check migration status
npx prisma migrate status

# For dangerous migrations (adding NOT NULL column to large table):
# 1. Add column as nullable
# 2. Backfill data
# 3. Add NOT NULL constraint
# Never do this in a single migration on a large table โ€” it takes an exclusive lock

# Zero-downtime approach with manual migration:
# Step 1: Add nullable column (no lock issue)
ALTER TABLE projects ADD COLUMN tags TEXT[] DEFAULT '{}';

# Step 2: Backfill in batches (Prisma script)
# Step 3: Add default + NOT NULL (quick on already-filled column)
ALTER TABLE projects ALTER COLUMN tags SET NOT NULL;
// scripts/backfill-project-tags.ts
// Safe backfill script with batches + progress tracking

async function backfillProjectTags() {
  const BATCH_SIZE = 500;
  let cursor: string | undefined;
  let processed = 0;

  while (true) {
    const projects = await prisma.project.findMany({
      where: { tags: null },
      take: BATCH_SIZE,
      cursor: cursor ? { id: cursor } : undefined,
      skip: cursor ? 1 : 0,
      select: { id: true },
    });

    if (projects.length === 0) break;

    await prisma.project.updateMany({
      where: { id: { in: projects.map((p) => p.id) } },
      data: { tags: [] },
    });

    processed += projects.length;
    cursor = projects[projects.length - 1].id;
    console.log(`Backfilled ${processed} projects`);

    // Pause between batches to avoid overwhelming the database
    await new Promise((r) => setTimeout(r, 100));
  }

  console.log(`Backfill complete: ${processed} projects updated`);
}

See Also


Working With Viprasol

Prisma is excellent for developer experience but requires deliberate configuration to perform well at scale. We design Prisma schemas with proper indexes, audit queries for N+1 problems, configure connection pooling that works with serverless Prisma deployments, and write safe migration procedures for production databases with millions of rows.

Database engineering โ†’ | Talk to our engineers โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.