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
userIdonProfileenforces 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.prismalocally → runnpx prisma migrate dev --name <change>→ commit the new folder underprisma/migrations/*. - You push to GitHub.
- Your pipeline sets
DATABASE_URLfor 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 deployper 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)