MANDATORY when designing schemas, writing migrations, creating indexes, or making architectural database decisions - enforces PostgreSQL 18 best practices including AIO, UUIDv7, temporal constraints, and modern indexing strategies
Applies PostgreSQL 18 best practices when designing schemas, writing migrations, or creating indexes. Enforces modern features like native UUIDv7, virtual generated columns, temporal constraints, and skip-scan indexes for optimal performance.
/plugin marketplace add troykelly/claude-skills/plugin install issue-driven-development@troykelly-skillsThis skill is limited to using the following tools:
PostgreSQL 18 introduces transformational changes: the AIO subsystem delivers 3x I/O performance, native UUIDv7 replaces UUID libraries, and temporal constraints enable bi-temporal data modeling. This skill ensures you leverage these capabilities correctly.
Core principle: Design for PostgreSQL 18's strengths. Don't port patterns from older versions or other databases.
Announce at start: "I'm applying database-architecture to ensure PostgreSQL 18 best practices."
This skill is MANDATORY when ANY of these patterns are touched:
| Pattern | Examples |
|---|---|
**/migrations/** | migrations/001_create_tables.sql |
**/*schema*.sql | db/schema.sql |
**/db/**/*.sql | db/functions/calculate.sql |
**/*index*.sql | db/indexes.sql |
**/models/** | src/models/user.ts |
**/*entity*.ts | src/entities/order.entity.ts |
**/*model*.py | app/models/product.py |
PostgreSQL 18's AIO subsystem delivers up to 3x I/O performance improvement. Design schemas to benefit:
-- Enable read_stream for sequential scans
-- PG18 automatically uses AIO for:
-- - Sequential scans
-- - COPY operations
-- - Vacuum operations
-- - Index builds
-- Design for larger, sequential access patterns
-- AIO benefits sequential operations more than random access
Checklist:
PostgreSQL 18 includes native uuidv7() function. Use it instead of extensions:
-- DEPRECATED: Don't use extensions for UUIDs
-- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- SELECT uuid_generate_v4();
-- DEPRECATED: Don't use gen_random_uuid() for new tables
-- SELECT gen_random_uuid();
-- CORRECT: Use native UUIDv7 for new primary keys
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
created_at timestamptz DEFAULT now()
);
-- UUIDv7 benefits:
-- 1. Time-ordered: natural chronological sorting
-- 2. Index-friendly: sequential inserts, no page splits
-- 3. Distributed-safe: no coordination needed
-- 4. Sortable: first 48 bits are millisecond timestamp
Migration pattern for existing tables:
-- Add new UUIDv7 column alongside existing
ALTER TABLE legacy_table ADD COLUMN id_v7 uuid DEFAULT uuidv7();
-- Backfill with time-ordered UUIDs (preserves order)
UPDATE legacy_table SET id_v7 = uuidv7() WHERE id_v7 IS NULL;
-- For historical data, generate UUIDs that preserve timestamp order
-- Use application code to generate UUIDv7 from original created_at
Checklist:
uuidv7() for primary keysuuid-ossp extensionuuid_generate_v4() columnsPostgreSQL 18 supports virtual (computed-on-read) generated columns:
-- STORED: Computed on write, stored on disk (PG12+)
ALTER TABLE products ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', name || ' ' || description)) STORED;
-- VIRTUAL: Computed on read, no storage (PG18+)
ALTER TABLE orders ADD COLUMN total_with_tax numeric
GENERATED ALWAYS AS (subtotal * (1 + tax_rate)) VIRTUAL;
-- When to use VIRTUAL:
-- - Simple calculations
-- - Values that would bloat storage
-- - Infrequently accessed computed values
-- - Values dependent on runtime context
-- When to use STORED:
-- - Expensive computations
-- - Indexed columns (virtual columns cannot be indexed directly)
-- - Frequently accessed values
Checklist:
PostgreSQL 18 introduces temporal primary keys and foreign keys:
-- Temporal table with validity period
CREATE TABLE product_prices (
product_id uuid REFERENCES products(id),
price numeric NOT NULL,
valid_from timestamptz NOT NULL,
valid_to timestamptz NOT NULL,
-- Temporal primary key: unique product per time period
PRIMARY KEY (product_id, valid_from, valid_to WITHOUT OVERLAPS)
);
-- Temporal foreign key: reference must be valid at point in time
CREATE TABLE order_items (
id uuid PRIMARY KEY DEFAULT uuidv7(),
order_id uuid REFERENCES orders(id),
product_id uuid,
ordered_at timestamptz NOT NULL,
-- Ensures product_id references a valid price at ordered_at time
FOREIGN KEY (product_id, PERIOD(ordered_at, ordered_at))
REFERENCES product_prices (product_id, PERIOD(valid_from, valid_to))
);
Bi-temporal pattern:
-- Track both validity time AND transaction time
CREATE TABLE contracts (
id uuid PRIMARY KEY DEFAULT uuidv7(),
customer_id uuid REFERENCES customers(id),
terms jsonb NOT NULL,
-- Validity time: when the contract is effective
valid_from timestamptz NOT NULL,
valid_to timestamptz NOT NULL DEFAULT 'infinity',
-- Transaction time: when we recorded this version
recorded_at timestamptz NOT NULL DEFAULT now(),
superseded_at timestamptz NOT NULL DEFAULT 'infinity',
-- Ensure no overlapping validity periods per customer
EXCLUDE USING gist (
customer_id WITH =,
tstzrange(valid_from, valid_to) WITH &&
) WHERE (superseded_at = 'infinity')
);
Checklist:
PostgreSQL 18 can skip-scan B-tree indexes, making composite indexes more versatile:
-- This index now supports queries on BOTH columns
CREATE INDEX idx_orders_status_date ON orders(status, created_at);
-- PG17 and earlier: Only efficient for status queries
SELECT * FROM orders WHERE status = 'pending';
-- PG18: Also efficient for date-only queries (skip scan)
SELECT * FROM orders WHERE created_at > '2026-01-01';
-- Skip scan jumps between status values, checking dates in each
Index design implications:
-- Multi-column indexes are now more valuable
-- Put high-cardinality column second for skip scan benefit
CREATE INDEX idx_events_type_user ON events(event_type, user_id);
-- Both of these are now efficient:
SELECT * FROM events WHERE event_type = 'login';
SELECT * FROM events WHERE user_id = 'abc-123';
Checklist:
-- Standard table template for PG18
CREATE TABLE entity_name (
-- Primary key: Always UUIDv7
id uuid PRIMARY KEY DEFAULT uuidv7(),
-- Foreign keys: Reference with ON DELETE behavior
parent_id uuid REFERENCES parent_table(id) ON DELETE CASCADE,
-- Required fields: NOT NULL with sensible defaults
status text NOT NULL DEFAULT 'pending',
-- Timestamps: Always timestamptz, never timestamp
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Soft delete: Use validity period, not boolean
deleted_at timestamptz, -- NULL = not deleted
-- JSON data: Use jsonb, never json
metadata jsonb NOT NULL DEFAULT '{}',
-- Constraints: Named for clarity
CONSTRAINT entity_name_status_check CHECK (status IN ('pending', 'active', 'completed'))
);
-- Standard indexes
CREATE INDEX idx_entity_name_parent_id ON entity_name(parent_id);
CREATE INDEX idx_entity_name_created_at ON entity_name(created_at);
CREATE INDEX idx_entity_name_status ON entity_name(status) WHERE deleted_at IS NULL;
| Element | Convention | Example |
|---|---|---|
| Tables | snake_case, plural | order_items |
| Columns | snake_case | created_at |
| Primary keys | id | id uuid |
| Foreign keys | {table_singular}_id | order_id |
| Indexes | idx_{table}_{columns} | idx_orders_status |
| Constraints | {table}_{purpose}_check | orders_amount_check |
| Functions | snake_case, verb first | calculate_total() |
-- Time-based partitioning for large tables
CREATE TABLE events (
id uuid DEFAULT uuidv7(),
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
-- Monthly partitions
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- Automated partition creation (use pg_partman or similar)
-- Or create future partitions in migration
-- UUIDv7 benefit: Partition pruning works because UUIDs are time-ordered
-- Queries on id range can prune partitions
-- migrations/YYYYMMDDHHMMSS_description.sql
-- Wrap in transaction
BEGIN;
-- Version check
DO $$
BEGIN
IF current_setting('server_version_num')::int < 180000 THEN
RAISE EXCEPTION 'Requires PostgreSQL 18 or higher';
END IF;
END $$;
-- Migration logic here
CREATE TABLE ...;
-- Verify migration
DO $$
BEGIN
-- Add assertions about expected state
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'new_table') THEN
RAISE EXCEPTION 'Migration verification failed';
END IF;
END $$;
COMMIT;
-- SAFE: Adding nullable column
ALTER TABLE orders ADD COLUMN notes text;
-- SAFE: Adding column with default (PG11+ doesn't rewrite table)
ALTER TABLE orders ADD COLUMN priority int NOT NULL DEFAULT 0;
-- DANGEROUS: Adding NOT NULL to existing column (locks table)
-- Instead, do in steps:
ALTER TABLE orders ADD COLUMN notes_new text;
UPDATE orders SET notes_new = COALESCE(notes, 'none') WHERE notes_new IS NULL;
ALTER TABLE orders ALTER COLUMN notes_new SET NOT NULL;
ALTER TABLE orders DROP COLUMN notes;
ALTER TABLE orders RENAME COLUMN notes_new TO notes;
-- DANGEROUS: Changing column type (rewrites table)
-- Consider: Add new column, migrate data, drop old column
-- SAFE: Creating index concurrently
CREATE INDEX CONCURRENTLY idx_orders_notes ON orders(notes);
-- Note: Cannot be in transaction, requires separate migration step
-- Step 1: Add new column (no lock)
ALTER TABLE orders ADD COLUMN new_status text;
-- Step 2: Backfill in batches (application or background job)
UPDATE orders SET new_status = status WHERE new_status IS NULL LIMIT 10000;
-- Repeat until complete
-- Step 3: Add constraints once backfilled
ALTER TABLE orders ALTER COLUMN new_status SET NOT NULL;
-- Step 4: Add new index concurrently
CREATE INDEX CONCURRENTLY idx_orders_new_status ON orders(new_status);
-- Step 5: Update application to use new column
-- Step 6: Drop old column in future migration
| Index Type | Use Case | Example |
|---|---|---|
| B-tree | Equality, range, sorting | CREATE INDEX ... ON orders(created_at) |
| Hash | Equality only (rarely better) | CREATE INDEX ... USING hash ON lookups(key) |
| GiST | Ranges, geometric, full-text | CREATE INDEX ... USING gist ON events(tstzrange(...)) |
| GIN | Arrays, JSONB, full-text | CREATE INDEX ... USING gin ON docs(metadata) |
| BRIN | Very large, naturally ordered | CREATE INDEX ... USING brin ON logs(created_at) |
-- Index specific paths for frequent queries
CREATE INDEX idx_metadata_type ON documents((metadata->>'type'));
-- GIN for flexible key/value queries
CREATE INDEX idx_metadata_gin ON documents USING gin(metadata);
-- GIN with specific operator class for containment queries
CREATE INDEX idx_metadata_path ON documents USING gin(metadata jsonb_path_ops);
-- Supports: metadata @> '{"type": "invoice"}'
-- Smaller index, faster for containment queries
-- Index only active records
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending' AND deleted_at IS NULL;
-- Index only non-null values
CREATE INDEX idx_users_email_verified ON users(email)
WHERE email_verified_at IS NOT NULL;
-- Unique partial index for soft deletes
CREATE UNIQUE INDEX idx_users_email_unique ON users(email)
WHERE deleted_at IS NULL;
-- Index on function result
CREATE INDEX idx_users_email_lower ON users(lower(email));
-- Index on JSONB expression
CREATE INDEX idx_orders_customer_email ON orders((data->>'customer_email'));
-- Index on date part
CREATE INDEX idx_events_date ON events(date(created_at));
-- Always use ANALYZE for accurate timing
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE status = 'pending';
-- Look for:
-- - Seq Scan on large tables (needs index?)
-- - High actual rows vs estimated rows (stale statistics?)
-- - Buffers: shared hit vs read ratio (memory pressure?)
-- - Loops with high count (N+1 query?)
-- Increase statistics for columns with skewed distributions
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
-- Force statistics update
ANALYZE orders;
-- Check current statistics
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders';
-- Pagination: Use keyset, not offset
-- BAD:
SELECT * FROM orders ORDER BY created_at LIMIT 20 OFFSET 10000;
-- GOOD:
SELECT * FROM orders
WHERE created_at < $last_created_at
ORDER BY created_at DESC
LIMIT 20;
-- Counting: Use estimates for UI when exact not needed
-- BAD:
SELECT count(*) FROM large_table WHERE status = 'active';
-- GOOD (for UI display):
SELECT reltuples::bigint AS estimate
FROM pg_class WHERE relname = 'large_table';
-- Existence check: Use EXISTS, not COUNT
-- BAD:
SELECT count(*) > 0 FROM orders WHERE user_id = $1;
-- GOOD:
SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = $1);
When designing or modifying schema, post this artifact:
<!-- DATABASE_ARCHITECTURE:START -->
## Database Architecture Summary
### Tables Modified/Created
| Table | Change | Rationale |
|-------|--------|-----------|
| orders | Created | New e-commerce functionality |
| order_items | Created | Line items for orders |
### PostgreSQL 18 Features Used
- [ ] UUIDv7 primary keys
- [ ] Virtual generated columns
- [ ] Temporal constraints
- [ ] Skip-scan indexes
### Indexes Added
| Table | Index | Type | Purpose |
|-------|-------|------|---------|
| orders | idx_orders_status_date | B-tree | Skip-scan for status and date queries |
| orders | idx_orders_metadata | GIN | JSONB containment queries |
### Migration Safety
- [ ] All migrations are idempotent
- [ ] No table rewrites on production data
- [ ] Indexes created CONCURRENTLY
- [ ] Backward compatible with current application
### Performance Considerations
- [ ] Query patterns documented
- [ ] Index usage verified with EXPLAIN ANALYZE
- [ ] Partition strategy appropriate for data volume
- [ ] Statistics targets adjusted for skewed columns
**Verified At:** [timestamp]
<!-- DATABASE_ARCHITECTURE:END -->
Before completing database architecture work:
This skill integrates with:
postgres-rls - RLS is layered on top of schema designpostgis - Spatial data types and indexestimescaledb - Time-series extensions and hypertableslocal-service-testing - Test migrations against real PostgreSQLThis skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.