Advanced & vendor frontiers
Where SQL stops being uniform. Each of these is genuinely useful, supported differently by each engine, and comes with a judgement call about whether the database is the right place for it at all.
JSON in SQL
MySQL JSON | Postgres jsonb | Postgres json | |
|---|---|---|---|
| Storage | Binary, parsed | Binary, parsed | Raw text |
| Key order | Not preserved | Not preserved | Preserved |
| Duplicate keys | Last wins | Last wins | Kept |
| Indexable | Via generated column or functional index | Directly, with GIN | No |
-- MySQL path extraction
SELECT payload->'$.user.name' AS quoted, -- "ada"
payload->>'$.user.name' AS unquoted -- ada
FROM events;
-- comparing a -> result to a plain string silently fails to match.
-- Postgres
SELECT payload -> 'user' ->> 'name' FROM events;
SELECT payload #>> '{user,name}' FROM events;
-- containment — the Postgres query that GIN accelerates
SELECT * FROM events WHERE payload @> '{"type":"signup"}';
CREATE INDEX idx_payload ON events USING GIN (payload);
-- JSON_TABLE: expand an array into rows (SQL:2016, both engines)
SELECT e.id, t.sku, t.qty
FROM events e,
JSON_TABLE(e.payload, '$.items[*]' COLUMNS (
sku VARCHAR(32) PATH '$.sku',
qty INT PATH '$.qty'
)) t;A good hybrid: store the payload as JSON and promote the two or three fields you actually query into generated columns with indexes (Part 6, ch 54).
Arrays and composite types
A genuine Postgres capability with no MySQL equivalent.
-- Postgres
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
tags TEXT[] NOT NULL DEFAULT '{}'
);
SELECT * FROM posts WHERE 'sql' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['sql','db']; -- contains both
CREATE INDEX ON posts USING GIN (tags);
-- unnest to rows, and aggregate back
SELECT id, tag FROM posts, unnest(tags) AS tag;
SELECT array_agg(tag ORDER BY tag) FROM tags;The test: if the element would ever need a column of its own — a weight, a created_at, a source — it is a join table, not an array.
Full-text search
-- MySQL
CREATE TABLE docs (id INT PRIMARY KEY, body TEXT,
FULLTEXT KEY ft_body (body));
SELECT id, MATCH(body) AGAINST ('innodb btree' IN NATURAL LANGUAGE MODE) AS score
FROM docs
WHERE MATCH(body) AGAINST ('innodb btree' IN NATURAL LANGUAGE MODE)
ORDER BY score DESC;
-- Postgres: tsvector + GIN, with stemming and weighting
ALTER TABLE docs ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;
CREATE INDEX ON docs USING GIN (tsv);
SELECT id, ts_rank(tsv, q) AS rank
FROM docs, to_tsquery('english', 'innodb & btree') q
WHERE tsv @@ q ORDER BY rank DESC;When search is a product feature — relevance tuning, facets, synonyms, fuzzy matching, autocomplete — use a search engine. Knowing which side of that line you are on matters more than knowing the syntax.
Temporal tables
SQL:2011 defines two independent time dimensions, and conflating them is the usual mistake.
- System time — when the database knew it. Maintained automatically; an audit trail.
- Valid time — when the fact was true in the world. Application-maintained; a price effective from a date, a contract term.
Neither MySQL nor Postgres implements system versioning natively (MariaDB does). In practice you build it:
-- the common pattern: a history table plus a trigger
CREATE TABLE prices_history (
LIKE prices,
valid_from DATETIME(3) NOT NULL,
valid_to DATETIME(3) NOT NULL,
PRIMARY KEY (id, valid_from)
);
-- "what was the price when this order was placed?"
-- a non-equi join, exactly as in Part 2 ch 21
SELECT o.id, p.amount
FROM orders o
JOIN prices_history p
ON p.sku = o.sku
AND o.created_at >= p.valid_from
AND o.created_at < p.valid_to;TABLESAMPLE
-- Postgres: sample ~1% of BLOCKS, not rows. very fast. SELECT * FROM events TABLESAMPLE SYSTEM (1); -- BERNOULLI samples rows independently: more uniform, slower SELECT * FROM events TABLESAMPLE BERNOULLI (1); -- MySQL has neither. the usual approximation: SELECT * FROM events WHERE RAND() < 0.01; -- full scan!
Better: pick a random id in the known range and take the next row at or above it, or maintain a random column with an index. Both are O(log n) instead of O(n log n).
Set operations
| Operation | Returns | Duplicates | MySQL |
|---|---|---|---|
UNION ALL | Everything from both | Kept | Yes |
UNION | Everything, deduplicated | Removed | Yes |
INTERSECT | Rows in both | Removed | 8.0.31+ |
EXCEPT | In the first, not the second | Removed | 8.0.31+ |
-- the rules that trip people up -- 1. both sides need the same column COUNT and compatible types -- 2. column NAMES come from the FIRST query -- 3. ORDER BY applies to the WHOLE result and goes at the end (SELECT id, name FROM a ORDER BY name LIMIT 5) -- per-branch: parenthesise UNION ALL (SELECT id, name FROM b ORDER BY name LIMIT 5) ORDER BY name; -- overall
Before INTERSECT and EXCEPT existed in MySQL, the
equivalents were an inner join and a NOT EXISTS anti-join
respectively — which are still often faster, because they can use indexes
rather than materializing and deduplicating both sides.
Views and materialized views
| View | Materialized view | |
|---|---|---|
| Stores data | No — a stored query | Yes |
| Freshness | Always current | As of the last refresh |
| Query cost | The underlying query, every time | A table read |
| Indexable | No | Yes |
| MySQL | Yes | No — use a table plus a refresh job |
-- Postgres
CREATE MATERIALIZED VIEW daily_revenue AS
SELECT DATE(created_at) AS d, SUM(total) AS revenue
FROM orders GROUP BY 1;
CREATE UNIQUE INDEX ON daily_revenue (d); -- required for:
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
-- without CONCURRENTLY, the refresh takes an exclusive lock
-- and the view is unreadable while it runs.SELECT * FROM v_summary.
Views are excellent for encapsulating a join or enforcing a row filter for security. They are not a performance tool — that is a materialized view, or a summary table maintained on write.
Procedures, functions, triggers
| Invoked | Returns | Main risk | |
|---|---|---|---|
| Procedure | CALL | Out params, result sets | Logic outside version control and deploy |
| Function | In an expression | One value | Called per row — can be catastrophic in a WHERE |
| Trigger | Automatically on DML | — | Invisible. The statement you ran is not all that ran. |
Against: it is usually outside your repository, has no tests, no code review, no staged rollout, and no observability. Debugging means reading
SHOW CREATE PROCEDURE in production. Triggers are the worst of
this — a cascade of them turns one INSERT into work nobody can
see from the query log.
The workable line: use them for things that genuinely must be atomic with the write and enforced regardless of client — an audit row, a denormalized counter. Keep business logic in the application. And whatever you use, keep the definitions in migration files so they are versioned like everything else.
WHERE clause is called once per
examined row, and it makes the predicate non-sargable so the index is not used
(Part 9). A function that itself runs a query turns one statement into an N+1
inside the database. This is a common cause of a query that is fast on 1,000
rows and unusable on 1,000,000.Part 9 turns to performance: writing SQL the engine can execute well, and the handful of habits that account for most query problems.