Prisma ORM Basics: Schema, Queries and Real-World Patterns
Every production Node.js or TypeScript app eventually hits the same wall: your database queries become a tangled mess of raw SQL strings, you ship a typo that only blows up in production, and your team spends Friday afternoon debugging why user.adress returned undefined instead of throwing an error at write time. This is the moment developers discover Prisma — not as a trendy tool, but as a genuine solution to a painful daily problem. Prisma is a next-generation ORM that puts your database schema at the centre of your codebase, generates a fully type-safe client, and gives you an API that feels like writing TypeScript rather than wrestling with SQL dialects.
Traditional ORMs like Sequelize or TypeORM were designed when JavaScript was untyped and callbacks were king. They tack types on as an afterthought, leaving you with models that drift from your actual schema over time. Prisma flips this entirely — the schema.prisma file is the single source of truth, and everything — your migrations, your client types, your auto-complete — is derived from it. When your schema changes, your TypeScript errors change too, catching bugs before they reach users.
By the end of this article you'll be able to define a real schema with relationships, run type-safe CRUD queries, handle relations in a single round-trip with nested writes, and avoid the three mistakes that trip up most developers in their first Prisma project. You'll also understand why Prisma makes the architectural choices it does — which is the knowledge that sticks.
Setting Up Prisma: Schema as the Single Source of Truth
Prisma's setup is deliberately opinionated, and that's a feature. You install three things: the Prisma CLI (for migrations and codegen), the Prisma Client (the generated query engine you import in your app), and a database connector. The CLI reads your schema.prisma file and generates a Node.js client that's perfectly tailored to your schema — not a generic client with optional types bolted on.
The schema.prisma file has three blocks: datasource (which database and where), generator (what to generate — almost always the JS client), and your model definitions. Models map directly to database tables, but they're richer — you declare field types, constraints, default values, indexes and relations all in one place.
This matters because the schema is the contract between your app and your database. When you run prisma migrate dev, Prisma diffs the schema against the current database state and generates a SQL migration file. When you run prisma generate, it outputs a client with TypeScript types that exactly match that schema. There's no manual type-writing, no drift — the database and your TypeScript types are always in sync.
// datasource tells Prisma which database engine to use and where to find it. // The DATABASE_URL is read from your .env file — never hardcode credentials. datasource db { provider = "postgresql" url = env("DATABASE_URL") } // generator tells Prisma what to produce when you run `prisma generate`. // The output is the fully-typed PrismaClient you import in your app. generator client { provider = "prisma-client-js" } // A model maps to a database table. // Field names become the column names (snake_case in DB via @@map if needed). model User { id Int @id @default(autoincrement()) // Primary key, auto-incremented email String @unique // Unique constraint on the column name String createdAt DateTime @default(now()) // Set automatically on INSERT orders Order[] // Relation: one User has many Orders } model Order { id Int @id @default(autoincrement()) totalPrice Float placedAt DateTime @default(now()) userId Int // Foreign key column in the DB user User @relation(fields: [userId], references: [id]) // Relation scalar items OrderItem[] } model OrderItem { id Int @id @default(autoincrement()) productName String quantity Int unitPrice Float orderId Int order Order @relation(fields: [orderId], references: [id]) }
// You'll see:
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "shopdb", schema "public"
✔ Generated Prisma Client (v5.x.x) to ./node_modules/@prisma/client
The following migration(s) have been created and applied:
migrations/
└─ 20240601120000_init/
└─ migration.sql
Your database is now in sync with your Prisma schema.
CRUD with Prisma Client: Type-Safe Queries That Actually Make Sense
Once you've run prisma generate, you import PrismaClient and get an object with a property for every model in your schema. Each model property exposes methods like findUnique, findMany, create, update, upsert and delete. These aren't magic strings — they're fully typed, so your IDE auto-completes field names and TypeScript yells at you if you pass a field that doesn't exist.
The real power shows up with select and include. With select, you specify exactly which fields to return — Prisma generates a SELECT col1, col2 query rather than SELECT *. With include, you tell Prisma to JOIN related tables and return them as nested objects. You control whether you're doing a lean read or a rich read, and Prisma generates efficient SQL for each case.
Prisma's where clause uses a structured object rather than a SQL string, which eliminates an entire class of SQL injection bugs by design. You describe the filter as data ({ email: { endsWith: '@company.com' } }), and Prisma parameterises it safely under the hood. This is the difference between an ORM that prevents bugs and one that just makes bugs more comfortable to write.
import { PrismaClient } from '@prisma/client'; // Instantiate once and reuse — creating a new PrismaClient per request // opens a new connection pool each time, which will exhaust your DB connections fast. const prisma = new PrismaClient(); async function runOrderExamples() { // ── CREATE ────────────────────────────────────────────────────────── // Nested write: creates the User AND their first Order in a single transaction. // If the Order insert fails, the User insert is rolled back automatically. const newUser = await prisma.user.create({ data: { email: 'alice@example.com', name: 'Alice Chen', orders: { create: [ { totalPrice: 49.99, items: { create: [ { productName: 'Mechanical Keyboard', quantity: 1, unitPrice: 49.99 } ] } } ] } }, // include tells Prisma to JOIN and return the related orders in the response. // Without this, `newUser.orders` would be undefined. include: { orders: { include: { items: true } } } }); console.log('Created user:', JSON.stringify(newUser, null, 2)); // ── READ (findUnique) ──────────────────────────────────────────────── // findUnique requires a @unique or @id field — it throws a runtime error // if you try to filter on a non-unique field, which catches logic bugs early. const existingUser = await prisma.user.findUnique({ where: { email: 'alice@example.com' }, select: { id: true, name: true, // Nested select: only fetch order IDs and totals — no unnecessary data orders: { select: { id: true, totalPrice: true, placedAt: true } } } }); console.log('Found user:', existingUser); // ── READ (findMany with filter) ────────────────────────────────────── // Find all orders over £20 placed in the last 7 days, sorted newest first. const recentLargeOrders = await prisma.order.findMany({ where: { totalPrice: { gt: 20 }, // gt = greater than placedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } // last 7 days }, orderBy: { placedAt: 'desc' }, take: 10, // LIMIT 10 — pagination include: { user: { select: { name: true, email: true } } } }); console.log(`Found ${recentLargeOrders.length} recent large orders`); // ── UPDATE ────────────────────────────────────────────────────────── // update requires a unique identifier in `where`. Prisma won't let you // run a bulk update without using updateMany — accidental mass-updates // are a class of bug Prisma's API actively resists. const updatedUser = await prisma.user.update({ where: { email: 'alice@example.com' }, data: { name: 'Alice Zhang' } // Only this field changes }); console.log('Updated name:', updatedUser.name); // ── DELETE ────────────────────────────────────────────────────────── // Always clean up in reverse dependency order, or configure cascading deletes // in your schema. Prisma won't silently ignore FK constraint violations. await prisma.orderItem.deleteMany({ where: { order: { userId: newUser.id } } }); await prisma.order.deleteMany({ where: { userId: newUser.id } }); await prisma.user.delete({ where: { id: newUser.id } }); console.log('Cleanup complete.'); } runOrderExamples() .catch(console.error) .finally(() => prisma.$disconnect()); // Always disconnect when the script ends
"id": 1,
"email": "alice@example.com",
"name": "Alice Chen",
"createdAt": "2024-06-01T12:00:00.000Z",
"orders": [
{
"id": 1,
"totalPrice": 49.99,
"placedAt": "2024-06-01T12:00:00.000Z",
"userId": 1,
"items": [
{
"id": 1,
"productName": "Mechanical Keyboard",
"quantity": 1,
"unitPrice": 49.99,
"orderId": 1
}
]
}
]
}
Found user: { id: 1, name: 'Alice Chen', orders: [ { id: 1, totalPrice: 49.99, placedAt: 2024-06-01T12:00:00.000Z } ] }
Found 1 recent large orders
Updated name: Alice Zhang
Cleanup complete.
Transactions and Raw Queries: When You Need the Escape Hatch
Prisma's nested writes handle the common case where you want to create related records atomically. But sometimes you need to run multiple independent operations — decrement a product's stock and create an order — where both must succeed or both must fail. That's what prisma.$transaction() is for.
Prisma gives you two transaction flavours. The array form (prisma.$transaction([op1, op2])) batches operations in a single DB transaction and is perfect when you can build all operations upfront. The interactive form (prisma.$transaction(async (tx) => { ... })) gives you a transaction-scoped client so you can use the result of one query to build the next — exactly what you need for 'check stock, then place order' logic.
Sometimes you genuinely need raw SQL — complex aggregations, database-specific functions, or queries that Prisma's API can't yet express. prisma.$queryRaw handles this safely with tagged template literals, which parameterise values automatically. You get SQL's full power without reopening the injection vulnerability door that Prisma's structured API closed.
import { PrismaClient, Prisma } from '@prisma/client'; const prisma = new PrismaClient(); // Real-world checkout: deduct stock and create the order atomically. // If the stock deduction fails (e.g. stock hits 0), the order is not created. async function processCheckout(userId: number, productId: number, quantity: number) { // Interactive transaction — we need the updated stock value BEFORE creating the order. const result = await prisma.$transaction(async (tx) => { // Step 1: Check and decrement stock. // updateMany with a conditional where clause prevents overselling without a race condition. const stockUpdate = await tx.product.updateMany({ where: { id: productId, stock: { gte: quantity } // Only update if enough stock exists RIGHT NOW }, data: { stock: { decrement: quantity } } }); // If count is 0, the WHERE clause didn't match — stock was insufficient. if (stockUpdate.count === 0) { // Throwing inside a transaction automatically triggers a rollback. throw new Error(`Insufficient stock for product ${productId}`); } // Step 2: Fetch the product price (we need it for the order total). const product = await tx.product.findUnique({ where: { id: productId }, select: { name: true, price: true } }); if (!product) throw new Error(`Product ${productId} not found`); // Step 3: Create the order now that we've confirmed and reserved stock. const order = await tx.order.create({ data: { userId, totalPrice: product.price * quantity, items: { create: [{ productName: product.name, quantity, unitPrice: product.price }] } }, include: { items: true } }); return order; // This value becomes the resolved value of the transaction }); console.log('Order placed successfully:', result.id); return result; } // ── RAW QUERY ──────────────────────────────────────────────────────────────── // Use $queryRaw when you need something Prisma's API can't express — // here, a revenue report grouped by month using PostgreSQL's date_trunc. async function getMonthlyRevenue(year: number) { // Prisma.sql is a tagged template literal — values are ALWAYS parameterised. // Never use string concatenation here: `WHERE year = ${year}` would be safe, // but `WHERE name = '${userInput}'` would be an injection vulnerability. const rows = await prisma.$queryRaw<{ month: Date; revenue: number }[]>( Prisma.sql` SELECT date_trunc('month', placed_at) AS month, SUM(total_price) AS revenue FROM orders WHERE EXTRACT(YEAR FROM placed_at) = ${year} GROUP BY month ORDER BY month ASC ` ); console.log(`Revenue for ${year}:`); rows.forEach(row => { const monthLabel = row.month.toLocaleString('default', { month: 'long' }); console.log(` ${monthLabel}: £${Number(row.revenue).toFixed(2)}`); }); return rows; } // Run examples processCheckout(1, 42, 2) .then(() => getMonthlyRevenue(2024)) .catch(console.error) .finally(() => prisma.$disconnect());
Revenue for 2024:
January: £1240.50
February: £980.00
March: £2105.75
April: £1875.00
May: £3200.99
June: £49.99
Prisma Migrations in Practice: Evolving Your Schema Safely
Migrations are where many teams get burned. The mental model to hold onto is this: prisma migrate dev is for local development, and prisma migrate deploy is for staging and production. They're deliberately different commands with different safety profiles.
migrate dev is interactive — it detects schema drift, prompts you to name migrations, and re-runs seed scripts. It's designed for a developer who's actively shaping the schema. migrate deploy is non-interactive and deterministic — it applies only pending migrations that are already committed to version control, making it safe to run in a CI/CD pipeline without human intervention.
The workflow that actually works in teams is: make your schema change, run migrate dev locally to generate the SQL and validate it, commit both schema.prisma and the new migration file, and let CI run migrate deploy against staging. Never edit a migration file that has already been applied to any environment — Prisma checksums them, and tampering breaks the migration history. If you made a mistake in the last migration, create a new migration that corrects it.
# ── LOCAL DEVELOPMENT ──────────────────────────────────────────────────────── # Step 1: You've added a new `phoneNumber` field to the User model in schema.prisma. # Run migrate dev to generate and immediately apply the migration to your local DB. npx prisma migrate dev --name add_phone_number_to_users # Prisma will output the generated SQL so you can review it: # ALTER TABLE "User" ADD COLUMN "phoneNumber" TEXT; # Always read this output — migrations are permanent. # Step 2: After migrate dev, regenerate the client so your TypeScript types # include the new `phoneNumber` field immediately. # (migrate dev actually does this automatically, but it's good to know the standalone command) npx prisma generate # Step 3: Explore your data mid-development with Prisma Studio — a local browser UI. # Useful for manually inspecting rows without writing throwaway scripts. npx prisma studio # Opens at http://localhost:5555 # ── SEEDING ─────────────────────────────────────────────────────────────────── # Run your seed script (defined in package.json under prisma.seed) to # populate the DB with realistic test data after a fresh migration. npx prisma db seed # ── CI / PRODUCTION DEPLOYMENT ─────────────────────────────────────────────── # In your Dockerfile or CI pipeline, apply pending migrations non-interactively. # This command NEVER creates new migrations — it only applies committed ones. npx prisma migrate deploy # ── CHECKING SYNC ───────────────────────────────────────────────────────────── # If you suspect your DB is out of sync with your schema (e.g. a direct DB edit), # this command shows the diff without making any changes. Read-only diagnostic. npx prisma migrate status
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "shopdb"
Applying migration `20240602093000_add_phone_number_to_users`
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20240602093000_add_phone_number_to_users/
└─ migration.sql
Your database is now in sync with your Prisma schema.
✔ Generated Prisma Client (v5.x.x)
# Output of: npx prisma migrate status (on a fully synced DB)
3 migrations found in prisma/migrations
DATABASE
3 migrations have been applied
No pending migrations.
| Feature / Aspect | Prisma ORM | Sequelize / TypeORM |
|---|---|---|
| Type safety | Auto-generated from schema — always in sync | Manual model definitions — types can drift from DB |
| Schema definition | Single `schema.prisma` file (source of truth) | Decorators on classes or separate config files |
| Migration tooling | First-class `migrate dev` / `migrate deploy` split | Migration generation is error-prone; history tracking is fragile |
| Query API | Structured objects — no SQL strings required | Mix of chainable methods and raw SQL strings |
| SQL injection risk | Parameterised by design; `$queryRaw` uses tagged templates | Possible if raw queries are string-concatenated carelessly |
| Learning curve | Steeper initial setup; very productive once schema is set | Quicker to start; complexity grows as schema grows |
| Raw SQL escape hatch | `$queryRaw` and `$executeRaw` with safe parameterisation | Available but inconsistently documented |
| Relation handling | Nested writes and `include` are first-class API features | Associations require explicit join config and eager loading setup |
| Database support | PostgreSQL, MySQL, SQLite, SQL Server, MongoDB, CockroachDB | PostgreSQL, MySQL, SQLite, SQL Server (MongoDB varies) |
| Studio / GUI | Built-in `prisma studio` browser UI | No equivalent; third-party tools only |
🎯 Key Takeaways
schema.prismais your single source of truth — your DB schema, your TypeScript types, and your migrations all flow from it. Change it in one place, and everything downstream updates.- Prisma's structured query API (
where: { price: { gt: 20 } }) isn't just developer-friendly — it eliminates SQL injection at the architectural level by making parameterisation automatic. - The
migrate devvsmigrate deploysplit is intentional: dev is for humans shaping a schema, deploy is for machines applying a known-good history. Mixing them up is how production databases get corrupted. - The interactive transaction pattern —
prisma.$transaction(async (tx) => { ... })with a conditionalupdateManycheck — is the correct solution to optimistic concurrency problems like preventing overselling in an e-commerce cart.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Instantiating PrismaClient inside a route handler or request function — Each call creates a new connection pool, starving your database of connections within minutes under any load. The symptom is 'too many clients' errors or hanging queries. Fix: create a single shared instance in
lib/prisma.ts, export it, and import that one instance everywhere. In Next.js, attach it toglobalto survive hot-module replacement in dev mode. - ✕Mistake 2: Using
findFirstwhen you meanfindUnique—findFirstscans the table and returns the first row matching the WHERE clause; it has no uniqueness guarantee and is slower.findUniquerequires a@uniqueor@idfield, uses an index lookup, and makes your intent explicit. The symptom is silently getting the 'wrong' record when two users share similar data. Fix: reservefindFirstfor genuinely non-unique lookups; usefindUniquewhenever you're fetching by email, ID or any unique identifier. - ✕Mistake 3: Editing an already-applied migration file instead of creating a new one — Prisma checksums every migration file and stores the checksum in the
_prisma_migrationstable. If you edit an applied file,migrate deploywill throw a checksum mismatch error and refuse to proceed on any environment where that migration was already applied. The fix is to create a new corrective migration withmigrate dev --name fix_previous_mistake— this creates a new SQL file that undoes or extends the previous one, leaving history intact.
Interview Questions on This Topic
- QHow does Prisma's type-safe client stay in sync with the database schema, and what happens if you change the schema without regenerating the client?
- QExplain the difference between `prisma.$transaction([op1, op2])` (the array form) and the interactive transaction callback form. When would you choose one over the other?
- QIf a junior developer said 'I use `$queryRaw` with template string concatenation because it's easier', what would you tell them — and what's the Prisma-safe alternative?
Frequently Asked Questions
Do I need to write SQL to use Prisma?
No — Prisma's client API handles the vast majority of queries through its structured JavaScript/TypeScript object syntax. For complex aggregations or database-specific functions that Prisma can't express, you can use prisma.$queryRaw with tagged template literals, which keeps your queries safe from injection while giving you full SQL power.
Can Prisma be used with an existing database I didn't design from scratch?
Yes — run npx prisma db pull and Prisma will introspect your existing database and generate a schema.prisma file that reflects its current structure. You can then use this as your starting point for Prisma Client and future migrations. It's not always perfect for complex legacy schemas, but it gets you 80% of the way there instantly.
What's the difference between `include` and `select` in Prisma queries?
include adds related models on top of all the fields Prisma would return by default — think of it as 'give me everything, plus these joined relations'. select lets you specify exactly which fields to return and replaces the defaults entirely. You can nest select inside include to fetch a relation but only pick certain fields from it, which is the most common pattern for keeping response payloads lean.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.