| name | database-architecture |
| description | 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 |
| allowed-tools | ["Read","Grep","Glob","Bash","mcp__github__*"] |
| model | opus |
PostgreSQL 18 Database Architecture
Overview
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."
When This Skill Applies
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 Features to Leverage
1. Asynchronous I/O (AIO) Subsystem
PostgreSQL 18's AIO subsystem delivers up to 3x I/O performance improvement. Design schemas to benefit:
Checklist:
2. Native UUIDv7 Support
PostgreSQL 18 includes native uuidv7() function. Use it instead of extensions:
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
created_at timestamptz DEFAULT now()
);
Migration pattern for existing tables:
ALTER TABLE legacy_table ADD COLUMN id_v7 uuid DEFAULT uuidv7();
UPDATE legacy_table SET id_v7 = uuidv7() WHERE id_v7 IS NULL;
Checklist:
3. Virtual Generated Columns
PostgreSQL 18 supports virtual (computed-on-read) generated columns:
ALTER TABLE products ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', name || ' ' || description)) STORED;
ALTER TABLE orders ADD COLUMN total_with_tax numeric
GENERATED ALWAYS AS (subtotal * (1 + tax_rate)) VIRTUAL;
Checklist:
4. Temporal Constraints (SQL:2011)
PostgreSQL 18 introduces temporal primary keys and foreign keys:
CREATE TABLE product_prices (
product_id uuid REFERENCES products(id),
price numeric NOT NULL,
valid_from timestamptz NOT NULL,
valid_to timestamptz NOT NULL,
PRIMARY KEY (product_id, valid_from, valid_to WITHOUT OVERLAPS)
);
CREATE TABLE order_items (
id uuid PRIMARY KEY DEFAULT uuidv7(),
order_id uuid REFERENCES orders(id),
product_id uuid,
ordered_at timestamptz NOT NULL,
FOREIGN KEY (product_id, PERIOD(ordered_at, ordered_at))
REFERENCES product_prices (product_id, PERIOD(valid_from, valid_to))
);
Bi-temporal pattern:
CREATE TABLE contracts (
id uuid PRIMARY KEY DEFAULT uuidv7(),
customer_id uuid REFERENCES customers(id),
terms jsonb NOT NULL,
valid_from timestamptz NOT NULL,
valid_to timestamptz NOT NULL DEFAULT 'infinity',
recorded_at timestamptz NOT NULL DEFAULT now(),
superseded_at timestamptz NOT NULL DEFAULT 'infinity',
EXCLUDE USING gist (
customer_id WITH =,
tstzrange(valid_from, valid_to) WITH &&
) WHERE (superseded_at = 'infinity')
);
Checklist:
5. Skip Scan on B-tree Indexes
PostgreSQL 18 can skip-scan B-tree indexes, making composite indexes more versatile:
CREATE INDEX idx_orders_status_date ON orders(status, created_at);
SELECT * FROM orders WHERE status = 'pending';
SELECT * FROM orders WHERE created_at > '2026-01-01';
Index design implications:
CREATE INDEX idx_events_type_user ON events(event_type, user_id);
SELECT * FROM events WHERE event_type = 'login';
SELECT * FROM events WHERE user_id = 'abc-123';
Checklist:
Schema Design Principles
Table Design
CREATE TABLE entity_name (
id uuid PRIMARY KEY DEFAULT uuidv7(),
parent_id uuid REFERENCES parent_table(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
metadata jsonb NOT NULL DEFAULT '{}',
CONSTRAINT entity_name_status_check CHECK (status IN ('pending', 'active', 'completed'))
);
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;
Naming Conventions
| 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() |
Partitioning Strategy
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);
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
Migration Best Practices
Migration Template
BEGIN;
DO $$
BEGIN
IF current_setting('server_version_num')::int < 180000 THEN
RAISE EXCEPTION 'Requires PostgreSQL 18 or higher';
END IF;
END $$;
CREATE TABLE ...;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'new_table') THEN
RAISE EXCEPTION 'Migration verification failed';
END IF;
END $$;
COMMIT;
Safe Schema Changes
ALTER TABLE orders ADD COLUMN notes text;
ALTER TABLE orders ADD COLUMN priority int NOT NULL DEFAULT 0;
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;
CREATE INDEX CONCURRENTLY idx_orders_notes ON orders(notes);
Zero-Downtime Migration Pattern
ALTER TABLE orders ADD COLUMN new_status text;
UPDATE orders SET new_status = status WHERE new_status IS NULL LIMIT 10000;
ALTER TABLE orders ALTER COLUMN new_status SET NOT NULL;
CREATE INDEX CONCURRENTLY idx_orders_new_status ON orders(new_status);
Indexing Strategy
Index Types and Usage
| 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) |
JSONB Indexing
CREATE INDEX idx_metadata_type ON documents((metadata->>'type'));
CREATE INDEX idx_metadata_gin ON documents USING gin(metadata);
CREATE INDEX idx_metadata_path ON documents USING gin(metadata jsonb_path_ops);
Partial Indexes
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending' AND deleted_at IS NULL;
CREATE INDEX idx_users_email_verified ON users(email)
WHERE email_verified_at IS NOT NULL;
CREATE UNIQUE INDEX idx_users_email_unique ON users(email)
WHERE deleted_at IS NULL;
Expression Indexes
CREATE INDEX idx_users_email_lower ON users(lower(email));
CREATE INDEX idx_orders_customer_email ON orders((data->>'customer_email'));
CREATE INDEX idx_events_date ON events(date(created_at));
Query Optimization
Explain Analyze
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE status = 'pending';
Statistics Targets
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders';
Common Query Patterns
SELECT * FROM orders ORDER BY created_at LIMIT 20 OFFSET 10000;
SELECT * FROM orders
WHERE created_at < $last_created_at
ORDER BY created_at DESC
LIMIT 20;
SELECT count(*) FROM large_table WHERE status = 'active';
SELECT reltuples::bigint AS estimate
FROM pg_class WHERE relname = 'large_table';
SELECT count(*) > 0 FROM orders WHERE user_id = $1;
SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = $1);
Database Architecture Artifact
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 -->
Checklist
Before completing database architecture work:
Integration
This skill integrates with:
postgres-rls - RLS is layered on top of schema design
postgis - Spatial data types and indexes
timescaledb - Time-series extensions and hypertables
local-service-testing - Test migrations against real PostgreSQL
References