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 },
});
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())
}
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?
}
// 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);
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])
}
- The unique
userId
onProfile
enforces one profile per user.
Query:
const user = await prisma.user.findUnique({
where: { id: 1 },
include: { profile: true },
});
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])
}
Query:
const withPosts = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 5,
},
},
});
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[]
}
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
}
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 },
});
The Prisma CLI you’ll actually use
You asked for: prisma generate, prisma migrate, prisma deploy, prisma push.
In modern Prisma, there’s no standaloneprisma deploy
. The prod command isprisma 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:
- You change
schema.prisma
locally → runnpx prisma migrate dev --name <change>
→ commit the new folder underprisma/migrations/*
. - You push to GitHub.
- Your pipeline sets
DATABASE_URL
for staging and runs:
npx prisma migrate deploy
This applies pending migrations to the staging DB, updates _prisma_migrations
, then builds and starts the app for testing.
- 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
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 itsDATABASE_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)