1. Defining a Prisma Schema
Prisma is an ORM built around a single declarative file, schema.prisma,
where you describe your database connection, which client to generate, and every model
(table) in your database. Everything else — migrations, the type-safe client, even
autocompletion in your editor — is generated from this one file, so it functions as the
single source of truth for your data layer.
npm install prisma --save-dev
npm install @prisma/client
npx prisma init --datasource-provider postgresql
prisma init creates a prisma/schema.prisma file and a
.env with a DATABASE_URL placeholder. A schema has three
parts: a generator block (what to generate — almost always the JS/TS
client), a datasource block (which database and how to connect), and one
or more model blocks (your tables):
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(uuid())
title String
body String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Each field has a type (String, Boolean,
DateTime, and more) plus optional attributes: @id marks the
primary key, @default(uuid()) auto-generates a UUID on insert,
@unique enforces a uniqueness constraint at the database level (not just
in your application code), and @updatedAt automatically stamps the current
time on every update. The posts Post[] field on User and the
author/authorId pair on Post together define a
one-to-many relation — you'll go much deeper on relation modeling in Week 5, but this
week's models are enough to establish the pattern.
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
DATABASE_URL
.env should be in .gitignore from the start of the project — it's how database credentials, and later JWT secrets in Week 6 and API keys in general, accidentally end up in git history. Commit a .env.example with the placeholder shape instead, so teammates know what variables to set without ever seeing real values.
2. Migrations with prisma migrate dev
A migration is a versioned, timestamped SQL file that describes exactly how to change
the database schema from one state to the next. Rather than writing that SQL by hand,
prisma migrate dev diffs your current schema.prisma against
the migration history, generates the SQL to make the database match, applies it to your
local dev database, and regenerates the Prisma client — all in one command.
npx prisma migrate dev --name init
# Prisma will:
# 1. Compare schema.prisma to the migration history
# 2. Generate prisma/migrations/<timestamp>_init/migration.sql
# 3. Apply it to the database configured in DATABASE_URL
# 4. Regenerate the type-safe client in node_modules/@prisma/client
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
Every migration file gets committed to git — the prisma/migrations/
directory is your schema's changelog, and it's what lets a teammate (or a CI pipeline,
or the production deploy in Week 14) reconstruct the exact same database structure by
replaying every migration in order. When you change a model later — add a column, add
an index — you edit schema.prisma and run
prisma migrate dev --name add-post-tags again; Prisma diffs against what's
already in prisma/migrations/ and generates only the incremental change,
not a full rebuild.
# migrate dev is for local development only -- it can prompt interactively
# and, in some cases, ask to reset the database. In CI/CD and production,
# use the non-interactive deploy command instead:
npx prisma migrate deploy
Once a migration has been applied anywhere (even just your own machine), editing its SQL after the fact desyncs your migration history from what other environments have actually run. If you need to change something, create a new migration that alters the previous state — that's exactly what the changelog model is for, the same way you'd never rewrite a merged git commit.
3. The Generated Client & Basic CRUD
prisma generate (run automatically by migrate dev, and
manually whenever you only change the schema without a full migration) produces a
PrismaClient class with a method for every model, fully typed against your
actual schema — autocomplete knows every field name and type, and passing the wrong
shape is a compile error, not a runtime surprise.
import { PrismaClient } from "@prisma/client";
// A single shared instance for the whole app -- creating a new
// PrismaClient per request would exhaust the database's connection
// pool almost immediately.
export const prisma = new PrismaClient();
// Create
const user = await prisma.user.create({
data: { email: "ada@example.com", name: "Ada Lovelace" },
});
// Read one
const found = await prisma.user.findUnique({
where: { id: user.id },
});
// Read one, throwing if not found (useful paired with Week 3's NotFoundError)
const foundOrThrow = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
});
// Read many, with filtering, sorting, and pagination
const users = await prisma.user.findMany({
where: { email: { contains: "@example.com" } },
orderBy: { createdAt: "desc" },
take: 20,
skip: 0,
});
// Update
const updated = await prisma.user.update({
where: { id: user.id },
data: { name: "Ada King" },
});
// Delete
await prisma.user.delete({
where: { id: user.id },
});
findUnique requires filtering on a field marked @id or
@unique in the schema and returns null if nothing matches;
findMany can filter on any field and always returns an array, empty if
nothing matches. Reaching for findUniqueOrThrow when a missing record
really is exceptional (rather than an expected empty state) saves you a manual
if (!result) check — Prisma throws a
PrismaClientKnownRequestError you can catch and translate into your Week
3 NotFoundError shape.
npx prisma studio opens a local web UI against your configured database where you can browse, filter, and edit rows by hand — genuinely useful for confirming a migration or a query did what you expected, without writing a one-off script or reaching for a separate SQL client.
4. Connecting Express Routes to Real Persistence
With the client set up, swapping an Express route from an in-memory array to real persistence is mostly mechanical — the Router, validation, and centralized error handling from Weeks 2 and 3 stay exactly as they are; only the data-access line inside each handler changes.
import { Router } from "express";
import { Prisma } from "@prisma/client";
import { prisma } from "../lib/prisma.js";
import { validateBody } from "../middleware/validate.js";
import { createUserSchema, type CreateUserInput } from "../schemas/user.js";
import { NotFoundError, ConflictError } from "../lib/errors.js";
import { asyncHandler } from "../lib/async-handler.js";
const usersRouter = Router();
usersRouter.get("/", asyncHandler(async (req, res) => {
const users = await prisma.user.findMany({ orderBy: { createdAt: "desc" } });
res.json(users);
}));
usersRouter.get("/:id", asyncHandler(async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) throw new NotFoundError("User");
res.json(user);
}));
usersRouter.post("/", validateBody(createUserSchema), asyncHandler(async (req, res) => {
const input = req.body as CreateUserInput;
try {
const user = await prisma.user.create({ data: input });
res.status(201).location(`/api/users/${user.id}`).json(user);
} catch (err) {
// Prisma's error code for a violated unique constraint
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new ConflictError("A user with that email already exists");
}
throw err;
}
}));
export default usersRouter;
That P2002 code is Prisma's way of surfacing a database-level constraint
violation (the @unique on email) as a catchable, typed error
instead of a raw SQL exception — checking for it and translating it into Week 3's
ConflictError is what makes a duplicate email return a clean
409 with your API's standard error shape, instead of an unhandled 500.
import { prisma } from "./lib/prisma.js";
// ... app setup, app.listen() ...
process.on("SIGTERM", async () => {
await prisma.$disconnect();
process.exit(0);
});
prisma a singleton, especially with tsx watch
In development, hot-reloading tools can re-execute your module graph on every save, which — if new PrismaClient() isn't guarded — creates a fresh client (and a fresh connection pool) on every reload without closing the old one, eventually exhausting the database's max connections. Exporting one instance from src/lib/prisma.ts and importing it everywhere, as shown above, is the fix; Week 14's production setup revisits this with a stricter singleton guard for serverless environments.
5. Hands-on Exercise
Migrate the notes API from an in-memory array to a real Postgres database
Take the notes API from Weeks 2 and 3 and give it real persistence with Prisma, keeping every route's validation, status codes, and error shape unchanged.
Requirements:
- Run
prisma init --datasource-provider postgresql, and define aNotemodel withid(@id @default(uuid())),title,body,tags String[], andcreatedAt/updatedAt. - Run
prisma migrate dev --name initagainst a local Postgres instance (or a free hosted Postgres of your choice) and confirm the table exists withprisma studio. - Create
src/lib/prisma.tsexporting a single sharedPrismaClientinstance, and rewrite every route innotesRouterto use it instead of the in-memory array. - Add a second migration that adds a
@uniqueconstraint ontitle, and update thePOSTroute to catch Prisma'sP2002error code and throw your existingConflictError. - Add graceful shutdown that calls
prisma.$disconnect()onSIGTERM. - Restart the server and confirm notes created before the restart are still there — the concrete proof that persistence, not just the API contract, actually changed.
If you don't want to install Postgres locally, a free-tier hosted Postgres instance (several providers offer one) plus the connection string it gives you in DATABASE_URL works identically for this exercise — Prisma doesn't care whether the database is local or remote, only that the URL is reachable.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does prisma/migrations/ get committed to git instead of being gitignored like node_modules/?
Why does prisma/migrations/ get committed to git instead of being gitignored like node_modules/?
The migrations directory is the versioned history of every schema change, in order — it's what lets a teammate's machine, a CI pipeline, or a production deploy reconstruct the exact same database structure by replaying each migration. Without it committed, only the current schema.prisma snapshot would exist with no record of how to get an existing database from an older state to the current one.
Q2
Why can creating a new PrismaClient instance per request eventually break an app, even though each individual query works fine?
Why can creating a new PrismaClient instance per request eventually break an app, even though each individual query works fine?
Each PrismaClient instance opens and manages its own connection pool to the database. Creating one per request means every request opens a new pool that's never closed, and the database has a hard limit on total concurrent connections — under real traffic, or even repeated hot-reloads in development, that limit gets exhausted and every further query starts failing to connect, independent of whether any single query itself is correct.
Q3
What's the difference between findUnique and findMany that determines which one you'd use for a "get one user by id" route?
What's the difference between findUnique and findMany that determines which one you'd use for a "get one user by id" route?
findUnique can only filter on a field marked @id or @unique and returns either a single record or null, which matches "get one user by id" exactly and lets TypeScript's type reflect that a single object (or nothing) comes back. findMany can filter on any field but always returns an array, even when exactly one row matches — using it for a single-record lookup would mean unwrapping an array of length one for no benefit, and it wouldn't enforce uniqueness the way findUnique does.
Q4
Why check for Prisma's P2002 error code in the POST handler instead of letting the centralized error handler's generic 500 branch catch it?
Why check for Prisma's P2002 error code in the POST handler instead of letting the centralized error handler's generic 500 branch catch it?
A unique-constraint violation is an expected, recoverable outcome caused directly by the client's input (a duplicate email or title), not an unexpected bug — treating it as a 500 would tell the client "something went wrong on our end" when the real, actionable message is "this value is already taken," which maps to a 409 Conflict. Catching P2002 specifically and translating it into ConflictError keeps that distinction visible to the client instead of collapsing every failure into a generic internal error.