12. Databases
12.1 SQL Fundamentals
Structured Query Language (SQL) is the standard language for interacting with relational databases. Mastering its core concepts enables you to design efficient schemas, write safe queries, and optimize performance.
12.1.1 Database Concepts
A relational database organizes data into tables (relations) consisting of rows (tuples) and columns (attributes). Key theoretical foundations include:
- ACID properties – Atomicity, Consistency, Isolation, Durability – guaranteeing reliable transaction processing.
- Normalization – Process to reduce redundancy and improve data integrity. Normal forms:
- 1NF – Atomic column values, no repeating groups.
- 2NF – 1NF + no partial dependency on a composite primary key.
- 3NF – 2NF + no transitive dependency.
- BCNF – Every determinant is a candidate key.
- Keys and Constraints – Primary keys uniquely identify rows; foreign keys enforce relationships. Additional constraints:
NOT NULL,UNIQUE,CHECK,DEFAULT. - Schema Design Principles – Choose appropriate data types, minimize nullable columns, use indexing strategically, and document relationships.
12.1.2 CRUD Operations
CRUD (Create, Read, Update, Delete) maps directly to SQL statements. Parameterized queries protect against SQL injection.
-- CREATE
INSERT INTO users (name, email) VALUES ('John', 'john@example.com');
-- READ
SELECT * FROM users WHERE id = 1;
-- UPDATE
UPDATE users SET name = 'Jane' WHERE id = 1;
-- DELETE
DELETE FROM users WHERE id = 1;
Using placeholders (e.g., $1, ?) lets the driver safely bind values.
12.1.3 Joins
Joins combine rows from two or more tables based on related columns.
| Join Type | Description |
|---|---|
| INNER JOIN | Returns only matching rows from both tables. |
| LEFT JOIN | Returns all rows from the left table, plus matched rows from the right (NULL if none). |
| RIGHT JOIN | Mirror of LEFT JOIN. |
| FULL OUTER JOIN | Returns all rows when there is a match in either table. |
| CROSS JOIN | Cartesian product – every row of left combined with every row of right. |
| SELF JOIN | Table joined to itself, useful for hierarchical data. |
Example:
SELECT u.name, p.title
FROM users u
INNER JOIN posts p ON u.id = p.user_id;
Performance tip: ensure join columns are indexed; analyze with EXPLAIN ANALYZE.
12.1.4 Indexing
Indexes speed up data retrieval by providing quick lookup paths.
- B-tree indexes – Default, efficient for range queries.
- Hash indexes – Equality lookups only.
- Partial indexes – Index a subset of rows (
WHEREclause). - Composite indexes – Multiple columns; order matters.
Creation example:
CREATE INDEX idx_users_email ON users(email);
Key concepts:
- Selectivity – Ratio of distinct values to total rows; higher selectivity yields better index benefit.
- Covering indexes – Include all columns needed by a query, allowing index‑only scans.
- Clustered vs non‑clustered – In PostgreSQL, the table heap is unordered; indexes are always non‑clustered, but you can cluster a table on an index physically.
- Avoid over‑indexing – Each index adds write overhead; monitor with
pg_stat_user_indexes.
12.2 PostgreSQL
PostgreSQL is a powerful, open‑source object‑relational database known for extensibility, standards compliance, and robust performance.
12.2.1 Installation
You can run PostgreSQL locally, via Docker, or on managed services.
- Local – Homebrew (
brew install postgresql), apt (sudo apt-get install postgresql), Windows installer. - Docker –
docker run --name postgres -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres:15 - Managed – Supabase, Neon, Railway, AWS RDS (provide ready‑to‑use instances).
Connection string format:
postgresql://user:pass@host:5432/dbname?schema=public
12.2.2 Tables
Define tables with CREATE TABLE. Choose appropriate data types:
| Category | Types |
|---|---|
| Serial/Auto‑increment | SERIAL, BIGSERIAL |
| Identifier | UUID (via uuid-ossp extension) |
| Text | VARCHAR(n), TEXT |
| Temporal | TIMESTAMP, TIMESTAMPTZ (timezone‑aware) |
| JSON | JSONB (binary, indexable) |
| Boolean | BOOLEAN |
| Numeric | NUMERIC(precision, scale) for exact decimals |
Example:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT now(),
is_active BOOLEAN DEFAULT TRUE,
profile_data JSONB
);
Modify schemas safely with ALTER TABLE (add/drop columns, change types, add constraints). For large tables, consider partitioning by range or list to improve query performance and maintenance.
12.2.3 Relationships
Foreign keys enforce referential integrity.
- One‑to‑One – e.g.,
users↔profiles(unique foreign key). - One‑to‑Many – e.g.,
users→posts(many posts per user). - Many‑to‑Many – requires a join table, e.g.,
posts↔tagsviapost_tags.
Define with ON DELETE CASCADE or SET NULL to control child row behavior when a parent is removed.
Performance tip: always index foreign‑key columns; use EXPLAIN to verify join plans.
12.2.4 Optimization
Scaling PostgreSQL involves connection management, replication, and query tuning.
- Connection pooling – Tools like
PgBouncerreduce overhead of frequent connections. - Read replicas – Offload SELECT queries to standby servers.
- Query optimization –
- Avoid N+1 problems by fetching related data in a single query or using
JOIN. - Prefer cursor‑based pagination (
WHERE id > $1 ORDER BY id LIMIT $2OFFSET) overOFFSETfor large datasets. - Use
LIMIT/OFFSETonly for small pages.
- Avoid N+1 problems by fetching related data in a single query or using
- Materialized views – Store pre‑computed results; refresh periodically.
- Monitoring –
pg_stat_statementstracks query execution times;VACUUMand autovacuum keep tables bloat‑free. - – Adjust
wal_level,checkpoint_timeout, andmax_wal_sizefor write‑heavy workloads.
12.3 Prisma ORM
Prisma is a modern TypeScript‑first ORM that simplifies database access in Node.js/Next.js applications.
12.3.1 Setup
Install the CLI and client, then initialize the project.
npm install prisma @prisma/client
npx prisma init
This creates a prisma folder with schema.prisma and a .env file. Add your database URL:
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb?schema=public"
Generate the client:
npx prisma generate
In Next.js, instantiate PrismaClient as a singleton in development (to avoid hot‑reload creating many instances) and globally in production:
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
12.3.2 Schema
The prisma.schema file defines models, fields, enums, composite types, and indexes.
Example model:
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[] @relation("UserPosts")
profile Profile? @relation("UserProfile")
createdAt DateTime @default(now())
}
model Profile {
id Int @id @default(autoincrement())
bio String?
userId Int @unique
user User @relation("UserProfile", fields: [userId], references: [id])
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int
author User @relation("UserPosts", fields: [authorId], references: [id])
tags Tag[] @relation("PostTags")
createdAt DateTime @default(now())
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[] @relation("PostTags")
}
@@index([email])
Features:
- Enums – Define a fixed set of values (
enum Role { ADMIN USER }). - Composite types – Group related scalar fields (
type Address { street String city String zip String }). - Indexes –
@@index([email])creates a B‑tree index;@@uniqueenforces uniqueness. - Prisma‑level validations – Use
@db.VarChar(255)or custom validation functions in application logic.
12.3.3 Migrations
Prisma Migrations manage schema changes safely.
- Development –
npx prisma migrate dev --name initcreates a migration folder and applies changes. - Production –
npx prisma migrate deployapplies pending migrations. - Reset –
npx prisma migrate resetdrops and recreates the database (use with caution). - Baseline – If you have an existing database,
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --scriptgenerates a baseline migration.
Migration history lives in prisma/migrations. Handle failed migrations by inspecting the error, fixing the schema, and re‑running prisma migrate resolve --rolled-back or prisma migrate reset in dev. Drift detection (prisma migrate check) alerts when the database diverges from the schema.
12.3.4 Relations
Prisma expresses relations via model fields and the @relation attribute.
- One‑to‑One –
profile Profile? @relation("UserProfile", fields: [profileId], references: [id])plus@@unique([profileId]). - One‑to‑Many –
posts Post[]on the User model; inverseauthor User @relation("UserPosts"). - Many‑to‑Many – Two approaches:
- Implicit – Prisma creates a hidden join table when you define
posts Post[] @relation("PostTags")on both sides. - Explicit – Define a join model (
model PostTag { post Post @relation(fields: [postId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) @@id([postId, tagId]) }) giving you control over extra columns.
- Implicit – Prisma creates a hidden join table when you define
- Referential actions – Set
onDelete: CascadeoronDelete: SetNullin the relation attribute to control cascading behavior.
12.3.5 Transactions
Prisma supports interactive and batch transactions to guarantee atomicity across multiple operations.
Interactive transaction (array of promises):
await prisma.$transaction([
prisma.user.create({ data: { email: 'alice@example.com', name: 'Alice' } }),
prisma.profile.create({ data: { bio: 'Hello', user: { connect: { email: 'alice@example.com' } } } })
])
Async callback transaction (for complex logic):
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: { email: 'bob@example.com' } })
await tx.post.create({ data: { title: 'First post', author: { connect: { id: user.id } } } })
// additional steps…
})
You can specify isolation levels (ReadUncommitted, ReadCommitted, RepeatableRead, Serializable) by passing maxWait and timeout options. Errors inside the transaction trigger an automatic rollback; successful completion commits all changes.
Best practices:
- Keep transactions short to reduce lock contention.
- Handle validation errors before entering a transaction.
- Use
prisma.$queryRawfor raw SQL when needed, but prefer Prisma’s typed API for safety.