| name | sql-patterns |
| description | SQL query patterns: parameterized queries, keyset pagination, UPSERT, window functions, CTEs, aggregation with FILTER, soft delete, audit trails, row-level security, and migration best practices. Use when writing or reviewing SQL queries and schema changes. |
SQL Patterns Skill
When to Activate
- Writing SQL queries for a new feature
- Reviewing SQL for performance or security issues
- Designing database schema for a new entity
- Writing database migrations
- Optimizing slow queries
- Setting up row-level security or audit logging
- Replacing OFFSET pagination with keyset cursors on a large or growing table
- Adding UPSERT logic to eliminate check-then-insert race conditions in concurrent writes
Core Query Patterns
SELECT — always explicit columns
SELECT * FROM users WHERE id = $1;
SELECT id, name, email, created_at FROM users WHERE id = $1;
Parameterized queries — non-negotiable
SELECT id, name FROM users WHERE email = $1 AND is_active = $2;
SELECT id, name FROM users WHERE email = ? AND is_active = ?;
Explicit JOIN types
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
SELECT u.name, COALESCE(SUM(o.total), 0) AS lifetime_value
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;
Pagination
Keyset (cursor) pagination — for large tables
SELECT id, name, created_at
FROM users
WHERE is_active = TRUE
ORDER BY created_at DESC, id DESC
LIMIT 20;
SELECT id, name, created_at
FROM users
WHERE is_active = TRUE
AND (created_at, id) < ($1, $2)
ORDER BY created_at DESC, ID DESC
LIMIT 20;
Why not OFFSET? OFFSET N reads and discards N rows — O(N). Keyset is O(log N) with a composite index.
CREATE INDEX idx_users_cursor ON users(created_at DESC, id DESC)
WHERE is_active = TRUE;
OFFSET pagination — only for small datasets
SELECT * FROM categories ORDER BY name LIMIT 50 OFFSET $1;
Aggregation Patterns
Conditional counts with FILTER
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE status = 'completed') AS completed,
COUNT(*) FILTER (WHERE status = 'refunded') AS refunded,
SUM(total) FILTER (WHERE status = 'completed') AS revenue
FROM orders
WHERE created_at >= NOW() - INTERVAL '12 months'
GROUP BY 1
ORDER BY 1;
Window functions
SELECT
id,
amount,
SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at) AS running_total
FROM transactions;
SELECT
user_id,
product_id,
purchase_count,
RANK() OVER (PARTITION BY user_id ORDER BY purchase_count DESC) AS rank
FROM user_product_stats;
SELECT
date,
revenue,
LAG(revenue, 1) OVER (ORDER BY date) AS prev_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY date) AS change
FROM daily_revenue;
CTEs (Common Table Expressions)
WITH active_users AS (
SELECT id, name, email
FROM users
WHERE is_active = TRUE
AND created_at >= NOW() - INTERVAL '90 days'
),
user_order_counts AS (
SELECT user_id, COUNT(*) AS order_count
FROM orders
WHERE created_at >= NOW() - INTERVAL '90 days'
GROUP BY user_id
)
SELECT
u.name,
u.email,
COALESCE(o.order_count, 0) AS recent_orders
FROM active_users u
LEFT JOIN user_order_counts o ON u.id = o.user_id
ORDER BY recent_orders DESC;
UPSERT
INSERT INTO user_preferences (user_id, key, value, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, key)
DO UPDATE SET
value = EXCLUDED.value,
updated_at = NOW();
INSERT INTO feature_flags (key, enabled)
VALUES ($1, FALSE)
ON CONFLICT (key) DO NOTHING;
Schema Patterns
Standard table template
CREATE TABLE entities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON entities
FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
Soft delete
UPDATE entities SET deleted_at = NOW() WHERE id = $1;
SELECT * FROM entities WHERE deleted_at IS NULL;
CREATE VIEW active_entities AS
SELECT * FROM entities WHERE deleted_at IS NULL;
Index Strategy
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
Index rules
- Every foreign key column should have an index
- Composite indexes: put the most selective column first
- Partial indexes save space when queries always filter a boolean/status
- Never over-index — each index slows INSERT/UPDATE/DELETE
EXPLAIN ANALYZE
Run on any query that might be slow:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.id, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.is_active = TRUE
GROUP BY u.id;
Look for:
Seq Scan on large tables — likely missing index
Hash Join vs Nested Loop — nested loop is bad for large result sets
Rows estimate vs actual rows — large mismatches indicate stale statistics (ANALYZE)
Anti-Patterns
String Interpolation Instead of Parameterized Queries
Wrong:
query = "SELECT * FROM users WHERE email = '" + email + "'";
Correct:
SELECT id, name FROM users WHERE email = $1;
SELECT id, name FROM users WHERE email = ?;
Why: String interpolation allows SQL injection; parameterized queries separate code from data and let the database engine handle escaping.
SELECT * in Production Queries
Wrong:
SELECT * FROM orders WHERE user_id = $1;
Correct:
SELECT id, status, total, created_at FROM orders WHERE user_id = $1;
Why: SELECT * transfers columns the application never uses, breaks application code when columns are added or renamed, and prevents the query planner from using index-only scans.
OFFSET Pagination on Large Tables
Wrong:
SELECT id, name FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 9980;
Correct:
SELECT id, name, created_at FROM products
WHERE created_at < $1
ORDER BY created_at DESC
LIMIT 20;
Why: OFFSET N forces the database to read and discard N rows on every page; keyset pagination navigates directly to the cursor position using an index, keeping cost O(log N).
Implicit Comma Joins Instead of Explicit JOIN Syntax
Wrong:
SELECT u.name, o.total
FROM users u, orders o
WHERE u.id = o.user_id;
Correct:
SELECT u.name, o.total
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
Why: Implicit comma joins obscure intent, mix join conditions with filter conditions in WHERE, and are easy to accidentally omit — producing a full Cartesian product.
Check-Then-Insert Race Condition Instead of UPSERT
Wrong:
SELECT 1 FROM user_preferences WHERE user_id = $1 AND key = $2;
INSERT INTO user_preferences (user_id, key, value) VALUES ($1, $2, $3);
Correct:
INSERT INTO user_preferences (user_id, key, value, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, key)
DO UPDATE SET value = EXCLUDED.value, updated_at = NOW();
Why: The check-then-insert pattern has a TOCTOU race condition and requires two network round-trips; ON CONFLICT DO UPDATE is atomic and idempotent.
Checklist