7. MySQL¶
Intermediate · 14 min read
MySQL is the most widely deployed open-source database: WordPress, Shopify, Uber, GitHub and countless startups run on it, and every cloud offers it (Amazon RDS / Aurora, Google Cloud SQL, Azure Database for MySQL). If your company's orders and users live in MySQL, your RAG app and text-to-SQL agent will be talking to it.
This topic covers what is different about MySQL compared with the standard SQL from topics 1–4: the traps, the syntax and the features worth knowing for interviews.
Version and how to run these examples
Written for MySQL 8.4 LTS (most of it works on 8.0). Unlike topics 1–6, these examples aren't run on this site's SQLite: start a free local server with Docker and run them yourself.
7.1 Connect from Python¶
# pip install pymysql
import pymysql
con = pymysql.connect(host="localhost", user="root", password="pass",
database="shop", charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor) # rows as dicts
with con.cursor() as cur:
cur.execute("SELECT id, name FROM customers WHERE city = %s", ("Pune",)) # %s placeholders
rows = cur.fetchall()
con.commit() # pymysql does NOT autocommit by default: forget this and your writes vanish
| Driver | Install | Placeholder | Notes |
|---|---|---|---|
| PyMySQL | pip install pymysql |
%s |
pure Python, easy to install |
| mysql-connector-python | pip install mysql-connector-python |
%s |
Oracle's official driver |
| SQLAlchemy URL | — | — | mysql+pymysql://root:pass@localhost:3306/shop |
Always pass values as parameters
%s here is a placeholder, not Python string formatting. cur.execute(f"... WHERE city = '{city}'") is
SQL injection waiting to happen.
7.2 Data types that matter¶
CREATE TABLE customers (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
city VARCHAR(60),
plan ENUM('free', 'pro', 'team') NOT NULL DEFAULT 'free',
balance DECIMAL(12, 2) NOT NULL DEFAULT 0.00, -- money: never FLOAT
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- really TINYINT(1)
profile JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
SELECT LAST_INSERT_ID(); -- the id just generated in this connection
| Type | Use it for | Trap |
|---|---|---|
INT / BIGINT |
ids, counts | INT tops out at ~2.1 billion: use BIGINT for ids that grow fast (events, logs) |
DECIMAL(p, s) |
money | FLOAT / DOUBLE are approximate |
VARCHAR(n) / TEXT |
strings | TEXT can't have a default and is only indexable by prefix |
DATETIME |
timestamps | stored as written, no time zone. Store UTC |
TIMESTAMP |
timestamps | converted to UTC and back using the session time zone; ends in 2038 |
JSON |
flexible attributes | validated on insert; index via generated columns (7.6) |
ENUM |
small fixed lists | adding a value means ALTER TABLE |
utf8 is not UTF-8
In MySQL, utf8 is an alias for utf8mb3: at most 3 bytes per character, so emoji and some Indic and CJK
characters fail or get mangled. Always use utf8mb4 (the default since 8.0) for the table and the
connection (charset="utf8mb4"). LLM output is full of characters that need 4 bytes.
Collations: why 'a' = 'A' is true¶
The default collation utf8mb4_0900_ai_ci is accent-insensitive and case-insensitive:
SELECT 'Priya' = 'PRIYA', 'resume' = 'résumé'; -- 1, 1
SELECT 'Priya' = 'PRIYA' COLLATE utf8mb4_0900_as_cs; -- 0 (accent- and case-sensitive)
That's convenient for user search, and surprising for unique constraints: alice@x.com and ALICE@x.com count as
duplicates. Choose a _bin or _as_cs collation for columns where case matters (tokens, hashes).
7.3 Dialect differences you will hit¶
| Need | MySQL | Standard / PostgreSQL |
|---|---|---|
| Quote an identifier | `order` (backticks) |
"order" |
| Concatenate | CONCAT(a, ' ', b) · CONCAT_WS(', ', a, b) |
a || ' ' || b |
| Integer division | 7 DIV 2 → 3; 7 / 2 → 3.5000 |
7 / 2 → 3 (Postgres) |
| Limit with offset | LIMIT 20 OFFSET 40 or LIMIT 40, 20 |
LIMIT 20 OFFSET 40 |
| FULL OUTER JOIN | not supported: LEFT JOIN … UNION … RIGHT JOIN |
FULL JOIN |
| INTERSECT / EXCEPT | 8.0.31+ | yes |
| String list per group | GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') |
STRING_AGG(name, ', ') |
| Days between dates | DATEDIFF(d2, d1) |
d2 - d1 |
| Add an interval | DATE_ADD(d, INTERVAL 7 DAY) or d + INTERVAL 7 DAY |
d + INTERVAL '7 days' |
| Difference in minutes | TIMESTAMPDIFF(MINUTE, t1, t2) |
EXTRACT(EPOCH FROM t2 - t1) / 60 |
| Month bucket | DATE_FORMAT(d, '%Y-%m-01') |
DATE_TRUNC('month', d) |
| Booleans | TRUE is just 1 |
real boolean type |
| Regex | REGEXP_LIKE(s, 'pat'), s REGEXP 'pat' |
s ~ 'pat' |
Emulating FULL OUTER JOIN:
SELECT c.name, o.id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
UNION
SELECT c.name, o.id FROM customers c RIGHT JOIN orders o ON o.customer_id = c.id;
GROUP_CONCAT silently truncates
The result is cut at group_concat_max_len bytes (default 1024) with only a warning. Building a long context
string for an LLM? Raise it first: SET SESSION group_concat_max_len = 1000000;
ONLY_FULL_GROUP_BY and strict mode¶
MySQL 8 enables ONLY_FULL_GROUP_BY and STRICT_TRANS_TABLES by default. The first rejects selecting a column that
is neither grouped nor aggregated (old MySQL silently picked a random row). The second makes bad data an
error rather than a silently truncated or zeroed value. If a legacy app turns them off, expect quiet data bugs.
7.4 Upserts: INSERT … ON DUPLICATE KEY UPDATE¶
When a PRIMARY KEY or UNIQUE index would be violated, update the existing row instead:
INSERT INTO daily_usage (user_id, day, calls, tokens)
VALUES (42, '2026-10-20', 1, 380) AS new -- row alias (8.0.19+)
ON DUPLICATE KEY UPDATE
calls = daily_usage.calls + new.calls, -- increment, don't overwrite
tokens = daily_usage.tokens + new.tokens;
The older form tokens = tokens + VALUES(tokens) still works but VALUES() here is deprecated since 8.0.20.
| Statement | Behaviour on a duplicate key |
|---|---|
INSERT … ON DUPLICATE KEY UPDATE |
updates the existing row (the usual choice) |
INSERT IGNORE |
skips the row and turns other errors into warnings: use with care |
REPLACE INTO |
deletes the old row and inserts a new one: new auto-id, fires delete triggers, cascades |
7.5 CTEs and window functions (8.0+)¶
MySQL 8 supports everything from Advanced SQL: WITH, WITH RECURSIVE, ROW_NUMBER, RANK,
LAG/LEAD, running totals and named windows.
-- latest ticket per customer
WITH ranked AS (
SELECT t.*, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM tickets t
)
SELECT * FROM ranked WHERE rn = 1;
Recursive CTEs stop at cte_max_recursion_depth (default 1000). Generate a calendar of dates:
WITH RECURSIVE days AS (
SELECT DATE('2026-10-01') AS d
UNION ALL
SELECT d + INTERVAL 1 DAY FROM days WHERE d < '2026-10-31'
)
SELECT d FROM days;
Still on MySQL 5.7?
No CTEs or window functions. 5.7 reached end of life in October 2023: if you meet it in an interview, "upgrade to 8.x" is part of the answer.
7.6 JSON columns, and how to index them¶
SELECT id,
meta -> '$.feature' AS feature_json, -- JSON value: "chat" (with quotes)
meta ->> '$.feature' AS feature, -- unquoted text: chat
meta ->> '$.tokens.in' AS tokens_in
FROM llm_calls
WHERE meta ->> '$.feature' = 'chat';
A function on a column can't use a normal index. Expose the JSON field as a generated column and index that:
ALTER TABLE llm_calls
ADD COLUMN feature VARCHAR(30) AS (meta ->> '$.feature') STORED,
ADD INDEX idx_llm_calls_feature (feature);
-- or a functional index directly (8.0.13+; note the double parentheses)
CREATE INDEX idx_email_lower ON customers ((LOWER(email)));
JSON arrays get a multi-valued index (8.0.17+), used by MEMBER OF, JSON_CONTAINS and JSON_OVERLAPS:
CREATE INDEX idx_tags ON tickets ((CAST(meta -> '$.tags' AS CHAR(30) ARRAY)));
SELECT id FROM tickets WHERE 'refund' MEMBER OF (meta -> '$.tags');
Turn a JSON array into rows with JSON_TABLE:
SELECT t.id, j.tag
FROM tickets t,
JSON_TABLE(t.meta, '$.tags[*]' COLUMNS (tag VARCHAR(30) PATH '$')) AS j;
7.7 Full-text search (keyword retrieval for RAG)¶
ALTER TABLE docs ADD FULLTEXT INDEX ft_docs (title, body);
-- natural language mode: ranked by relevance
SELECT id, title, MATCH(title, body) AGAINST ('refund policy') AS score
FROM docs
WHERE MATCH(title, body) AGAINST ('refund policy')
ORDER BY score DESC
LIMIT 5;
-- boolean mode: + must, - must not, * prefix
SELECT id, title FROM docs
WHERE MATCH(title, body) AGAINST ('+refund* -shipping' IN BOOLEAN MODE);
The MATCH column list must match a FULLTEXT index exactly. InnoDB ignores words shorter than
innodb_ft_min_token_size (default 3) and common stopwords, so searching for "AI" finds nothing by default.
For serious hybrid search, many teams pair MySQL with a search engine (OpenSearch) or a vector store.
Vectors in MySQL
MySQL 9.0 added a VECTOR data type (STRING_TO_VECTOR('[0.1, 0.2]')), but in Community Edition you can store
vectors, not run indexed similarity search. Similarity functions arrived in Oracle's HeatWave service. If you
need vector search next to relational data today, PostgreSQL + pgvector (topic 8) is the
common choice.
7.8 Indexes and EXPLAIN¶
InnoDB stores each table clustered by its primary key: the rows are the leaves of the primary-key B-tree. Every secondary index stores the primary key value to find the row. Consequences:
- A short, increasing primary key (
BIGINT AUTO_INCREMENT) keeps inserts fast and secondary indexes small. - Random UUIDs as the primary key scatter inserts across the tree. Prefer
UUID_TO_BIN(UUID(), 1)(time-ordered) stored inBINARY(16), or keep an integer key and a separate UUID column.
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND created_at >= '2026-10-01';
EXPLAIN ANALYZE SELECT ...; -- 8.0.18+: actually runs it, shows real time and rows
EXPLAIN FORMAT=TREE SELECT ...; -- readable plan tree
The type column of EXPLAIN, from worst to best:
type |
Meaning |
|---|---|
ALL |
full table scan: the one to fix |
index |
full scan of an index |
range |
index range (BETWEEN, >, IN) |
ref |
index lookup on a non-unique value |
eq_ref |
one row per join via a unique key |
const |
at most one row, by primary key or unique key |
Also check key (the index actually used), rows (the estimate) and Extra: Using filesort and
Using temporary mean extra sort or temp-table work; Using index means a covering index.
7.9 Transactions, isolation and locking¶
InnoDB's default isolation level is REPEATABLE READ. Plain SELECTs read a consistent snapshot (MVCC), so readers
don't block writers. Locking reads (FOR UPDATE, FOR SHARE) and writes use next-key locks, which also lock
the gaps between index entries, so other transactions can't insert phantom rows into the range.
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 7 FOR UPDATE; -- lock the row
UPDATE accounts SET balance = balance - 100 WHERE id = 7;
COMMIT;
A job queue that many workers can poll without blocking each other (8.0+):
START TRANSACTION;
SELECT id, payload FROM jobs
WHERE status = 'queued'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED; -- each worker gets different rows
UPDATE jobs SET status = 'running' WHERE id IN (...);
COMMIT;
Deadlocks are normal: retry them
InnoDB detects deadlocks and rolls back one transaction with error 1213. Your code should retry the whole
transaction. Reduce them by touching rows in a consistent order and keeping transactions short. Also watch out
for UPDATE … WHERE on an unindexed column: InnoDB then locks every row it scans.
7.10 MySQL interview checklist¶
- Explain why
utf8mb4and notutf8. - Explain the clustered primary key and why random UUID keys hurt.
- Write an upsert with
ON DUPLICATE KEY UPDATE; compare it withREPLACEandINSERT IGNORE. - Index a JSON field with a generated column.
- Read the
typeandExtracolumns ofEXPLAIN. - Explain REPEATABLE READ, MVCC and gap locks.
- Build a worker queue with
FOR UPDATE SKIP LOCKED.
Interview questions¶
What's the difference between DATETIME and TIMESTAMP in MySQL?
DATETIME stores the value exactly as given, with no time zone, from year 1000 to 9999. TIMESTAMP converts
from the session time zone to UTC on write and back on read, and only covers 1970 to 2038. Store UTC in
DATETIME (or keep the server and session in UTC) to avoid both the 2038 limit and time-zone surprises.
Why can't MySQL use my index for WHERE YEAR(created_at) = 2026?
The index is sorted by created_at, not by YEAR(created_at), so the database must compute the function for
every row. Rewrite it as a range: created_at >= '2026-01-01' AND created_at < '2027-01-01'. Or add a functional
index if you really need that expression.
InnoDB vs MyISAM?
InnoDB (the default since 5.5) has transactions, row-level locking, foreign keys and crash recovery. MyISAM has none of these: it uses table-level locks and can lose data after a crash. Use InnoDB; MyISAM only appears in legacy systems.
How would you scale reads on MySQL for a GenAI dashboard?
Send the heavy analytical queries to read replicas (asynchronous replication, so accept some lag; read your own writes from the primary), add covering indexes for the dashboard queries, cache the results, and move true analytics to a warehouse (BigQuery, Snowflake, ClickHouse) fed by CDC rather than running them on the primary.
Practice¶
- Create
llm_callswith a JSONmetacolumn, add a generatedfeaturecolumn, index it, and confirm withEXPLAINthatWHERE feature = 'chat'uses the index. - Write a daily usage upsert that increments
callsandtokens, then run it twice and check the totals. - Open two
mysqlshells and make them deadlock on two rows. Read the error, then fix the lock order.
Next: PostgreSQL — the database most GenAI stacks pick, and why.