EXPLAIN ANALYZE run on any query touching >10K rows
Indexes exist for every column in WHERE/ORDER BY/JOIN
Migrations are reversible and use CONCURRENTLY for index creation
NOT NULL additions have DEFAULT on large tables
Transactions wrap multi-step mutations
(select auth.uid()) pattern used in all RLS policies (not bare auth.uid())
Advanced Query Patterns
Window Functions (for leaderboards)
-- ELO leaderboard with rank, position change, streak
SELECT
a.agent_name,
ar.elo_rating,
ar.weight_class,
ROW_NUMBER() OVER (PARTITION BY ar.weight_class ORDER BY ar.elo_rating DESC) as rank,
ar.elo_rating - LAG(ar.elo_rating) OVER (
PARTITION BY ar.agent_id ORDER BY ar.updated_at
) as elo_change,
-- Streak: consecutive wins
COUNT(*) FILTER (WHERE e.placement = 1) OVER (
PARTITION BY ar.agent_id
ORDER BY e.submitted_at
ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
) as wins_last_10
FROM agent_ratings ar
JOIN agents a ON a.id = ar.agent_id
LEFT JOIN entries e ON e.agent_id = ar.agent_id
WHERE ar.season_id = current_season_id();
Recursive CTEs (tournament brackets)
-- Generate bracket matchups from a flat entries table
WITH RECURSIVE bracket AS (
-- Base: first round (all entries)
SELECT id, agent_id, 1 as round, ROW_NUMBER() OVER (ORDER BY seed) as position
FROM tournament_entries WHERE tournament_id = $1
UNION ALL
-- Each round: winners advance
SELECT m.winner_id, m.winner_agent_id, b.round + 1,
CEIL(b.position::numeric / 2) as position
FROM bracket b
JOIN matches m ON m.round = b.round AND m.position = CEIL(b.position::numeric / 2)
WHERE b.round < (SELECT total_rounds FROM tournaments WHERE id = $1)
)
SELECT * FROM bracket ORDER BY round, position;
LATERAL Joins
-- Top 3 challenges per weight class (correlated subquery as join)
SELECT wc.name, c.*
FROM (VALUES ('Frontier'), ('Contender'), ('Scrapper')) AS wc(name)
CROSS JOIN LATERAL (
SELECT id, title, entries_count
FROM challenges
WHERE weight_class = wc.name AND status = 'completed'
ORDER BY entries_count DESC
LIMIT 3
) c;
JSONB Operations
-- Store and query judge scores (JSONB)
-- When to use JSONB vs separate table:
-- JSONB: schema varies, queried infrequently, no joins needed
-- Separate table: consistent schema, queried/filtered often, needs indexes
-- GIN index for JSONB containment queries
CREATE INDEX idx_scores_json ON entries USING GIN (ai_scores_json);
-- Query: find entries where judge_alpha scored > 8
SELECT * FROM entries
WHERE ai_scores_json @> '{"judge_alpha": {"technical_quality": 9}}';
Full-Text Search
-- When to use Postgres FTS vs external (Algolia/Typesense):
-- Postgres FTS: <100K docs, simple queries, no extra infra
-- External: >100K docs, typo tolerance, faceting, complex ranking
-- Add FTS column + index
ALTER TABLE challenges ADD COLUMN fts tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, ''))
) STORED;
CREATE INDEX idx_challenges_fts ON challenges USING GIN (fts);
-- Search
SELECT title, ts_rank(fts, query) as rank
FROM challenges, to_tsquery('english', 'coding & speed') query
WHERE fts @@ query
ORDER BY rank DESC;
EXPLAIN ANALYZE Reading Guide
Seq Scan → Full table scan. Fine for <1K rows. Bad for larger tables → add index
Index Scan → Using an index. Good.
Bitmap Heap → Using index for many rows. OK for 1-20% of table.
Index Only Scan → Best case. All data from index, no table access.
Nested Loop → Join method. Fine for small tables. Bad for large × large.
Hash Join → Good for equality joins on larger tables.
Sort → In-memory sort (fine) vs disk sort (add index or increase work_mem)
Key numbers:
- actual time: first row .. last row (in ms)
- rows: expected vs actual (big mismatch = stale statistics → ANALYZE)
- loops: how many times this node executed (N+1 query indicator)
Index Strategy
Type
Use For
Example
B-tree (default)
Equality, range, sorting
user_id, created_at, elo_rating
GIN
JSONB, arrays, FTS
ai_scores_json, tags, fts
GiST
Geometry, ranges
IP ranges, date ranges
BRIN
Large sequential data
Time-series (transcript events by timestamp)
Partial
Subset of rows
WHERE status = 'active' — only index active rows
Composite
Multi-column queries
(weight_class, elo_rating DESC) for leaderboard
-- Partial index: only index submitted entries (not drafts)
CREATE INDEX idx_entries_submitted ON entries (agent_id, final_score)
WHERE status = 'submitted';
-- Composite index: column ORDER MATTERS (leftmost = most selective)
CREATE INDEX idx_ratings_leaderboard ON agent_ratings (weight_class, elo_rating DESC);
-- Create indexes CONCURRENTLY (doesn't lock table)
CREATE INDEX CONCURRENTLY idx_votes_entry ON votes (entry_id);
Migration Safety
❌ Dangerous Migrations
-- Locks entire table until backfill complete on large tables
ALTER TABLE entries ADD COLUMN calculated_mps numeric NOT NULL;
-- Drops column that might still be referenced
ALTER TABLE agents DROP COLUMN legacy_field;
-- Creates index with full table lock
CREATE INDEX idx_large_table ON large_table (column);
✅ Safe Migrations
-- Step 1: Add nullable column (instant, no lock)
ALTER TABLE entries ADD COLUMN calculated_mps numeric;
-- Step 2: Backfill in batches (no lock)
UPDATE entries SET calculated_mps = 0 WHERE calculated_mps IS NULL AND id < 1000;
UPDATE entries SET calculated_mps = 0 WHERE calculated_mps IS NULL AND id BETWEEN 1000 AND 2000;
-- ... repeat in batches
-- Step 3: Add default and NOT NULL (after backfill complete)
ALTER TABLE entries ALTER COLUMN calculated_mps SET DEFAULT 0;
ALTER TABLE entries ALTER COLUMN calculated_mps SET NOT NULL;
-- Index creation: always CONCURRENTLY
CREATE INDEX CONCURRENTLY idx_entries_mps ON entries (calculated_mps);
Blue-Green Column Migration
Add new column (nullable)
Deploy code that writes to BOTH old and new columns
Backfill new column from old column
Deploy code that reads from new column
Drop old column (after confirming no reads)
Advanced Supabase Patterns
Advisory Locks (prevent race conditions)
-- Lightweight lock for ELO updates — no table locks needed
CREATE OR REPLACE FUNCTION update_agent_elo(p_agent_id uuid, p_new_elo int)
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
-- Acquire advisory lock keyed to this agent
PERFORM pg_advisory_xact_lock(hashtext(p_agent_id::text));
UPDATE agent_ratings SET elo_rating = p_new_elo WHERE agent_id = p_agent_id;
END; $$;
Generated Columns
-- Auto-computed column — always in sync
ALTER TABLE agents ADD COLUMN display_weight_class text GENERATED ALWAYS AS (
CASE
WHEN model_power_score >= 85 THEN 'Frontier'
WHEN model_power_score >= 60 THEN 'Contender'
WHEN model_power_score >= 30 THEN 'Scrapper'
ELSE 'Underdog'
END
) STORED;
Table Partitioning (for scale)
-- Partition transcript events by month
CREATE TABLE transcript_events (
id bigint GENERATED ALWAYS AS IDENTITY,
entry_id uuid NOT NULL,
event_data jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
CREATE TABLE transcript_events_2026_03 PARTITION OF transcript_events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE transcript_events_2026_04 PARTITION OF transcript_events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
2026-03-21: Initial skill — advanced PostgreSQL for Arena
1---2name: advanced-postgres3description: Advanced PostgreSQL patterns for Supabase — CTEs, window functions, JSONB, FTS, EXPLAIN ANALYZE, index strategy, migration safety, partitioning, advisory locks.4---56# Advanced PostgreSQL78## Quick Reference — Review Checks9101. [ ] **EXPLAIN ANALYZE** run on any query touching >10K rows112. [ ] **Indexes** exist for every column in WHERE/ORDER BY/JOIN123. [ ] **Migrations** are reversible and use CONCURRENTLY for index creation134. [ ] **NOT NULL additions** have DEFAULT on large tables145. [ ] **Transactions** wrap multi-step mutations156. [ ] **`(select auth.uid())`** pattern used in all RLS policies (not bare `auth.uid()`)1617---1819## Advanced Query Patterns2021### Window Functions (for leaderboards)22```sql23-- ELO leaderboard with rank, position change, streak24SELECT 25 a.agent_name,26 ar.elo_rating,27 ar.weight_class,28 ROW_NUMBER() OVER (PARTITION BY ar.weight_class ORDER BY ar.elo_rating DESC) as rank,29 ar.elo_rating - LAG(ar.elo_rating) OVER (30 PARTITION BY ar.agent_id ORDER BY ar.updated_at31 ) as elo_change,32 -- Streak: consecutive wins33 COUNT(*) FILTER (WHERE e.placement = 1) OVER (34 PARTITION BY ar.agent_id 35 ORDER BY e.submitted_at 36 ROWS BETWEEN 9 PRECEDING AND CURRENT ROW37 ) as wins_last_1038FROM agent_ratings ar39JOIN agents a ON a.id = ar.agent_id40LEFT JOIN entries e ON e.agent_id = ar.agent_id41WHERE ar.season_id = current_season_id();42```4344### Recursive CTEs (tournament brackets)45```sql46-- Generate bracket matchups from a flat entries table47WITH RECURSIVE bracket AS (48 -- Base: first round (all entries)49 SELECT id, agent_id, 1 as round, ROW_NUMBER() OVER (ORDER BY seed) as position50 FROM tournament_entries WHERE tournament_id = $15152 UNION ALL5354 -- Each round: winners advance55 SELECT m.winner_id, m.winner_agent_id, b.round + 1, 56 CEIL(b.position::numeric / 2) as position57 FROM bracket b58 JOIN matches m ON m.round = b.round AND m.position = CEIL(b.position::numeric / 2)59 WHERE b.round < (SELECT total_rounds FROM tournaments WHERE id = $1)60)61SELECT * FROM bracket ORDER BY round, position;62```6364### LATERAL Joins65```sql66-- Top 3 challenges per weight class (correlated subquery as join)67SELECT wc.name, c.*68FROM (VALUES ('Frontier'), ('Contender'), ('Scrapper')) AS wc(name)69CROSS JOIN LATERAL (70 SELECT id, title, entries_count71 FROM challenges72 WHERE weight_class = wc.name AND status = 'completed'73 ORDER BY entries_count DESC74 LIMIT 375) c;76```7778### JSONB Operations79```sql80-- Store and query judge scores (JSONB)81-- When to use JSONB vs separate table:82-- JSONB: schema varies, queried infrequently, no joins needed83-- Separate table: consistent schema, queried/filtered often, needs indexes8485-- GIN index for JSONB containment queries86CREATE INDEX idx_scores_json ON entries USING GIN (ai_scores_json);8788-- Query: find entries where judge_alpha scored > 889SELECT * FROM entries 90WHERE ai_scores_json @> '{"judge_alpha": {"technical_quality": 9}}';91```9293### Full-Text Search94```sql95-- When to use Postgres FTS vs external (Algolia/Typesense):96-- Postgres FTS: <100K docs, simple queries, no extra infra97-- External: >100K docs, typo tolerance, faceting, complex ranking9899-- Add FTS column + index100ALTER TABLE challenges ADD COLUMN fts tsvector 101 GENERATED ALWAYS AS (102 to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, ''))103 ) STORED;104105CREATE INDEX idx_challenges_fts ON challenges USING GIN (fts);106107-- Search108SELECT title, ts_rank(fts, query) as rank109FROM challenges, to_tsquery('english', 'coding & speed') query110WHERE fts @@ query111ORDER BY rank DESC;112```113114---115116## EXPLAIN ANALYZE Reading Guide117118```119Seq Scan → Full table scan. Fine for <1K rows. Bad for larger tables → add index120Index Scan → Using an index. Good.121Bitmap Heap → Using index for many rows. OK for 1-20% of table.122Index Only Scan → Best case. All data from index, no table access.123Nested Loop → Join method. Fine for small tables. Bad for large × large.124Hash Join → Good for equality joins on larger tables.125Sort → In-memory sort (fine) vs disk sort (add index or increase work_mem)126127Key numbers:128- actual time: first row .. last row (in ms)129- rows: expected vs actual (big mismatch = stale statistics → ANALYZE)130- loops: how many times this node executed (N+1 query indicator)131```132133## Index Strategy134135| Type | Use For | Example |136|------|---------|---------|137| B-tree (default) | Equality, range, sorting | `user_id`, `created_at`, `elo_rating` |138| GIN | JSONB, arrays, FTS | `ai_scores_json`, `tags`, `fts` |139| GiST | Geometry, ranges | IP ranges, date ranges |140| BRIN | Large sequential data | Time-series (transcript events by timestamp) |141| Partial | Subset of rows | `WHERE status = 'active'` — only index active rows |142| Composite | Multi-column queries | `(weight_class, elo_rating DESC)` for leaderboard |143144```sql145-- Partial index: only index submitted entries (not drafts)146CREATE INDEX idx_entries_submitted ON entries (agent_id, final_score)147WHERE status = 'submitted';148149-- Composite index: column ORDER MATTERS (leftmost = most selective)150CREATE INDEX idx_ratings_leaderboard ON agent_ratings (weight_class, elo_rating DESC);151152-- Create indexes CONCURRENTLY (doesn't lock table)153CREATE INDEX CONCURRENTLY idx_votes_entry ON votes (entry_id);154```155156---157158## Migration Safety159160### ❌ Dangerous Migrations161```sql162-- Locks entire table until backfill complete on large tables163ALTER TABLE entries ADD COLUMN calculated_mps numeric NOT NULL;164165-- Drops column that might still be referenced166ALTER TABLE agents DROP COLUMN legacy_field;167168-- Creates index with full table lock169CREATE INDEX idx_large_table ON large_table (column);170```171172### ✅ Safe Migrations173```sql174-- Step 1: Add nullable column (instant, no lock)175ALTER TABLE entries ADD COLUMN calculated_mps numeric;176177-- Step 2: Backfill in batches (no lock)178UPDATE entries SET calculated_mps = 0 WHERE calculated_mps IS NULL AND id < 1000;179UPDATE entries SET calculated_mps = 0 WHERE calculated_mps IS NULL AND id BETWEEN 1000 AND 2000;180-- ... repeat in batches181182-- Step 3: Add default and NOT NULL (after backfill complete)183ALTER TABLE entries ALTER COLUMN calculated_mps SET DEFAULT 0;184ALTER TABLE entries ALTER COLUMN calculated_mps SET NOT NULL;185186-- Index creation: always CONCURRENTLY187CREATE INDEX CONCURRENTLY idx_entries_mps ON entries (calculated_mps);188```189190### Blue-Green Column Migration1911. Add new column (nullable)1922. Deploy code that writes to BOTH old and new columns1933. Backfill new column from old column1944. Deploy code that reads from new column1955. Drop old column (after confirming no reads)196197---198199## Advanced Supabase Patterns200201### Advisory Locks (prevent race conditions)202```sql203-- Lightweight lock for ELO updates — no table locks needed204CREATE OR REPLACE FUNCTION update_agent_elo(p_agent_id uuid, p_new_elo int)205RETURNS void LANGUAGE plpgsql AS $$206BEGIN207 -- Acquire advisory lock keyed to this agent208 PERFORM pg_advisory_xact_lock(hashtext(p_agent_id::text));209210 UPDATE agent_ratings SET elo_rating = p_new_elo WHERE agent_id = p_agent_id;211END; $$;212```213214### Generated Columns215```sql216-- Auto-computed column — always in sync217ALTER TABLE agents ADD COLUMN display_weight_class text GENERATED ALWAYS AS (218 CASE 219 WHEN model_power_score >= 85 THEN 'Frontier'220 WHEN model_power_score >= 60 THEN 'Contender'221 WHEN model_power_score >= 30 THEN 'Scrapper'222 ELSE 'Underdog'223 END224) STORED;225```226227### Table Partitioning (for scale)228```sql229-- Partition transcript events by month230CREATE TABLE transcript_events (231 id bigint GENERATED ALWAYS AS IDENTITY,232 entry_id uuid NOT NULL,233 event_data jsonb NOT NULL,234 created_at timestamptz NOT NULL DEFAULT now()235) PARTITION BY RANGE (created_at);236237CREATE TABLE transcript_events_2026_03 PARTITION OF transcript_events238 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');239CREATE TABLE transcript_events_2026_04 PARTITION OF transcript_events240 FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');241```242243## Sources244- PostgreSQL documentation (CTEs, window functions, indexes, partitioning)245- Supabase RLS performance benchmarks246- postgres.js library patterns247- system-design-primer database section248249## Changelog250- 2026-03-21: Initial skill — advanced PostgreSQL for Arena
Run npx skillmds add nickgallick/advanced-postgres in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Advanced PostgreSQL patterns for Supabase — CTEs, window functions, JSONB, FTS, EXPLAIN ANALYZE, index strategy, migration safety, partitioning, advisory locks. It is listed under Coding & Dev Tools on SkillMD.
This skill has not completed SkillMD's automated safety review yet. Capability flags: docs only. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
nickgallick (@nickgallick) published this skill. Their other Agent Skills are listed on their SkillMD profile.