Week 4: Prisma Schema, Migrations & Queries

Every route you've written since Week 2 has stored data in an in-memory array that vanishes every time the server restarts — this week that changes for good. You'll define a real database schema with Prisma, generate and apply migrations that evolve it safely over time, and use Prisma's generated, fully type-safe client to run CRUD queries from Express routes. This is the foundation Week 5 builds directly on top of (relations, the N+1 problem, and transactions), and it's the same Prisma client every later week's persistence layer — from the auth tables in Week 6 to the background job records in Week 12 — assumes is already wired up.

Module 4 of 22 Week 4 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Define a database schema in schema.prisma and generate the type-safe client from it
  • Create and apply migrations with prisma migrate dev as the schema evolves
  • Wire an Express route to real CRUD queries against a persistent database

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.

terminal
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):

prisma/schema.prisma
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.

.env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
Never commit a real 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.

terminal
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
prisma/migrations/20260315120000_init/migration.sql (generated -- do not hand-edit)
-- 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.

terminal -- the production equivalent, used in CI/CD (Week 14)
# 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
Never hand-edit an already-applied migration file

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.

src/lib/prisma.ts
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();
basic CRUD with the generated client
// 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.

Prisma Studio for visually inspecting data

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.

src/routes/users.ts
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.

src/main.ts -- graceful shutdown matters once there's a real connection to close
import { prisma } from "./lib/prisma.js";

// ... app setup, app.listen() ...

process.on("SIGTERM", async () => {
  await prisma.$disconnect();
  process.exit(0);
});
Keep 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

Hands-on

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:

  1. Run prisma init --datasource-provider postgresql, and define a Note model with id (@id @default(uuid())), title, body, tags String[], and createdAt/updatedAt.
  2. Run prisma migrate dev --name init against a local Postgres instance (or a free hosted Postgres of your choice) and confirm the table exists with prisma studio.
  3. Create src/lib/prisma.ts exporting a single shared PrismaClient instance, and rewrite every route in notesRouter to use it instead of the in-memory array.
  4. Add a second migration that adds a @unique constraint on title, and update the POST route to catch Prisma's P2002 error code and throw your existing ConflictError.
  5. Add graceful shutdown that calls prisma.$disconnect() on SIGTERM.
  6. 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.
Hint

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/?

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?

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?

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?

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.