PostgreSQL best practices — schema design, query performance, indexing, connection management, RLS, concurrency, monitoring, and migrations. Load when writing SQL, designing schemas, or optimizing PostgreSQL queries.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add irahardianto/antigravity-setup --skill postgres-idioms --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Postgres Idioms?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/irahardianto-postgres-idioms)More formats (shields.io, HTML) on the badges page.
---
name: postgres-idioms
description: >-
PostgreSQL best practices — schema design, query performance, indexing,
connection management, RLS, concurrency, monitoring, and migrations.
Load when writing SQL, designing schemas, or optimizing PostgreSQL queries.
---
# PostgreSQL Idioms and Best Practices
PostgreSQL rewards set-based thinking, explicit joins, and query plan awareness.
Idiomatic PostgreSQL = readable, performant, migration-safe, secure.
> Scope: PostgreSQL-specific patterns. For database design principles
> (normalization, naming, migration strategy), see `@.agents/rules/database-design-principles.md`.
> For deep-dive references on individual topics, see `references/` in this skill directory.
## Priority Guide
| Priority | Category | Impact |
|---|---|---|
| 1 | Query Performance & Indexing | CRITICAL |
| 2 | Connection Management | CRITICAL |
| 3 | Security & RLS | CRITICAL |
| 4 | Schema Design | HIGH |
| 5 | Concurrency & Locking | MEDIUM-HIGH |
| 6 | Data Access Patterns | MEDIUM |
| 7 | Monitoring & Diagnostics | LOW-MEDIUM |
| 8 | Advanced Features | LOW |
---
## 1. Query Performance & Indexing (CRITICAL)
### Always EXPLAIN Before Optimizing
```sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...;
```
**Red flags in query plans:**
- `Seq Scan` on large table → missing index
- `Rows Removed by Filter` → poor selectivity or wrong index
- `read >> hit` in Buffers → data not cached, cold query
- `Sort Method: external merge` → `work_mem` too low
- `Nested Loop` with high row count → consider `Hash Join`
### Index Strategy
**Choose the right index type:**
| Type | Use When | Operators |
|---|---|---|
| B-tree (default) | General sorted data | `=`, `<`, `>`, `BETWEEN`, `IN`, `IS NULL` |
| GIN | JSONB, arrays, full-text search | `@>`, `?`, `?&`, `@@` |
| GiST | Geometric, range, nearest-neighbor | `&&`, `@>`, `<->` (KNN) |
| BRIN | Large time-series, naturally ordered | Range queries on ordered columns |
| Hash | Equality-only (marginal B-tree improvement) | `=` |
**Composite indexes — column order matters (leftmost prefix rule):**
```sql
-- ✅ Equality columns first, range columns last
CREATE INDEX idx_orders_status_date ON orders (status, created_at);
-- Works: WHERE status = 'pending'
-- Works: WHERE status = 'pending' AND created_at > '2024-01-01'
-- FAILS: WHERE created_at > '2024-01-01' (alone — no leftmost match)
```
**Partial indexes for filtered queries (5-20x smaller):**
```sql
CREATE INDEX idx_users_active_email ON users (email)
WHERE deleted_at IS NULL;
```
**Covering indexes to avoid heap fetches:**
```sql
-- INCLUDE non-searchable columns for index-only scans
CREATE INDEX idx_orders_status ON orders (status)
INCLUDE (customer_id, total);
```
**Always index foreign keys.** PostgreSQL does NOT auto-index FK columns:
```sql
-- ❌ Missing index on FK — causes Seq Scan on JOIN and CASCADE
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT REFERENCES customers(id) ON DELETE CASCADE
);
-- ✅ Always create FK index
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
```
**Detect missing indexes** with `pg_stat_user_tables`:
```sql
SELECT schemaname, relname, seq_scan, idx_scan,
seq_tup_read, n_live_tup
FROM pg_stat_user_tables
WHERE seq_scan > 100
AND n_live_tup > 10000
ORDER BY seq_tup_read DESC;
```
**Index creation on production — always `CONCURRENTLY`:**
```sql
CREATE INDEX CONCURRENTLY idx_orders_date ON orders (created_at);
-- Does NOT block writes (but takes longer, requires retry on failure)
```
---
## 2. Connection Management (CRITICAL)
Each PostgreSQL connection costs ~1-3 MB RAM. Unbounded connections crash the database.
### Connection Pooling
Use a connection pooler (PgBouncer, pgcat, Supavisor) between app and database.
| Pool Mode | Behavior | Prepared Statements? | Use When |
|---|---|---|---|
| **Transaction** | Connection returned after each transaction | ❌ No | Default — most applications |
| **Session** | Connection held for entire client session | ✅ Yes | Prepared statements, temp tables, `SET` commands |
**Critical**: Disable prepared statements in your ORM/driver when using transaction mode pooling.
### Idle Connection Cleanup
```sql
-- Kill idle-in-transaction connections after 30s (holds locks, blocks VACUUM)
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
-- Kill fully idle connections after 10 minutes
ALTER SYSTEM SET idle_session_timeout = '10min';
SELECT pg_reload_conf();
```
### Statement Timeout
```sql
-- Prevent runaway queries (set per session or globally)
SET statement_timeout = '30s';
-- Or per transaction
SET LOCAL statement_timeout = '5s';
```
### Monitor Active Connections
```sql
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
```
---
## 3. Security & RLS (CRITICAL)
### Principle of Least Privilege
```sql
-- ❌ Overly broad — any SQL injection becomes catastrophic
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_user;
-- ✅ Minimal, specific grants
CREATE ROLE app_readonly;
GRANT USAGE ON SCHEMA public TO app_readonly;
GRANT SELECT ON users, orders, products TO app_readonly;
CREATE ROLE app_writer;
GRANT USAGE ON SCHEMA public TO app_writer;
GRANT SELECT, INSERT, UPDATE ON orders TO app_writer;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_writer;
-- No DELETE, no DDL, no other tables
```
### Row Level Security (RLS) for Multi-Tenant Data
RLS enforces data isolation at the database level — defense in depth beyond application filtering:
```sql
-- ❌ Application-level filtering only (bug or bypass = data leak)
SELECT * FROM orders WHERE user_id = $current_user_id;
-- ✅ Database-enforced isolation
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- Applies to table owner too
CREATE POLICY orders_user_isolation ON orders
USING (user_id = current_setting('app.current_user_id')::BIGINT);
```
**Set the session variable in your application before queries:**
```sql
SET LOCAL app.current_user_id = '42';
SELECT * FROM orders; -- Returns only user 42's orders, regardless of query
```
### RLS Performance Optimization
```sql
-- ❌ Function called per row (1M rows = 1M function calls)
CREATE POLICY orders_policy ON orders
USING (get_current_user_id() = user_id);
-- ✅ Wrap in subquery — called once, result cached
CREATE POLICY orders_policy ON orders
USING (user_id = (SELECT current_setting('app.current_user_id')::BIGINT));
```
**Use `SECURITY DEFINER` functions for complex permission checks:**
```sql
CREATE OR REPLACE FUNCTION is_team_member(team_id BIGINT)
RETURNS BOOLEAN
LANGUAGE sql SECURITY DEFINER STABLE
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1 FROM team_members
WHERE team_members.team_id = is_team_member.team_id
AND team_members.user_id = current_setting('app.current_user_id')::BIGINT
);
$$;
```
> `SECURITY DEFINER` functions bypass RLS on tables they touch.
> Always validate the caller identity inside the function.
> Always set `search_path` explicitly to prevent search path injection.
---
## 4. Schema Design (HIGH)
### Data Types — Choose Correctly
| ❌ Avoid | ✅ Use Instead | Why |
|---|---|---|
| `timestamp` | `timestamptz` | Timezone-aware, stores UTC internally |
| `float` / `real` | `numeric` | Exact decimal arithmetic (financial data) |
| `varchar(n)` | `text` | No performance penalty in PG, avoids arbitrary limits |
| `serial` | `bigint generated always as identity` | SQL-standard, supports `ALWAYS`/`BY DEFAULT` |
| `uuid` (v4 random) | `uuid` (v7 time-ordered) | Avoids index fragmentation on large tables |
### Primary Key Strategy
```sql
-- ✅ Single database: IDENTITY (sequential, 8 bytes, SQL-standard)
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);
-- ✅ Distributed systems: UUIDv7 (time-ordered, no fragmentation)
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;
CREATE TABLE orders (
id UUID DEFAULT uuid_generate_v7() PRIMARY KEY
);
```
**Avoid random UUID v4 as PK on large tables** — scattered inserts cause index fragmentation and poor cache locality.
### Naming Conventions
- **Lowercase snake_case only** — PostgreSQL folds unquoted identifiers to lowercase
- Never use double-quoted identifiers unless unavoidable
- Tables: plural (`users`, `orders`), or singular if team convention is consistent
- Indexes: `idx_{table}_{columns}` (e.g., `idx_orders_customer_id`)
- Constraints: `{table}_{columns}_{type}` (e.g., `orders_customer_id_fk`, `users_email_unique`)
### Safe Constraint Migrations
PostgreSQL does NOT support `ADD CONSTRAINT IF NOT EXISTS`:
```sql
-- ❌ Syntax error
ALTER TABLE profiles ADD CONSTRAINT IF NOT EXISTS profiles_email_unique UNIQUE (email);
-- ✅ Idempotent constraint creation
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'profiles_email_unique'
AND conrelid = 'public.profiles'::regclass
) THEN
ALTER TABLE public.profiles ADD CONSTRAINT profiles_email_unique UNIQUE (email);
END IF;
END $$;
```
### Table Partitioning (100M+ rows)
```sql
-- ✅ Declarative range partitioning for time-series data
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY,
created_at TIMESTAMPTZ NOT NULL,
data JSONB
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2024_q1 PARTITION OF events
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE events_2024_q2 PARTITION OF events
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- Benefits: partition pruning (5-20x faster queries), per-partition VACUUM,
-- easy archival (DROP old partitions vs DELETE + VACUUM)
```
**When to partition:**
- Tables exceeding 100M rows
- Time-series data with range queries
- Multi-tenant data with tenant-scoped queries
- High-churn tables where VACUUM is problematic
---
## 5. Concurrency & Locking (MEDIUM-HIGH)
### Prevent Deadlocks — Consistent Lock Ordering
```sql
-- ✅ Acquire locks in PK order before updating
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
```
### Keep Transactions Short
```sql
-- ❌ Long transaction holds locks, blocks VACUUM
BEGIN;
SELECT * FROM orders FOR UPDATE;
-- ... HTTP call to external service (5s) ...
UPDATE orders SET status = 'paid';
COMMIT;
-- ✅ Fetch data, process externally, then short write transaction
-- Step 1: read (no lock)
SELECT * FROM orders WHERE id = $1;
-- Step 2: external processing
-- Step 3: short write transaction
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = $1 AND status = 'pending';
COMMIT;
```
### SKIP LOCKED for Queue Processing
```sql
-- ✅ Workers skip locked rows — 10x throughput vs blocking
UPDATE jobs SET status = 'processing', locked_by = $worker_id
WHERE id = (
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED
) RETURNING *;
```
### Advisory Locks for Application-Level Coordination
```sql
-- Transaction-scoped: released on COMMIT/ROLLBACK
SELECT pg_advisory_xact_lock(hashtext('daily_report'));
-- Try without blocking (returns false if already locked)
SELECT pg_try_advisory_lock(hashtext('cron_job_xyz'));
```
---
## 6. Data Access Patterns (MEDIUM)
### CTEs Over Subqueries for Readability
```sql
-- ✅ CTE — readable, debuggable, materializable
WITH active_tasks AS (
SELECT id, title, priority, user_id
FROM tasks WHERE status = 'active'
)
SELECT u.name, COUNT(at.id) AS task_count
FROM users u
JOIN active_tasks at ON u.id = at.user_id
GROUP BY u.name;
```
### Keyset Pagination Over OFFSET
```sql
-- ❌ OFFSET — O(n) scans all preceding rows, degrades at depth
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 10000;
-- ✅ Keyset — O(1) consistent performance regardless of page depth
SELECT * FROM products
WHERE (created_at, id) > ($last_created_at, $last_id)
ORDER BY created_at, id LIMIT 20;
```
### UPSERT — Atomic Insert-or-Update
```sql
-- ✅ No race conditions, no check-then-insert anti-pattern
INSERT INTO settings (user_id, key, value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now();
```
### N+1 Query Prevention
```sql
-- ❌ N+1: 1 query for list + N queries for details
SELECT id FROM orders WHERE user_id = $1;
-- then for each: SELECT * FROM order_items WHERE order_id = $id;
-- ✅ Single JOIN or batch IN
SELECT o.*, oi.*
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = $1;
```
### Batch Inserts and Bulk Loading
```sql
-- ❌ One INSERT per row (N round-trips, N transaction logs)
INSERT INTO events (type, data) VALUES ('click', '{}');
INSERT INTO events (type, data) VALUES ('view', '{}');
-- ✅ Multi-row INSERT (1 round-trip)
INSERT INTO events (type, data) VALUES
('click', '{}'),
('view', '{}'),
('scroll', '{}');
-- ✅ COPY for massive imports (fastest)
COPY events (type, data) FROM STDIN WITH (FORMAT csv);
```
### Explicit JOIN Syntax
**Never** use implicit joins in `WHERE`. Always use explicit `JOIN ... ON`.
### Parameterized Queries
**Never** concatenate strings into SQL. Always use `$1`, `$2` parameterized queries.
See `@.agents/rules/security-principles.md`.
---
## 7. Monitoring & Diagnostics (LOW-MEDIUM)
### pg_stat_statements — Top Resource Consumers
```sql
-- Enable: add to shared_preload_libraries, restart
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Top 10 queries by total execution time
SELECT query, calls, total_exec_time, mean_exec_time,
rows, shared_blks_hit, shared_blks_read
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
```
### VACUUM & ANALYZE
```sql
-- Run ANALYZE after large data changes (updates planner statistics)
ANALYZE orders;
-- Check autovacuum health
SELECT schemaname, relname, n_dead_tup, last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
```
**Tune autovacuum for high-churn tables:**
```sql
ALTER TABLE high_churn_table SET (
autovacuum_vacuum_scale_factor = 0.01, -- Default 0.20 (20%)
autovacuum_analyze_scale_factor = 0.005 -- Default 0.10 (10%)
);
```
> Standard `VACUUM` runs concurrently (no exclusive lock).
> Only `VACUUM FULL` requires exclusive access (rewrites table).
---
## 8. Advanced Features (LOW)
### Full-Text Search
```sql
-- ✅ Use tsvector + GIN index (100x faster than LIKE '%term%')
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);
SELECT * FROM articles WHERE search_vector @@ to_tsquery('english', 'postgres & performance');
```
For leading wildcard search (`LIKE '%term'`), use `pg_trgm` extension with GIN/GiST index.
### JSONB Indexing
```sql
-- GIN with jsonb_path_ops: 2-3x smaller, supports @> only
CREATE INDEX idx_events_data ON events USING GIN (data jsonb_path_ops);
-- Expression index for specific key lookups
CREATE INDEX idx_events_type ON events ((data->>'type'));
```
---
## Anti-Patterns Checklist
- ❌ Missing indexes on foreign keys
- ❌ N+1 queries (use `JOIN` or batch `IN`)
- ❌ String concatenation in queries (SQL injection risk)
- ❌ Storing comma-separated values in a single column (use arrays or junction table)
- ❌ `OFFSET` pagination on large datasets (use keyset)
- ❌ `timestamp` without timezone (use `timestamptz`)
- ❌ `varchar(n)` without reason (use `text`)
- ❌ Random UUID v4 as PK on large tables (use `bigint identity` or UUIDv7)
- ❌ Check-then-insert pattern (race condition — use `UPSERT`)
- ❌ `serial` for new tables (use `bigint generated always as identity`)
- ❌ `SELECT *` in production queries (list specific columns)
- ❌ `GRANT ALL` to application roles (principle of least privilege)
- ❌ Application-only data filtering without RLS (defense in depth)
- ❌ Long transactions with external I/O inside (hold locks, block VACUUM)
- ❌ Prepared statements with transaction-mode pooling (connection mismatch)
- ❌ Double-quoted identifiers (force case sensitivity, error-prone)
## Related
- Database Design Principles: `@.agents/rules/database-design-principles.md`
- Security Principles: `@.agents/rules/security-principles.md`
- Performance Optimization Principles: `@.agents/rules/performance-optimization-principles.md`
- Deep-dive references: `references/` directory in this skill
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!