DEV Community

Cover image for Prisma for Beginners (Especially if you’ve used Mongoose): A Friendly, Practical Guide
Md Enayetur Rahman
Md Enayetur Rahman

Posted on

Prisma for Beginners (Especially if you’ve used Mongoose): A Friendly, Practical Guide

If you’ve tinkered with Mongoose and recently met Prisma, it’s normal to know the syntax but not the “why.” This post connects the dots—from ORM vs ODM to relationships, type-safety, and migrations—using the exact questions you asked.


ORM vs. ODM (for absolute beginners)

  • ORM (Object-Relational Mapper): Works with relational databases (PostgreSQL, MySQL, SQLite). You write code using objects; the tool generates SQL under the hood. Prisma is an ORM.
  • ODM (Object-Document Mapper): Works with document databases (MongoDB). You work with documents (JSON-like). Mongoose is an ODM.

Mental model:

  • ORM ↔ tables, rows, foreign keys.
  • ODM ↔ collections, documents, references/embedded docs.

“Prisma lets me write queries with objects” — Yes!

Instead of raw SQL, you describe queries with plain JS/TS objects. Prisma translates them into safe, parameterized SQL.

// Find one user by a unique field
const user = await prisma.user.findUnique({
  where: { userId: 'u_123' }, // must be unique in your schema
});

// Filter + sort + pick fields
const users = await prisma.user.findMany({
  where: { isActive: true, name: 'Enayet' },
  orderBy: { createdAt: 'desc' },
  take: 10,
  select: { id: true, name: true, email: true },
});
Enter fullscreen mode Exit fullscreen mode

What is “type safety” and why it helps

Type safety means your editor can warn you before running code if you pass the wrong type.

Example: if age is an Int in your model, Prisma won’t let you do { age: "twenty" }. You get autocomplete, fewer runtime bugs, and more confidence.


What is a Prisma model?

A model in schema.prisma describes a table (relational DB) or a collection/document shape (MongoDB provider). Fields map to columns (or document keys), and attributes add constraints/behavior.

model User {
  id        Int       @id @default(autoincrement())
  userId    String    @unique
  email     String    @unique
  isActive  Boolean   @default(true)
  posts     Post[]              // 1→many relation (virtual field)
  profile   Profile?            // 1→1 relation (optional)
  createdAt DateTime  @default(now())
}
Enter fullscreen mode Exit fullscreen mode

Pattern per field: name Type [modifiers] [@attributes]

Examples: ? (nullable), [] (list), @id, @unique, @default(), @relation(...).


Prisma model vs Mongoose model (what’s the difference?)

Prisma (ORM)

  • You define a schema (schema.prisma) that maps to the database schema.
  • Prisma generates a typed client you import in code.
  • Changing models usually means creating/applying a migration to change the DB structure.

Mongoose (ODM)

  • You define schemas in code (new mongoose.Schema({...})) that shape documents at the application level.
  • MongoDB itself is schemaless; constraints/indexes are optional and separate.
  • No SQL migrations—document shape can evolve without altering a fixed table schema (though you often write scripts to backfill/clean data).

Side-by-side:

// Prisma (schema.prisma)
model User {
  id     Int    @id @default(autoincrement())
  email  String @unique
  age    Int?
}
Enter fullscreen mode Exit fullscreen mode
// Mongoose (JavaScript)
const UserSchema = new mongoose.Schema({
  email: { type: String, unique: true, required: true },
  age: { type: Number }, // optional
});
export const User = mongoose.model('User', UserSchema);
Enter fullscreen mode Exit fullscreen mode

Relationships in Prisma (with code you can copy)

1) One-to-One

model User {
  id      Int      @id @default(autoincrement())
  profile Profile?
}

model Profile {
  id     Int   @id @default(autoincrement())
  bio    String
  userId Int   @unique
  user   User  @relation(fields: [userId], references: [id])
}
Enter fullscreen mode Exit fullscreen mode
  • The unique userId on Profile enforces one profile per user.

Query:

const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: { profile: true },
});
Enter fullscreen mode Exit fullscreen mode

2) One-to-Many

model User {
  id     Int     @id @default(autoincrement())
  posts  Post[]          // one user → many posts
}

model Post {
  id     Int    @id @default(autoincrement())
  title  String
  userId Int
  user   User   @relation(fields: [userId], references: [id])
}
Enter fullscreen mode Exit fullscreen mode

Query:

const withPosts = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: {
      where: { published: true },
      orderBy: { createdAt: 'desc' },
      take: 5,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

3) Many-to-Many

Implicit (no explicit join model; Prisma creates it):

model User {
  id    Int    @id @default(autoincrement())
  roles Role[]
}

model Role {
  id    Int    @id @default(autoincrement())
  name  String @unique
  users User[]
}
Enter fullscreen mode Exit fullscreen mode

Explicit (custom join model with extra fields):

model User {
  id        Int        @id @default(autoincrement())
  userRoles UserRole[]
}

model Role {
  id        Int        @id @default(autoincrement())
  name      String     @unique
  userRoles UserRole[]
}

model UserRole {
  userId     Int
  roleId     Int
  assignedAt DateTime @default(now())
  assignedBy Int?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)

  @@id([userId, roleId]) // composite PK to prevent duplicates
}
Enter fullscreen mode Exit fullscreen mode

Queries:

// connect an existing role (implicit M:N)
await prisma.user.update({
  where: { id: 1 },
  data: { roles: { connect: { id: 10 } } },
});

// explicit M:N because we add metadata:
await prisma.userRole.create({
  data: { userId: 1, roleId: 10, assignedBy: 99 },
});
Enter fullscreen mode Exit fullscreen mode

The Prisma CLI you’ll actually use

You asked for: prisma generate, prisma migrate, prisma deploy, prisma push.

In modern Prisma, there’s no standalone prisma deploy. The prod command is prisma migrate deploy.

npx prisma generate

  • Rebuilds the Prisma Client (the typed library you import).
  • Run after you change models/enums/generator settings.
  • Does not touch the DB.

npx prisma migrate dev --name <change>

  • In dev, creates a new migration (SQL files) and applies it to your dev DB.
  • Also updates the generated client.

npx prisma migrate deploy

  • In staging/prod, applies pending, committed migrations to that environment’s DB.
  • It does not generate new SQL; it just runs what’s already in prisma/migrations/*.

npx prisma db push

  • Pushes your current schema to the DB without creating migration files.
  • Good for quick prototypes; not a best practice for production history.

Other helpful ones:

npx prisma migrate status (see pending/applied),

npx prisma migrate reset (drop + reapply in dev),

npx prisma studio (GUI to browse data).


How Prisma keeps track of migrations

When you run migrations, Prisma writes a record into a special table (e.g., _prisma_migrations) in your database:

  • Each migration has an ID, name, checksum, applied timestamp.
  • On migrate deploy, Prisma compares what’s in that table with the migrations in your repo and applies only what’s missing, in order.
  • If a migration already ran, it’s skipped. That’s how deploys are idempotent and safe across environments.

“What happens when migration code is deployed in GitHub?”

Typical flow with CI/CD:

  1. You change schema.prisma locally → run npx prisma migrate dev --name <change> → commit the new folder under prisma/migrations/*.
  2. You push to GitHub.
  3. Your pipeline sets DATABASE_URL for staging and runs:
   npx prisma migrate deploy
Enter fullscreen mode Exit fullscreen mode

This applies pending migrations to the staging DB, updates _prisma_migrations, then builds and starts the app for testing.

  1. After smoke tests pass, you promote to production and run the same command with the production DATABASE_URL. Already-applied migrations are skipped; new ones are applied in order.

Tiny GitHub Actions sketch:

- name: Apply migrations (staging)
  env:
    DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
  run: npx prisma migrate deploy

# ...later, after approval...

- name: Apply migrations (prod)
  env:
    DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
  run: npx prisma migrate deploy
Enter fullscreen mode Exit fullscreen mode

Wrap-up

  • ORM vs ODM: relational vs document worlds. Prisma (ORM) maps models to real DB schemas; Mongoose (ODM) shapes documents in code.
  • Queries as objects: Prisma turns your object filters into safe SQL.
  • Type safety: fewer runtime surprises, better DX.
  • Models & relations: clear, declarative; 1-1, 1-many, many-many are straightforward.
  • Migrations: create in dev, apply in staging/prod with migrate deploy; Prisma tracks them in _prisma_migrations.
  • CI/CD: pushing to GitHub triggers migrate deploy per environment (using its DATABASE_URL), then smoke tests, then production.

You now have the mental model to move confidently from “I know the syntax” to “I understand how it all fits together.”


Top comments (0)