Skip to content
Developer docs

Database: Postgres and migrations

App sites keep their data in the site's own Postgres database. Your app adds its own tables with SQL migrations and reads and writes them with tagged SQL. This page shows how, with examples you can copy.

Where your data lives

  • On your machine: a Postgres you start yourself (Test on a throwaway Postgres). Nothing else is needed.
  • On a customer's site: that site's own Postgres database, which the platform runs for them. You never get a connection string to it. Your code reaches it only through the request's database client, c.var.db (the withDb guard).
  • When they publish: the platform runs your new migrations in their database before the new code goes live. If one fails, the site keeps serving the version before.
  • Website sites (astro-static) have no server and no database. An app that stores data needs the App template (astro-app).
Postgres is the only database

A dependency on another database (MySQL, MongoDB, SQLite, Redis, Firebase and similar) fails the package check. So does a second database client: always use the client the site gives you.

Tagged SQL, not Prisma models

The site's prisma/schema.prisma belongs to the site, and your bundle can't change it. So your tables are not Prisma models: there is no db.booking.findMany() for them. You create them with a migration and read and write them with Prisma's tagged raw SQL, on the same client.

Every ${value} inside the tag is sent to Postgres as a bound parameter of a prepared statement, never pasted into the SQL text. Input can't change the query, whatever it contains.

UseFor
db.$queryRaw<Row[]>`…`Reads, and writes that return rows (RETURNING). Give it the row type.
db.$executeRaw`…`Writes. It answers the number of rows changed.
Prisma.sql`…`A piece of SQL with its own parameters, put inside another query (an optional condition).
Prisma.join(ids)A list of values for IN (…), each one a parameter.
Prisma.emptyNothing, when an optional piece is left out.

Never $queryRawUnsafe, $executeRawUnsafe, Prisma.raw() with input, or SQL built with +, .join() or a plain template string. The review's security level and the code reviewer look for them.

Never do this
// NEVER: the value becomes part of the SQL text.
await db.$queryRawUnsafe(`SELECT * FROM app_my_app_bookings WHERE note = '${q}'`);
await db.$queryRaw(Prisma.raw('… ' + q));
const sql = 'SELECT … WHERE id = ' + id;

In SQL, write the database's names (snake_case: user_id, created_at), name every column you read (no SELECT *), and always end a read with a LIMIT.

Examples

These use the tables from the migration below, for an app with the slug my-app. Your route file lives in src/server/apps/my-app/.

A typed read, scoped to its owner

src/server/apps/my-app/routes.ts
import {Hono} from 'hono';

import {problem, requireAuth, withDb, withViewer} from '../../../platform/guards';

type Booking = {id: number; starts_at: Date; status: string; note: string | null};

export const routes = new Hono().get('/bookings/:id', withViewer, requireAuth, withDb, async c => {
  const id = Number(c.req.param('id'));
  if (!Number.isSafeInteger(id) || id < 1) return c.json(problem('not_found', 'No such booking.'), 404);

  // The owner is IN the query: another person's id finds nothing, so it answers 404.
  const [booking] = await c.var.db.$queryRaw<Booking[]>`
    SELECT id, starts_at, status, note
    FROM app_my_app_bookings
    WHERE id = ${id} AND user_id = ${c.var.viewer.id!}
    LIMIT 1`;
  if (!booking) return c.json(problem('not_found', 'No such booking.'), 404);
  return c.json({booking});
});

A write

// Insert and get the new id back in the same round trip.
const [created] = await db.$queryRaw<{id: number}[]>`
  INSERT INTO app_my_app_bookings (user_id, service_id, starts_at, note)
  VALUES (${me}, ${serviceId}, ${startsAt}, ${note})
  RETURNING id`;

// $executeRaw answers how many rows changed. A conditional update is the whole check:
// someone else's booking, or one that is already cancelled, changes nothing.
const changed = await db.$executeRaw`
  UPDATE app_my_app_bookings
  SET status = 'cancelled', updated_at = now()
  WHERE id = ${id} AND user_id = ${me} AND status = 'booked'`;
if (changed === 0) return c.json(problem('not_found', 'No such booking.'), 404);

Build every write column by column from validated input. Never take user_id, status or a price from the request body: they come from the server.

A list of ids: IN (…)

import {Prisma} from '../../../generated/prisma/client';

// Prisma.join turns the list into bound parameters: IN ($1, $2, $3).
// It throws on an empty list, and a request may send at most 100 ids.
if (ids.length === 0 || ids.length > 100) {
  return c.json(problem('invalid_ids', 'Send 1 to 100 ids.'), 422);
}
const services = await db.$queryRaw<{id: number; title: string}[]>`
  SELECT id, title
  FROM app_my_app_services
  WHERE id IN (${Prisma.join(ids)})
  LIMIT 100`;

A join and an aggregate in one query

type Row = {service_id: number; title: string; bookings: bigint; next_start: Date | null};

// One query instead of "load the services, then count each one's bookings".
const rows = await db.$queryRaw<Row[]>`
  SELECT s.id AS service_id, s.title,
         count(b.id) AS bookings,
         min(b.starts_at) FILTER (WHERE b.starts_at > now()) AS next_start
  FROM app_my_app_services s
  LEFT JOIN app_my_app_bookings b ON b.service_id = s.id AND b.status = 'booked'
  GROUP BY s.id, s.title
  ORDER BY s.title
  LIMIT 50`;

// count() comes back as a BigInt, and JSON.stringify throws on a BigInt.
return c.json({services: rows.map(r => ({...r, bookings: Number(r.bookings)}))});

Counting at request time is fine for an owner's report on a small table. On a busy public page, or a table that keeps growing, keep a counter column that the same write updates, or cache the answer.

BigInt to Number

Postgres bigint values come back as JavaScript BigInt: count(), sum() of integers, and BIGINT or BIGSERIAL columns. JSON.stringify throws on them, and the Site Cache doesn't store them.

// Either convert in code…
const total = Number(row.bookings);          // safe below 2^53
const id = row.id.toString();                // a BIGSERIAL id, sent as a string
// …or cast in SQL, so it comes back as a number.
SELECT count(*)::int AS bookings FROM …

Pages: keyset, never OFFSET

import {Prisma} from '../../../generated/prisma/client';

const PAGE = 20;
const before = Number(c.req.query('before') ?? '');
const cursor = Number.isSafeInteger(before) && before > 0 ? before : null;

// Index (user_id, id) covers this: no rows are read and thrown away, however deep the page.
const rows = await db.$queryRaw<{id: number; starts_at: Date; status: string}[]>`
  SELECT id, starts_at, status
  FROM app_my_app_bookings
  WHERE user_id = ${me} ${cursor ? Prisma.sql`AND id < ${cursor}` : Prisma.empty}
  ORDER BY id DESC
  LIMIT ${PAGE + 1}`;

const more = rows.length > PAGE;
return c.json({items: rows.slice(0, PAGE), nextBefore: more ? rows[PAGE - 1].id : null});

OFFSET reads and throws away every row before the page, so deep pages get slower and slower. Keyset paging asks for the rows after the last one shown. Sorted by something else (a time, a score), the cursor is that value plus the id, and so is the index.

A sort the visitor picks

// A column or sort order can't be a parameter: pick it from a fixed map, never from input.
const SORT = {
  newest: Prisma.sql`created_at DESC, id DESC`,
  title: Prisma.sql`title ASC, id ASC`,
} as const;
const order = SORT[input.sort as keyof typeof SORT] ?? SORT.newest;
await db.$queryRaw`SELECT id, title FROM app_my_app_services ORDER BY ${order} LIMIT 50`;

Migrations

Every table, column, index and constraint your app needs comes from a migration: a plain SQL file in your bundle. The platform runs each one once per site, in name order, with the site's own migrations.

Naming

WhatRule
Folderprisma/migrations/<14 digits>_app_<slug>_<name>/migration.sql
In the bundleUnder files.astro-app/: migrations are for App sites only.
14 digitsThe time you wrote it, in UTC: year, month, day, hour, minute, second. Later migrations get later numbers.
SlugYour app's slug with dashes turned into underscores: my-app becomes my_app.
NameWhat it changes, in lowercase letters, digits and underscores, up to 60 characters.
Tables and indexesEvery name starts with app_<slug>_ (the same underscores). Never a name the site or another app could use.

A migration anywhere else, or with another name, is refused by the package check. See Where your files may go.

A first migration

files.astro-app/prisma/migrations/20261101093000_app_my_app_bookings/migration.sql
BEGIN;

CREATE TABLE IF NOT EXISTS app_my_app_services (
  id          SERIAL PRIMARY KEY,
  title       VARCHAR(120) NOT NULL,
  minutes     INTEGER NOT NULL CHECK (minutes BETWEEN 5 AND 480),
  price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS app_my_app_bookings (
  id          SERIAL PRIMARY KEY,
  service_id  INTEGER NOT NULL REFERENCES app_my_app_services (id) ON DELETE RESTRICT,
  user_id     INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
  starts_at   TIMESTAMPTZ NOT NULL,
  status      VARCHAR(16) NOT NULL DEFAULT 'booked' CHECK (status IN ('booked', 'cancelled')),
  note        VARCHAR(500),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- One index per query shape: equality columns first, then the sort column.
CREATE INDEX IF NOT EXISTS app_my_app_bookings_user_idx
  ON app_my_app_bookings (user_id, id);
CREATE INDEX IF NOT EXISTS app_my_app_bookings_service_idx
  ON app_my_app_bookings (service_id, starts_at);

COMMIT;
  • Wrap it in BEGIN; and COMMIT;: all of it applies, or none of it.
  • Write it so it can run twice: CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, CREATE INDEX IF NOT EXISTS.
  • Money in whole cents (INTEGER), times as TIMESTAMPTZ, text with a length that matches your validator, NOT NULL unless “unknown” is a real state, and a CHECK for fixed sets of values.
  • Point at a person with user_id and a foreign key to users (id). Never copy their email or name into your tables.
  • One index per query shape, starting with the columns you filter on. Postgres doesn't index foreign keys by itself.

Forward only

  • Never edit, rename or delete a migration once a version with it has shipped. It has already run on customers' sites, and the platform never runs it again. Every change is a new migration.
  • For a while, the previous version of your app runs on the new schema (and a site can go back to it). Every migration must be safe for the version before.
  • A migration that drops, truncates or changes a column's type waits for the site owner's approval when they publish. Avoid them, and never put one in the same version that stops using the column.
  • Nothing changes the schema at runtime: no CREATE TABLE or ALTER TABLE in a route.

Expand, backfill, contract

Renaming a column, splitting one, or making one required takes several versions:

  1. ExpandAdd the new column, nullable or with a default. The code writes both columns and still reads the old one.
  2. BackfillCopy the old values into the new column, in batches.
  3. SwitchA new version reads the new column and still writes both.
  4. ContractA later version, once no live version reads the old column: stop writing it, then drop it. Add NOT NULL only after the backfill is complete.
-- Version 1.1, migration 20261201090000_app_my_app_customer_note: EXPAND
BEGIN;
ALTER TABLE app_my_app_bookings ADD COLUMN IF NOT EXISTS customer_note VARCHAR(500);
COMMIT;

-- Version 1.1, migration 20261201090100_app_my_app_customer_note_backfill: BACKFILL, one batch
UPDATE app_my_app_bookings SET customer_note = note
WHERE id IN (
  SELECT id FROM app_my_app_bookings
  WHERE customer_note IS NULL AND note IS NOT NULL
  LIMIT 5000
);

-- Version 1.3, a later migration once no version reads "note": CONTRACT
BEGIN;
ALTER TABLE app_my_app_bookings DROP COLUMN IF EXISTS note;
COMMIT;

Never in one version: rename or drop a column or table the running code reads, add a NOT NULL column without a default to a table that has rows, change a column's type in place, or add a unique constraint before the duplicates are gone.

Backfills in batches

One UPDATE over millions of rows locks the table and can time out. Update a bounded batch (a few thousand rows) per migration, as above, and repeat it in later migrations; or let a background job work through the rest in bounded batches.

Test on a throwaway Postgres

Run every migration on an empty database and on one that already has your previous version's tables and some rows. Throw the database away as often as you like. Never point these commands at a database you care about.

# A throwaway Postgres (delete it and start again whenever you like)
docker run -d --name app-db -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16

# .env in the template folder
DATABASE_URL=postgresql://postgres:dev@localhost:5432/postgres

npx prisma migrate deploy      # the site's migrations, then yours, in name order
npx prisma migrate deploy      # again: "No pending migrations to apply"
npm run dev

# Start from an empty database
docker rm -f app-db
# Integration tests run against a real Postgres and EMPTY its tables first.
TEST_DATABASE_URL=postgresql://postgres:dev@localhost:5432/postgres npm test

Review does the same: the build level installs your app on a fresh copy of the template and runs your migrations on a throwaway Postgres. Ship sample data so the reviewer's test site isn't empty.

What you can't do

Not allowedDo this instead
Change prisma/schema.prisma or add Prisma models.SQL migrations for your tables, tagged SQL to use them.
Create, change or drop the site's own tables (users, roles, sign-in, jobs), or write to them.Your own app_<slug>_ tables. Read a user's name by id when a screen needs it; never their password hash.
Another database, a second Prisma client, or a connection string of your own.c.var.db from the withDb guard.
SQL built from strings, or the Unsafe raw methods.The tagged forms, with a fixed map for column and sort names.
Edit a migration that has shipped.A new migration.
Demo rows in a migration.Sample data for review, which never reaches a customer's site.
Next

Read Best practices for authorization, performance and the checklist to run before you submit.