Skip to main content
Back to Journal
Node.jsDeveloper Tools

Prisma: The Node.js ORM That Finally Gets It Right

I have used both Sequelize and TypeORM in production projects, and both left me frustrated in different ways. Sequelize's API is sprawling and the TypeScript support feels bolted on. TypeORM's decorator-based approach is fine until you hit migration issues that force you to dig through the source code to understand what went wrong.

A coworker recommended Prisma, and after using it for three months, I think it is the best ORM experience I have had in the Node.js ecosystem.

The Schema File

Prisma uses a declarative schema file instead of decorators or JavaScript model definitions. You describe your data model in a schema.prisma file, and Prisma generates everything from it.

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

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

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

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

Reading this schema, you immediately understand the data model. Users have many posts. Posts belong to a user. The @unique, @default, and @relation attributes are self-documenting. Compare this to a Sequelize model definition and the difference in clarity is stark.

Migrations

When you change the schema, Prisma generates a SQL migration for you.

npx prisma migrate dev --name add-published-field

This compares your schema to the current database state, generates the SQL to bring them in sync, applies the migration, and regenerates the Prisma Client. The migration file is pure SQL, so you can review exactly what it will do before applying it to production.

-- Migration: add-published-field
ALTER TABLE "Post" ADD COLUMN "published" BOOLEAN NOT NULL DEFAULT false;

If you need to customize the migration (for example, adding a data migration alongside a schema change), you can edit the SQL file before applying it. This is something that automatic migration tools often get wrong, but Prisma handles it well.

The Generated Client

Running npx prisma generate creates a TypeScript client that is fully typed based on your schema. This is the feature that sold me.

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

const prisma = new PrismaClient();

// Full autocompletion on all fields
const user = await prisma.user.create({
  data: {
    email: '[email protected]',
    name: 'Bryan',
    posts: {
      create: [
        { title: 'First Post', content: 'Hello world' },
      ],
    },
  },
  include: {
    posts: true,
  },
});

// user.posts is typed as Post[]
// user.email is typed as string
// user.name is typed as string | null

Every query method returns the correct TypeScript type based on the fields you select and the relations you include. If you add include: { posts: true }, the return type includes the posts array. If you omit it, accessing user.posts is a type error. The types match exactly what the database returns.

CRUD Operations

The API for common operations is clean and consistent.

// Find many with filtering and sorting
const recentPosts = await prisma.post.findMany({
  where: {
    published: true,
    author: {
      email: { contains: '@example.com' },
    },
  },
  orderBy: { createdAt: 'desc' },
  take: 10,
});

// Update
const updated = await prisma.user.update({
  where: { id: 1 },
  data: { name: 'Bryan Ortiz' },
});

// Delete
await prisma.post.delete({
  where: { id: 42 },
});

// Upsert
const user = await prisma.user.upsert({
  where: { email: '[email protected]' },
  update: { name: 'Bryan' },
  create: { email: '[email protected]', name: 'Bryan' },
});

The filtering syntax supports nested relations, logical operators (AND, OR, NOT), and most comparison operators. It covers 90% of what you need without dropping to raw SQL.

Relations

Prisma handles one-to-one, one-to-many, and many-to-many relationships with clear syntax in the schema. The nested create/connect/disconnect operations in the client make it easy to work with related data.

// Create a user with posts in one operation
const user = await prisma.user.create({
  data: {
    email: '[email protected]',
    name: 'New User',
    posts: {
      create: [
        { title: 'Post 1', content: 'Content 1' },
        { title: 'Post 2', content: 'Content 2' },
      ],
    },
  },
});

// Connect an existing post to a different author
await prisma.post.update({
  where: { id: 5 },
  data: {
    author: { connect: { id: 2 } },
  },
});

The Raw SQL Escape Hatch

For complex queries that the Prisma Client API cannot express, you can always drop to raw SQL.

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

Having the escape hatch available means you never hit a wall. Use the type-safe client for standard operations, and drop to SQL for the complex stuff. This is exactly the right balance.

Why It Matters

The combination of a declarative schema, generated migrations, and a fully typed client means you can change your database schema and immediately see type errors everywhere your code needs to be updated. Add a required field? The compiler tells you every place that creates a record without that field. Rename a column? Every reference lights up in your editor.

This tight feedback loop between your database and your application code is something I did not have with Sequelize or TypeORM. It catches bugs at compile time that would otherwise surface in production as runtime errors.

Prisma is not perfect. The generated client can be large for serverless deployments. Some edge cases in the query API require raw SQL workarounds. And the Prisma team moves fast, so APIs occasionally change between versions. But for most Node.js applications that need a database, I think it is the best option available right now.

prismaormdatabasetypescriptpostgresqlmigrations