SQL
Writes, reviews, and optimizes SQL queries; designs schemas, indexes, and constraints; plans migrations for any relational database. Use when a query is slow, EXPLAIN shows a seque…
Iván
@ivangdavila
Install
$ openclaw skills install @ivangdavila/sqlUser preferences and memory live in ~/Clawic/data/sql/ (see setup.md on first use, memory-template.md for the file format). If you have data at an old location (~/sql/ or ~/clawic/sql/), move it to ~/Clawic/data/sql/.
When To Use
- Writing, reviewing, or optimizing SQL: queries, joins, CTEs, window functions, upserts
- Designing tables, keys, types, indexes, and constraints, or normalizing an existing model
- Diagnosing a slow query, a deadlock, a lock timeout, wrong totals, or duplicated rows
- Planning migrations and DDL that must not take a live database down
- Operating a database: backups, restores, monitoring, pooling, replication, partitioning
- Moving data in or out: CSV loads, dumps, engine-to-engine migrations
- Not for PostgreSQL server internals (vacuum tuning,
work_mem, xid wraparound) — that ispg; not for ORM-level modeling in a framework — that isprisma
Quick Reference
| Situation | Play |
|---|---|
| Query slow, cause unknown | EXPLAIN (ANALYZE, BUFFERS), fix the worst node first (→ Reading EXPLAIN, then performance.md) |
| Query was fast yesterday | Stats, data growth, or plan flip — the regression chain in debug.md |
| Index exists but is not used | Function on the column, type mismatch, wrong column order, or low selectivity (→ Traps, performance.md) |
| Totals inflated after adding a JOIN | 1:N fan-out — aggregate before joining (→ Traps) |
| Rows missing after adding a JOIN | LEFT JOIN filtered in WHERE became an inner join (→ Traps) |
| Paginating past the first few thousand rows | Keyset, never OFFSET (patterns.md) |
| Deadlock, lock timeout, or "could not obtain lock" | transactions.md — lock ordering and isolation |
| "Too many connections" / app hangs on connect | Pool sizing before max_connections (operations.md, orm.md) |
| Read-modify-write race, job queue | SELECT ... FOR UPDATE, add SKIP LOCKED for queues (patterns.md) |
| Schema change on a live table | Expand → migrate → contract, lock_timeout first (operations.md) |
| Designing a model from scratch | Keys, cardinality, normal forms, when to denormalize (modeling.md) |
| Known shape needed (tenants, tags, audit, state, history) | schemas.md |
| Storing or querying JSON / semi-structured data | json.md |
| Cohorts, funnels, retention, rollups, materialized views | analytics.md |
| Loading a CSV, dump/restore, engine-to-engine move | data-loading.md |
| Timestamps off by hours, DST, week/fiscal boundaries | datetime.md |
| Statement works on one engine, fails on another | dialects.md |
| Grants, least privilege, RLS, PII erasure, encryption | security.md |
| Seeding fixtures, isolating tests, testing a migration | testing.md |
| ORM emits terrible SQL, N+1, mystery transactions | orm.md |
| Single node at its limit: replicas, sharding, caching | scaling.md |
| Choosing an engine | SQLite embedded/local · PostgreSQL default for servers · MySQL when the platform dictates it · SQL Server in .NET/Windows shops (dialects.md) |
| Anything else | Reproduce on the smallest table that shows it, then: schema-shaped → modeling.md/schemas.md · query-shaped → patterns.md · slow → performance.md · ops-shaped → operations.md |
Core Rules
- Parameterize values; allowlist identifiers. Placeholders (
?,$1) stop injection for values, but table/column names cannot be bound — when those are dynamic, check them against a hardcoded allowlist, never interpolate user input. Full attack surface, includingLIKEandORDER BYinjection:security.md. - BIGINT (or UUIDv7) primary keys by default.
INToverflows at 2,147,483,647 — at a sustained 100 inserts/s that is 2.1B ÷ 100/s ≈ 248 days, and the fix is an outage-grade type change. Random UUIDv4 keys fragment the B-tree; UUIDv7/ULID keep insert locality (modeling.md). - Index for the query shape: equality columns first, then range/sort.
(user_id, created_at)servesWHERE user_id = ? AND created_at > ?andWHERE user_id = ?alone — nevercreated_atalone. A sequential scan on a filter matching more than roughly 5-10% of rows is the planner being right, not broken. - Index every foreign key column yourself. MySQL/InnoDB creates the index automatically; PostgreSQL, SQLite, and SQL Server do not. Without it, every join on the FK and every parent
DELETE(worse withON DELETE CASCADE) scans the whole child table — the slowest delete in most schemas is this one missing index. - Transactions stay short and never wait on the outside world. No HTTP calls, no user input inside
BEGIN...COMMIT: open transactions hold locks, and in PostgreSQL they also block vacuum, causing table bloat. Anything open past the >1 min monitoring threshold (operations.md) gets investigated. - NULL is three-valued.
NOT IN (subquery)returns zero rows if the subquery yields a single NULL — useNOT EXISTS.x = NULLis never true — useIS NULL.COUNT(col)skips NULLs;COUNT(*)counts rows. Aggregates over zero rows return NULL, not 0 — wrap inCOALESCEwhen a chart or invariant expects a number. - Types that avoid the next migration. Money →
NUMERIC/DECIMAL(float money loses cents in aggregation); timestamps →TIMESTAMPTZstored as UTC (datetime.md); strings →TEXTin PostgreSQL and SQLite (varchar(255)is a cargo-cult limit you will later raise); MySQL charset →utf8mb4(MySQL'sutf8is 3-byte and rejects emoji). - Migrations are additive first. Rename/retype/drop happens over multiple deploys with both versions live in between (expand-migrate-contract,
operations.md). A single-deploy column rename breaks every instance still running old code. - Rank before you tune.
pg_stat_statementsordered bytotal_exec_time(or the MySQL slow query log digested bypt-query-digest) tells you which query costs the most overall — usually not the one someone complained about. Total cost = mean latency × call count: a 5 ms query called 10,000×/min (50 s/min) outranks a 2 s report run hourly. Optimizing an unranked query is guessing.
Reading EXPLAIN
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 5; -- PostgreSQL
EXPLAIN ANALYZE SELECT ...; -- MySQL >=8.0.18
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 5; -- SQLite
SET STATISTICS PROFILE ON; -- SQL Server (or the graphical plan)
Read actual behavior, not just the plan. Plain EXPLAIN shows estimates only, and estimates are the part that lies.
Seq Scan/type: ALLon a large table with a selective filter → missing or unusable index (→ Traps for what disables one)Rows Removed by Filterhigh → the index found candidates but the filter did the work; extend the index to cover the filter- Estimated vs actual rows off by more than 10× → stale stats: run
ANALYZE tablename;. Still off → the planner assumes column independence; declare the correlation (CREATE STATISTICSon PostgreSQL >=10, histogram on MySQL 8) Buffers: readlarge vshit→ data is coming from disk; recheck on a warm cache before concluding- Nested Loop over thousands of outer rows → usually the >10× misestimate above feeding a bad join choice
- Node-by-node interpretation, join algorithms, and what to change for each:
performance.md
Index Strategy
-- Composite: equality columns first, range/sort last (rule 3)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Covering: index-only scan, no heap fetch (PostgreSQL >=11, SQL Server INCLUDE)
CREATE INDEX idx_orders_user ON orders(user_id) INCLUDE (total);
-- Partial/filtered: index only the rows you query (PostgreSQL, SQLite, SQL Server)
CREATE INDEX idx_orders_pending ON orders(user_id) WHERE status = 'pending';
-- Expression: make a function sargable (MySQL >=8.0.13 supports functional indexes)
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
- A plain B-tree on a low-cardinality column (
statuswith 5 values) rarely helps; a partial index on the rare value you actually query does. - An index on
(a, b)already servesWHERE a = ?— creating a second index on(a)alone adds write cost and reclaims nothing. Check for redundant prefixes before adding. - PostgreSQL with a non-C locale ignores B-tree indexes for
LIKE 'term%'— add thetext_pattern_opsopclass for prefix search. - Index-only scans still hit the heap for pages not marked all-visible; if
EXPLAINshowsHeap Fetcheshigh, the table needs aVACUUM(operations.md). - Every index taxes writes and consumes disk: drop unused ones (
pg_stat_user_indexeswhereidx_scan = 0), but only after uptime covers a full business cycle — month-end reports use indexes nothing else does.
Portability
| Feature | PostgreSQL | MySQL | SQLite | SQL Server |
|---|---|---|---|---|
| Limit | LIMIT n | LIMIT n | LIMIT n | TOP n / OFFSET-FETCH |
| Upsert | ON CONFLICT | ON DUPLICATE KEY | ON CONFLICT | MERGE |
| Boolean | true/false | 1/0 (TINYINT) | 1/0 | 1/0 (BIT) |
| Concat | || | CONCAT() | || | + or CONCAT() |
| Auto-id | GENERATED / SERIAL | AUTO_INCREMENT | INTEGER PRIMARY KEY | IDENTITY |
| Returning rows from DML | RETURNING | — (MariaDB has it) | RETURNING (>=3.35) | OUTPUT |
| Aggregate FILTER | Yes | CASE only | Yes (>=3.30) | CASE only |
| Transactional DDL | Yes | No (implicit commit) | Yes | Yes |
| Default string compare | Case-sensitive | Case-insensitive (_ci collations) | Case-sensitive | Case-insensitive by default |
Date functions, quoting, NULL sort order, collation, and the rest of the divergences: dialects.md.
Output Gates
Before emitting SQL, verify:
- Every value is a placeholder, and every dynamic identifier came from an allowlist?
UPDATE/DELETEhas aWHERE, or the full-table effect is explicitly intended?- Destructive DML was previewed as the equivalent
SELECTfirst? - No 1:N join feeds an aggregate without pre-aggregation, and no
DISTINCTis papering over one? LIMIT/TOPhas a deterministicORDER BYwith a unique tiebreaker?- New table: primary key type per rule 2, timestamps with zone, uniqueness scoped to the right columns, every FK column indexed (rule 4)?
- DDL against a live table:
lock_timeoutset, and the change is expand-only? - Every construct used exists in the target engine (→ Portability,
dialects.md)?
Configuration
User-dependent variables. Defaults apply until the user states a preference; store them in ~/Clawic/data/sql/config.yaml.
| Variable | Type | Default | Effect |
|---|---|---|---|
| dialect | postgres | mysql | mariadb | sqlite | sqlserver | postgres | Syntax of every emitted statement and which caveats from Portability and dialects.md are surfaced |
| engine_version | text (e.g. 15, 8.0.35) | none | Gates features with a version floor (RETURNING, functional indexes, NULLS NOT DISTINCT); with none set, the conservative form is emitted |
| identifier_style | snake_case | camelCase | PascalCase | snake_case | Naming of tables, columns, indexes, and constraints in generated DDL |
| table_naming | plural | singular | plural | Whether generated tables read users or user |
| pk_type | bigint-identity | uuidv7 | uuidv4 | natural | bigint-identity | The primary key emitted by every CREATE TABLE (rule 2, modeling.md) |
| destructive_guard | bool | true | When true, UPDATE/DELETE without WHERE, DROP, and TRUNCATE are emitted as a transaction-wrapped preview with the matching SELECT first |
| migration_tool | text (flyway, alembic, golang-migrate, sqitch, ...) | none | File naming and up/down structure of generated migrations (operations.md) |
| timezone_policy | utc | local | utc | Whether timestamps are stored and compared as UTC and how datetime.md examples render |
| lock_timeout | text (duration: 500ms, 2s, 10s) | 2s | The SET lock_timeout emitted before every DDL statement against a live table (→ Traps, operations.md) |
| batch_size | number (rows, 100-100000) | 5000 | Chunk size for batched DELETE/UPDATE, backfills, and bulk loads (patterns.md, data-loading.md, json.md) |
Preference areas — customizable dimensions; a stated preference gets recorded in config.yaml and applied:
- Tooling — client (psql, mysql, sqlite3, sqlcmd, a GUI), migration runner, SQL linter/formatter, local database as a container vs installed service — affects
operations.mdandtesting.mdexamples - Conventions — index and constraint naming, keyword casing, CTE vs subquery preference, comment style in DDL — affects every emitted statement
- Platform — managed service (RDS, Aurora, Cloud SQL, Neon, PlanetScale) vs self-hosted, available extensions, collation and locale — affects which features may be assumed
- Safety posture — how much confirmation destructive DDL/DML needs, whether production credentials are ever used directly, read-only-by-default sessions — affects
operations.mdand thedestructive_guardgate - Output format — full runnable script vs snippet, whether a down/rollback migration accompanies every up, inline comments — affects the shape of every deliverable
- Work order — schema-first vs query-first design, review gate before a migration reaches production — affects the sequence in
modeling.mdandoperations.md - Integrations — ORM in use, warehouse/BI target, monitoring stack — affects
orm.mdandanalytics.mdadvice - Constraints — vetoed features (no triggers, no stored procedures, no vendor extensions), compliance regime (retention windows, GDPR erasure) — affects
schemas.mdandsecurity.mdchoices - Thresholds — the operational budgets a shop usually standardizes:
lock_timeout,batch_size, retry cap on deadlock/serialization failures, pool sizing rule, and the alert levels (connection saturation, disk free, transaction age, backup age) — affectstransactions.mdretry loops and the Alert Thresholds table inoperations.md - Cadence — how often scheduled work runs: restore drills, rollup and materialized-view refresh, the trailing recompute window, data-quality assertion runs, automated partition creation, backup interval — affects
operations.md,analytics.md,schemas.md, andtesting.mdschedules
Traps
| Trap | Why it fails | Do instead |
|---|---|---|
WHERE YEAR(created_at) = 2024 | Function on the column hides it from the index | Range: created_at >= '2024-01-01' AND created_at < '2025-01-01' |
BETWEEN '2024-01-01' AND '2024-01-31' on a timestamp | Upper bound is midnight — the whole last day is excluded with no error | Half-open range: >= '2024-01-01' AND < '2024-02-01' |
WHERE varchar_col = 123 | Implicit cast applies to the column, disabling its index | Match types: quote the literal or fix the schema |
LEFT JOIN t ... WHERE t.col = x | WHERE runs after the join and filters the NULL rows out → an INNER JOIN, with no error | Move the predicate into ON, or test t.id IS NULL for an anti-join |
Join 1:N then SUM/COUNT | Fan-out duplicates left-side rows before aggregation | Aggregate in a subquery/CTE, then join the result |
DISTINCT added to make duplicates go away | Hides a fan-out bug and forces a sort of the whole result | Find the join that multiplies rows; DISTINCT is a diagnosis, not a fix |
LIKE '%term' | Leading wildcard defeats B-tree ordering | Full-text search (schemas.md) or a trigram index (pg_trgm) |
Composite (a, b) for WHERE b = ? | B-tree is sorted by a first; b alone is unordered | Separate index on b, or reorder if a is always filtered |
WHERE a = ? OR b = ? | One index cannot serve two independent predicates; usually a full scan | UNION ALL of two indexed queries, deduplicated if needed |
LIMIT 10 without ORDER BY | Row order is undefined — "top 10" changes between runs and after a vacuum | Always ORDER BY with a unique tiebreaker column |
ORDER BY random() LIMIT n | Full scan plus sort of the entire table | TABLESAMPLE or a random-key probe (patterns.md) |
SELECT * in application code | Blocks index-only scans, fetches columns you drop, breaks on schema change | Name the columns |
DDL without lock_timeout | ALTER TABLE queues behind one long query and every new query queues behind it — brief lock, full outage | SET lock_timeout (default 2s, → Configuration) then retry (operations.md) |
Wrapping TRUNCATE/DDL in a transaction on MySQL | MySQL commits implicitly on DDL: the rollback you planned does not exist | Take a backup or use a copy table; transactional DDL is PostgreSQL/SQLite/SQL Server only |
Where Experts Disagree
- Surrogate vs natural keys. Surrogate (
BIGINT/UUID) is the default: natural keys change, and a changing PK cascades into every child row. Natural keys legitimately win on pure junction tables and on immutable code tables (ISO currency, country) where the extra id buys nothing (modeling.md). - Foreign keys at scale. Some high-write shops drop FK constraints because they add per-write lock and index cost and complicate online schema-change tooling. Default: keep them — orphan cleanup costs more than the writes saved. Drop only with a measured write bottleneck and integrity enforced elsewhere.
- Logic in the database. Triggers and stored procedures give atomicity nothing in the app can match and centralize rules across many clients; they are also invisible to code review, hard to test, and version-controlled poorly. Default: constraints and simple audit triggers in the database, business workflows in the application (
schemas.md). - Soft delete everywhere. Undo and audit are real requirements, but a
deleted_aton every table poisons every query and every unique constraint. Default: hard delete plus an audit log; soft-delete only the tables users actually restore (schemas.md).
Related Skills
More Clawic skills, get them at https://clawic.com/skills/sql (install if the user confirms):
pg— PostgreSQL server internals: vacuum, work_mem, wraparoundmysql— MySQL and InnoDB specificssqlite— SQLite concurrency, pragmas, and type affinityprisma— Node.js ORM modelingdbt— warehouse transformations and tests
Feedback
- If useful, star it: https://clawic.com/skills/sql
- Latest version: https://clawic.com/skills/sql
Part of Clawic, the verified skill library. Get this skill: https://clawic.com/skills/sql.
Related skills
PostgreSQL
@ivangdavilaTunes, designs, and operates PostgreSQL: slow queries, indexes, schemas, migrations, vacuum, locks, replication, backups. Use when writing Postgres SQL or psql, designing tables and indexes, reading an EXPLAIN plan, running DDL or a migration against a live table, or when a query suddenly got slow, a table keeps growing while rows stay flat, autovacuum cannot keep up, "too many clients already" appears, a replica lags, deadlocks and lock waits pile up, pg_wal fills the disk, or an upgrade, a restore, or a partitioning plan is on the table. Covers connection pooling and PgBouncer, full-text, trigram and pgvector search, JSONB, roles and row-level security, PITR, extensions, and managed Postgres (RDS, Aurora, Cloud SQL, Neon, Supabase). Not for cross-engine SQL portability or ORM-level modeling.
MongoDB
@ivangdavilaDesigns MongoDB schemas, indexes, and aggregation pipelines, and debugs slow queries, connection errors, and replica set failures. Use when modeling documents, deciding embed vs reference, reading an explain plan, or fixing a COLLSCAN, and when a query times out, a pipeline aborts at the memory limit, a cursor dies mid-loop, the pool exhausts and server selection times out, writes fail with duplicate key or "not writable primary", a secondary lags, the oplog window closes, a shard key hotspots, or WiredTiger cache stalls the cluster. Covers mongosh and Compass, Atlas, Mongoose and driver connection strings, transactions and retry loops, change streams, time-series collections, Atlas Search and vector search, sharding, backups, restores, and upgrades. Not for SQL or relational modeling — normalization instincts actively mislead here.
SQL Toolkit
@gitgoodordietryingQuery, design, migrate, and optimize SQL databases. Use when working with SQLite, PostgreSQL, or MySQL — schema design, writing queries, creating migrations, indexing, backup/restore, and debugging slow queries. No ORMs required.
JSON
@ivangdavilaParses, validates, transforms, and designs JSON payloads that survive real parsers, real clients, and real data sizes. Use when a parse fails at a byte offset or on a trailing comma, an id or an amount loses precision, accents and emoji come back as mojibake, a field is null when it should be absent, duplicate keys silently win, a JSON Schema passes what it should reject or a `$ref` will not resolve, a multi-gigabyte file will not fit in memory, a jq, JMESPath, or JSONPath expression returns nothing, two documents must be diffed or patched, a webhook signature fails after the body was re-serialized, untrusted input must be parsed safely, or a response shape has to change without breaking existing clients. Covers NDJSON, JSON Patch, canonical form, JSON columns in SQL, and JSON5/JSONC config files. Not for constraining LLM output (`structured-output`), for YAML, TOML, XML, or CSV (their own skills), or for designing REST endpoints (`rest-api`).
Database Operations
@jgarrison929Use when designing database schemas, writing migrations, optimizing SQL queries, fixing N+1 problems, creating indexes, setting up PostgreSQL, configuring EF Core, implementing caching, partitioning tables, or any database performance question.
JavaScript
@ivangdavilaWrites, debugs, and reviews JavaScript: async and the event loop, coercion, closures, dates, Unicode, regex, and modern ES2023+ APIs. Use when JS throws TypeError or "undefined is not a function", a promise never settles or a rejection goes unhandled, NaN or [object Object] appears, dates shift by a day, sorting or equality misbehaves, memory grows, a regex hangs, JSON loses precision, a Node process won't exit or ignores signals, or fetch doesn't reject on a 404; also when choosing data structures, handling errors, profiling slow code, or checking whether a feature is safe for a target Node or browser version. Covers Node and browser runtime edges. Not for TypeScript type-system design or framework internals.