DS DevShelfHub Projects · AI tools
Cheatsheets / MySQL
Cheatsheet · Dev tooling

MySQL: InnoDB, JSON, CTEs and Window Functions Reference Guide

By DevShelfHub

Tables, indexes, JOINs, transactions, stored procedures, JSON, window functions, and replication — MySQL 8+ reference covering InnoDB, the utf8mb4 charset, and the modern SQL features that close the gap with PostgreSQL.

116 items 9 min InnoDB JSON EXPLAIN

Start hereQuick start · 6 you’ll reach for daily

Connectmysql -h h -u u -p db
DescribeSHOW CREATE TABLE t\G
ExplainEXPLAIN ANALYZE ...
UpsertON DUPLICATE KEY UPDATE ...
JSON pathdata->>'$.user.id'
Stats refreshANALYZE TABLE t

Target versions · paceVersions

Targets: mysql ≥ 8.0 InnoDB default engine utf8mb4_0900_ai_ci

MySQL 8 brought CTEs (WITH + recursive), window functions, native JSON path, functional indexes, invisible indexes, EXPLAIN ANALYZE, and CHECK constraints — ditch any guide that pre-dates 8.0. MariaDB has diverged on JSON + window-function semantics. Prefer utf8mb4 over utf8 (the legacy 3-byte alias). This sheet pins to MySQL 8.0+; flagged where MariaDB differs.

Install · connect · userSetup

bash
# macOS — Homebrew
brew install mysql
brew services start mysql

# Linux — Debian/Ubuntu
sudo apt install mysql-server
sudo systemctl enable --now mysql

# Docker — disposable dev instance
docker run --name mysql -e MYSQL_ROOT_PASSWORD=dev -p 3306:3306 -d mysql:8

# First-run hardening
mysql_secure_installation

# Connect (CLI)
mysql -h 127.0.0.1 -u root -p shop          # password prompt
mysql -h 127.0.0.1 -u app -p --ssl-mode=REQUIRED
mysql --defaults-file=~/.my.cnf shop        # store creds in client section

# Create user + db
CREATE DATABASE shop CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
CREATE USER 'app'@'%' IDENTIFIED BY 'secret';
GRANT ALL PRIVILEGES ON shop.* TO 'app'@'%';
FLUSH PRIVILEGES;

The clientmysql CLI & meta-commands

Connection & navigation

USE dbname;Switch database.
SHOW DATABASES;List databases.
SHOW TABLES;List tables in the current db.
DESCRIBE t; / DESC t;Columns + types. Same as SHOW COLUMNS FROM t.
SHOW CREATE TABLE t\GReproducible DDL. \G = vertical output.
SHOW INDEX FROM t;Indexes + cardinality.
SHOW PROCESSLIST;Live connections + running queries.
SHOW STATUS LIKE 'Threads%';Server counters by pattern.
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';Read a runtime setting.

Workflow

source path/file.sql;Run a script from within the CLI.
mysql db < file.sqlRun a script from the shell.
SELECT ... INTO OUTFILE '/tmp/out.csv' FIELDS TERMINATED BY ','Server-side export. Needs FILE priv + secure_file_priv.
LOAD DATA LOCAL INFILE 'data.csv' INTO TABLE t FIELDS TERMINATED BY ','Client-side CSV import. Preferred — no server fs access needed.
\GVertical output for a single statement.
pager less -SPipe output through a pager (wide rows).
SET autocommit = 0;Manual transaction mode for the session.
Shell one-liners: mysql -e "SELECT NOW()" db for a single statement, mysqldump db t1 t2 > dump.sql for a logical backup, mysqlsh for the X DevAPI / scripting shell.

Pick once, pay laterData types

Numeric & text

TINYINT · SMALLINT · INT · BIGINT1 / 2 / 4 / 8 byte integers. Add UNSIGNED to drop the sign bit.
DECIMAL(p,s) / NUMERIC(p,s)Exact decimal. Use for money.
FLOAT · DOUBLE4 / 8 byte IEEE float. Lossy — not money.
VARCHAR(n)Up to n chars. Pick n realistically — VARCHAR(255) is folklore, not a rule.
TEXT · MEDIUMTEXT · LONGTEXTOff-row strings (64KB / 16MB / 4GB).
CHAR(n)Fixed-length. Faster on short, uniform fields (UUIDs, country codes).
BLOB · LONGBLOBRaw bytes. Avoid huge blobs — use object storage.

Time, identity, structured

DATETIME(n)YYYY-MM-DD HH:MM:SS with fractional seconds. No timezone. Range 1000–9999.
TIMESTAMP(n)Stored as UTC, displayed in session tz. Range 1970–2038. Auto-init on insert.
DATE · TIME · YEARCalendar date / time-of-day / year.
BIGINT UNSIGNED AUTO_INCREMENTIdentity column (per-table sequence).
BINARY(16) + UUID_TO_BIN(uuid(), 1)Compact UUID storage; ,1 swaps to time-ordered for B-tree friendliness.
JSONNative JSON. Validated on insert. Cannot be indexed directly — use functional indexes.
ENUM('a','b','c')Closed-set values. Cheap to store, painful to change.
SET('a','b','c')Bitmask of values. Rarely worth the hassle.

DDL · constraintsSchema & constraints

CREATE TABLE t (id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, ...) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ciModern table boilerplate. InnoDB is the only engine you should use.
ALTER TABLE t ADD COLUMN c VARCHAR(64) NULL, ALGORITHM=INSTANTInstant DDL (8.0.12+). Metadata-only when possible.
ALTER TABLE t MODIFY c BIGINT NOT NULLChange type / nullability.
ALTER TABLE t ADD CONSTRAINT chk_price CHECK (price >= 0)CHECK constraint (enforced from 8.0.16+).
ALTER TABLE t ADD CONSTRAINT fk_u FOREIGN KEY (u_id) REFERENCES users(id) ON DELETE CASCADENamed FK.
CREATE INDEX idx_email ON users (email), ALGORITHM=INPLACE, LOCK=NONEOnline index build. Preferred in production.
ALTER TABLE t ADD COLUMN total_lower VARCHAR(64) GENERATED ALWAYS AS (LOWER(total)) STOREDGenerated column. Index it for case-insensitive lookups.
ALTER TABLE t ALTER INDEX idx_email INVISIBLETest impact of dropping an index without removing it.
RENAME TABLE old TO newAtomic rename.
DROP TABLE IF EXISTS t;Drop.

SELECT · JOIN · aggregationQueries

Basics

SELECT col, expr AS alias FROM t WHERE ... ORDER BY ... LIMIT n OFFSET mRead shape.
SELECT SQL_CALC_FOUND_ROWS ...Legacy — prefer a separate COUNT(*).
SELECT ... FROM t IGNORE INDEX (idx)Hint planner to skip an index. Use with care.
SELECT ... FROM t FORCE INDEX (idx)Force a specific index.
WHERE col <=> vNULL-safe equality. Treats NULL = NULL as true.

Joins

a INNER JOIN b ON a.b_id = b.idMatch in both.
a LEFT JOIN b ON ...Keep all from a.
a RIGHT JOIN b ON ...Keep all from b. Rare — flip the order instead.
a CROSS JOIN bCartesian product.
a STRAIGHT_JOIN bForce left-to-right join order (debugging hint).
USING (id)Shared-column shorthand.

Aggregation

COUNT(*) · COUNT(col)* counts rows; col skips NULL.
COUNT(DISTINCT col)Cardinality.
GROUP_CONCAT(col ORDER BY x SEPARATOR ', ')String-join per group. Watch group_concat_max_len.
JSON_ARRAYAGG(col) / JSON_OBJECTAGG(k, v)Aggregate into JSON.
WITH ROLLUPSubtotal rows at each grouping level.
ANY_VALUE(col)Tell the planner you don’t care which value (under ONLY_FULL_GROUP_BY).

Rank, lag, running totals, WITHWindow functions & CTEs

ROW_NUMBER() OVER (PARTITION BY g ORDER BY x)1..N within each group.
RANK() · DENSE_RANK()Ranking with / without gaps.
LAG(col, 1) OVER (ORDER BY ts)Previous row.
LEAD(col, 1) OVER (ORDER BY ts)Next row.
SUM(x) OVER (ORDER BY ts ROWS BETWEEN ... AND ...)Frame-bounded running aggregate.
NTILE(4) OVER (ORDER BY x)Quartile / N-tile buckets.
WITH cte AS (SELECT ...) SELECT * FROM cteName a subquery for reuse.
WITH RECURSIVE r AS (... UNION ALL SELECT ...)Walk trees / generate series.
sql
-- Top 3 orders per customer (MySQL 8+)
SELECT customer_id, order_id, amount
FROM (
  SELECT
    customer_id,
    order_id,
    amount,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn
  FROM orders
) t
WHERE rn <= 3;

-- Running total by day
SELECT
  day,
  revenue,
  SUM(revenue) OVER (ORDER BY day
                     ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM daily_revenue;

-- Recursive CTE — generate dates
WITH RECURSIVE days (d) AS (
  SELECT DATE '2026-01-01'
  UNION ALL
  SELECT d + INTERVAL 1 DAY FROM days WHERE d < '2026-01-31'
)
SELECT d FROM days;

Schemaless inside MySQL 8JSON

JSON_OBJECT('k', v, ...)Build a JSON object.
JSON_ARRAY(v1, v2, ...)Build a JSON array.
data->'$.key'Extract as JSON.
data->>'$.key'Extract + unquote → TEXT.
JSON_EXTRACT(data, '$.a[0]')Same as ->; multi-path returns array.
JSON_CONTAINS(data, '"pro"', '$.user.plan')Containment test.
JSON_SET(data, '$.a', v)Replace or insert at path.
JSON_MERGE_PATCH(a, b)RFC 7396 merge. Preferred over JSON_MERGE_PRESERVE.
JSON_REMOVE(data, '$.a')Delete at path.
JSON_LENGTH(data, '$.arr')Array / object size.
JSON_TABLE(data, '$' COLUMNS (id INT PATH '$.id'))Pivot JSON into a relational rowset.
CREATE INDEX idx ON t ((CAST(data->>'$.k' AS CHAR(64))))Functional index on a JSON path (8.0.13+).
sql
-- Table with JSON column + generated/indexed columns
CREATE TABLE events (
  id    BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  ts    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  data  JSON         NOT NULL,
  -- Functional index (MySQL 8.0.13+) — index on a JSON expression
  KEY idx_user_plan ((CAST(data->>'$.user.plan' AS CHAR(16))))
);

INSERT INTO events (data) VALUES
  ('{"type":"login","user":{"id":42,"plan":"pro"}}'),
  ('{"type":"click","user":{"id":7,"plan":"free"},"path":"/pricing"}');

-- Extract — -> keeps JSON, ->> unquotes to text
SELECT
  data->>'$.user.id'   AS user_id,
  data->'$.user.plan'  AS plan_json
FROM events
WHERE data->>'$.user.plan' = 'pro';

-- Mutate — JSON_SET, JSON_REMOVE, JSON_MERGE_PATCH
UPDATE events
SET data = JSON_SET(data, '$.user.tier', 'gold')
WHERE id = 1;

-- JSON_TABLE — pivot JSON into rows (8.0+)
SELECT t.user_id, t.plan
FROM events e,
     JSON_TABLE(e.data, '$' COLUMNS (
       user_id INT  PATH '$.user.id',
       plan    TEXT PATH '$.user.plan'
     )) AS t;

INSERT · UPDATE · DELETEUpsert & DML

INSERT INTO t (...) VALUES (...)Single row. LAST_INSERT_ID() gives the generated PK.
INSERT INTO t (...) VALUES (...), (...), (...)Multi-row insert — one round-trip, one binlog event.
INSERT INTO t SELECT ... FROM srcBulk insert from a query.
INSERT INTO t (...) VALUES (...) AS new ON DUPLICATE KEY UPDATE c = new.cUpsert. Row alias form (8.0.19+). Preferred.
INSERT IGNORE INTO t ...Skip dup-key + warning-as-error cases. Use sparingly.
REPLACE INTO t ...Legacy — delete + insert. Cascades and re-fires triggers.
UPDATE a JOIN b ON ... SET a.c = b.cMulti-table update.
DELETE a FROM a JOIN b ON ... WHERE ...Multi-table delete — named target before FROM.
TRUNCATE TABLE tFast wipe + resets AUTO_INCREMENT. Cannot be rolled back.
sql
-- Upsert — MySQL syntax (no ON CONFLICT)
INSERT INTO users (email, name, login_count)
VALUES ('a@x.com', 'Ana', 1) AS new       -- MySQL 8.0.19+ row alias
ON DUPLICATE KEY UPDATE
  name        = new.name,
  login_count = users.login_count + 1;

-- Older syntax — VALUES() function (deprecated in 8.0.20+)
INSERT INTO users (email, name)
VALUES ('a@x.com', 'Ana')
ON DUPLICATE KEY UPDATE name = VALUES(name);

-- REPLACE — delete then insert. Watch out: re-fires triggers, drops FKs
REPLACE INTO users (email, name) VALUES ('a@x.com', 'Ana');

-- INSERT IGNORE — silently skip duplicate-key + many other errors
INSERT IGNORE INTO inventory (sku, qty) VALUES ('a', 5), ('b', 2);

-- Bulk upsert from a VALUES table
INSERT INTO inventory (sku, qty)
VALUES ('a', 5), ('b', 2), ('c', 9) AS new
ON DUPLICATE KEY UPDATE qty = new.qty;

B-tree, prefix, fulltext, functionalIndexes

CREATE INDEX idx ON t (col)Default B-tree.
CREATE INDEX idx ON t (a, b)Composite. Leftmost-prefix rule applies.
CREATE UNIQUE INDEX uq ON t (email)Unique constraint.
CREATE INDEX idx ON t (col(64))Prefix index for long text. Smaller index, weaker selectivity.
CREATE FULLTEXT INDEX ft ON t (body)Inverted index for MATCH ... AGAINST.
CREATE SPATIAL INDEX sp ON t (geom)R-tree for geometry columns.
CREATE INDEX idx ON t ((CAST(data->>'$.x' AS CHAR(64))))Functional index on an expression.
ALTER TABLE t ADD INDEX (a) VISIBLE / INVISIBLEToggle visibility — planner ignores when invisible.
OPTIMIZE TABLE tRebuild + reclaim space (heavy operation).

Read the plan, fix the queryEXPLAIN & planner

EXPLAIN queryPlan only.
EXPLAIN ANALYZE queryPlan + real timing (8.0.18+). Runs the query.
EXPLAIN FORMAT=JSON queryMachine-readable plan.
type: const / eq_ref / ref / range / index / ALLAccess type — left-to-right best to worst.
key, key_len, refWhich index, how many bytes, what was compared.
rows × filteredEstimated rows scanned × %% surviving WHERE.
Extra: Using indexCovering index — no row fetch.
Extra: Using filesortSort can’t be served by an index.
Extra: Using temporaryBuilt a temp table — usually bad on big sets.
ANALYZE TABLE tRefresh index statistics.
SET optimizer_switch = 'block_nested_loop=off'Twiddle planner flags for testing.
sql
-- Plan only — fast
EXPLAIN
SELECT * FROM orders WHERE customer_id = 42;

-- Plan + real execution stats (MySQL 8.0.18+)
EXPLAIN ANALYZE
SELECT o.id, c.email
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.created_at >= NOW() - INTERVAL 7 DAY
ORDER BY o.created_at DESC
LIMIT 50;

-- JSON output — paste into a plan visualizer
EXPLAIN FORMAT=JSON SELECT ...;

-- Read the EXPLAIN columns:
--   type:       const > eq_ref > ref > range > index > ALL (worst)
--   key:        which index actually got picked
--   rows:       estimated rows scanned
--   filtered:   % of those rows surviving WHERE
--   Extra:      "Using index" (covering), "Using filesort" (sort spill),
--               "Using temporary" (temp table — usually bad on big sets)

-- Refresh stats
ANALYZE TABLE orders;

InnoDB · isolation · locksTransactions & locks

START TRANSACTION; ... COMMIT; / ROLLBACK;Explicit transaction.
SAVEPOINT s; ROLLBACK TO SAVEPOINT s;Nested rollback within a tx.
SET TRANSACTION ISOLATION LEVEL REPEATABLE READInnoDB default. Each tx sees one snapshot.
READ COMMITTEDEach statement gets a fresh snapshot. Often better for high concurrency.
SERIALIZABLEReads take shared locks. Heavy — rarely worth it.
SELECT ... FOR UPDATEPessimistic row lock.
SELECT ... FOR UPDATE SKIP LOCKEDWork-queue pattern (8.0+).
SELECT ... FOR UPDATE NOWAITError immediately if locked.
SELECT ... LOCK IN SHARE MODELegacy — use FOR SHARE in 8.0+.
SHOW ENGINE INNODB STATUS\GLatest deadlock, lock waits, buffer-pool stats.
GET_LOCK('name', 10) / RELEASE_LOCK('name')App-level named lock.

Binlog · GTID · groupReplication & backups

log_bin = ON, server_id = 1, gtid_mode = ON, enforce_gtid_consistency = ONMinimal source-server settings.
CHANGE REPLICATION SOURCE TO SOURCE_HOST='primary', SOURCE_AUTO_POSITION=1Wire up a replica (8.0.23+ syntax).
START REPLICA; STOP REPLICA;Toggle replica threads.
SHOW REPLICA STATUS\GLag, errors, retrieved/applied GTIDs.
SHOW BINARY LOGS;List binlog files on the source.
PURGE BINARY LOGS BEFORE NOW() - INTERVAL 7 DAYCap binlog retention.
mysqldump --single-transaction --routines --triggers --set-gtid-purged=AUTOConsistent logical backup. Good for < ~50 GB.
mysqlpump / xtrabackupParallel logical / hot physical backups.

Accounts, grants, rolesUsers & security

CREATE USER 'app'@'%' IDENTIFIED BY 'secret'Create a login user. 'app'@'%' = any host.
ALTER USER 'app'@'%' IDENTIFIED BY 'new'Rotate password. Preferred over SET PASSWORD.
GRANT SELECT, INSERT ON shop.* TO 'app'@'%'Per-database privs.
REVOKE ... ON ... FROM ...Take privileges back.
CREATE ROLE 'readonly'; GRANT SELECT ON shop.* TO 'readonly'Reusable bundle of privileges (8.0+).
GRANT 'readonly' TO 'app'@'%';Assign role.
SET DEFAULT ROLE 'readonly' TO 'app'@'%'Auto-activate on login.
SHOW GRANTS FOR 'app'@'%'Audit a user’s privileges.
ALTER USER ... REQUIRE SSLForce TLS for the user.
FLUSH PRIVILEGES;Reload the grant tables. Rarely needed in 8.0+.

Tiny shop schemaEnd-to-end · Order analytics

Three InnoDB tables, one composite index, one analytical query. Copy-paste runs as-is on a fresh database.

sql
-- Tiny shop schema with the choices that age well in MySQL 8 / InnoDB
CREATE TABLE customer (
  id         BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  email      VARCHAR(255) NOT NULL,
  created_at DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  UNIQUE KEY uq_customer_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE product (
  id    BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  sku   VARCHAR(64)    NOT NULL,
  price DECIMAL(10,2)  NOT NULL,
  UNIQUE KEY uq_product_sku (sku),
  CHECK (price >= 0)
) ENGINE=InnoDB;

CREATE TABLE `order` (
  id           BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  customer_id  BIGINT UNSIGNED NOT NULL,
  total        DECIMAL(12,2)   NOT NULL,
  placed_at    DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  KEY idx_order_customer_placed (customer_id, placed_at DESC),
  CONSTRAINT fk_order_customer
    FOREIGN KEY (customer_id) REFERENCES customer(id)
) ENGINE=InnoDB;

-- Top 5 spenders in the last 30 days
SELECT c.email, SUM(o.total) AS spend
FROM `order` o
JOIN customer c ON c.id = o.customer_id
WHERE o.placed_at >= NOW() - INTERVAL 30 DAY
GROUP BY c.email
ORDER BY spend DESC
LIMIT 5;

Best practiceGood to know

Always utf8mb4, never utf8. The bare utf8 alias is the legacy 3-byte encoding that can’t store emoji or rarer CJK. utf8mb4_0900_ai_ci is the right default in MySQL 8.
Run mysqldump with --single-transaction on InnoDB. You get a consistent snapshot without locking writers. Without it the dump can interleave concurrent writes — restored data is no longer point-in-time consistent.
Use ALGORITHM=INSTANT when adding columns. MySQL 8.0.12+ can add a column without rewriting the table. Falls back automatically if not eligible — safe to always specify.

Common trapsWatch out for

TIMESTAMP dies in 2038. 32-bit Unix epoch with a 1970–2038 range. Use DATETIME(3) if your data outlives the Y2038 boundary — the trade-off is no auto-tz conversion.
An unindexed join doesn’t error — it just gets slow forever. InnoDB will happily nested-loop a 1M×1M table scan. Watch type: ALL in EXPLAIN and the rows column on hot queries.
Default isolation is REPEATABLE READ, not READ COMMITTED. InnoDB’s RR uses gap locks, which cause counter-intuitive deadlocks under high contention. Many shops run READ COMMITTED in prod — pick deliberately, not by default.

Go deeperSee also

MySQL FAQ

What is MySQL used for?

MySQL is an open-source relational database used for web applications, e-commerce platforms, SaaS products, and analytics workloads. It is the M in the LAMP and MEAN stacks, the default database for WordPress and Drupal, and widely available on Amazon RDS and Google Cloud SQL.

What is the difference between InnoDB and MyISAM in MySQL?

InnoDB is the default MySQL 8 storage engine. It supports ACID transactions, foreign keys, row-level locking, and crash recovery. MyISAM lacks transactions and foreign key support. Use InnoDB for all new tables.

How do CTEs work in MySQL?

A CTE (Common Table Expression) is a named temporary result set defined with the WITH clause before a SELECT, INSERT, UPDATE, or DELETE. Recursive CTEs use WITH RECURSIVE and a UNION ALL to walk hierarchies like org charts or threaded comments without recursive stored procedures.

What are window functions in MySQL 8?

Window functions (ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM OVER, AVG OVER) compute aggregates across a sliding partition of rows without collapsing them into one row per group. They require an OVER clause with optional PARTITION BY and ORDER BY, evaluated after WHERE and GROUP BY.

How do I tune MySQL query performance?

Start with EXPLAIN ANALYZE to see which indexes MySQL uses. Add composite indexes for multi-column WHERE and ORDER BY clauses; use covering indexes to avoid table lookups. Run ANALYZE TABLE to refresh optimizer statistics, and check the slow query log for queries exceeding long_query_time.

How do I use MySQL with Python?

Use mysql-connector-python (pip install mysql-connector-python) or PyMySQL for a pure-Python driver. For SQLAlchemy, use the mysql+mysqlconnector:// dialect. Open a connection with mysql.connector.connect(host=..., user=..., password=..., database=...) and always use parameterized queries (cursor.execute("SELECT * FROM t WHERE id=%s", (uid,))) to prevent SQL injection.