March 27, 202610 min read

Prisma: The TypeScript ORM That Doesn't Feel Like an ORM

A complete guide to Prisma ORM covering schema modeling, migrations, CRUD operations, relations, transactions, and how it compares to Sequelize, TypeORM, and Drizzle.

prisma typescript orm database nodejs
Ad 336x280

Traditional ORMs make you choose: write raw SQL and lose type safety, or use a query builder and lose readability. Prisma takes a different approach entirely. You define your data model in a schema file, and Prisma generates a fully typed client that knows your database structure at compile time. No magic strings. No runtime surprises. Auto-complete that actually works.

If you've ever written a TypeORM entity decorator, tracked down a Sequelize query that silently returned the wrong shape, or spent an hour trying to figure out why a Knex migration didn't run, Prisma will feel like a breath of fresh air.

What Prisma Actually Is

Prisma is three tools in one:

  1. Prisma Client -- An auto-generated, type-safe query builder for Node.js and TypeScript
  2. Prisma Migrate -- A declarative migration system based on your schema
  3. Prisma Studio -- A GUI to view and edit your database data
The workflow is schema-first. You define your data model in a schema.prisma file, and everything else flows from there. The client is generated from the schema, migrations are derived from schema changes, and your TypeScript types exactly match your database structure.

The Schema File

This is the heart of Prisma. The schema.prisma file defines your database connection, data models, and relationships.

// prisma/schema.prisma

generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "postgresql" // or mysql, sqlite, mongodb, cockroachdb
url = env("DATABASE_URL")
}

model User {
id Int @id @default(autoincrement())
email String @unique
name String?
role Role @default(USER)
posts Post[]
profile Profile?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([email])
}

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

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

model Profile {
id Int @id @default(autoincrement())
bio String
user User @relation(fields: [userId], references: [id])
userId Int @unique
}

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

enum Role {
USER
ADMIN
MODERATOR
}

This schema is human-readable, declarative, and the single source of truth for your database structure. You can glance at it and immediately understand: Users have many Posts, each Post has one Author, Users have an optional Profile, and Posts can have many Tags (many-to-many).

Migrations

When you change the schema, Prisma creates a migration:

npx prisma migrate dev --name add-user-profile

This command compares your schema to the current database state, generates a SQL migration file, applies it to your development database, and regenerates Prisma Client. The migration files are plain SQL, stored in prisma/migrations/, and committed to version control.

-- prisma/migrations/20260327120000_add_user_profile/migration.sql
CREATE TABLE "Profile" (
    "id" SERIAL NOT NULL,
    "bio" TEXT NOT NULL,
    "userId" INTEGER NOT NULL,
    CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId");

ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

You can review the SQL before it runs, modify it if needed, and track exactly what changed and when. No opaque ORM magic -- just SQL you can read and understand.

For production deployments:

npx prisma migrate deploy

This runs all pending migrations in order, without generating new ones or regenerating the client.

CRUD Operations

Once you run npx prisma generate, you get a fully typed client. Here's what working with it looks like:

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

const prisma = new PrismaClient();

// CREATE
const user = await prisma.user.create({
data: {
email: 'alice@example.com',
name: 'Alice',
profile: {
create: {
bio: 'Software developer and coffee enthusiast',
},
},
},
include: {
profile: true,
},
});
// user is fully typed: { id: number, email: string, name: string | null, ... }

// READ
const allUsers = await prisma.user.findMany({
where: {
role: 'ADMIN',
},
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 5,
},
},
});

// FIND UNIQUE
const specificUser = await prisma.user.findUnique({
where: { email: 'alice@example.com' },
});
// Returns User | null, correctly typed

// UPDATE
const updatedUser = await prisma.user.update({
where: { id: 1 },
data: {
name: 'Alice Johnson',
role: 'ADMIN',
},
});

// DELETE
const deletedUser = await prisma.user.delete({
where: { id: 1 },
});

The key differentiator is the type safety. When you write prisma.user.create({ data: { ... } }), TypeScript knows exactly which fields are required, which are optional, and what types they accept. If you try to pass email: 42 or forget a required field, you get a compile-time error, not a runtime crash.

Relations

Prisma handles relationships elegantly, both in schema definition and querying.

// Create a post with an existing author
const post = await prisma.post.create({
  data: {
    title: 'Understanding Prisma Relations',
    content: 'Relations in Prisma are...',
    author: {
      connect: { id: userId },
    },
    tags: {
      connectOrCreate: [
        {
          where: { name: 'prisma' },
          create: { name: 'prisma' },
        },
        {
          where: { name: 'database' },
          create: { name: 'database' },
        },
      ],
    },
  },
  include: {
    author: true,
    tags: true,
  },
});

// Nested reads -- get a user with their published posts and each post's tags
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
where: { published: true },
include: {
tags: true,
},
},
profile: true,
},
});

The connect, create, connectOrCreate, and disconnect operations give you fine-grained control over how related records are managed. And the return type of each query exactly matches what you included -- if you didn't include: { posts: true }, the posts field won't exist on the result type. No accidentally accessing undefined relationships.

Filtering and Pagination

Prisma's filtering API is expressive and fully typed:

// Complex filtering
const posts = await prisma.post.findMany({
  where: {
    AND: [
      { published: true },
      {
        OR: [
          { title: { contains: 'prisma', mode: 'insensitive' } },
          { tags: { some: { name: 'database' } } },
        ],
      },
      { createdAt: { gte: new Date('2026-01-01') } },
    ],
  },
  orderBy: [
    { createdAt: 'desc' },
  ],
  // Cursor-based pagination
  take: 20,
  skip: 1,
  cursor: {
    id: lastPostId,
  },
});

// Offset-based pagination
const page2 = await prisma.post.findMany({
skip: 20,
take: 20,
orderBy: { createdAt: 'desc' },
});

// Count for pagination metadata
const totalPosts = await prisma.post.count({
where: { published: true },
});

Every filter operator (contains, startsWith, gte, in, some, every, none) is typed. You can't use contains on a number field or gte on a boolean. The compiler catches it.

Transactions

Prisma supports both interactive transactions and batch operations:

// Interactive transaction -- full control
const transfer = await prisma.$transaction(async (tx) => {
  // Deduct from sender
  const sender = await tx.account.update({
    where: { id: senderId },
    data: { balance: { decrement: amount } },
  });

if (sender.balance < 0) {
throw new Error('Insufficient funds');
// Transaction automatically rolls back
}

// Credit to receiver
const receiver = await tx.account.update({
where: { id: receiverId },
data: { balance: { increment: amount } },
});

// Create transaction record
await tx.transactionLog.create({
data: {
senderId,
receiverId,
amount,
timestamp: new Date(),
},
});

return { sender, receiver };
});

// Batch transaction -- multiple independent operations
const [updatedUser, newPost] = await prisma.$transaction([
prisma.user.update({
where: { id: 1 },
data: { postCount: { increment: 1 } },
}),
prisma.post.create({
data: { title: 'New Post', authorId: 1 },
}),
]);

The interactive transaction gives you a transaction client (tx) that works exactly like the regular client. If any operation fails or you throw an error, everything rolls back. This is much cleaner than manually managing transaction state.

Raw Queries

When the Prisma API isn't enough, you can drop down to raw SQL:

// Tagged template literal -- safe from SQL injection
const users = await prisma.$queryRaw<User[]>
  SELECT u.*, COUNT(p.id) as post_count
  FROM "User" u
  LEFT JOIN "Post" p ON p."authorId" = u.id
  WHERE u.role = ${role}
  GROUP BY u.id
  HAVING COUNT(p.id) > ${minPosts}
  ORDER BY post_count DESC
;

// Execute without returning results
await prisma.$executeRaw
UPDATE "Post"
SET published = true
WHERE "createdAt" < ${cutoffDate}
;

The tagged template literal prevents SQL injection by parameterizing all interpolated values. You get the flexibility of raw SQL with the safety of parameterized queries.

Compared to Other ORMs

Prisma vs Sequelize: Sequelize uses JavaScript classes and decorators, defines models imperatively, and has weaker TypeScript support. Prisma's schema-first approach with generated types is significantly better for TypeScript projects. Sequelize has been around longer and has more community knowledge, but Prisma is the clear choice for new TypeScript projects. Prisma vs TypeORM: TypeORM also uses decorators and is more traditional in its ORM approach. It supports the Active Record and Data Mapper patterns. TypeORM's TypeScript support is better than Sequelize's but worse than Prisma's -- you can still get runtime type mismatches that Prisma catches at compile time. TypeORM is more flexible for complex patterns; Prisma is more opinionated and safer. Prisma vs Drizzle: Drizzle is the closest competitor. It's also TypeScript-first and type-safe, but takes a SQL-first approach -- its query syntax looks more like SQL translated to TypeScript. Drizzle is lighter weight, has no schema file or generation step, and gives you more control over the exact SQL produced. Choose Drizzle if you want to stay closer to SQL. Choose Prisma if you prefer a higher-level abstraction with better tooling (Studio, Migrate).

Common Mistakes

Not using include or select. By default, Prisma returns all scalar fields but no relations. If you access user.posts without including it, you get undefined, not an empty array. Always explicitly include the relations you need. Forgetting to regenerate the client. After changing schema.prisma, you need to run npx prisma generate (or npx prisma migrate dev, which does it automatically). Stale client = stale types. N+1 queries. Prisma doesn't automatically batch related queries. If you loop through users and access user.posts in each iteration, you get N+1 queries. Use include to eager-load relations instead.
// BAD: N+1 queries
const users = await prisma.user.findMany();
for (const user of users) {
  const posts = await prisma.post.findMany({
    where: { authorId: user.id },
  });
}

// GOOD: Single query with include
const users = await prisma.user.findMany({
include: { posts: true },
});

Not using @@index for frequently queried fields. Prisma won't automatically create database indexes. If you filter by authorId frequently, add @@index([authorId]) to your schema.

What's Next

Prisma has become the default ORM for TypeScript projects, and for good reason. The type safety, developer experience, and tooling are a generation ahead of traditional ORMs. The schema-first approach might feel unfamiliar if you're used to defining models in code, but once you experience having your database schema, TypeScript types, and migrations all derived from a single source of truth, going back feels dangerous.

Start with a small project -- a REST API with Express or a Next.js app with a few models. Get comfortable with the schema language, the generated client, and the migration workflow. Then reach for the advanced features (raw queries, interactive transactions, middleware) as your needs grow.

Build database-backed projects and level up your backend skills at CodeUp.

Ad 728x90