SQL Database
Design, query, and operate relational databases safely and performantly. Multi-dialect (PostgreSQL,
MySQL, SQLite) with explicit guidance per engine where it matters.
When to Use
- Designing or reviewing schemas, indexes, or constraints
- Writing migrations or evolving a deployed schema safely
- Tuning slow queries or interpreting
EXPLAIN output
- Choosing between SQL engines for a new service
- Integrating SQLAlchemy, Diesel, SQLx, or another ORM/query builder
- Reviewing SQL for injection risk, N+1 queries, or other anti-patterns
Related Skills
Reference Guides
Load on demand — do not read all files upfront.
| Topic |
File |
Load When |
| Indexing strategy |
references/indexing.md |
Designing or auditing indexes |
| Query patterns |
references/query-patterns.md |
Tuning, pagination, N+1, EXPLAIN deep dives |
| PostgreSQL specifics |
references/postgresql.md |
JSONB, arrays, RLS, MVCC, type quirks |
| MySQL specifics |
references/mysql.md |
InnoDB, online DDL, isolation, partitioning |
| SQLite specifics |
references/sqlite.md |
Embedded apps, FTS5, WAL mode, pragmas |
Iron Laws
- Always parameterize. Never concatenate or interpolate user input into SQL strings.
- Always profile with
EXPLAIN. Decisions about indexes and query shape need evidence.
- Always migrate. Every DDL change goes through a versioned migration with a rollback path.
- Never
SELECT * in application code. Name the columns you need.
- Never trust the optimizer for arbitrary input. Add the indexes that hot paths require.
Schema Design
Normalization
Start in 3rd normal form. Denormalize only after measurement proves a join is the bottleneck.
Premature denormalization creates update anomalies and maintenance burden.
Primary Keys
| Engine |
Recommended PK |
Notes |
| PostgreSQL |
BIGINT GENERATED ALWAYS AS IDENTITY |
Use uuidv7() (PG18+) only when needed |
| MySQL |
BIGINT UNSIGNED AUTO_INCREMENT |
InnoDB clusters by PK — keep narrow + monotonic |
| SQLite |
INTEGER PRIMARY KEY |
Aliases ROWID; do not use BIGINT |
- Avoid random UUIDs (UUIDv4) as clustered PKs — they fragment the heap and bloat indexes.
- If global uniqueness is required, store the UUID in a secondary
UNIQUE column.
- Sequences and auto-increment counters have gaps after rollbacks or crashes — this is expected,
not a bug. Do not try to make IDs consecutive.
Foreign Keys
- Always declare FKs at the database level. Application-only referential integrity drifts.
- Specify
ON DELETE / ON UPDATE behavior explicitly (CASCADE, RESTRICT, SET NULL).
- PostgreSQL and MySQL do not auto-index FK columns. Add the index manually — without it, joins
are slow and
DELETE on the parent acquires a full-table lock.
- SQLite: enable FKs explicitly per connection:
PRAGMA foreign_keys = ON;.
Naming Conventions
snake_case for tables, columns, constraints, and indexes
- Singular table names (
user, order) — match the row, not the collection
_id suffix for foreign keys (customer_id, not customer)
- Constraint names:
pk_<table>, fk_<table>_<column>, uq_<table>_<column>,
idx_<table>_<column>
- PostgreSQL lowercases unquoted identifiers — never quote a mixed-case name into existence
Required Columns
Every reference table gets:
- A primary key
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() (or dialect equivalent)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() with a trigger or ORM hook
- Indexes on every FK and every queried column
Data Types
| Purpose |
PostgreSQL |
MySQL |
SQLite |
| Integer ID |
BIGINT GENERATED ALWAYS AS IDENTITY |
BIGINT UNSIGNED AUTO_INCREMENT |
INTEGER PRIMARY KEY |
| Timestamp |
TIMESTAMPTZ |
DATETIME(6) |
TEXT (ISO-8601) |
| Money / decimal |
NUMERIC(p, s) |
DECIMAL(p, s) |
NUMERIC |
| Variable text |
TEXT |
VARCHAR(n) + utf8mb4 |
TEXT |
| Boolean |
BOOLEAN |
TINYINT(1) |
INTEGER 0/1 |
| JSON |
JSONB (always — never JSON) |
JSON |
TEXT + JSON1 ext. |
| Enum |
CREATE TYPE foo AS ENUM (...) |
lookup table (preferred) |
TEXT + CHECK |
| UUID |
UUID |
BINARY(16) (use UUID_TO_BIN) |
BLOB or TEXT |
| Bytes |
BYTEA |
VARBINARY(n) / BLOB |
BLOB |
Universal Type Rules
- Money is
NUMERIC / DECIMAL, never FLOAT or DOUBLE. Floats lose pennies.
- Timestamps are timezone-aware. Store UTC; convert at the edge.
- Use the smallest type that fits — narrower rows mean more rows per page and better cache
behavior.
NOT NULL everywhere it is semantically required. Provide DEFAULT for common values.
PostgreSQL Type Notes
- Prefer
TEXT over VARCHAR(n) — there is no performance difference and length limits belong in
CHECK constraints.
- Prefer
TIMESTAMPTZ — never TIMESTAMP (no timezone), TIMETZ, or TIMESTAMP(n).
- Avoid:
SERIAL / BIGSERIAL (use IDENTITY), MONEY (use NUMERIC), CHAR(n).
- Use
CITEXT (or an expression index on LOWER(col)) for case-insensitive lookups.
MySQL Type Notes
- Default charset / collation:
utf8mb4 / utf8mb4_0900_ai_ci. Plain utf8 is not UTF-8.
- Prefer
DATETIME over TIMESTAMP — TIMESTAMP has a 2038 cliff and timezone surprises.
ENUM bakes values into the schema and is hard to evolve — use a lookup table instead.
Indexing
Quick rules. See references/indexing.md for full design patterns.
What to Index
- Columns appearing in
WHERE, JOIN, ORDER BY, or GROUP BY
- Every foreign key column (manual, not automatic)
- Columns frequently filtered together — as a single composite index
Composite Index Order
Leftmost-prefix rule: equality predicates first, then range, then sort.
-- Query: WHERE user_id = ? AND status = ? AND created_at > ? ORDER BY created_at
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
A range predicate (>, <, BETWEEN) stops index usage for columns that follow it.
Index Types Worth Knowing
- Covering /
INCLUDE — non-key columns added to enable index-only scans.
- Partial (PostgreSQL, SQLite) —
... WHERE status = 'active' shrinks index to hot rows.
- Expression —
INDEX ON users (LOWER(email)) for case-insensitive matches.
- PostgreSQL GIN —
JSONB, arrays, full-text search.
- PostgreSQL GiST — ranges, geometry, exclusion constraints.
- MySQL
FULLTEXT — text search via MATCH ... AGAINST.
- SQLite FTS5 — virtual table for full-text search.
Avoid Over-Indexing
Every index slows writes and consumes storage. Audit periodically:
-- PostgreSQL: indexes that have never been read
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0;
-- MySQL 8.0+
SELECT object_schema, object_name, index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL AND count_star = 0;
Query Patterns
See references/query-patterns.md for the full cookbook (pagination
styles, EXPLAIN walkthroughs, window functions, CTEs).
Anti-Patterns to Avoid
| Anti-pattern |
Why it hurts |
Fix |
SELECT * |
Wastes I/O; breaks index-only scans |
Name the columns |
Function in WHERE |
WHERE YEAR(created_at) = 2024 skips index |
created_at >= '2024-01-01' AND < '2025-01-01' |
OFFSET pagination |
OFFSET 100000 re-scans skipped rows |
Cursor / keyset pagination |
| Correlated subquery |
Runs once per outer row |
Convert to JOIN + GROUP BY, or window fn |
DISTINCT to fix joins |
Hides a missing or wrong join condition |
Fix the join; aggregate intentionally |
| N+1 queries from ORM |
One query per parent row |
Eager-load, batch-load, or join |
Implicit CROSS JOIN |
Comma-separated FROM without ON |
Use explicit INNER JOIN ... ON |
Cursor (Keyset) Pagination
-- BAD: OFFSET cost grows with offset
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
-- GOOD: O(log n) lookup using the previous page's last (created_at, id)
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Batch Writes
-- BAD: one round-trip per row
INSERT INTO products (name, price) VALUES ('A', 1);
INSERT INTO products (name, price) VALUES ('B', 2);
-- GOOD: one round-trip
INSERT INTO products (name, price) VALUES ('A', 1), ('B', 2), ('C', 3);
-- Better when many rows: COPY (PostgreSQL) or LOAD DATA INFILE (MySQL)
EXPLAIN Quick Read
-- PostgreSQL
EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;
-- MySQL 8.0+
EXPLAIN ANALYZE SELECT ... ;
EXPLAIN FORMAT=JSON SELECT ... ;
Red flags:
Seq Scan / type: ALL on a large table — missing or unusable index
Using filesort / Using temporary — ORDER BY or GROUP BY cannot use an index
- Estimated rows ≪ actual rows — run
ANALYZE <table> to refresh statistics
- Buffers: read ≫ shared hit — cold cache or missing index, lots of disk I/O
Migrations
Versioning
- Track applied migrations in a
schema_migrations(version, name, applied_at) table.
- Name migrations with a sequence and a verb:
0001_create_users.sql, 0042_add_email_index.sql.
- Each migration: one logical change. Never mix schema and large data backfills in one file.
- Provide an
up and down (rollback) script for every migration.
- Never edit a deployed migration — write a new one that supersedes it.
Safe Schema Evolution
- Adding a column: nullable + default
NULL is instant. NOT NULL with a volatile default
(NOW(), gen_random_uuid()) requires a full rewrite — backfill in batches first, then add the
constraint.
- Renaming a column: do it in three deploys — add new, dual-write, drop old.
- Adding indexes online:
- PostgreSQL:
CREATE INDEX CONCURRENTLY (cannot run inside a transaction).
- MySQL:
ALTER TABLE ... ADD INDEX, ALGORITHM=INPLACE, LOCK=NONE.
- DDL transactionality: PostgreSQL wraps DDL in transactions; MySQL does not for most DDL.
- Test on a production-shaped dataset before deploying.
ORM Integration
SQLAlchemy (Python)
from sqlalchemy import ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, selectinload
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(unique=True, index=True)
orders: Mapped[list["Order"]] = relationship(back_populates="user")
class Order(Base):
__tablename__ = "order"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True)
user: Mapped[User] = relationship(back_populates="orders")
# N+1 prevention — eager-load with a single extra query.
stmt = select(User).options(selectinload(User.orders))
SQLx (Rust)
#[derive(sqlx::FromRow)]
struct User {
id: i64,
email: String,
}
let user = sqlx::query_as::<_, User>(
r#"SELECT id, email FROM "user" WHERE id = $1"#,
)
.bind(user_id)
.fetch_one(&pool)
.await?;
Universal ORM Rules
- ORMs handle parameterization — never f-string or
format! user input into SQL.
- Watch the wire: log generated SQL during development. ORMs hide N+1 queries by default.
- Eager-load explicitly when you know you will iterate child relations.
- Drop to raw SQL for analytical queries the ORM cannot express efficiently.
Connection Pooling
- Always pool. Opening a connection per request exhausts file descriptors and wastes time.
- Pool size: start at
2 * CPU cores for the database server; tune from there. More connections
than the DB can usefully run causes contention, not throughput.
- Set timeouts:
acquire_timeout, idle_timeout, max_lifetime.
- PostgreSQL at scale: front the database with
pgbouncer (transaction pooling mode for
short-lived statements; session pooling if you use prepared statements or SET LOCAL).
- MySQL: use a pool in the application (HikariCP, r2d2, sqlx pool). Match
max_connections on
the server.
- Serverless / Lambda: use a connection proxy (RDS Proxy, pgbouncer, PlanetScale). Direct pools
do not survive cold starts well.
Security
Parameterized Queries (Mandatory)
# BAD — SQL injection
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
# GOOD — parameter binding
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
// BAD
let q = format!("SELECT * FROM users WHERE id = {user_id}");
// GOOD
sqlx::query("SELECT * FROM users WHERE id = $1").bind(user_id);
Placeholder syntax: PostgreSQL $1, MySQL ?, SQLite ? or :name.
Access Control
- Grant the minimum privileges needed. Application roles get
SELECT, INSERT, UPDATE, DELETE
on specific tables — never ALL PRIVILEGES, never SUPERUSER.
- Separate read-only and read-write credentials; route reads to replicas with the read role.
- For multi-tenant systems, enforce isolation in the database, not just the application — PostgreSQL
Row Level Security is the gold standard. See references/postgresql.md.
Secrets
- Never commit credentials. Read from environment, secret manager, or mounted secrets file.
- Rotate credentials regularly. Application code must re-read credentials, not cache them.
Choosing a Dialect
| Need |
Pick |
| OLTP with rich types, JSONB, RLS, advanced indexing |
PostgreSQL |
| OLTP at very high write throughput, mature tooling |
MySQL (InnoDB) — consider PlanetScale for managed Vitess |
| Embedded, single-writer, zero-config |
SQLite |
| Analytics over warehoused data |
Use a warehouse (Snowflake, BigQuery, Databricks) — not a transactional DB |
Anti-Patterns Recap
SELECT * in production code
- Missing index on a foreign key column
OFFSET-based pagination on large datasets
- Function calls or implicit casts on indexed columns in
WHERE
- Random UUIDv4 as a clustered primary key
- Schema changes outside the migration system
- Business logic in stored procedures (keep it in application code)
- Multiple databases sharing a schema across services (use one owner per schema)
- Trusting the ORM to be efficient without inspecting generated SQL
- Storing money in
FLOAT or timestamps without a timezone
Source: mitch-avis/agent-skills — distributed by TomeVault.
1---2name: mitch-avis-agent-skills-sql-database3description: SQL Database4---56# SQL Database78Design, query, and operate relational databases safely and performantly. Multi-dialect (PostgreSQL,9MySQL, SQLite) with explicit guidance per engine where it matters.1011## When to Use1213- Designing or reviewing schemas, indexes, or constraints14- Writing migrations or evolving a deployed schema safely15- Tuning slow queries or interpreting `EXPLAIN` output16- Choosing between SQL engines for a new service17- Integrating SQLAlchemy, Diesel, SQLx, or another ORM/query builder18- Reviewing SQL for injection risk, N+1 queries, or other anti-patterns1920## Related Skills2122- [python](../python/SKILL.md) — SQLAlchemy / asyncpg integration23- [python-async](../python-async/SKILL.md) — async DB drivers and connection pools24- [rust](../rust/SKILL.md) — Diesel / SQLx / SeaORM integration25- [observability](../observability/SKILL.md) — query metrics, slow-query logging, trace spans26- [systematic-debugging](../systematic-debugging/SKILL.md) — diagnose query regressions with27 `EXPLAIN`2829## Reference Guides3031Load on demand — do not read all files upfront.3233| Topic | File | Load When |34| -------------------- | --------------------------------- | ----------------------------------------------- |35| Indexing strategy | references/indexing.md | Designing or auditing indexes |36| Query patterns | references/query-patterns.md | Tuning, pagination, N+1, EXPLAIN deep dives |37| PostgreSQL specifics | references/postgresql.md | JSONB, arrays, RLS, MVCC, type quirks |38| MySQL specifics | references/mysql.md | InnoDB, online DDL, isolation, partitioning |39| SQLite specifics | references/sqlite.md | Embedded apps, FTS5, WAL mode, pragmas |4041## Iron Laws42431. **Always parameterize.** Never concatenate or interpolate user input into SQL strings.442. **Always profile with `EXPLAIN`.** Decisions about indexes and query shape need evidence.453. **Always migrate.** Every DDL change goes through a versioned migration with a rollback path.464. **Never `SELECT *` in application code.** Name the columns you need.475. **Never trust the optimizer for arbitrary input.** Add the indexes that hot paths require.4849## Schema Design5051### Normalization5253Start in 3rd normal form. Denormalize only after measurement proves a join is the bottleneck.54Premature denormalization creates update anomalies and maintenance burden.5556### Primary Keys5758| Engine | Recommended PK | Notes |59| ---------- | ------------------------------------------- | ----------------------------------------------- |60| PostgreSQL | `BIGINT GENERATED ALWAYS AS IDENTITY` | Use `uuidv7()` (PG18+) only when needed |61| MySQL | `BIGINT UNSIGNED AUTO_INCREMENT` | InnoDB clusters by PK — keep narrow + monotonic |62| SQLite | `INTEGER PRIMARY KEY` | Aliases `ROWID`; do not use `BIGINT` |6364- Avoid random UUIDs (UUIDv4) as **clustered** PKs — they fragment the heap and bloat indexes.65- If global uniqueness is required, store the UUID in a secondary `UNIQUE` column.66- Sequences and auto-increment counters have **gaps** after rollbacks or crashes — this is expected,67 not a bug. Do not try to make IDs consecutive.6869### Foreign Keys7071- Always declare FKs at the database level. Application-only referential integrity drifts.72- Specify `ON DELETE` / `ON UPDATE` behavior explicitly (`CASCADE`, `RESTRICT`, `SET NULL`).73- **PostgreSQL and MySQL do not auto-index FK columns.** Add the index manually — without it, joins74 are slow and `DELETE` on the parent acquires a full-table lock.75- **SQLite**: enable FKs explicitly per connection: `PRAGMA foreign_keys = ON;`.7677### Naming Conventions7879- `snake_case` for tables, columns, constraints, and indexes80- Singular table names (`user`, `order`) — match the row, not the collection81- `_id` suffix for foreign keys (`customer_id`, not `customer`)82- Constraint names: `pk_<table>`, `fk_<table>_<column>`, `uq_<table>_<column>`,83 `idx_<table>_<column>`84- PostgreSQL lowercases unquoted identifiers — never quote a mixed-case name into existence8586### Required Columns8788Every reference table gets:8990- A primary key91- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` (or dialect equivalent)92- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` with a trigger or ORM hook93- Indexes on every FK and every queried column9495## Data Types9697| Purpose | PostgreSQL | MySQL | SQLite |98| ------------------ | --------------------------------------- | ---------------------------------- | --------------------- |99| Integer ID | `BIGINT GENERATED ALWAYS AS IDENTITY` | `BIGINT UNSIGNED AUTO_INCREMENT` | `INTEGER PRIMARY KEY` |100| Timestamp | `TIMESTAMPTZ` | `DATETIME(6)` | `TEXT` (ISO-8601) |101| Money / decimal | `NUMERIC(p, s)` | `DECIMAL(p, s)` | `NUMERIC` |102| Variable text | `TEXT` | `VARCHAR(n)` + `utf8mb4` | `TEXT` |103| Boolean | `BOOLEAN` | `TINYINT(1)` | `INTEGER` 0/1 |104| JSON | `JSONB` (always — never `JSON`) | `JSON` | `TEXT` + JSON1 ext. |105| Enum | `CREATE TYPE foo AS ENUM (...)` | lookup table (preferred) | `TEXT` + `CHECK` |106| UUID | `UUID` | `BINARY(16)` (use `UUID_TO_BIN`) | `BLOB` or `TEXT` |107| Bytes | `BYTEA` | `VARBINARY(n)` / `BLOB` | `BLOB` |108109### Universal Type Rules110111- **Money is `NUMERIC` / `DECIMAL`, never `FLOAT` or `DOUBLE`.** Floats lose pennies.112- **Timestamps are timezone-aware.** Store UTC; convert at the edge.113- **Use the smallest type that fits** — narrower rows mean more rows per page and better cache114 behavior.115- **`NOT NULL` everywhere it is semantically required.** Provide `DEFAULT` for common values.116117### PostgreSQL Type Notes118119- Prefer `TEXT` over `VARCHAR(n)` — there is no performance difference and length limits belong in120 `CHECK` constraints.121- Prefer `TIMESTAMPTZ` — never `TIMESTAMP` (no timezone), `TIMETZ`, or `TIMESTAMP(n)`.122- Avoid: `SERIAL` / `BIGSERIAL` (use `IDENTITY`), `MONEY` (use `NUMERIC`), `CHAR(n)`.123- Use `CITEXT` (or an expression index on `LOWER(col)`) for case-insensitive lookups.124125### MySQL Type Notes126127- Default charset / collation: `utf8mb4` / `utf8mb4_0900_ai_ci`. Plain `utf8` is **not** UTF-8.128- Prefer `DATETIME` over `TIMESTAMP` — `TIMESTAMP` has a 2038 cliff and timezone surprises.129- `ENUM` bakes values into the schema and is hard to evolve — use a lookup table instead.130131## Indexing132133Quick rules. See [references/indexing.md](references/indexing.md) for full design patterns.134135### What to Index136137- Columns appearing in `WHERE`, `JOIN`, `ORDER BY`, or `GROUP BY`138- Every foreign key column (manual, not automatic)139- Columns frequently filtered together — as a single composite index140141### Composite Index Order142143**Leftmost-prefix rule:** equality predicates first, then range, then sort.144145```sql146-- Query: WHERE user_id = ? AND status = ? AND created_at > ? ORDER BY created_at147CREATE INDEX idx_orders_user_status_created148 ON orders (user_id, status, created_at DESC);149```150151A range predicate (`>`, `<`, `BETWEEN`) stops index usage for columns that follow it.152153### Index Types Worth Knowing154155- **Covering / `INCLUDE`** — non-key columns added to enable index-only scans.156- **Partial** (PostgreSQL, SQLite) — `... WHERE status = 'active'` shrinks index to hot rows.157- **Expression** — `INDEX ON users (LOWER(email))` for case-insensitive matches.158- **PostgreSQL GIN** — `JSONB`, arrays, full-text search.159- **PostgreSQL GiST** — ranges, geometry, exclusion constraints.160- **MySQL `FULLTEXT`** — text search via `MATCH ... AGAINST`.161- **SQLite FTS5** — virtual table for full-text search.162163### Avoid Over-Indexing164165Every index slows writes and consumes storage. Audit periodically:166167```sql168-- PostgreSQL: indexes that have never been read169SELECT schemaname, relname, indexrelname, idx_scan170 FROM pg_stat_user_indexes171 WHERE idx_scan = 0;172173-- MySQL 8.0+174SELECT object_schema, object_name, index_name175 FROM performance_schema.table_io_waits_summary_by_index_usage176 WHERE index_name IS NOT NULL AND count_star = 0;177```178179## Query Patterns180181See [references/query-patterns.md](references/query-patterns.md) for the full cookbook (pagination182styles, EXPLAIN walkthroughs, window functions, CTEs).183184### Anti-Patterns to Avoid185186| Anti-pattern | Why it hurts | Fix |187| ------------------------- | -------------------------------------------- | ------------------------------------------------ |188| `SELECT *` | Wastes I/O; breaks index-only scans | Name the columns |189| Function in `WHERE` | `WHERE YEAR(created_at) = 2024` skips index | `created_at >= '2024-01-01' AND < '2025-01-01'` |190| `OFFSET` pagination | `OFFSET 100000` re-scans skipped rows | Cursor / keyset pagination |191| Correlated subquery | Runs once per outer row | Convert to `JOIN` + `GROUP BY`, or window fn |192| `DISTINCT` to fix joins | Hides a missing or wrong join condition | Fix the join; aggregate intentionally |193| N+1 queries from ORM | One query per parent row | Eager-load, batch-load, or join |194| Implicit `CROSS JOIN` | Comma-separated `FROM` without `ON` | Use explicit `INNER JOIN ... ON` |195196### Cursor (Keyset) Pagination197198```sql199-- BAD: OFFSET cost grows with offset200SELECT id, title, created_at201 FROM posts202 ORDER BY created_at DESC, id DESC203 LIMIT 20 OFFSET 100000;204205-- GOOD: O(log n) lookup using the previous page's last (created_at, id)206SELECT id, title, created_at207 FROM posts208 WHERE (created_at, id) < ($1, $2)209 ORDER BY created_at DESC, id DESC210 LIMIT 20;211```212213### Batch Writes214215```sql216-- BAD: one round-trip per row217INSERT INTO products (name, price) VALUES ('A', 1);218INSERT INTO products (name, price) VALUES ('B', 2);219220-- GOOD: one round-trip221INSERT INTO products (name, price) VALUES ('A', 1), ('B', 2), ('C', 3);222223-- Better when many rows: COPY (PostgreSQL) or LOAD DATA INFILE (MySQL)224```225226### `EXPLAIN` Quick Read227228```sql229-- PostgreSQL230EXPLAIN (ANALYZE, BUFFERS) SELECT ... ;231232-- MySQL 8.0+233EXPLAIN ANALYZE SELECT ... ;234EXPLAIN FORMAT=JSON SELECT ... ;235```236237Red flags:238239- **`Seq Scan` / `type: ALL`** on a large table — missing or unusable index240- **`Using filesort` / `Using temporary`** — `ORDER BY` or `GROUP BY` cannot use an index241- **Estimated rows ≪ actual rows** — run `ANALYZE <table>` to refresh statistics242- **Buffers: read** ≫ **shared hit** — cold cache or missing index, lots of disk I/O243244## Migrations245246### Versioning247248- Track applied migrations in a `schema_migrations(version, name, applied_at)` table.249- Name migrations with a sequence and a verb: `0001_create_users.sql`, `0042_add_email_index.sql`.250- Each migration: one logical change. Never mix schema and large data backfills in one file.251- Provide an `up` and `down` (rollback) script for every migration.252- Never edit a deployed migration — write a new one that supersedes it.253254### Safe Schema Evolution255256- **Adding a column**: nullable + default `NULL` is instant. `NOT NULL` with a volatile default257 (`NOW()`, `gen_random_uuid()`) requires a full rewrite — backfill in batches first, then add the258 constraint.259- **Renaming a column**: do it in three deploys — add new, dual-write, drop old.260- **Adding indexes online**:261 - PostgreSQL: `CREATE INDEX CONCURRENTLY` (cannot run inside a transaction).262 - MySQL: `ALTER TABLE ... ADD INDEX, ALGORITHM=INPLACE, LOCK=NONE`.263- **DDL transactionality**: PostgreSQL wraps DDL in transactions; MySQL does not for most DDL.264- **Test on a production-shaped dataset** before deploying.265266## ORM Integration267268### SQLAlchemy (Python)269270```python271from sqlalchemy import ForeignKey, select272from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, selectinload273274275class Base(DeclarativeBase):276 pass277278279class User(Base):280 __tablename__ = "user"281 id: Mapped[int] = mapped_column(primary_key=True)282 email: Mapped[str] = mapped_column(unique=True, index=True)283 orders: Mapped[list["Order"]] = relationship(back_populates="user")284285286class Order(Base):287 __tablename__ = "order"288 id: Mapped[int] = mapped_column(primary_key=True)289 user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True)290 user: Mapped[User] = relationship(back_populates="orders")291292293# N+1 prevention — eager-load with a single extra query.294stmt = select(User).options(selectinload(User.orders))295```296297### SQLx (Rust)298299```rust300#[derive(sqlx::FromRow)]301struct User {302 id: i64,303 email: String,304}305306let user = sqlx::query_as::<_, User>(307 r#"SELECT id, email FROM "user" WHERE id = $1"#,308)309.bind(user_id)310.fetch_one(&pool)311.await?;312```313314### Universal ORM Rules315316- ORMs handle parameterization — never f-string or `format!` user input into SQL.317- Watch the wire: log generated SQL during development. ORMs hide N+1 queries by default.318- Eager-load explicitly when you know you will iterate child relations.319- Drop to raw SQL for analytical queries the ORM cannot express efficiently.320321## Connection Pooling322323- **Always pool.** Opening a connection per request exhausts file descriptors and wastes time.324- **Pool size**: start at `2 * CPU cores` for the database server; tune from there. More connections325 than the DB can usefully run causes contention, not throughput.326- **Set timeouts**: `acquire_timeout`, `idle_timeout`, `max_lifetime`.327- **PostgreSQL at scale**: front the database with `pgbouncer` (transaction pooling mode for328 short-lived statements; session pooling if you use prepared statements or `SET LOCAL`).329- **MySQL**: use a pool in the application (HikariCP, r2d2, sqlx pool). Match `max_connections` on330 the server.331- **Serverless / Lambda**: use a connection proxy (RDS Proxy, pgbouncer, PlanetScale). Direct pools332 do not survive cold starts well.333334## Security335336### Parameterized Queries (Mandatory)337338```python339# BAD — SQL injection340cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")341342# GOOD — parameter binding343cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))344```345346```rust347// BAD348let q = format!("SELECT * FROM users WHERE id = {user_id}");349350// GOOD351sqlx::query("SELECT * FROM users WHERE id = $1").bind(user_id);352```353354Placeholder syntax: PostgreSQL `$1`, MySQL `?`, SQLite `?` or `:name`.355356### Access Control357358- Grant the minimum privileges needed. Application roles get `SELECT`, `INSERT`, `UPDATE`, `DELETE`359 on specific tables — never `ALL PRIVILEGES`, never `SUPERUSER`.360- Separate read-only and read-write credentials; route reads to replicas with the read role.361- For multi-tenant systems, enforce isolation in the database, not just the application — PostgreSQL362 Row Level Security is the gold standard. See [references/postgresql.md](references/postgresql.md).363364### Secrets365366- Never commit credentials. Read from environment, secret manager, or mounted secrets file.367- Rotate credentials regularly. Application code must re-read credentials, not cache them.368369## Choosing a Dialect370371| Need | Pick |372| --------------------------------------------------- | -------------------------------------------------------------------------- |373| OLTP with rich types, JSONB, RLS, advanced indexing | PostgreSQL |374| OLTP at very high write throughput, mature tooling | MySQL (InnoDB) — consider PlanetScale for managed Vitess |375| Embedded, single-writer, zero-config | SQLite |376| Analytics over warehoused data | Use a warehouse (Snowflake, BigQuery, Databricks) — not a transactional DB |377378## Anti-Patterns Recap379380- `SELECT *` in production code381- Missing index on a foreign key column382- `OFFSET`-based pagination on large datasets383- Function calls or implicit casts on indexed columns in `WHERE`384- Random UUIDv4 as a clustered primary key385- Schema changes outside the migration system386- Business logic in stored procedures (keep it in application code)387- Multiple databases sharing a schema across services (use one owner per schema)388- Trusting the ORM to be efficient without inspecting generated SQL389- Storing money in `FLOAT` or timestamps without a timezone390391---392> Source: [mitch-avis/agent-skills](https://github.com/mitch-avis/agent-skills) — distributed by [TomeVault](https://tomevault.io).393<!-- tomevault:4.0:skill_md:2026-05-22 -->