Use when designing, querying, indexing or operating a MySQL or MariaDB database and engine-specific behaviour matters — schema and type choices, index design, reading EXPLAIN, online schema change, replication and replica lag, locking and InnoDB deadlocks, charset traps, and server config. NOT portable SELECT/JOIN/window-function craft (that is `sql`), NOT PostgreSQL engine behaviour like VACUUM or JSONB (that is `postgresdb`), NOT the PlanetScale/Vitess branch-and-deploy workflow (that is `p...
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill mysql --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Mysql?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-mysql)More formats (shields.io, HTML) on the badges page.
---
name: mysql
description: "Use when designing, querying, indexing or operating a MySQL or MariaDB database and engine-specific behaviour matters — schema and type choices, index design, reading EXPLAIN, online schema change, replication and replica lag, locking and InnoDB deadlocks, charset traps, and server config. NOT portable SELECT/JOIN/window-function craft (that is `sql`), NOT PostgreSQL engine behaviour like VACUUM or JSONB (that is `postgresdb`), NOT the PlanetScale/Vitess branch-and-deploy workflow (that is `planetscale`)."
tags: [mysql, mariadb, innodb, explain, replication, indexing, online-ddl, database]
recommends: [sql, postgresdb, planetscale, db-migrations, backups]
origin: risco
---
# MySQL / MariaDB engine
You are working below portable SQL, at the layer where the answer depends on *which engine* is
running. This skill owns MySQL 8.4 LTS and MariaDB 11.8 LTS: the **InnoDB** clustered-index storage
model, MySQL-flavoured DDL and types, index design and the leftmost-prefix rule, reading `EXPLAIN`
and fixing the plan, online DDL, replication, locking/deadlocks, and day-2 server config.
The dividing line is simple: **if the answer is identical on PostgreSQL, it belongs in `sql`, not
here.** `sql` owns the dialect-independent SELECT grammar. `mysql` owns how *this* engine stores,
plans, locks, and replicates. `postgresdb` is the peer engine for the other database — same body
shape, different facts, never the same answer.
## When to use
- Designing or reviewing a MySQL/MariaDB schema: engine choice, integer/`DECIMAL`/`VARCHAR` sizing,
`utf8mb4` charset/collation, `JSON` + generated/`STORED` columns, PK design for InnoDB.
- A query is slow or scans too many rows; reading `EXPLAIN` / `EXPLAIN ANALYZE` / `FORMAT=JSON`.
- Choosing or adding an index: composite column order, covering indexes, prefix indexes on `TEXT`,
invisible indexes for safe rollout, why an index is *not* used.
- Schema change on a large/hot table without downtime: `ALGORITHM=INSTANT/INPLACE/COPY`, pt-osc, gh-ost.
- Replication: binlog row format, GTID (incl. tagged GTIDs), replica lag, semi-sync, group replication.
- Locking/concurrency: deadlocks, gap/next-key locks, `REPEATABLE READ`, `SELECT ... FOR UPDATE`.
- Operating the server: buffer pool, `caching_sha2_password` + TLS, slow-query log, `performance_schema`.
- Migrating 5.7/8.0 → 8.4 LTS, or reasoning about MySQL ↔ MariaDB divergence.
## When NOT to use
| The ask | Goes to |
|---|---|
| Portable query craft — joins, window functions, CTEs, NULL 3VL | `sql` |
| PostgreSQL engine behaviour — MVCC, VACUUM, RLS, JSONB, PgBouncer | `postgresdb` |
| PlanetScale / Vitess branch + deploy-request workflow, no-FK design | `planetscale` |
| Vendor-neutral migration *theory* — expand-contract, batched backfill | `db-migrations` |
| ORM / query-builder API ergonomics | `drizzle-orm`, `prisma-orm` |
| Backup *strategy* / retention / restore drills as a discipline | `backups` |
| OLAP / columnar analytics | `clickhouse-analytics`, `duckdb` |
The boundaries with `planetscale` and `db-migrations` are sharp: this skill owns the raw-MySQL
mechanics (`EXPLAIN`, index choice, `ALGORITHM=`, gh-ost). PlanetScale wraps those in its platform
workflow; `db-migrations` wraps them in vendor-neutral strategy. You own the knobs they ride on.
## Pick your version first
Get this wrong and every later decision (auth, vector, isolation defaults) is wrong too.
| Target | Use it when | Watch out |
|---|---|---|
| **MySQL 8.4 LTS** | Default for conservative production. GA 2024-04-30, supported through April 2032. | `mysql_native_password` is **disabled by default** here. |
| **MySQL 9.x Innovation** | Only if you need `VECTOR` or the newest features and accept short support. | Short-lived track; `mysql_native_password` is **removed**. Not for stable prod. |
| **MariaDB 11.8 LTS** | The fork; 2025 yearly LTS, first MariaDB LTS with native vector search. | Auth, vector syntax, and `RETURNING` differ from MySQL — **not drop-in compatible**; `innodb_snapshot_isolation` defaults **ON**. |
`VECTOR` is a MySQL 9.0 (Innovation) feature, *not* in 8.4 LTS. MariaDB 11.8 also has `VECTOR` but
with different functions (`VEC_DISTANCE_COSINE()` vs MySQL's `STRING_TO_VECTOR()`) — see
`references/mysql-vs-mariadb.md`. Do not assume one's vector SQL runs on the other.
## Non-negotiables
1. **`utf8mb4`, always — at the column level.** Legacy `utf8` (alias `utf8mb3`) is 3-byte and
silently truncates emoji and supplementary characters. Default collation is `utf8mb4_0900_ai_ci`.
Setting it on the connection only is not enough; set it on the column.
2. **Small monotonic PRIMARY KEY.** An InnoDB table *is* its PK B-tree, and every secondary index
stores the PK as its row pointer. A random `UUID`/`CHAR(36)` PK bloats every secondary index and
wrecks insert locality. Use `BIGINT AUTO_INCREMENT` or an ordered UUIDv7 stored as `BINARY(16)`.
3. **`binlog_format=ROW` + GTID.** ROW is the only reliable replication format; GTID gives each
transaction a globally unique id with auto-skip so it applies at most once per replica.
4. **`caching_sha2_password` + TLS.** It is the default auth plugin and SHA-256 based; clients need
TLS for first-time auth. `mysql_native_password` is disabled by default in 8.4 and gone in 9.0 —
do not design around it.
5. **Index column order follows the leftmost prefix.** `INDEX (a,b,c)` serves `a`, `a,b`, `a,b,c` —
never `b` alone. Put equality columns first, then the range/`ORDER BY` column.
6. **Never `ALTER` a hot table without choosing an algorithm.** Default `COPY` locks and rebuilds.
Pick `INSTANT`/`INPLACE`, or use gh-ost / pt-osc, *before* you run it at peak.
7. **`REPEATABLE READ` + next-key (gap) locks → short, consistently-ordered transactions.** This is
the InnoDB default and the usual deadlock source. Acquire rows in the same order everywhere.
8. **Measure with `EXPLAIN ANALYZE`, do not guess.** The optimizer's `rows` is an estimate;
`EXPLAIN ANALYZE` runs the query and reports actual rows and timing.
## Index decision
| You have | Use |
|---|---|
| One column in `WHERE`, high selectivity | Single-column index |
| Multiple `WHERE` columns + an `ORDER BY` | Composite index: equality cols first, then range/sort col (leftmost prefix) |
| Query reads only indexed columns | Covering index (add the selected cols) — avoids the PK back-lookup |
| Filtering a long `TEXT`/`VARCHAR` prefix | Prefix index `col(20)` — can't be covering, watch selectivity |
| Rolling out an index on a hot table safely | `INVISIBLE` index, then flip `VISIBLE` once verified |
```sql
-- Bad: separate single-column indexes; the optimizer uses at most one, then filesorts.
CREATE INDEX idx_uid ON orders (user_id);
CREATE INDEX idx_created ON orders (created_at);
-- Query: WHERE user_id = ? AND created_at >= ? ORDER BY created_at DESC
-- Good: one composite index — equality (user_id) first, then the range/sort column.
-- This serves the WHERE and the ORDER BY with no separate sort step.
CREATE INDEX idx_user_created ON orders (user_id, created_at);
```
## Read EXPLAIN
`EXPLAIN` shows the plan; `EXPLAIN ANALYZE` runs it and reports actual rows/time;
`EXPLAIN FORMAT=JSON` shows cost and used-key-parts. Read the access `type` first — it is the ladder
from worst to best:
`ALL` (full scan) → `index` (full index scan) → `range` → `ref` → `eq_ref` → `const`.
Anything `ALL` on a large table is a red flag. Then check `rows` (estimated rows examined),
`filtered` (% surviving the `WHERE`), and the `Extra` flags: `Using filesort` (extra sort pass),
`Using temporary` (materialised temp table), `Using index` (covering — good, no back-lookup).
The most common cause of a missed index is a **non-sargable predicate** — a function or implicit
charset/type cast wrapping the indexed column:
```sql
-- Bad: DATE() wraps the indexed column → the index on created_at can't be used → type=ALL.
SELECT * FROM orders WHERE DATE(created_at) = '2026-06-01';
-- Good: range over the raw column → index range scan (type=range).
SELECT * FROM orders
WHERE created_at >= '2026-06-01' AND created_at < '2026-06-02';
```
A subtler version: joining a `utf8mb4` column to a `latin1` column, or a `VARCHAR` to an `INT`,
forces a per-row cast and disables the index. Make both sides the same type and collation. Full
field-by-field reading, the `type` ladder, and every "why no index" cause are in
`references/indexing-and-explain.md`.
## Online DDL chooser
| Operation / situation | Use |
|---|---|
| Add column at end, rename column, set default, drop index | `ALGORITHM=INSTANT` — metadata-only, near-free (8.0+) |
| Add secondary index, change column nullability inplace | `ALGORITHM=INPLACE, LOCK=NONE` — rebuilds without blocking most writes |
| What INSTANT/INPLACE can't do, on a small/cold table | `ALGORITHM=COPY` — locks + rebuilds; fine off-hours |
| Same change on a large/hot table, zero downtime | `gh-ost` or `pt-online-schema-change` — shadow table + swap |
```sql
-- INSTANT: adding a column at the end is metadata-only in 8.0+. Always be explicit so a
-- silent fall-through to COPY (which locks) can't happen.
ALTER TABLE orders ADD COLUMN note VARCHAR(255) NULL, ALGORITHM=INSTANT, LOCK=NONE;
```
```bash
# gh-ost: build a shadow table, copy + tail the binlog, then atomic cutover. Always --dry-run
# first; throttle on replica lag so you don't melt production.
gh-ost \
--host=primary.db --database=shop --table=orders \
--alter="ADD INDEX idx_user_created (user_id, created_at)" \
--max-lag-millis=1500 --throttle-control-replicas="replica1.db" \
--execute # drop --execute to dry-run
```
If gh-ost refuses to read the binlog, run `pt-online-schema-change`, which uses triggers instead.
Both, plus the rollback path and how this composes with `db-migrations` expand-contract theory, are
in `references/online-ddl-and-migrations.md`.
## Copy-paste patterns
```sql
-- Covering index: the query reads only (user_id, status, total), so put them all in the index.
-- EXPLAIN then shows "Using index" — no trip back to the PK leaf for each row.
SELECT status, total FROM orders WHERE user_id = ?;
CREATE INDEX idx_cover ON orders (user_id, status, total);
```
```sql
-- GTID replication on the replica: GTID auto-positioning, no log file/pos bookkeeping.
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='primary.db', SOURCE_USER='repl', SOURCE_PASSWORD='***',
SOURCE_SSL=1, SOURCE_AUTO_POSITION=1;
START REPLICA;
```
```sql
-- Replica lag: read the field, don't eyeball. Seconds_Behind_Source is coarse; for accuracy use
-- performance_schema replication tables. NULL means replication is broken, not "0 lag".
SHOW REPLICA STATUS\G -- Replica_IO_Running / Replica_SQL_Running / Seconds_Behind_Source
SELECT * FROM performance_schema.replication_applier_status_by_worker;
```
```sql
-- Deadlock post-mortem: InnoDB rolls back the cheaper transaction and logs the cycle here.
SHOW ENGINE INNODB STATUS\G -- read the LATEST DETECTED DEADLOCK section
```
```bash
# Consistent logical dump without locking every table: single transaction over InnoDB.
mysqldump --single-transaction --set-gtid-purged=AUTO --routines --triggers shop > shop.sql
```
Replication topologies (async / semi-sync / group replication / InnoDB Cluster + MySQL Router),
failover, and read-replica routing are in `references/replication-and-ha.md`.
## MySQL vs MariaDB divergence
They share a heritage and diverge in ways that break copy-pasted SQL. Do not assume parity.
| Area | MySQL 8.4 / 9.x | MariaDB 11.8 |
|---|---|---|
| Default auth | `caching_sha2_password` | `mysql_native_password` / `ed25519` |
| `VECTOR` | MySQL 9.0+ only; `STRING_TO_VECTOR()` | Native in 11.8; `VEC_DISTANCE_COSINE()` — different syntax |
| `RETURNING` | `INSERT ... RETURNING` only (8.0+) | `INSERT`/`UPDATE`/`DELETE ... RETURNING` |
| Sequences | No `CREATE SEQUENCE` | `CREATE SEQUENCE` supported |
| System-versioned (temporal) tables | Not supported | `WITH SYSTEM VERSIONING` supported |
| Snapshot isolation | RR snapshot, no write-conflict detection | `innodb_snapshot_isolation` defaults **ON** |
| JSON | Native binary `JSON` type | Historically a `LONGTEXT` alias; check version |
Depth and both-direction migration gotchas: `references/mysql-vs-mariadb.md`.
## Anti-patterns / rationalizations → STOP
| Rationalization | Reality | Do instead |
|---|---|---|
| "`utf8` is Unicode, it's fine." | `utf8` = 3-byte `utf8mb3`; emoji silently become `????`. | `utf8mb4` at the column level. |
| "A random UUID PK is clean and unique." | Random PK bloats every secondary index and kills insert locality in the clustered index. | `BIGINT AUTO_INCREMENT` or ordered UUIDv7 as `BINARY(16)`. |
| "`STATEMENT` binlog is smaller, use it." | Non-deterministic statements replicate wrong; silent data drift on replicas. | `binlog_format=ROW`. |
| "Just keep using `mysql_native_password`." | Disabled by default in 8.4, removed in 9.0 — your upgrade breaks. | `caching_sha2_password` + TLS. |
| "Wrapping the column in `DATE()`/`LOWER()` is readable." | Function on an indexed column → full scan. | Rewrite to a sargable range; or add a generated column + index. |
| "`SELECT *` is convenient." | Pulls wide InnoDB rows off-disk and defeats covering indexes. | Select only needed columns. |
| "I'll hold the transaction open while I do other work." | RR + gap locks held long → deadlocks and lock waits everywhere. | Keep transactions short; commit fast; order rows consistently. |
| "`ALTER` it now, traffic is fine." | `COPY` algorithm locks a multi-GB table; outage at peak. | Pick `INSTANT`/`INPLACE`, or gh-ost off-peak. |
| "`EXPLAIN` says 12 rows, so it's fast." | `rows` is an *estimate* from stats. | Confirm with `EXPLAIN ANALYZE` (actual rows/time). |
| "MyISAM is faster for our table." | No transactions, no FKs, table-level locks, crash-unsafe. | InnoDB for anything transactional. |
## Verify
Run `scripts/verify.sh` from your project root. It is read-only and **never connects to a
database** — it heuristically lints discovered `*.sql` and `*.cnf`/`my.cnf` files for the foot-guns
above (legacy `utf8`, MyISAM, random-UUID PK, `binlog_format=STATEMENT`, `mysql_native_password`,
function-wrapped indexed columns) and checks balanced delimiters. It exits non-zero only on
unbalanced delimiters or a committed `binlog_format=STATEMENT`; every schema heuristic is advisory.
It optionally runs `sqlfluff --dialect mysql` if installed.
## See Also
- `../sql/SKILL.md` — portable, engine-agnostic SELECT/JOIN/window-function craft.
- `../postgresdb/SKILL.md` — the peer engine (PostgreSQL): MVCC, VACUUM, RLS, JSONB.
- `../planetscale/SKILL.md` — the PlanetScale/Vitess platform workflow on top of MySQL.
- `db-migrations` — vendor-neutral migration strategy (expand-contract) that the `ALGORITHM=`/gh-ost
mechanics here ride on.
- `backups` — backup strategy, retention, and restore drills as a discipline.
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!