Prisma & Your Database
Designing tables, connecting them safely, and the handful of read and write commands that cover almost everything you will ever need.
What Prisma is
Prisma lets you talk to a database using JavaScript instead of writing SQL. You describe your tables in one file, and Prisma generates a client with autocomplete for every table and column.
The schema file is the single source of truth. Change it, regenerate, and your code updates.
Model = a table. Field = a column. Migration = a recorded change to the table structure, so every environment can apply the same change in the same order.
The schema file
generator client {
provider = "prisma-client-js" // generate a JS client
}
datasource db {
provider = "mysql" // or "postgresql"
url = env("DATABASE_URL") // read from .env
}
model Widget {
id Int @id @default(autoincrement())
title String
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
That model creates a Widget table and gives you db.widget in code — the model name, lowercased.
The template you start from uses provider = "sqlite" instead — one local file, no server to set up. Its README notes that SQLite works in production only if your app runs as a single instance, and suggests MySQL or PostgreSQL otherwise. This note's examples use MySQL.
Field types and modifiers
| Write | Meaning |
|---|---|
String | Text. Short by default — see the trap below. |
Int / Float | Numbers. |
Boolean | true / false. |
DateTime | A date and time. |
Json | Any structure. Handy, with a catch — see below. |
String? | The ? means optional (can be null). |
@id | The primary key. |
@default(...) | Value used when you do not provide one. |
@unique | No two rows may share this value. |
@updatedAt | Automatically set to "now" on every update. |
@db.Text | Long text on MySQL and PostgreSQL. Use for URLs. Not on SQLite — see the trap below. |
On MySQL, a plain String becomes VARCHAR(191). That is fine for names, and far too short for a Shopify CDN URL, which regularly exceeds it.
You will not notice in testing with short values. It fails in production with a raw database error.
imageUrl String // breaks on real URLs
imageUrl String? @db.Text // correct
Rule: any column holding a URL, a long description, or pasted content gets @db.Text.
Except on SQLite. The template starts on SQLite (provider = "sqlite", a local dev.sqlite file), where a plain String has no length limit and @db.Text is an error: Native type Text is not supported for sqlite connector. Leave it off while you are on SQLite, and add it when you move to MySQL or PostgreSQL.
Connecting tables
In a Shopify app nearly everything belongs to a shop, so nearly every table gets a shop column holding the shop's domain — the same value your code reads from session.shop. When one of your tables belongs to another of your tables, you link them with a relation.
model Widget {
id Int @id @default(autoincrement())
shop String // e.g. "my-store.myshopify.com"
title String
options WidgetOption[] // one widget has many options
}
model WidgetOption {
id Int @id @default(autoincrement())
widgetId Int // the link
label String
widget Widget @relation(fields: [widgetId], references: [id], onDelete: Cascade)
}
Three pieces make a relation work:
widgetId— a normal column holding the parent's id.widget Widget @relation(...)— tells Prisma how they connect.options WidgetOption[]on the parent — the other direction.
Only the first is a real column. The other two exist so you can write option.widget or widget.options in code. onDelete: Cascade is explained in the next section.
Unique combinations
Sometimes a value must be unique per shop, not globally:
model Widget {
shop String
slug String
@@unique([shop, slug]) // two shops may both use "blue"
}
onDelete — the setting that deletes your data
This decides what happens to child rows when the parent is deleted.
| Setting | When the parent is deleted |
|---|---|
Cascade | All children are deleted too, automatically and silently. |
Restrict | The delete is blocked while children exist. This is what you get if you write no onDelete at all. |
SetNull | Children are kept; their link is set to null (needs an optional field). |
It is tempting to give every table a relation to the template's Session model. Don't. The template deletes a shop's session row every time the app is uninstalled, and any relation to it turns that into a problem:
With onDelete: Cascade, that single delete removes every row the shop ever created — instantly, with no warning and no undo. With no onDelete, the database refuses the delete and the uninstall webhook crashes with Foreign key constraint violated, on every retry Shopify sends.
So tie shop data to a plain shop column, and keep relations for links between your own tables. There, Cascade is genuinely useful: deleting a widget should take its options with it.
Migrations
A migration is a saved instruction such as "add a column called enabled". Prisma writes them for you and keeps them in order, so your laptop, a teammate's laptop and production all end up with identical tables.
# after editing schema.prisma — development only
npx prisma migrate dev --name add_enabled_flag
# on production — applies pending migrations, nothing else
npx prisma migrate deploy
# rebuild the client after pulling someone else's changes
npx prisma generate
db.myNewTable is undefined, even though it is clearly in the schema. The generated client is stale — usually after switching git branches.
npx prisma generate fixes it. Remember this one; it costs people hours.
migrate dev can decide your database is out of sync and offer to reset it. migrate reset deletes every row. db push changes tables with no migration record and can drop columns silently.
Production gets migrate deploy and nothing else.
Reading data
import db from "../db.server";
// many rows
const widgets = await db.widget.findMany({
where: { shop: session.shop, enabled: true },
orderBy: { createdAt: "desc" },
take: 20, // limit
skip: 0, // offset — page 2 would be skip: 20
});
// one row by a unique field
const widget = await db.widget.findUnique({ where: { id: 42 } });
// one row by any conditions
const mine = await db.widget.findFirst({
where: { id: 42, shop: session.shop },
});
// how many
const total = await db.widget.count({ where: { shop: session.shop } });
findUnique only accepts fields marked @id or @unique. The moment you want to add a safety condition like shop, switch to findFirst — which is exactly what you want for multi-shop safety.
Picking columns, and following relations
// only the fields you need (faster, and avoids leaking extra data)
await db.widget.findMany({
select: { id: true, title: true },
});
// bring the children along
await db.widget.findFirst({
where: { id: 42, shop: session.shop },
include: { options: true },
});
// filter by something on the parent
await db.widgetOption.findMany({
where: { widget: { shop: session.shop } },
});
Useful conditions
where: {
title: { contains: "blue" }, // search
id: { in: [1, 2, 3] }, // any of these
deletedAt: null, // is empty
createdAt: { gte: someDate }, // on or after
OR: [{ title: { contains: "a" } }, { slug: { contains: "a" } }],
}
Writing data
// create
await db.widget.create({
data: { title: "New widget", shop: session.shop },
});
// update one row
await db.widget.update({
where: { id: 42 },
data: { title: "Renamed" },
});
// update it if it exists, otherwise create it
// (Setting has shop String @unique — one settings row per shop)
await db.setting.upsert({
where: { shop: session.shop },
update: { enabled: false },
create: { shop: session.shop, enabled: false },
});
// many at once
await db.widget.createMany({ data: [{ ... }, { ... }] });
await db.widget.updateMany({ where: { shop: session.shop }, data: { enabled: true } });
upsert is worth knowing early. It removes the "check if it exists, then decide" dance you would otherwise write constantly.
Deleting safely
// WRONG — any shop can delete any row by guessing an id
await db.widget.delete({ where: { id: Number(form.get("id")) } });
// RIGHT — scoped to the shop that asked
await db.widget.deleteMany({
where: { id: Number(form.get("id")), shop: session.shop },
});
delete cannot be scoped
delete and update only accept unique fields in where, so you cannot add shop as a safety check. deleteMany and updateMany accept any conditions.
So for anything triggered by user input, prefer deleteMany and updateMany. A shop then simply cannot touch another shop's row, even by guessing ids. As a bonus, they do not throw when nothing matches.
JSON columns: useful, with one catch
A Json field stores any shape without designing new tables. Good for flexible settings and structures that vary.
Prisma serialises the value for you. If you hand it an already-stringified value, Prisma stores a string, and reading it back gives you a string, not an object.
// Store the object directly — recommended
await db.widget.create({ data: { config: { color: "blue" } } });
widget.config.color; // "blue" — an object
// Stringify first, and you get a string back
await db.widget.create({ data: { config: JSON.stringify({ color: "blue" }) } });
widget.config.color; // undefined!
Neither is wrong — but be consistent, because mixed rows are painful. When reading data you did not write, handle both:
const config = typeof widget.config === "string"
? JSON.parse(widget.config)
: widget.config ?? {};
JSON columns cannot be searched or indexed like normal columns. If you find yourself filtering by something inside the JSON, that something deserves its own column.
Designing a table: five questions
- Does it belong to a shop? Then it needs a
shopcolumn — not a relation toSession. - Does it belong to another of your tables? Then it needs a relation — choose
onDeletedeliberately. - Could any field hold a URL or long text? Give it
@db.Text. - Must anything be unique — globally, or only per shop (
@@unique)? - Will you ever filter by it? Then it is a column, not a key inside a JSON blob.
Add createdAt and updatedAt to everything. They cost nothing and you will want them the first time you debug.
Common errors
Unique constraint failed
You inserted a duplicate of a @unique value. Either use upsert, or check first. Remember @@unique([a, b]) only blocks the combination.
Foreign key constraint violated
Either you referenced a parent row that does not exist, or you tried to delete a parent that still has children and the relation has no onDelete (Restrict). If it appears when the app is uninstalled, one of your tables is linked to Session — replace that relation with a shop column.
Record to update not found
update and delete throw when nothing matches. Use updateMany/deleteMany, which simply affect zero rows instead.
Data too long for column
A String received more than 191 characters. Add @db.Text and run a migration.
Too many connections
Usually a missing db.server.js singleton, so each dev reload opens a new connection pool. Import the shared client everywhere; never call new PrismaClient() in a route.
Cheat sheet
# schema
String @db.Text long text and URLs
Json flexible structure (can't be filtered well)
@@unique([a, b]) unique per combination
onDelete: Cascade deletes children with the parent
# commands
npx prisma migrate dev --name x after editing the schema (dev)
npx prisma migrate deploy production
npx prisma generate client undefined? run this
npx prisma studio browse the data
# read
findMany many rows findUnique one, by unique field
findFirst one, any where count how many
# write
create update upsert createMany updateMany deleteMany
# always
where: { shop: session.shop } on every shop-data query
deleteMany / updateMany for anything user-triggered