Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Prisma: The Entity Framework of TypeScript

For .NET engineers who know: EF Core — DbContext, migrations, LINQ queries, Include(), ThenInclude(), AsNoTracking(), and Scaffold-DbContext You’ll learn: How Prisma maps onto every EF Core concept you already know, and where the abstractions diverge in ways that will catch you off guard Time: 15-20 min read


The .NET Way (What You Already Know)

EF Core is a Code-First ORM (or Database-First via scaffolding). You define your domain model as C# classes, configure the mapping in DbContext (via fluent API or data annotations), generate migrations with Add-Migration, and query using LINQ. The full cycle:

// 1. Define the model
public class Order
{
    public int Id { get; set; }
    public decimal Total { get; set; }
    public DateTime CreatedAt { get; set; }
    public int CustomerId { get; set; }
    public Customer Customer { get; set; }
    public List<OrderItem> Items { get; set; } = new();
}

// 2. Configure DbContext
public class AppDbContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
    public DbSet<Customer> Customers { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .HasOne(o => o.Customer)
            .WithMany(c => c.Orders)
            .HasForeignKey(o => o.CustomerId);

        modelBuilder.Entity<Order>()
            .Property(o => o.Total)
            .HasPrecision(12, 2);
    }
}

// 3. Migrate
// dotnet ef migrations add AddOrders
// dotnet ef database update

// 4. Query
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .Where(o => o.Total > 100)
    .OrderByDescending(o => o.CreatedAt)
    .Take(20)
    .AsNoTracking()
    .ToListAsync();

This pattern — model, context, migration, query — is what Prisma replicates in TypeScript. The vocabulary is different but the structure is nearly isomorphic.


The Prisma Way

Installation

npm install prisma --save-dev
npm install @prisma/client

npx prisma init --datasource-provider postgresql

This creates:

  • prisma/schema.prisma — your schema file (replaces DbContext + model classes)
  • .env with a DATABASE_URL placeholder

The Schema File: DbContext + Models Combined

In EF Core, you have separate C# files for each entity class plus a DbContext to configure them. In Prisma, everything lives in a single schema.prisma file: the database connection, generator configuration, and all model definitions.

// prisma/schema.prisma

// 1. Generator — what Prisma should generate
generator client {
  provider = "prisma-client-js"
}

// 2. Datasource — your database connection
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// 3. Models — equivalent to your EF Core entity classes + OnModelCreating config
model Customer {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  createdAt DateTime @default(now()) @map("created_at")
  orders    Order[]

  @@map("customers")
}

model Order {
  id         Int         @id @default(autoincrement())
  total      Decimal     @db.Decimal(12, 2)
  createdAt  DateTime    @default(now()) @map("created_at")
  customerId Int         @map("customer_id")
  customer   Customer    @relation(fields: [customerId], references: [id])
  items      OrderItem[]

  @@map("orders")
}

model OrderItem {
  id        Int     @id @default(autoincrement())
  orderId   Int     @map("order_id")
  productId Int     @map("product_id")
  quantity  Int
  price     Decimal @db.Decimal(10, 2)
  order     Order   @relation(fields: [orderId], references: [id])
  product   Product @relation(fields: [productId], references: [id])

  @@map("order_items")
}

model Product {
  id         Int         @id @default(autoincrement())
  name       String
  sku        String      @unique
  price      Decimal     @db.Decimal(10, 2)
  orderItems OrderItem[]

  @@map("products")
}

Schema attribute mapping:

EF Core ConfigurationPrisma Equivalent
[Key] / .HasKey()@id
[DatabaseGenerated(Identity)]@default(autoincrement())
[Required] / .IsRequired()Field with no ? (non-optional)
[MaxLength(200)] / .HasMaxLength()@db.VarChar(200)
.HasPrecision(12, 2)@db.Decimal(12, 2)
[Index(IsUnique = true)]@unique or @@unique([field1, field2])
@@Index / .HasIndex()@@index([field1, field2])
[Column("created_at")]@map("created_at")
[Table("orders")]@@map("orders")
HasOne().WithMany()@relation() on the FK side
Owned() / value objectsNot directly supported; use embedded JSON or separate table
.HasDefaultValue()@default(value)
.HasDefaultValueSql("now()")@default(now())
[Timestamp] / IsRowVersion()No direct equivalent; use updatedAt DateTime @updatedAt

Generating the Client: prisma generate

After editing the schema, you run:

npx prisma generate

This is equivalent to the EF Core model snapshot being updated. It reads your schema.prisma and generates a fully-typed TypeScript client in node_modules/@prisma/client. The generated client has:

  • TypeScript types for every model
  • Typed input/output types for every operation
  • Typed filter, orderBy, and include arguments

The generated types are not something you write — they are entirely inferred from your schema. This is the most significant Prisma advantage: your query types are always in sync with your schema without any manual DTO definition.

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

const prisma = new PrismaClient();

That prisma instance gives you fully typed access to every model.

Migrations: prisma migrate

# Create a migration (equivalent to Add-Migration)
npx prisma migrate dev --name add_orders_table

# Apply to production (equivalent to Update-Database in CI/CD)
npx prisma migrate deploy

prisma migrate dev does three things:

  1. Generates a SQL migration file in prisma/migrations/
  2. Applies it to your development database
  3. Regenerates the Prisma client (prisma generate)

prisma migrate deploy applies pending migrations without generating or prompting — intended for CI/CD and production.

EF Core vs Prisma migration workflow:

StepEF CorePrisma
Create migrationdotnet ef migrations add <Name>npx prisma migrate dev --name <name>
Apply to devdotnet ef database update(done automatically by migrate dev)
Apply to productiondotnet ef database update (or bundle)npx prisma migrate deploy
View pendingdotnet ef migrations listnpx prisma migrate status
Rollbackdotnet ef database update <PreviousMigration>Manual SQL (see article 5.4)
Reset dev DBdotnet ef database drop && dotnet ef database updatenpx prisma migrate reset
Scaffold from existing DBdotnet ef dbcontext scaffoldnpx prisma db pull

Migration files are plain SQL stored in prisma/migrations/<timestamp>_<name>/migration.sql. Unlike EF Core’s C# migration files (with Up() and Down() methods), Prisma migration files are SQL-only and have no auto-generated Down(). See article 5.4 for rollback strategies.

Querying: Prisma Client vs EF Core LINQ

This is where the two ORMs feel most different in daily use. EF Core uses LINQ — method chaining that translates to SQL at runtime. Prisma uses a JavaScript object API where you pass nested objects describing your query.

EF Core vs Prisma API comparison:

OperationEF Core (C#)Prisma (TypeScript)
Find by PKcontext.Orders.FindAsync(id)prisma.order.findUnique({ where: { id } })
Find one (throws if missing)context.Orders.SingleAsync(...)prisma.order.findUniqueOrThrow({ where: { id } })
Find firstcontext.Orders.FirstOrDefaultAsync(...)prisma.order.findFirst({ where: {...} })
Get allcontext.Orders.ToListAsync()prisma.order.findMany()
Filter.Where(o => o.Total > 100)findMany({ where: { total: { gt: 100 } } })
Sort.OrderByDescending(o => o.CreatedAt)findMany({ orderBy: { createdAt: 'desc' } })
Limit.Take(20)findMany({ take: 20 })
Skip.Skip(40)findMany({ skip: 40 })
Select fields.Select(o => new { o.Id, o.Total })findMany({ select: { id: true, total: true } })
Include relation.Include(o => o.Customer)findMany({ include: { customer: true } })
Nested include.ThenInclude(c => c.Address)include: { customer: { include: { address: true } } }
Createcontext.Orders.Add(order); await context.SaveChangesAsync()prisma.order.create({ data: { ... } })
Update by PKcontext.Entry(order).State = Modified;prisma.order.update({ where: { id }, data: { ... } })
Upsert.AddOrUpdate() (not built-in, use FindAsync + update)prisma.order.upsert({ where, create, update })
Deletecontext.Orders.Remove(order)prisma.order.delete({ where: { id } })
Delete manycontext.Orders.RemoveRange(...)prisma.order.deleteMany({ where: { ... } })
Countcontext.Orders.CountAsync(...)prisma.order.count({ where: { ... } })
Aggregate.SumAsync(o => o.Total)prisma.order.aggregate({ _sum: { total: true } })
Group by.GroupBy(o => o.CustomerId)prisma.order.groupBy({ by: ['customerId'], _sum: { total: true } })

Complete examples side by side:

// EF Core — get orders with customer and items, filtered and paginated
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .Where(o => o.Total > 100 && o.CreatedAt >= DateTime.UtcNow.AddDays(-30))
    .OrderByDescending(o => o.CreatedAt)
    .Skip(page * pageSize)
    .Take(pageSize)
    .AsNoTracking()
    .ToListAsync();
// Prisma — equivalent query
const orders = await prisma.order.findMany({
  where: {
    total: { gt: 100 },
    createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
  },
  include: {
    customer: true,
    items: {
      include: {
        product: true,
      },
    },
  },
  orderBy: { createdAt: 'desc' },
  skip: page * pageSize,
  take: pageSize,
});
// EF Core — create with nested relation
var order = new Order
{
    CustomerId = 1,
    Total = 249.99m,
    Items = new List<OrderItem>
    {
        new() { ProductId = 5, Quantity = 2, Price = 124.99m }
    }
};
context.Orders.Add(order);
await context.SaveChangesAsync();
// Prisma — create with nested relation
const order = await prisma.order.create({
  data: {
    customerId: 1,
    total: 249.99,
    items: {
      create: [
        { productId: 5, quantity: 2, price: 124.99 },
      ],
    },
  },
  include: { items: true },
});
// EF Core — upsert pattern
var existing = await context.Customers
    .FirstOrDefaultAsync(c => c.Email == email);
if (existing is null)
{
    context.Customers.Add(new Customer { Email = email, Name = name });
}
else
{
    existing.Name = name;
}
await context.SaveChangesAsync();
// Prisma — built-in upsert
const customer = await prisma.customer.upsert({
  where: { email },
  create: { email, name },
  update: { name },
});

Filtering Reference

Prisma’s where clause uses nested operator objects. The mapping from SQL/LINQ:

// Equality
where: { status: 'active' }

// Comparison
where: { total: { gt: 100 } }           // >
where: { total: { gte: 100 } }          // >=
where: { total: { lt: 100 } }           // <
where: { total: { lte: 100 } }          // <=
where: { total: { not: 100 } }          // !=

// String
where: { name: { contains: 'Smith' } }          // LIKE '%Smith%'
where: { name: { startsWith: 'Jo' } }           // LIKE 'Jo%'
where: { name: { endsWith: 'son' } }            // LIKE '%son'
where: { email: { contains: '@', mode: 'insensitive' } } // ILIKE

// Null checks
where: { deletedAt: null }              // IS NULL
where: { deletedAt: { not: null } }     // IS NOT NULL

// In / Not In
where: { status: { in: ['active', 'pending'] } }
where: { status: { notIn: ['deleted'] } }

// AND (default when multiple keys at same level)
where: { total: { gt: 100 }, status: 'active' }

// OR
where: { OR: [{ status: 'active' }, { status: 'pending' }] }

// AND explicit
where: { AND: [{ total: { gt: 100 } }, { createdAt: { gte: startDate } }] }

// NOT
where: { NOT: { status: 'deleted' } }

// Relation filter — orders that have at least one item
where: { items: { some: { quantity: { gt: 0 } } } }

// Relation filter — all items satisfy condition
where: { items: { every: { price: { gt: 0 } } } }

// Relation filter — no items match condition
where: { items: { none: { quantity: 0 } } }

Pagination: Cursor vs Offset

// Offset pagination (like EF Core Skip/Take)
const page2 = await prisma.order.findMany({
  skip: 20,
  take: 10,
  orderBy: { createdAt: 'desc' },
});

// Cursor pagination (more efficient for large datasets)
// First page — no cursor
const firstPage = await prisma.order.findMany({
  take: 10,
  orderBy: { id: 'asc' },
});

// Subsequent page — pass the last item's id as cursor
const nextPage = await prisma.order.findMany({
  take: 10,
  skip: 1,             // skip the cursor record itself
  cursor: { id: firstPage[firstPage.length - 1].id },
  orderBy: { id: 'asc' },
});

Cursor pagination is more efficient for deep pages on large tables because it uses an indexed seek rather than counting rows. The tradeoff: you cannot jump to an arbitrary page — only forward and back.

Transactions

// EF Core transaction
using var transaction = await context.Database.BeginTransactionAsync();
try
{
    context.Orders.Add(order);
    await context.SaveChangesAsync();
    context.Inventory.Update(inventoryUpdate);
    await context.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}
// Prisma — interactive transaction (most flexible, like EF Core)
const [order, inventory] = await prisma.$transaction(async (tx) => {
  const newOrder = await tx.order.create({
    data: { customerId: 1, total: 99.99 },
  });

  const updatedInventory = await tx.inventory.update({
    where: { productId: 5 },
    data: { quantity: { decrement: 1 } },
  });

  return [newOrder, updatedInventory];
});

// Prisma — batch transaction (simpler, all succeed or all fail)
const [deleteOld, createNew] = await prisma.$transaction([
  prisma.order.deleteMany({ where: { createdAt: { lt: cutoffDate } } }),
  prisma.order.create({ data: { customerId: 1, total: 50 } }),
]);

The interactive transaction (async (tx) => { ... }) is the EF Core equivalent — you get a transaction-scoped client and can use any Prisma operations inside it. The batch transaction is a lighter syntax for a fixed list of operations.

Raw SQL

// EF Core raw SQL
var orders = await context.Orders
    .FromSqlRaw("SELECT * FROM orders WHERE customer_id = {0}", customerId)
    .ToListAsync();

// EF Core — raw SQL that doesn't return entities
await context.Database.ExecuteSqlRawAsync(
    "UPDATE orders SET status = 'archived' WHERE created_at < {0}", cutoffDate);
// Prisma — raw query returning typed results
const orders = await prisma.$queryRaw<Order[]>`
  SELECT * FROM orders WHERE customer_id = ${customerId}
`;

// Prisma — raw execute (no return value needed)
const count = await prisma.$executeRaw`
  UPDATE orders SET status = 'archived' WHERE created_at < ${cutoffDate}
`;

// Prisma — raw with Prisma.sql for dynamic queries (safe parameterization)
import { Prisma } from '@prisma/client';

const minTotal = 100;
const orders = await prisma.$queryRaw<Order[]>(
  Prisma.sql`SELECT * FROM orders WHERE total > ${minTotal}`
);

Always use tagged template literals (the backtick syntax) with $queryRaw and $executeRaw. Prisma automatically parameterizes the values, preventing SQL injection. If you need to interpolate SQL fragments dynamically (like a column name), use Prisma.raw() — but do so carefully and never with user input.

Seeding

// prisma/seed.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // Upsert so seed is idempotent
  const admin = await prisma.customer.upsert({
    where: { email: 'admin@example.com' },
    update: {},
    create: {
      email: 'admin@example.com',
      name: 'Admin User',
    },
  });

  console.log(`Seeded customer: ${admin.id}`);
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());
// package.json — register the seed script
{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}
npx prisma db seed
# Also runs automatically after: npx prisma migrate reset

Introspection: Scaffold-DbContext Equivalent

If you are inheriting an existing database (rather than building code-first), Prisma can generate the schema from the existing tables:

# Like Scaffold-DbContext — generates schema.prisma from your existing database
npx prisma db pull

# Then generate the client from the introspected schema
npx prisma generate

This fills schema.prisma with model definitions inferred from your tables. You will need to add relation fields manually (Prisma can infer them from foreign keys but the field names may need adjustment). After reviewing and editing the generated schema, you can begin using Prisma Client immediately.


Key Differences

ConceptEF CorePrisma
Schema definitionC# entity classes + DbContextSingle schema.prisma file
Client generationImplicit — types are your C# classesExplicit — prisma generate produces TS types
Migration filesC# Up()/Down() methodsPlain SQL files (no auto-rollback)
Query APILINQ method chainingNested JS object API
Change trackingBuilt-in (AsNoTracking() opt-out)None — every query is stateless
Lazy loadingSupported (with proxies)Not supported — always explicit include
N+1 preventionInclude() requiredinclude required — same discipline
Raw SQLFromSqlRaw(), ExecuteSqlRaw()$queryRaw, $executeRaw
TransactionsBeginTransactionAsync()$transaction()
Database-firstScaffold-DbContextprisma db pull
SeedingHasData() in OnModelCreating + migrationsSeparate seed.ts script
Connection poolADO.NET pool (managed by driver)Built into Prisma Query Engine
Multiple DB supportYes (different providers)Separate datasource blocks (experimental)

Gotchas for .NET Engineers

1. The Prisma Query Engine is a Rust binary — cold starts matter in serverless

When you run prisma generate, Prisma downloads a Rust-compiled query engine binary for your platform. This binary handles connection pooling and query execution. In a long-running process (a traditional Node.js API server), this is invisible. In serverless environments (AWS Lambda, Vercel Edge Functions, Cloudflare Workers), the binary adds cold start time and can cause deployment size issues.

For serverless, use Prisma Accelerate (Prisma’s connection pool proxy service) or PgBouncer separately, and consider the DATABASE_URL pointing to a pooler endpoint rather than the direct database:

# Direct connection (fine for long-running servers)
DATABASE_URL="postgresql://user:pass@host:5432/db"

# PgBouncer / Prisma Accelerate (for serverless)
DATABASE_URL="postgresql://user:pass@pgbouncer-host:6432/db?pgbouncer=true&connection_limit=1"

The connection_limit=1 tells Prisma not to try to maintain a pool itself (the pooler handles it).

2. N+1 is completely invisible without include

EF Core supports lazy loading (though it is opt-in and generally discouraged). Prisma does not. If you access a relation that you did not include, you get null or undefined — not an extra query.

// This returns orders with customer = null (not loaded)
const orders = await prisma.order.findMany();

for (const order of orders) {
  console.log(order.customer); // undefined — not an automatic extra query
}

// This is the N+1 anti-pattern if done manually
const orders = await prisma.order.findMany();
for (const order of orders) {
  // This is a separate DB query per order — N+1
  const customer = await prisma.customer.findUnique({
    where: { id: order.customerId },
  });
}

// Correct — use include
const orders = await prisma.order.findMany({
  include: { customer: true },
});

The silver lining: Prisma’s design makes N+1 obvious and explicit. You will not accidentally trigger lazy loading through a navigation property access. But you must plan your include structure upfront.

3. findUnique vs findFirst — the semantics are different

This trips up developers expecting something like FirstOrDefaultAsync to just work everywhere.

// findUnique — ONLY works on fields marked @id or @unique in schema
// Uses a unique index — fast
const customer = await prisma.customer.findUnique({
  where: { id: 42 },       // OK — @id
});

const customer2 = await prisma.customer.findUnique({
  where: { email: 'foo@example.com' }, // OK — @unique
});

// This does NOT compile — 'name' is not @id or @unique
const customer3 = await prisma.customer.findUnique({
  where: { name: 'Chris' }, // TypeScript error
});

// findFirst — works on any field, returns first match (or null)
// May do a full table scan if field is not indexed
const customer4 = await prisma.customer.findFirst({
  where: { name: 'Chris' },
  orderBy: { createdAt: 'desc' },
});

findUnique gives you a TypeScript-level guarantee that you are querying on a unique field and that the result is either the record or null (never a list). findFirst is the general-purpose single-record fetch. Use findUnique whenever the field is @id or @unique — it is both faster and more semantically correct.

4. Prisma does not track changes — every update requires explicit fields

EF Core’s change tracker lets you load an entity, mutate its properties, and call SaveChangesAsync(). Prisma has no change tracker. Every update call must specify the new values explicitly.

// EF Core — mutate and save
var customer = await context.Customers.FindAsync(id);
customer.Name = "New Name";
customer.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync(); // EF Core computes the UPDATE from change tracking
// Prisma — must specify every field you want to change
const customer = await prisma.customer.update({
  where: { id },
  data: {
    name: 'New Name',
    updatedAt: new Date(),
  },
});

// You cannot do this:
const customer = await prisma.customer.findUnique({ where: { id } });
customer.name = 'New Name'; // mutating the object does nothing to the database
await prisma.customer.save(customer); // this method does not exist

This is actually safer in many respects — there are no accidental implicit saves — but it requires a mindset shift if you are used to the entity-mutation pattern.

5. Decimal requires special handling in TypeScript

PostgreSQL’s NUMERIC/DECIMAL type maps to Prisma’s Decimal type, which uses the decimal.js library internally. This is not a native JavaScript number — it is an object.

// Prisma returns Decimal objects for Decimal fields, not numbers
const order = await prisma.order.findUnique({ where: { id: 1 } });
console.log(typeof order.total); // 'object', not 'number'
console.log(order.total.toString()); // '249.99'
console.log(order.total.toNumber()); // 249.99 (native JS number, precision loss risk)

// Comparison
if (order.total.greaterThan(100)) { ... }    // correct
if (order.total > 100) { ... }               // does not work as expected
if (order.total.toNumber() > 100) { ... }    // works but loses precision for large values

// When creating/updating, pass a string or Decimal
await prisma.order.create({
  data: {
    total: new Decimal('249.99'), // or just '249.99' — Prisma accepts strings
    customerId: 1,
  },
});

For financial data, treat Decimal values as opaque objects and use the decimal.js API for arithmetic. Serialize them to strings for API responses (total.toString()), not toNumber().


Hands-On Exercise

Build a typed data access layer for a blog system.

Schema:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String
  posts     Post[]
  createdAt DateTime  @default(now()) @map("created_at")

  @@map("users")
}

model Post {
  id          Int       @id @default(autoincrement())
  title       String
  content     String
  published   Boolean   @default(false)
  authorId    Int       @map("author_id")
  author      User      @relation(fields: [authorId], references: [id])
  tags        Tag[]
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

  @@index([authorId])
  @@map("posts")
}

model Tag {
  id    Int    @id @default(autoincrement())
  name  String @unique
  posts Post[]

  @@map("tags")
}
npx prisma migrate dev --name init

Exercise — implement these functions:

// src/blog-repository.ts
import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient();

// 1. Get published posts with author, paginated, optional tag filter
export async function getPublishedPosts(options: {
  page: number;
  pageSize: number;
  tag?: string;
}) {
  const { page, pageSize, tag } = options;

  return prisma.post.findMany({
    where: {
      published: true,
      ...(tag ? { tags: { some: { name: tag } } } : {}),
    },
    include: {
      author: { select: { id: true, name: true } },
      tags: true,
    },
    orderBy: { createdAt: 'desc' },
    skip: page * pageSize,
    take: pageSize,
  });
}

// 2. Create a post with tags (create tags if they don't exist)
export async function createPost(data: {
  title: string;
  content: string;
  authorId: number;
  tags: string[];
}) {
  return prisma.post.create({
    data: {
      title: data.title,
      content: data.content,
      authorId: data.authorId,
      tags: {
        connectOrCreate: data.tags.map((name) => ({
          where: { name },
          create: { name },
        })),
      },
    },
    include: { tags: true, author: true },
  });
}

// 3. Publish a post — return null if not found
export async function publishPost(id: number) {
  try {
    return await prisma.post.update({
      where: { id },
      data: { published: true },
    });
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
      return null; // Record not found
    }
    throw e;
  }
}

// 4. Get post counts grouped by author
export async function getPostCountByAuthor() {
  return prisma.post.groupBy({
    by: ['authorId'],
    _count: { id: true },
    orderBy: { _count: { id: 'desc' } },
  });
}

Quick Reference

# Setup
npm install prisma --save-dev && npm install @prisma/client
npx prisma init --datasource-provider postgresql

# Development cycle
npx prisma migrate dev --name <description>  # create + apply migration
npx prisma generate                          # regenerate client after schema change
npx prisma db pull                           # introspect existing DB into schema
npx prisma db push                           # push schema to DB without migration (prototyping)
npx prisma migrate reset                     # drop DB, re-apply all migrations, seed
npx prisma migrate status                    # list applied/pending migrations
npx prisma studio                            # open browser-based data editor

# Production
npx prisma migrate deploy                    # apply pending migrations (no prompts)
npx prisma db seed                           # run seed script
// Common query patterns
import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient();

// Instantiation (singleton pattern for production)
// lib/prisma.ts
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

// CRUD
await prisma.model.findUnique({ where: { id } })
await prisma.model.findUniqueOrThrow({ where: { id } })
await prisma.model.findFirst({ where: { ... } })
await prisma.model.findMany({ where, orderBy, skip, take, include, select })
await prisma.model.create({ data: { ... }, include: { ... } })
await prisma.model.update({ where: { id }, data: { ... } })
await prisma.model.upsert({ where, create, update })
await prisma.model.delete({ where: { id } })
await prisma.model.deleteMany({ where: { ... } })
await prisma.model.count({ where: { ... } })
await prisma.model.aggregate({ where, _sum, _avg, _min, _max, _count })
await prisma.model.groupBy({ by: ['field'], _count: true, orderBy })

// Transactions
await prisma.$transaction(async (tx) => { await tx.model.create(...) })
await prisma.$transaction([op1, op2, op3])

// Raw SQL
await prisma.$queryRaw<T[]>`SELECT * FROM table WHERE id = ${id}`
await prisma.$executeRaw`UPDATE table SET col = ${val} WHERE id = ${id}`

// Error handling
import { Prisma } from '@prisma/client';
if (e instanceof Prisma.PrismaClientKnownRequestError) {
  e.code // 'P2002' = unique constraint, 'P2025' = record not found
}

// Common error codes
// P2002 — Unique constraint failed
// P2003 — Foreign key constraint failed
// P2025 — Record to update/delete not found

Further Reading