Primary datastore. Schemas are owned by Prisma; this document covers the choices that live below the ORM.
- Version: PostgreSQL 18 (Alpine image in dev)
- Dev port:
5432(127.0.0.1bind only) - Init script:
docker/postgres/init.sql - Official docs: https://www.postgresql.org/docs/18/
Before adding an index or constraint, writing raw SQL ($queryRaw), changing the connection string shape, modifying the init script, or profiling a slow query.
Every runtime uses the DATABASE_URL env var. Examples:
# Dev (docker-compose)
postgres://postgres:postgres@localhost:5432/example_app
# Tests (docker-compose.test.yml)
postgres://postgres:postgres@localhost:55432/example_app_test
# Prod
postgresql://user:pass@db.internal:5432/example_app?sslmode=require&connection_limit=20&pool_timeout=10
- Always
sslmode=require(orverify-full) outside local dev. - Set
connection_limitto match app instance count × pod count — default 10 per client is fine for dev but blows up on autoscaling. - Use PgBouncer (transaction mode) for serverless or high-concurrency prod; document it in
docs/DEPLOYMENT.md.
Owned by Prisma. Rules that Prisma does not enforce:
- Lowercase snake_case for table and column names (
audit_logs,tenant_id). Prisma maps PascalCase model names to snake_case via@@map/@mapwhen the surrounding codebase expects it — we keep model names PascalCase and column/table names as Prisma's defaults. - Plural tables (
users,projects,audit_logs). Prisma model names are singular (User,Project,AuditLog). - IDs:
textwith a CUID2 generated in application code via Prisma'scuid(). Avoid integerserialfor any entity that appears in a URL — it leaks cardinality. - Timestamps:
timestamptz, always. Nevertimestamp(no time zone). Prisma'sDateTimemaps totimestamptzwith PostgreSQL. - Enums as
text+ check constraint, not SQLenum. Adding a value to a SQL enum requires a migration that can't run inside a transaction in some configurations; text + check is boring and safe.
Every foreign-key column gets an index — Postgres does not create one automatically. Prisma generates them when you write @@index.
model Project {
id String @id @default(cuid())
tenantId String
ownerId String
// ...
@@index([tenantId])
@@index([ownerId])
@@index([tenantId, updatedAt(sort: Desc)])
}- Multi-column indexes for the exact filter + sort your dashboards use — the last index above serves
WHERE tenantId = ? ORDER BY updatedAt DESC. - Partial indexes for soft-delete tables:
@@index([tenantId], where: { archivedAt: null })— Prisma 7 supports them via raw@@indexargs. - Never add an index "just in case"; each one costs write amplification and disk.
@@unique([tenantId, email])onusersenforces tenant-scoped uniqueness.@uniqueon a single column without a tenant scope is almost always wrong in a multi-tenant app — review before merging.
Default isolation is READ COMMITTED. Use SERIALIZABLE via prisma.$transaction(fn, { isolationLevel: 'Serializable' }) only when you genuinely need it — the library's session eviction, for example, relies on atomicity but uses Redis, not Postgres.
- Row-level locks: Prisma does not expose
SELECT ... FOR UPDATEdirectly. When needed, use$queryRawinside the transaction closure. Document why in a comment. - Long-running transactions: < 200 ms target. Anything longer blocks
vacuumand other writers. - Background jobs that touch auth data must use the library's services, not raw SQL — otherwise audit and cache invalidation silently skip.
Rules that apply on top of Prisma migrations:
- Additive first: add a nullable column, backfill, flip to
NOT NULL. One migration per step on large tables. - Never drop a column in the same release that stops writing to it. Ship the code change, confirm in prod, then drop in the next release.
- Renames are two migrations — add the new column, dual-write, cutover reads, drop the old column.
CREATE INDEX CONCURRENTLYfor production on tables > 1M rows. Prisma generatesCREATE INDEXby default; edit the generated SQL and addCONCURRENTLY, and drop the migration's implicit transaction by adding-- prisma-migrate-no-transactionat the top of the file.
Avoid unless Prisma genuinely cannot express it. If you must:
$queryRawwith tagged templates. Never string concatenation.const rows = await prisma.$queryRaw<RowShape[]>`SELECT ... WHERE tenant_id = ${tenantId}`;
- Parameters are always bound. Never interpolate user input into the template literal.
- Every raw query has a typed return type — no
unknown[]results leaking upwards.
healthcheckindocker-compose.ymlusespg_isready; our app's/healthendpoint independently runsSELECT 1via Prisma to cover connection pool exhaustion.- Structured
pg_stat_statementsextension is enabled in prod, not in dev. Document with your deploy platform. - Log slow queries at the Prisma level (
log: ['query', { level: 'warn', emit: 'event' }]) and ship the warnings, not the raw queries — they may contain tenant data.
Out of scope for this example; cover in docs/DEPLOYMENT.md. Summary of the policy we recommend:
- Daily logical
pg_dump --format=customto object storage, retention ≥ 30 days. - PITR via WAL archiving if your platform supports it.
- Document the restore test cadence. A backup you never restored is a rumor.
timestampwithout time zone — every downstream library mis-interprets the tz;timestamptzfixes it.- Case-sensitive collation (
LC_COLLATE 'C') combined with email comparison — our init script uses'C'for deterministic ordering, but application code compares email vialower(email)in raw SQL. Prisma handles this via the library'sfindByEmail. - Missing
ON DELETEon FKs — orphan rows accumulate. Prisma defaults toSetNullorRestrict; pick intentionally per relation. - Seq scans on a
tenantIdfilter — add the index. RunEXPLAIN ANALYZEto confirm. - Using
pg_dumpon a schema with extensions without--no-owner --no-privileges— restores fail on the target. Include in the runbook. vacuum fullin prod — acquires an access-exclusive lock. Usevacuumor autovacuum tuning.
- PostgreSQL 18 docs: https://www.postgresql.org/docs/18/
- Prisma + Postgres: https://www.prisma.io/docs/orm/overview/databases/postgresql
- Index cookbook: https://use-the-index-luke.com/