Database Review — Forge Skill
Overview
We use PostgreSQL via Supabase. Database design decisions are hard to change later, so they deserve careful review. Bad schema design, missing indexes, and unsafe migrations can cause outages.
Schema Design
Normalization
- Tables should be normalized to at least 3NF unless there's a documented performance reason for denormalization
- Avoid storing computed values that can be derived from other columns
- Use junction tables for many-to-many relationships
- Use JSONB columns sparingly — only when the schema is truly dynamic
Naming Conventions
- Tables:
snake_case, plural (users, blog_posts)
- Columns:
snake_case, singular (user_id, created_at)
- Primary keys:
id (UUID preferred)
- Foreign keys:
{referenced_table_singular}_id (e.g., user_id, post_id)
- Timestamps:
created_at, updated_at (with default values)
- Booleans:
is_ or has_ prefix (is_active, has_verified)
Required Columns
Every table should have:
id — UUID, primary key, default gen_random_uuid()
created_at — timestamptz, default now()
updated_at — timestamptz, updated via trigger or application code
Data Types
| Use |
Type |
Not This |
| IDs |
uuid |
serial, integer |
| Timestamps |
timestamptz |
timestamp (no timezone) |
| Money |
numeric(12,2) or bigint (cents) |
float, real |
| Status/enum |
PostgreSQL enum or text with check constraint |
Unconstrained text |
| JSON data |
jsonb |
json (not indexable) |
| Email |
citext or text with check |
varchar |
Index Strategy
When to Add Indexes
- Foreign key columns (PostgreSQL does NOT auto-index these)
- Columns used in WHERE clauses frequently
- Columns used in ORDER BY
- Columns used in JOIN conditions
- Unique constraints (these create indexes automatically)
When NOT to Add Indexes
- Small tables (< 1000 rows) — sequential scan is faster
- Columns with very low cardinality (e.g., boolean with 50/50 distribution)
- Write-heavy tables where read performance isn't critical
- Columns rarely used in queries
Index Types
-- B-tree (default, most common)
CREATE INDEX idx_posts_user_id ON posts (user_id);
-- Composite index (order matters — leftmost column first in queries)
CREATE INDEX idx_posts_user_date ON posts (user_id, created_at DESC);
-- Partial index (index only matching rows)
CREATE INDEX idx_posts_published ON posts (created_at)
WHERE is_published = true;
-- GIN index for JSONB
CREATE INDEX idx_users_metadata ON users USING GIN (metadata);
-- GiST index for full-text search
CREATE INDEX idx_posts_search ON posts USING GiST (to_tsvector('english', title || ' ' || body));
Index Review Checklist
N+1 Query Detection
The Problem
// BAD — N+1: 1 query for posts + N queries for authors
const posts = await supabase.from('posts').select('*');
for (const post of posts.data) {
const author = await supabase.from('users').select('*').eq('id', post.author_id).single();
}
// GOOD — join in a single query
const posts = await supabase
.from('posts')
.select('*, author:users(name, avatar_url)');
Signs of N+1 in Code Review
- Loops that contain database queries
Promise.all wrapping multiple identical queries with different IDs
useEffect that fetches related data after initial data loads
- Multiple sequential
.from() calls that could be a join
Fix Patterns
- Use Supabase's embedded selects (joins):
.select('*, relation(columns)')
- Use
IN queries: .in('id', arrayOfIds)
- Use database views for complex joins
- Use RPC functions for complex aggregations
Migration Safety
Safe Migration Checklist
Dangerous Migration Patterns
| Pattern |
Risk |
Safer Alternative |
DROP TABLE |
Data loss |
Rename to _deprecated_, drop later |
DROP COLUMN |
Data loss if column still used |
Add _deprecated suffix, drop after deploy |
ALTER COLUMN SET NOT NULL on existing data |
Fails if NULLs exist |
Add default, backfill, then add constraint |
CREATE INDEX on large table |
Table lock |
CREATE INDEX CONCURRENTLY |
| Renaming columns |
Breaks existing queries |
Add new column, migrate data, drop old |
| Changing column type |
Data loss or lock |
Add new column, migrate, swap |
Query Patterns
Pagination
// GOOD — cursor-based pagination
const { data } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
.lt('created_at', cursor)
.limit(20);
// ACCEPTABLE — offset pagination (for small datasets or admin tools)
const { data, count } = await supabase
.from('posts')
.select('*', { count: 'exact' })
.range(offset, offset + limit - 1);
Soft Deletes
// If using soft deletes
const { data } = await supabase
.from('posts')
.select('*')
.is('deleted_at', null); // Don't forget to filter!
Aggregations
-- Use database functions for aggregations, not client-side
CREATE OR REPLACE FUNCTION get_user_stats(uid uuid)
RETURNS TABLE (
post_count bigint,
comment_count bigint,
total_likes bigint
) AS $$
SELECT
(SELECT count(*) FROM posts WHERE author_id = uid),
(SELECT count(*) FROM comments WHERE user_id = uid),
(SELECT coalesce(sum(likes), 0) FROM posts WHERE author_id = uid);
$$ LANGUAGE sql SECURITY DEFINER;
Review Severity
| Issue |
Severity |
| Migration drops data without backup plan |
P0 — BLOCKED |
| N+1 query on user-facing page |
P1 — High |
| Missing index on foreign key |
P1 — High |
| Unsafe migration (locks table, no rollback) |
P1 — High |
Using timestamp instead of timestamptz |
P2 — Medium |
Missing updated_at column |
P3 — Low |
| Using offset pagination on large table |
P2 — Medium |
| Denormalization without justification |
P2 — Medium |
1---2name: database-review3description: Database Review — Forge Skill4---5# Database Review — Forge Skill67## Overview89We use PostgreSQL via Supabase. Database design decisions are hard to change later, so they deserve careful review. Bad schema design, missing indexes, and unsafe migrations can cause outages.1011## Schema Design1213### Normalization1415- Tables should be normalized to at least 3NF unless there's a documented performance reason for denormalization16- Avoid storing computed values that can be derived from other columns17- Use junction tables for many-to-many relationships18- Use JSONB columns sparingly — only when the schema is truly dynamic1920### Naming Conventions2122- Tables: `snake_case`, plural (`users`, `blog_posts`)23- Columns: `snake_case`, singular (`user_id`, `created_at`)24- Primary keys: `id` (UUID preferred)25- Foreign keys: `{referenced_table_singular}_id` (e.g., `user_id`, `post_id`)26- Timestamps: `created_at`, `updated_at` (with default values)27- Booleans: `is_` or `has_` prefix (`is_active`, `has_verified`)2829### Required Columns3031Every table should have:32- `id` — UUID, primary key, default `gen_random_uuid()`33- `created_at` — timestamptz, default `now()`34- `updated_at` — timestamptz, updated via trigger or application code3536### Data Types3738| Use | Type | Not This |39|-----|------|----------|40| IDs | `uuid` | `serial`, `integer` |41| Timestamps | `timestamptz` | `timestamp` (no timezone) |42| Money | `numeric(12,2)` or `bigint` (cents) | `float`, `real` |43| Status/enum | PostgreSQL `enum` or text with check constraint | Unconstrained `text` |44| JSON data | `jsonb` | `json` (not indexable) |45| Email | `citext` or `text` with check | `varchar` |4647## Index Strategy4849### When to Add Indexes5051- Foreign key columns (PostgreSQL does NOT auto-index these)52- Columns used in WHERE clauses frequently53- Columns used in ORDER BY54- Columns used in JOIN conditions55- Unique constraints (these create indexes automatically)5657### When NOT to Add Indexes5859- Small tables (< 1000 rows) — sequential scan is faster60- Columns with very low cardinality (e.g., boolean with 50/50 distribution)61- Write-heavy tables where read performance isn't critical62- Columns rarely used in queries6364### Index Types6566```sql67-- B-tree (default, most common)68CREATE INDEX idx_posts_user_id ON posts (user_id);6970-- Composite index (order matters — leftmost column first in queries)71CREATE INDEX idx_posts_user_date ON posts (user_id, created_at DESC);7273-- Partial index (index only matching rows)74CREATE INDEX idx_posts_published ON posts (created_at)75 WHERE is_published = true;7677-- GIN index for JSONB78CREATE INDEX idx_users_metadata ON users USING GIN (metadata);7980-- GiST index for full-text search81CREATE INDEX idx_posts_search ON posts USING GiST (to_tsvector('english', title || ' ' || body));82```8384### Index Review Checklist8586- [ ] Foreign keys have indexes87- [ ] Query patterns match index column order88- [ ] No duplicate or redundant indexes89- [ ] Partial indexes used where appropriate90- [ ] Index impact on write performance considered9192## N+1 Query Detection9394### The Problem9596```typescript97// BAD — N+1: 1 query for posts + N queries for authors98const posts = await supabase.from('posts').select('*');99for (const post of posts.data) {100 const author = await supabase.from('users').select('*').eq('id', post.author_id).single();101}102103// GOOD — join in a single query104const posts = await supabase105 .from('posts')106 .select('*, author:users(name, avatar_url)');107```108109### Signs of N+1 in Code Review110111- Loops that contain database queries112- `Promise.all` wrapping multiple identical queries with different IDs113- `useEffect` that fetches related data after initial data loads114- Multiple sequential `.from()` calls that could be a join115116### Fix Patterns117118- Use Supabase's embedded selects (joins): `.select('*, relation(columns)')`119- Use `IN` queries: `.in('id', arrayOfIds)`120- Use database views for complex joins121- Use RPC functions for complex aggregations122123## Migration Safety124125### Safe Migration Checklist126127- [ ] Migration is backwards-compatible (old code works with new schema)128- [ ] No `DROP COLUMN` without verifying the column is unused in all code129- [ ] No `ALTER COLUMN` that changes type in a way that loses data130- [ ] No `NOT NULL` constraint added to existing column without default value131- [ ] Large table migrations use batched operations or `CONCURRENTLY`132- [ ] Indexes created with `CONCURRENTLY` to avoid table locks133- [ ] Migration tested against production-like data volume134- [ ] Rollback plan exists135136### Dangerous Migration Patterns137138| Pattern | Risk | Safer Alternative |139|---------|------|-------------------|140| `DROP TABLE` | Data loss | Rename to `_deprecated_`, drop later |141| `DROP COLUMN` | Data loss if column still used | Add `_deprecated` suffix, drop after deploy |142| `ALTER COLUMN SET NOT NULL` on existing data | Fails if NULLs exist | Add default, backfill, then add constraint |143| `CREATE INDEX` on large table | Table lock | `CREATE INDEX CONCURRENTLY` |144| Renaming columns | Breaks existing queries | Add new column, migrate data, drop old |145| Changing column type | Data loss or lock | Add new column, migrate, swap |146147## Query Patterns148149### Pagination150151```typescript152// GOOD — cursor-based pagination153const { data } = await supabase154 .from('posts')155 .select('*')156 .order('created_at', { ascending: false })157 .lt('created_at', cursor)158 .limit(20);159160// ACCEPTABLE — offset pagination (for small datasets or admin tools)161const { data, count } = await supabase162 .from('posts')163 .select('*', { count: 'exact' })164 .range(offset, offset + limit - 1);165```166167### Soft Deletes168169```typescript170// If using soft deletes171const { data } = await supabase172 .from('posts')173 .select('*')174 .is('deleted_at', null); // Don't forget to filter!175```176177### Aggregations178179```sql180-- Use database functions for aggregations, not client-side181CREATE OR REPLACE FUNCTION get_user_stats(uid uuid)182RETURNS TABLE (183 post_count bigint,184 comment_count bigint,185 total_likes bigint186) AS $$187 SELECT188 (SELECT count(*) FROM posts WHERE author_id = uid),189 (SELECT count(*) FROM comments WHERE user_id = uid),190 (SELECT coalesce(sum(likes), 0) FROM posts WHERE author_id = uid);191$$ LANGUAGE sql SECURITY DEFINER;192```193194## Review Severity195196| Issue | Severity |197|-------|----------|198| Migration drops data without backup plan | P0 — BLOCKED |199| N+1 query on user-facing page | P1 — High |200| Missing index on foreign key | P1 — High |201| Unsafe migration (locks table, no rollback) | P1 — High |202| Using `timestamp` instead of `timestamptz` | P2 — Medium |203| Missing `updated_at` column | P3 — Low |204| Using offset pagination on large table | P2 — Medium |205| Denormalization without justification | P2 — Medium |