Semi-structured & modern warehouse SQL
JSON extraction, LATERAL/FLATTEN, QUALIFY idiom, array contains/overlap predicates.
- Lesson →★JSON extraction
JSON path syntax, casting from variant, and the NULL-vs-missing-key distinction all bite when extracting semi-structured data.
See it breakshowhide
SetupCREATE TABLE prizes(payload JSON); INSERT INTO prizes VALUES ('{"score":85}'),('{"score":9}');The trapSELECT json_extract_string(payload, '$.score') AS score FROM prizes ORDER BY json_extract_string(payload, '$.score');
85 9 The fixSELECT CAST(json_extract_string(payload, '$.score') AS INT) AS score FROM prizes ORDER BY CAST(json_extract_string(payload, '$.score') AS INT);
9 85 The leaderboard put '85' before '9' — values extracted from JSON are text until you type them, so they compare alphabetically; casting to INT after extraction orders them 9 then 85.
- Lesson →★JSON null vs missing key
A key present-with-null and a key absent are different facts — and your extraction function decides whether you can still tell them apart.
See it breakshowhide
SetupCREATE TABLE orders(oid INT, payload JSON); INSERT INTO orders VALUES (1,'{"tip":5}'),(2,'{"tip":null}'),(3,'{}');The trapSELECT COUNT(*) AS declined FROM orders WHERE json_extract_string(payload,'$.tip') IS NULL;
2 The fixSELECT COUNT(*) AS declined FROM orders WHERE json_type(json_extract(payload,'$.tip')) = 'NULL';
1 The declined-tip count read 2, not 1 — json_extract_string collapsed the kiosk that reported tip:null and the kiosk that never asked into one NULL, so a 'said no' and a 'wasn't asked' became the same fact.
- Lesson →LATERAL / FLATTEN
LATERAL/FLATTEN explodes arrays — re-introducing fan-out deliberately, closed by re-aggregation (P06 inverted).
See it breakshowhide
SetupCREATE TABLE shops(s VARCHAR); INSERT INTO shops VALUES ('north'),('south'); CREATE TABLE sales(s VARCHAR, item VARCHAR, n INT); INSERT INTO sales VALUES ('north','a',9),('north','b',5),('north','c',1),('south','x',7),('south','y',3);The trapSELECT s, item FROM sales ORDER BY n DESC LIMIT 2;
north a south x The fixSELECT sh.s, t.item FROM shops sh, LATERAL (SELECT item, n FROM sales sa WHERE sa.s = sh.s ORDER BY n DESC LIMIT 2) t ORDER BY sh.s, t.n DESC;
north a north b south x south y A global LIMIT 2 returned the two overall top sellers, not each shop's — a LATERAL subquery in FROM sees the current row, so 'for each shop, its top 2' is one statement of intent; a sealed subquery can't.
- Lesson →QUALIFY idiom
QUALIFY filters window results inline (dialect); GROUP BY ALL and friends are conveniences, not concepts.
See it breakshowhide
SetupCREATE TABLE carts(cart VARCHAR, twist VARCHAR, n INT); INSERT INTO carts VALUES ('e','plain',18),('e','salt',5),('w','plain',10);The trapSELECT cart, twist FROM carts QUALIFY ROW_NUMBER() OVER (PARTITION BY cart ORDER BY n DESC) = 1 ORDER BY cart;
e plain w plain The fixSELECT cart, twist FROM (SELECT cart, twist, ROW_NUMBER() OVER (PARTITION BY cart ORDER BY n DESC) AS rn FROM carts) WHERE rn = 1 ORDER BY cart;
e plain w plain Both return the best twist per cart, but QUALIFY is engine sugar — it filters the window inline where DuckDB/Snowflake/BigQuery allow it, and code that ships to Postgres must expand it back to the subquery-then-filter.
- Lesson →Array predicates
Array contains/overlap predicates vs join-through-unnest differ in cost and NULL semantics.
See it breakshowhide
SetupCREATE TABLE boxes(box VARCHAR, ings VARCHAR[]); INSERT INTO boxes VALUES ('b1',['rice','nut']),('b2',['rice',NULL]);The trapSELECT box FROM boxes WHERE NOT list_contains(ings, 'nut') ORDER BY box;
b2 The fixSELECT box FROM boxes WHERE NOT list_contains(ings, 'nut') AND NOT list_contains(ings, NULL) ORDER BY box;
0 rowsThe nut-safe filter called b2 safe, but its array has a NULL (unlabeled) ingredient that could be a nut — array predicates treat a NULL element as unknowable-therefore-no-match, so a NULL-aware check is needed before declaring a box safe.
- Lesson →UNNEST re-introduces fan-out
UNNEST is a deliberate one-to-many — parent-level metrics summed after exploding are multiplied by list length.
See it breakshowhide
SetupCREATE TABLE panels(panel VARCHAR, price INT, colors VARCHAR[]); INSERT INTO panels VALUES ('P1',30,['red','blue']),('P2',18,['green']);The trapSELECT SUM(price) AS revenue FROM (SELECT panel, price, UNNEST(colors) AS color FROM panels);
78 The fixSELECT SUM(price) AS revenue FROM panels;
48 The panel revenue summed to 78, not 48 — after UNNEST exploded each panel's color array, its price rode along once per color, so SUM multiplied it by the list length.
- Lesson →Array membership vs unnest-join
Membership predicates (list_contains/list_has_any-class) test without exploding — an unnest-join answers the same question at a different grain and must be re-deduped.
See it breakshowhide
SetupCREATE TABLE jars(jar VARCHAR, ingredients VARCHAR[]); INSERT INTO jars VALUES ('stew',['nut','nut','salt']),('jam',['fruit']);The trapSELECT COUNT(*) AS jars FROM (SELECT jar, UNNEST(ingredients) AS ing FROM jars) WHERE ing = 'nut';
2 The fixSELECT COUNT(*) AS jars FROM jars WHERE list_contains(ingredients, 'nut');
1 The unnest-count read 2 jars for 'nut', not 1 — stew's array lists the element 'nut' twice, so exploding the duplicate double-counted the jar; a membership predicate stays at jar grain and answers once.
- Lesson →Struct vs JSON on a missing field
Structs are typed nesting — a missing field is a compile-time binder error; JSON is schemaless — a missing key is a runtime NULL.
See it breakshowhide
SetupCREATE TABLE ping(id INT); INSERT INTO ping VALUES (1);
The trapSELECT id, {'depth': 4}.temp AS temp FROM ping;0 rowsThe fixSELECT id, {'depth': 4}.depth AS depth, json_extract('{"depth":4}', '$.temp') AS json_temp FROM ping;1 4 NULL Accessing a missing struct field raised a binder error before any data ran — a struct is typed nesting, so a typo fails at compile time; the same missing key in JSON returns a runtime NULL, silent forever.
Ready to practise?
Run graded Semi-structured & modern warehouse SQL problems in your browser — 8 traps covered here.