internal/skills/content/sql-guide/SKILL.md
SQL language guardrails, patterns, and best practices for AI-assisted development. Use when working with SQL files (.sql), database migrations, or when the user mentions SQL/databases. Provides query optimization patterns, indexing guidelines, transaction management, and migration standards specific to this project's coding standards.
npx skillsauth add ar4mirez/samuel sql-guideInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Applies to: PostgreSQL 15+, MySQL 8+, Database Migrations, Query Optimization
$1, ?, :name) without exceptionSELECT * in production codesnake_case, plural (users, order_items, audit_logs)snake_case, singular (email, created_at, is_active)id (integer or UUID depending on project convention)<singular_table>_id (user_id, order_id)idx_<table>_<columns> (idx_users_email, idx_orders_user_id_created_at)uq_<table>_<columns> (uq_users_email)ck_<table>_<description> (ck_orders_positive_total)is_ or has_ prefix (is_active, has_verified_email)created_at, updated_at, deleted_at (always TIMESTAMPTZ)INSERT statementsSELECT * in application code (acceptable in ad-hoc queries only)LIMIT to queries that return lists (prevent unbounded result sets)TRUNCATE or DROP in application code without explicit safeguardsEXISTS instead of COUNT(*) > 0 for existence checks-- GOOD: parameterized, explicit columns, bounded
SELECT u.id, u.email, u.created_at
FROM users u
WHERE u.email = $1
AND u.is_active = true
LIMIT 1;
-- BAD: string concatenation, SELECT *, no LIMIT
SELECT * FROM users WHERE email = '" + userInput + "';
WHERE, JOIN, and ORDER BY clausesUNIQUE indexes to enforce business rules at the database levelWHERE is_active = true)CONCURRENTLY for index creation on live tables (PostgreSQL)-- Composite index: equality (status) first, then range (created_at)
CREATE INDEX CONCURRENTLY idx_orders_status_created
ON orders (status, created_at DESC);
-- Partial index: only index active users
CREATE INDEX idx_users_email_active
ON users (email) WHERE is_active = true;
READ COMMITTED for most operations (PostgreSQL default)REPEATABLE READ when a transaction reads the same data multiple timesSERIALIZABLE for financial or inventory operations requiring strict consistencyCOMMIT or ROLLBACK -- never leave transactions hangingSAVEPOINT for partial rollback within complex transactionsBEGIN;
SAVEPOINT before_update;
UPDATE accounts SET balance = balance - 100.00 WHERE id = $1;
UPDATE accounts SET balance = balance + 100.00 WHERE id = $2;
-- If second update fails, roll back to savepoint
-- ROLLBACK TO SAVEPOINT before_update;
COMMIT;
up and down functionsIF NOT EXISTS, IF EXISTS guards)NULL first, backfill, then add NOT NULL constraintYYYYMMDDHHMMSS_description.sql or sequential numberingup and down in development before committing-- UP: 20250115120000_add_users_phone.sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_phone
ON users (phone) WHERE phone IS NOT NULL;
-- DOWN: 20250115120000_add_users_phone.sql
DROP INDEX CONCURRENTLY IF EXISTS idx_users_phone;
ALTER TABLE users DROP COLUMN IF EXISTS phone;
CREATE TABLE IF NOT EXISTS orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CONSTRAINT ck_orders_valid_status
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
total_cents INTEGER NOT NULL
CONSTRAINT ck_orders_positive_total CHECK (total_cents >= 0),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Auto-update updated_at (PostgreSQL)
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_orders_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
BIGINT GENERATED ALWAYS AS IDENTITY for auto-increment PKs (PostgreSQL 10+)UUID PKs when IDs are exposed externally or distributed systems require itTIMESTAMPTZ (not TIMESTAMP) for all time columns -- always store UTCNOT NULL constraints by default; allow NULL only when semantically meaningfulCHECK constraints to enforce domain rules at the database levelENUM types sparingly -- prefer VARCHAR with CHECK for easier migrationON DELETE CASCADE or ON DELETE SET NULL explicitly to every foreign keydeleted_at TIMESTAMPTZ) when audit trail is requiredPrefer CTEs over subqueries for readability and maintainability.
-- Readable: each step has a name
WITH active_orders AS (
SELECT user_id, COUNT(*) AS order_count, SUM(total_cents) AS total_spent
FROM orders
WHERE status != 'cancelled'
AND created_at >= now() - INTERVAL '30 days'
GROUP BY user_id
),
high_value_users AS (
SELECT user_id
FROM active_orders
WHERE total_spent > 50000 -- over $500
)
SELECT u.id, u.email, ao.order_count, ao.total_spent
FROM users u
JOIN active_orders ao ON ao.user_id = u.id
WHERE u.id IN (SELECT user_id FROM high_value_users)
ORDER BY ao.total_spent DESC;
Use window functions for ranking, running totals, and row comparisons without self-joins.
-- Rank users by spending within each region
SELECT
u.id,
u.region,
SUM(o.total_cents) AS total_spent,
RANK() OVER (PARTITION BY u.region ORDER BY SUM(o.total_cents) DESC) AS rank
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.region;
-- Running total of daily revenue
SELECT
date_trunc('day', created_at) AS day,
SUM(total_cents) AS daily_revenue,
SUM(SUM(total_cents)) OVER (ORDER BY date_trunc('day', created_at)) AS running_total
FROM orders
WHERE status = 'delivered'
GROUP BY date_trunc('day', created_at)
ORDER BY day;
-- PostgreSQL: insert or update on conflict
INSERT INTO user_preferences (user_id, theme, language)
VALUES ($1, $2, $3)
ON CONFLICT (user_id)
DO UPDATE SET
theme = EXCLUDED.theme,
language = EXCLUDED.language,
updated_at = now();
-- Batch insert with unnest (PostgreSQL)
INSERT INTO tags (name, category)
SELECT unnest($1::text[]), unnest($2::text[])
ON CONFLICT (name) DO NOTHING;
-- Batch update with VALUES list
UPDATE products AS p
SET price_cents = v.new_price
FROM (VALUES
(1, 2999),
(2, 4999),
(3, 1499)
) AS v(id, new_price)
WHERE p.id = v.id;
# Python (psycopg2/asyncpg) -- ALWAYS use parameterized queries
cursor.execute(
"SELECT id, email FROM users WHERE email = %s AND is_active = %s",
(email, True),
)
# Node.js (pg)
const result = await pool.query(
'SELECT id, email FROM users WHERE email = $1 AND is_active = $2',
[email, true]
);
# Go (database/sql)
row := db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email = $1 AND is_active = $2",
email, true,
)
Always use EXPLAIN ANALYZE to validate query plans before deploying.
-- Check execution plan and actual timing
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.id, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2025-01-01'
GROUP BY u.id, u.email
ORDER BY order_count DESC
LIMIT 20;
What to look for:
Seq Scan on large tables -- usually needs an indexNested Loop with large outer sets -- consider Hash Join via index or rewriteSort operations with high cost -- add index matching ORDER BYRows Removed by Filter much larger than Actual Rows -- index the filter columnBuffers: shared hit vs shared read -- high reads indicate cold cache| Access Pattern | Index Type | Example |
|---|---|---|
| Equality lookup | B-tree (default) | WHERE email = $1 |
| Range scan | B-tree | WHERE created_at > $1 |
| Full-text search | GIN + tsvector | WHERE search_vector @@ to_tsquery($1) |
| JSONB field access | GIN | WHERE metadata @> '{"key": "val"}' |
| Array containment | GIN | WHERE tags @> ARRAY['sql'] |
| Geometric/spatial | GiST | WHERE location <-> point($1,$2) < $3 |
| Pattern matching (LIKE 'abc%') | B-tree with text_pattern_ops | WHERE name LIKE 'abc%' |
-- BAD: N+1 (one query per user to fetch orders)
-- Application loops: for each user, SELECT orders WHERE user_id = ?
-- GOOD: single query with JOIN
SELECT u.id, u.email, o.id AS order_id, o.total_cents
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.is_active = true
ORDER BY u.id, o.created_at DESC;
-- GOOD: batch with ANY (when you have a list of IDs)
SELECT id, email, created_at
FROM users
WHERE id = ANY($1::bigint[]);
-- Cursor-based pagination (preferred for large datasets)
SELECT id, email, created_at
FROM users
WHERE created_at < $1 -- cursor: last seen created_at
AND is_active = true
ORDER BY created_at DESC
LIMIT 25;
-- Offset-based pagination (simpler but slower for deep pages)
SELECT id, email, created_at
FROM users
WHERE is_active = true
ORDER BY created_at DESC
LIMIT 25 OFFSET $1;
# PostgreSQL CLI
psql -h localhost -U myuser -d mydb # Connect
psql -f migration.sql mydb # Run migration file
pg_dump -Fc mydb > backup.dump # Backup (custom format)
pg_restore -d mydb backup.dump # Restore
# pgcli (enhanced CLI with autocomplete)
pgcli -h localhost -U myuser -d mydb
# Schema inspection
\dt # List tables
\d+ table_name # Describe table with details
\di # List indexes
\df # List functions
# golang-migrate
migrate create -ext sql -dir db/migrations -seq add_users_phone
migrate -path db/migrations -database "$DATABASE_URL" up
migrate -path db/migrations -database "$DATABASE_URL" down 1
# Alembic (Python/SQLAlchemy)
alembic revision -m "add users phone"
alembic upgrade head
alembic downgrade -1
# Prisma (Node.js)
npx prisma migrate dev --name add_users_phone
npx prisma migrate deploy
# dbmate
dbmate new add_users_phone
dbmate up
dbmate rollback
-- Find slow queries (requires pg_stat_statements extension)
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- Find unused indexes
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname NOT LIKE '%pkey%'
ORDER BY pg_relation_size(indexrelid) DESC;
-- Table sizes
SELECT relname, pg_size_pretty(pg_total_relation_size(relid))
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC;
-- Active connections and queries
SELECT pid, state, query, query_start, now() - query_start AS duration
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;
For detailed patterns and examples, see:
development
Zig language guardrails, patterns, and best practices for AI-assisted development. Use when working with Zig files (.zig), build.zig, or when the user mentions Zig. Provides comptime patterns, allocator conventions, C interop guidelines, and testing standards specific to this project's coding standards.
tools
WordPress framework guardrails, patterns, and best practices for AI-assisted development. Use when working with WordPress projects, or when the user mentions WordPress. Provides theme development, plugin architecture, REST API, blocks, and security guidelines.
tools
Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs. Use when testing web apps, automating browser interactions, or debugging frontend issues.
tools
Suite of tools for creating elaborate, multi-component web applications using modern frontend technologies (React, Tailwind CSS, shadcn/ui). Use for complex projects requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX pages.