agentleFS
nledfordnledford/engineering-review-board/skills/mysql-mariadb-sql-engineering/SKILL.md

mysql-mariadb-sql-engineering

Skill11 starsChanged 9 days agoMITView on GitHub

MySQL and MariaDB SQL engineering guidance. Use with sql-engineering when adding, changing, reviewing, testing, or optimizing MySQL or MariaDB schemas, SQL queries, migrations, transactions, constraints, indexes, views, stored routines, replication, privileges, storage engines, connection pooling, or query performance in any language stack. Explicitly name which engine and version a change targets when MySQL and MariaDB diverge. Do not use for unchanged-SQL ORM or adapter mechanics. Use api-design when migrations or query outputs affect published contracts and observability-engineering for MySQL/MariaDB telemetry signals.

The file

Show its rules and text

We split this skill into the rules it gives an agent

39 rules. About 0 are common, 12 are distinctive, and 27 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈12

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 27

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use this skill with sql-engineering for database-native MySQL and MariaDB work independent of the application language.

    line 8
  2. Distinctive:

    Never assume a MariaDB fix or feature applies to MySQL, or the reverse.

    line 22
  3. Distinctive:

    Do not use this skill for generic in-memory domain logic, ORM-only API usage with no SQL/schema behavior, or PostgreSQL/SQLite-specific …

    line 36
  4. Very specific:

    Verify with the target engine and version: migration from empty database, migration from a representative previous schema, focused query …

    line 79
  5. Distinctive:

    When in doubt, treat MySQL and MariaDB as related but distinct engines: state the target engine and version explicitly in schema docs, …

    line 139
  6. Very specific:

    Verify the engine of an existing table before assuming InnoDB behavior: SHOW TABLE STATUS LIKE 'table_name' or SELECT engine FROM …

    line 167
  7. Very specific:

    Use INT/BIGINT for integer identity and counts.

    line 182
  8. Very specific:

    Use DECIMAL(p,s) for money and other exact values.

    line 186
  9. Very specific:

    Use DATETIME for wall-clock/local values with no timezone conversion intent, and TIMESTAMP when you want automatic UTC storage and …

    line 189
  10. Very specific:

    Use VARCHAR(n) sized to the real constraint, not a round number picked without a rationale.

    line 193
  11. Very specific:

    Use ENUM sparingly, only for a genuinely closed, rarely-changing value set documented at the schema level.

    line 197
  12. Very specific:

    Use BOOLEAN/TINYINT(1) for two-state facts.

    line 202
  13. Very specific:

    Use native JSON on MySQL for genuinely semi-structured, sparse, or schema-flexible attributes only, not as a substitute for columns and …

    line 206
  14. Very specific:

    Use covering indexes (all selected/filtered/sorted columns present in the index) to let the optimizer serve a query from the index alone, …

    line 241
  15. Very specific:

    Use prefix indexes (INDEX (col(20))) for long VARCHAR/TEXT columns only after checking selectivity with SELECT COUNT(DISTINCT LEFT(col, …

    line 245
  16. Very specific:

    Do not index every column speculatively.

    line 250
  17. Very specific:

    Use FULLTEXT indexes (InnoDB, MySQL 5.6+/MariaDB 10.0.5+) for genuine text search instead of LIKE '%term%', which cannot use a B-tree index …

    line 255
  18. Very specific:

    Use PRIMARY KEY, NOT NULL, UNIQUE, and foreign keys for invariants the database must enforce.

    line 260
  19. Very specific:

    Use CHECK constraints for value-shape invariants: enforced since MySQL 8.0.16 and MariaDB 10.2.1.

    line 263
  20. Very specific:

    Run with sql_mode including STRICT_TRANS_TABLES (or STRICT_ALL_TABLES) in every environment, including local dev and CI.

    line 274
  21. Very specific:

    Keep predicates sargable: avoid wrapping an indexed column in a function or implicit type conversion in WHERE (WHERE DATE(created_at) = ?

    line 315
  22. Very specific:

    Use INSERT ... ON DUPLICATE KEY UPDATE for upserts.

    line 340
  23. Very specific:

    Prefer set-based operations and batched writes (INSERT ...

    line 345
  24. Very specific:

    Run EXPLAIN (or EXPLAIN FORMAT=JSON for detail on cost estimates, filtered percentage, and applied optimizations) before trusting a query's …

    line 353
  25. Very specific:

    Check key (which index was actually chosen — NULL means none was used), rows (estimated rows examined — large relative to expected result …

    line 359
  26. Very specific:

    Use EXPLAIN ANALYZE for real (not just estimated) execution timing and row counts: available on MySQL 8.0.18+ and MariaDB 10.1+ (ANALYZE …

    line 368
  27. Very specific:

    Use the slow query log (slow_query_log, long_query_time, log_queries_not_using_indexes) and performance_schema …

    line 379
  28. Very specific:

    Do not claim a performance improvement without comparable before/after evidence (timing or EXPLAIN ANALYZE output) captured under …

    line 384
  29. Distinctive:

    Use connection pooling (application-side pool, or a proxy such as ProxySQL/MySQL Router, or driver-native pooling) rather than opening a …

    line 398
  30. Distinctive:

    Always use parameterized queries / prepared statements from the driver, not string formatting, for any value that originates outside the …

    line 406
  31. Distinctive:

    Keep transactions short: open, do the minimal necessary reads/writes, commit or rollback promptly.

    line 410
  32. Very specific:

    Do not rely on autocommit-per-statement for multi-statement business operations.

    line 422
  33. Distinctive:

    Prefer repository recipes when they exist, especially for SQL scripts.

    line 476
  34. Very specific:

    Never put credentialed DSNs, passwords, or connection strings in argv, shell history, logs, examples, or reports.

    line 487
  35. Distinctive:

    Use TDD for query bugs when practical: write a failing fixture or integration test that proves the old behavior is wrong.

    line 503
  36. Distinctive:

    Use BDD examples for data behavior users can observe: duplicate prevention, authorization, lifecycle state, conflict responses, and …

    line 505
  37. Distinctive:

    Test constraints, transactions, and DDL against the actual target engine and version — not SQLite, not the other of MySQL/MariaDB, and not …

    line 507
  38. Distinctive:

    If the project claims support for both engines, run the suite against both in CI rather than assuming parity.

    line 509
  39. Distinctive:

    Before flagging a migration or query as broken on "MySQL/MariaDB" generically, confirm which specific engine and version was actually …

    line 522

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

MySQL And MariaDB SQL Engineering

Use this skill with sql-engineering for database-native MySQL and MariaDB work independent of the application language. Let the database-neutral skill establish shared SQL behavior and use this skill for MySQL/MariaDB-specific semantics. Use postgresql-sql-engineering or sqlite-sql-engineering instead when the target engine is PostgreSQL or SQLite, and only for comparison here. Use rust-persistence-sql for Rust SQLx or SeaQuery adapter details against a MySQL/MariaDB backend.

MySQL and MariaDB forked from a shared codebase and remain close, but they are no longer interchangeable: version numbers do not align, replication formats diverge, and each has features the other lacks. Identify which engine and version a change actually targets before relying on version-specific behavior. Never assume a MariaDB fix or feature applies to MySQL, or the reverse.

Use When

  • Designing or reviewing tables, constraints, indexes, views, stored procedures/functions, triggers, migrations, transactions, privileges, replication topology, or storage engine choice.
  • Writing or reviewing SQL queries, query plans (EXPLAIN), pagination, bulk operations, reporting queries, or data repair scripts.
  • A change needs database correctness, performance, security, observability, or rollback evidence on MySQL or MariaDB.
  • Deciding whether code must work identically on both engines, or is scoped to one.

Do not use this skill for generic in-memory domain logic, ORM-only API usage with no SQL/schema behavior, or PostgreSQL/SQLite-specific behavior except when comparing it to MySQL/MariaDB.

Security Review Routing

Load security-review when MySQL/MariaDB work touches user/role privileges, DEFINER-context views or routines, dynamic SQL or injection risk, tenant isolation, audit logging, sensitive migrations/backfills, data repair scripts, secrets (including .my.cnf/connection strings), or production-data access. Pair it with security-review-evidence when evidence includes redacted SQL, EXPLAIN/ANALYZE output, schema diffs, audit/log samples, dumps, or migration artifacts.

API and Observability Routing

  • Load api-design when MySQL/MariaDB migrations, views, stored routines, reporting outputs, or constraint-error mapping affect an external contract or compatibility promise.
  • Load observability-engineering when MySQL/MariaDB work changes slow-query log, migration, connection-pool, lock-wait, deadlock, replication-lag, or runbook signals.

Workflow

  1. Inspect the real database surface: migrations, schema dumps, SQL files, query builders, ORM mappings, seed data, test fixtures, migration tool, target engine (MySQL or MariaDB) and major/minor version, storage engines in use, replication topology, and CI database setup. Verify version-sensitive behavior in the official documentation for that target engine and version, not only the current documentation and not the other engine's documentation.
  2. State the data behavior before implementation. Use BDD-style examples for observable rules such as uniqueness, authorization, lifecycle transitions, conflict handling, and partial-write prevention.
  3. Model the boundary. Use DDD language for entities, value objects, aggregate-like consistency boundaries, repositories, and invariants. Do not let table shape leak into core domain APIs without a deliberate adapter.
  4. Design schema and migration order: new objects, backfill, constraints, indexes, locks, deploy compatibility, rollback, and validation queries.
  5. Implement SQL with bound parameters, explicit columns, deliberate transaction scope, and safe error mapping at the application edge.
  6. Verify with the target engine and version: migration from empty database, migration from a representative previous schema, focused query tests, and EXPLAIN/EXPLAIN ANALYZE inspection for performance-sensitive paths.

MySQL vs MariaDB: What Actually Diverges

Ranked by how often each difference causes an incident or a wrong assumption:

  1. Storage engine defaults and availability. Both default to InnoDB for normal transactional tables. MariaDB also ships Aria (MyISAM's crash-safe successor, used for some system tables) and, in server builds that include it, ColumnStore for analytics. MySQL ships NDB Cluster (MySQL Cluster) as a separate product; MariaDB does not. Do not assume a storage engine available on one is available, or behaves the same, on the other.
  2. Replication and clustering. MariaDB's Galera-based multi-master clustering (MariaDB Cluster) has no direct MySQL equivalent; MySQL's closest comparable is InnoDB Cluster/Group Replication, a different technology with different failure modes and operational tooling. Binlog formats and GTID implementations are not wire-compatible between the two engines even though both call the concept "GTID." Do not point one engine's replica at the other's binlog stream and expect it to work.
  3. JSON support. MySQL 5.7+ has a native binary JSON column type with validation and indexed generated columns. MariaDB has no native JSON type: JSON is an alias for LONGTEXT with a CHECK constraint that validates JSON shape, and it lacks MySQL's binary storage and some JSON functions. A schema that relies on MySQL's compact JSON storage or a JSON-typed generated/virtual index will not behave the same on MariaDB.
  4. Window functions and CTEs. MySQL added both in 8.0 (2018). MariaDB added both earlier, in 10.2 (2017). A query written against an old MySQL 5.7 target cannot use either; check the actual minimum supported version on each engine independently, not "MySQL 8 or newer" as a stand-in for MariaDB.
  5. Optimizer and EXPLAIN output. MariaDB has its own optimizer (including optimizer switches MySQL lacks, such as engine-condition pushdown tuning) and its own EXPLAIN FORMAT=JSON / ANALYZE FORMAT=JSON shape. MySQL 8.0.18+ supports EXPLAIN ANALYZE in a Postgres-like text tree format; MariaDB's ANALYZE FORMAT=JSON (10.1+) predates that and looks different. Do not copy EXPLAIN interpretation guidance across engines without checking the actual output shape.
  6. Authentication and user management. MySQL 8 defaults to caching_sha2_password; MariaDB defaults to mysql_native_password (or ed25519/unix_socket in some builds). Client libraries and connection poolers must support the target engine's default auth plugin, or connections fail after an otherwise-compatible schema migration.
  7. System versioning and temporal tables. MariaDB 10.3+ has built-in SYSTEM VERSIONING for temporal/history tables. MySQL has no equivalent; the same behavior requires application-level or trigger-based history tables.
  8. Invisible/instant columns and online DDL. Both support some form of ALGORITHM=INSTANT column add (MySQL 8.0.12+, MariaDB 10.3+/10.4+ depending on the operation), but the exact set of DDL operations eligible for instant/online execution differs by engine and version. Confirm the specific ALTER TABLE is actually instant/online on the target engine/version before assuming a large table migration is cheap.
  9. Licensing and distribution. MySQL Community Edition is GPL; MySQL Enterprise Edition is proprietary. MariaDB is GPL/LGPL/BSD depending on component, with no proprietary tier. This affects which build a vendor or managed-cloud offering actually ships, and which features (e.g., MySQL Enterprise Audit, Enterprise Encryption) exist at all.

When in doubt, treat MySQL and MariaDB as related but distinct engines: state the target engine and version explicitly in schema docs, migration comments, and CI matrix configuration, and test both independently if the codebase claims to support both.

Storage Engine Selection

  • InnoDB is the default and correct choice for almost all tables on both engines: it is transactional (ACID), supports row-level locking, foreign keys, crash recovery, and MVCC. Choose it unless a specific, named requirement rules it out.
  • MyISAM is legacy: no transactions, no foreign keys, table-level locking, and it is not crash-safe. Do not choose it for new tables. It survives in old schemas and for a narrow set of full-text-search or read-only archival cases predating InnoDB's full-text support (InnoDB has supported FULLTEXT indexes since MySQL 5.6 / MariaDB 10.0.5); re-evaluate those cases against InnoDB FULLTEXT before keeping MyISAM.
  • Aria (MariaDB only) is MyISAM's crash-safe successor, used internally for some system/temporary tables. It is not a general transactional engine; do not choose it as an InnoDB substitute for application tables that need transactions.
  • MEMORY/HEAP tables are useful only for genuinely disposable, small, session-scoped or cache data that can be lost on restart without consequence. Row-level locking is not available (table-level locks), and data does not survive a server restart.
  • CSV, ARCHIVE, BLACKHOLE are special-purpose (data exchange, compressed append-only logging, replication testing) and not appropriate as general-purpose application storage.
  • Verify the engine of an existing table before assuming InnoDB behavior: SHOW TABLE STATUS LIKE 'table_name' or SELECT engine FROM information_schema.tables WHERE table_schema = ? AND table_name = ?. A mixed-engine schema (some InnoDB, some MyISAM) cannot enforce foreign keys across those tables and loses transactional guarantees for the non-InnoDB ones.

Schema Design

Normalization and Data Types

  • Normalize first (3NF as the default target); denormalize only for a measured read need with an explicit ownership and refresh story, same as any other engine.
  • Choose types by semantics, not convenience:
    • Use INT/BIGINT for integer identity and counts; do not use VARCHAR for numeric IDs. Prefer BIGINT UNSIGNED for auto-increment surrogate keys expected to exceed ~2.1 billion rows (INT UNSIGNED for smaller, bounded tables).
    • Use DECIMAL(p,s) for money and other exact values; never FLOAT or DOUBLE for currency or anything compared for exact equality — binary floating point cannot represent most decimal fractions exactly.
    • Use DATETIME for wall-clock/local values with no timezone conversion intent, and TIMESTAMP when you want automatic UTC storage and conversion tied to the session/server timezone (and note TIMESTAMP's range ends in 2038 on both engines; use DATETIME for far-future dates).
    • Use VARCHAR(n) sized to the real constraint, not a round number picked without a rationale; use TEXT/MEDIUMTEXT/LONGTEXT only for genuinely unbounded content, and know that indexing them requires a prefix length (INDEX (col(191))) or a generated column.
    • Use ENUM sparingly, only for a genuinely closed, rarely-changing value set documented at the schema level; prefer a lookup table or CHECK constraint (MySQL 8.0.16+ / MariaDB 10.2+ enforce CHECK; earlier versions parse but silently ignore it) when the set changes with data rather than schema.
    • Use BOOLEAN/TINYINT(1) for two-state facts; both engines store it as TINYINT(1) — this is cosmetic, not a real boolean type, so do not rely on driver-level boolean coercion without checking how the client library maps it.
    • Use native JSON on MySQL for genuinely semi-structured, sparse, or schema-flexible attributes only, not as a substitute for columns and relations; on MariaDB, remember JSON is LONGTEXT plus a CHECK constraint, so its indexing and validation story differs (index it via a generated column, same as MySQL).
  • Set the connection/schema character set to utf8mb4 (not legacy utf8, which is a 3-byte-max alias that cannot store the full Unicode range, including many emoji and some CJK characters) and an appropriate collation (utf8mb4_0900_ai_ci on MySQL 8, utf8mb4_general_ci or a version-specific utf8mb4_unicode_520_ci/utf8mb4_uca1400_ai_ci on MariaDB depending on version). Set this at database, table, and column creation time deliberately; do not inherit a server default that predates the project.

Indexing Strategy

  • InnoDB tables are clustered by primary key: row data is physically stored in primary-key order, and every secondary index stores the primary key as a pointer back to the row. A wide or ever-changing primary key bloats every secondary index and causes page splits on insert. Prefer a narrow, monotonically increasing primary key (an auto-increment surrogate, or a BIGINT when using something like a Snowflake ID) for high-write tables; avoid UUID (random v4) as a primary key on write-heavy InnoDB tables because it causes random-order inserts, index fragmentation, and poor cache locality — if UUIDs are required, use a time-ordered variant (UUIDv7, or MySQL 8.0's UUID_TO_BIN(UUID(), true) swapped-byte encoding) or add a separate auto-increment clustering key.
  • Index every foreign key column. Unlike PostgreSQL, both engines require an index on the referencing column to define the foreign key constraint at all, so this is enforced at DDL time — but confirm the index actually matches your join/filter pattern rather than relying on the FK-mandated index as sufficient for query performance.
  • Build indexes to match real predicates: equality columns first, then range columns, matching the leftmost-prefix rule for composite indexes — an index on (a, b, c) serves queries filtering on a, a+b, or a+b+c, but not on b alone or c alone.
  • Use covering indexes (all selected/filtered/sorted columns present in the index) to let the optimizer serve a query from the index alone, avoiding a lookup into the clustered row; check Using index in EXPLAIN Extra to confirm.
  • Use prefix indexes (INDEX (col(20))) for long VARCHAR/TEXT columns only after checking selectivity with SELECT COUNT(DISTINCT LEFT(col, 20)) / COUNT(*) FROM table — a prefix index that is not selective enough will not be used, and it cannot serve as a covering index or sort key.
  • Do not index every column speculatively. Every index adds write cost (extra B-tree maintenance on every insert/update/delete) and storage. Justify each index with a real query, and drop indexes proven unused via sys.schema_unused_indexes (performance_schema-based, both engines) or the equivalent information_schema query.
  • Use FULLTEXT indexes (InnoDB, MySQL 5.6+/MariaDB 10.0.5+) for genuine text search instead of LIKE '%term%', which cannot use a B-tree index at all.

Constraints

  • Use PRIMARY KEY, NOT NULL, UNIQUE, and foreign keys for invariants the database must enforce; do not rely on application code alone for uniqueness or referential integrity.
  • Use CHECK constraints for value-shape invariants: enforced since MySQL 8.0.16 and MariaDB 10.2.1. Confirm the target version before relying on CHECK — older versions parse it but silently ignore it, which is a correctness trap, not a compatibility warning.
  • Choose foreign key actions deliberately (ON DELETE CASCADE/RESTRICT/SET NULL/NO ACTION, ON UPDATE CASCADE). InnoDB is the only engine here that enforces foreign keys; a table on MyISAM or MEMORY silently accepts and ignores FK syntax with no enforcement.
  • NOT NULL plus a documented DEFAULT beats a nullable column with application-level "treat null as X" logic; nulls should mean "genuinely unknown/inapplicable," not "empty by convention."
  • Run with sql_mode including STRICT_TRANS_TABLES (or STRICT_ALL_TABLES) in every environment, including local dev and CI. Without strict mode, both engines historically silently truncated or coerced out-of-range/invalid values instead of raising an error — a schema that looks constrained is not actually protecting data if strict mode is off. Confirm the effective mode with SELECT @@sql_mode, since managed hosting defaults vary.

Migrations

  • Distinguish online (non-blocking, ALGORITHM=INSTANT or INPLACE) DDL from a full table rebuild (ALGORITHM=COPY), and check which algorithm a specific ALTER TABLE actually uses on the target engine/version via EXPLAIN or by consulting the online-DDL support table in the version's documentation — the same-looking ALTER TABLE can be instant on one version and a full table copy (locking, or replication-lag-inducing) on another.
  • Adding a column with a default is instant on modern MySQL (8.0.12+) and MariaDB (10.3.7+/10.4+ depending on type); do not assume this on older targets, and never assume it cross-engine without checking each one.
  • For large tables where in-place/instant DDL is unavailable or still too disruptive, use an external online-schema-change tool (pt-online-schema-change, gh-ost) rather than a blocking ALTER TABLE in production; these differ in trigger-based vs. binlog-based approach and each has its own foreign-key and trigger caveats — confirm the repository's chosen tool before assuming either is a drop-in default.
  • Foreign-key-checked migrations on large tables can be slow; disabling FOREIGN_KEY_CHECKS for a single controlled migration transaction is sometimes appropriate, but never leave it disabled as ambient session state and never use it to skip validating data that should already satisfy the constraint.
  • Plan rollback and deploy-order compatibility the same as any other engine: additive-first changes, backfill, validate, then remove/rename in a later deploy.

Query And Transaction Checklist

  • Select explicit columns. Avoid SELECT * — it defeats covering indexes, breaks silently when columns are added/reordered, and wastes network/memory on unused data.
  • Bind user-controlled values via prepared statements or parameterized driver APIs. Never concatenate untrusted input into SQL.
  • Keep predicates sargable: avoid wrapping an indexed column in a function or implicit type conversion in WHERE (WHERE DATE(created_at) = ? cannot use an index on created_at; WHERE created_at >= ? AND created_at < ? can). Implicit string-to-number or number-to-string comparisons silently defeat indexes the same way — match types exactly.
  • Joins preserve intended cardinality; verify with row-count checks or EXPLAIN row estimates that a join is not silently multiplying rows.
  • Paginate with keyset pagination (WHERE (sort_col, id) > (?, ?) ORDER BY sort_col, id LIMIT ?) for large or frequently-changing result sets instead of LIMIT offset, count, which gets slower as offset grows (the server still scans and discards offset rows) and shifts results under concurrent writes.
  • Scope transactions to exactly one consistency boundary. Default isolation is REPEATABLE READ on both engines (unlike PostgreSQL's default of READ COMMITTED) — this affects phantom-read and gap-locking behavior; know which isolation level the code actually needs rather than accepting the default by omission.
  • Name lock-ordering and retry assumptions for concurrent writes. InnoDB's REPEATABLE READ uses next-key locking (record + gap locks) for index-scanning UPDATE/DELETE/SELECT ... FOR UPDATE, which can produce more blocking and more deadlocks than READ COMMITTED under high concurrency; consider READ COMMITTED explicitly for write-heavy hotspots where gap locking causes contention, after confirming the application does not depend on repeatable-read semantics elsewhere in the same transaction.
  • Use INSERT ... ON DUPLICATE KEY UPDATE for upserts; understand it requires a unique/primary key to trigger and that it is a MySQL/MariaDB-specific syntax with different semantics from PostgreSQL's ON CONFLICT (it can update on any unique key collision, not just the one you intended, if the table has more than one).
  • Prefer set-based operations and batched writes (INSERT ... VALUES (...), (...), (...) or bulk-load tools) over row-at-a-time loops from application code; each round trip costs real latency at scale, and single-row autocommit inserts are especially costly under sync_binlog=1 / innodb_flush_log_at_trx_commit=1 durability settings.

EXPLAIN And Query Optimization

  • Run EXPLAIN (or EXPLAIN FORMAT=JSON for detail on cost estimates, filtered percentage, and applied optimizations) before trusting a query's performance. Read the type column for join/access strategy — from best to worst it runs roughly system > const > eq_ref > ref > range > index > ALL (full table scan); ALL on a nontrivial table is the signal to add or fix an index.
  • Check key (which index was actually chosen — NULL means none was used), rows (estimated rows examined — large relative to expected result size means a weak filter or missing index), and Extra: Using filesort and Using temporary on a hot query usually mean the query needs an index that matches its ORDER BY/GROUP BY, or that it can be rewritten to avoid the sort; Using index (covering index) and Using index condition (index condition pushdown) are good signs; Using where alone after an index lookup just means a residual filter applied outside the index, which is often fine.
  • Use EXPLAIN ANALYZE for real (not just estimated) execution timing and row counts: available on MySQL 8.0.18+ and MariaDB 10.1+ (ANALYZE FORMAT=JSON on MariaDB uses a different, JSON-only output format than MySQL's text tree — do not expect the same shape). EXPLAIN ANALYZE actually executes the query (including UPDATE/DELETE/INSERT ... SELECT); wrap mutating statements in a transaction and roll back on disposable or safe data:
    START TRANSACTION;
    EXPLAIN ANALYZE UPDATE orders SET status = 'shipped' WHERE customer_id = ?;
    ROLLBACK;
  • Use the slow query log (slow_query_log, long_query_time, log_queries_not_using_indexes) and performance_schema (events_statements_summary_by_digest) or sys schema views (sys.statement_analysis) to find real offenders under production load rather than guessing from query text alone.
  • Do not claim a performance improvement without comparable before/after evidence (timing or EXPLAIN ANALYZE output) captured under representative data volume and warm/cold cache conditions — EXPLAIN alone estimates, it does not measure.
  • Watch for the N+1 query pattern from ORMs and application loops: one query to fetch a list, then one additional query per row to fetch related data. Replace with a single join, a batched WHERE id IN (...) fetch, or the ORM's eager-loading/prefetch feature. This is invisible in isolated unit tests against small fixtures and only shows up as real latency at production row counts — verify with query logging or count assertions in integration tests, not by inspection alone.

Connection Management And Transactions

  • Use connection pooling (application-side pool, or a proxy such as ProxySQL/MySQL Router, or driver-native pooling) rather than opening a new connection per request; connection setup/teardown and thread creation are real overhead, and MySQL/MariaDB's default thread-per-connection model means an unbounded connection count competes directly for server memory and CPU. Size the pool against the server's max_connections and the number of application instances sharing it, not against a per-instance guess in isolation.
  • Always use parameterized queries / prepared statements from the driver, not string formatting, for any value that originates outside the code. This is both a correctness practice (type coercion, quoting) and the primary SQL injection defense.
  • Keep transactions short: open, do the minimal necessary reads/writes, commit or rollback promptly. A transaction held open across an external network call, a slow report render, or user think-time holds InnoDB locks and old MVCC read-view snapshots (bloating the InnoDB undo log / history list) for the whole duration.
  • Handle deadlocks (ER_LOCK_DEADLOCK, error 1213) with a bounded retry at the transaction boundary; InnoDB detects and breaks deadlocks by rolling back one transaction automatically, so the calling code must be prepared to retry idempotently rather than treat it as a fatal error.
  • Set explicit, sane timeouts: innodb_lock_wait_timeout for row-lock waits, and a statement/connection timeout appropriate to the workload, rather than relying on defaults tuned for a different traffic profile.
  • Do not rely on autocommit-per-statement for multi-statement business operations; wrap them explicitly in START TRANSACTION / COMMIT so a partial failure cannot leave inconsistent state.

Anti-Patterns

Ranked roughly by how much damage each does in a typical application:

  1. SELECT * in application queries. Breaks covering indexes, pulls unnecessary large/TEXT/JSON columns over the wire, and silently changes behavior when a column is added. Select exactly what the caller needs.
  2. N+1 queries from ORMs or loops. One list query followed by one query per row for related data. Invisible at small scale, expensive in production. Fix with joins, WHERE id IN (...) batching, or eager loading.
  3. Storing data in the wrong type, especially: numeric IDs as VARCHAR, money as FLOAT/DOUBLE, dates/times as strings, booleans as ad hoc 'Y'/'N' strings instead of TINYINT(1)/BOOLEAN, and structured data as an unindexed TEXT blob of delimited values. Each blocks correct comparison, sorting, and indexing.
  4. Legacy utf8 (not utf8mb4) character set. Truncates or rejects 4-byte Unicode characters (emoji, some CJK) with confusing errors far from the actual cause. Use utf8mb4 for all new schemas.
  5. LIMIT offset, count pagination on large or hot tables. Degrades as offset grows and shifts under concurrent writes. Use keyset/seek pagination.
  6. No index on foreign key columns (only possible on non-InnoDB tables, since InnoDB requires one) or, more commonly, an index that does not actually match the query's filter/join/sort pattern.
  7. Speculative indexing — adding indexes "just in case" without a query to justify them. Every index has a real write-amplification and storage cost; unused indexes are pure overhead.
  8. Building SQL by string concatenation with any user-controlled value, instead of bound parameters. This is the direct path to SQL injection and should be treated as a security defect, not a style preference.
  9. Wrapping an indexed column in a function in WHERE (WHERE YEAR(created_at) = 2024), which prevents index use even though an equivalent range predicate would use it.
  10. Ambient non-strict sql_mode. Silently truncates or coerces invalid data instead of rejecting it at write time, undermining every type and length constraint in the schema.
  11. Long-held transactions or connections without pooling, causing lock contention, MVCC history-list bloat, and connection exhaustion under load.
  12. Assuming MySQL and MariaDB are interchangeable — deploying against one engine's tested behavior (JSON storage, replication, optimizer output, auth plugin) without verifying the other actually supports it the same way.
  13. Treating application-layer validation as a substitute for database constraints on invariants that matter (uniqueness, required fields, referential integrity).

Commands

Prefer repository recipes when they exist, especially for SQL scripts. When direct commands are needed:

mysql --defaults-extra-file=path/to/client.cnf -e "SOURCE path/to/query.sql;"
mysql -e "SHOW CREATE TABLE table_name\G"
mysql -e "EXPLAIN FORMAT=JSON SELECT ...;"
mysql -e "EXPLAIN ANALYZE SELECT ...;"          -- MySQL 8.0.18+ / MariaDB 10.1+ (different output)
mysqldump --single-transaction --routines --triggers db_name > dump.sql

Never put credentialed DSNs, passwords, or connection strings in argv, shell history, logs, examples, or reports; use a --defaults-extra-file, an environment variable sourced outside the command, or a repository-owned recipe instead.

For mutating EXPLAIN ANALYZE, always wrap in a transaction and roll back on disposable or safe data — it executes the statement, it does not simulate it:

START TRANSACTION;
EXPLAIN ANALYZE UPDATE ...;
ROLLBACK;

Testing And Review

  • Use TDD for query bugs when practical: write a failing fixture or integration test that proves the old behavior is wrong.
  • Use BDD examples for data behavior users can observe: duplicate prevention, authorization, lifecycle state, conflict responses, and atomicity.
  • Test constraints, transactions, and DDL against the actual target engine and version — not SQLite, not the other of MySQL/MariaDB, and not mocks. If the project claims support for both engines, run the suite against both in CI rather than assuming parity.
  • Review plans with EXPLAIN/EXPLAIN ANALYZE for expensive queries under representative data and current statistics (ANALYZE TABLE refreshes InnoDB's persistent optimizer statistics if they are stale). Do not claim a performance improvement without comparable evidence.
  • Review security for injection, least privilege, DEFINER-context routine and view scope, secret handling, audit logging, and safe error messages.
  • Review observability for slow queries, migration failures, connection-pool saturation, deadlocks, lock waits, and replication lag.

Anti-Pattern Verification Note

Before flagging a migration or query as broken on "MySQL/MariaDB" generically, confirm which specific engine and version was actually tested; a finding true on MySQL 5.7 may be fixed in MySQL 8.0, and a finding true on MySQL may not apply to MariaDB at all (or the reverse). Cite the engine and version alongside any claimed defect.

Successful Use

The final handoff names the data behavior protected, the target engine(s) and version(s) verified, migrations or SQL changed, database-specific checks run, query-plan or performance evidence when relevant, and any deploy, rollback, or cross-engine-compatibility risk that remains.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.