Semi-Structured Data: Intermediate
Thirty minutes into a SQL round at a marketplace company, the interviewer pastes one order into the shared editor:
{"order_id": 1001, "items": [{"sku": "A-12", "qty": 2, "price": 12.5}, ...], "coupon": "SPRING10"}
"Every order lands like this, one JSON document per row. I want units and revenue per order, all orders included, and the lines nested back inside each result row. Also, how many used no coupon?"
She says nothing about the other 41,999 documents. Some carry an empty items array. One app version sends price as the string "19.99". Older clients never send coupon at all. A query written against the tidy row on screen returns a confident total that is wrong in 3 places.
What she is scoring is whether you ask what the other rows look like before you flatten anything.
What you will be able to do
The flatten step rarely announces itself. "Top 10 SKUs by units sold this week." "Average line price across all orders." "How many orders contain a SKU from the clearance list?" None of these says array, yet each needs the items array inside every order's JSON turned into rows before an aggregate can reach it. The strong answer writes that flatten as its own named step, then builds the business logic on top of it.
What one output row means
UNNEST takes an array and returns a table with one row per element. Written in FROM after the parent table, it runs once for each parent row and joins its output back to that row, so every element row carries a copy of the parent's columns. The join is lateral: the function on the right reads a column from the row on the left, which is why it needs no join condition. The running example is orders_raw, 4 orders that each have a shipping_fee column and a payload whose items array holds {sku, qty, price} objects.
| order_id | shipping_fee | line_no | sku | qty |
|---|---|---|---|---|
| 1001 | 5.00 | 1 | A-12 | 2 |
| 1001 | 5.00 | 2 | B-07 | 1 |
| 1001 | 5.00 | 3 | C-33 | 3 |
| 1002 | 7.50 | 1 | A-12 | 1 |
| 1004 | 6.00 | 1 | D-90 | 2 |
| 1004 | 6.00 | 2 | A-12 | 1 |
Four orders went in and 6 rows came out: 3 for 1001, 1 for 1002, none for 1003 and 2 for 1004. The result's row count is the sum of the array lengths, and the grain has moved from one row per order to one row per order line. shipping_fee now repeats on every line of its order, which does no harm while it is only displayed and becomes a bug the moment a later step adds it up.
- ▸The grain, said out loud: after the flatten, one row is one line of one order.
- ▸The row count, predicted before running: the total of the array lengths, never the order count.
- ▸The parent key carried through, so every element row can be traced and grouped back.
- ▸Position kept with
WITH ORDINALITYwhenever line order carries meaning. - ▸The empty-array question raised early, even if the fix comes later.
The same move in every engine
| Engine | Flatten | Position column |
|---|---|---|
| Trino / Presto | `CROSS JOIN UNNEST(arr) AS t(x)` | `WITH ORDINALITY`, from 1 |
| BigQuery | `CROSS JOIN UNNEST(arr) AS x` | `WITH OFFSET`, from 0 |
| Snowflake | `FROM t, LATERAL FLATTEN(input => t.payload:items) f` | `f.index`, from 0 |
| Postgres | `CROSS JOIN LATERAL jsonb_array_elements(payload->'items') AS i(item)` | `WITH ORDINALITY`, from 1 |
| Spark SQL | `LATERAL VIEW explode(items) t AS x` | `posexplode`, from 0 |
Two arrays on one row
The follow-up that separates people who have flattened real payloads: each order also carries a promos array, and the answer needs both. Two independent lateral joins multiply, so an order with 3 items and 2 promos becomes 6 rows, every item paired with every promo. That shape is right only when the question is about pairs, and any sum of qty over it has silently doubled for that order.
- Element 1 of skus belongs with element 1 of qtys
- Trino and Postgres take both in one UNNEST(skus, qtys) and pad the shorter with NULL
- BigQuery unnests each WITH OFFSET and joins on equal offsets
- Rows out equal the longer array's length
- items and promos describe different things
- Flatten each one in its own CTE
- Aggregate each back to one row per order
- Join the 2 results on order_id
Counting parents after the flatten
The clearance-list disguise hides a second trap. After the flatten, COUNT(*) behind a clearance filter counts lines, so an order holding 2 clearance SKUs is counted twice. Counting orders needs COUNT(DISTINCT o.order_id), or an EXISTS over the unnested array, which never changes the grain at all. The EXISTS form is the one worth naming: it can stop at the first matching element of each order and returns exactly one verdict per order by construction.
order_id.This part of the question comes back as a required output shape. "One row per order, with its lines as an array." "Per customer, the distinct SKUs they bought, as a list." "Correct the prices and hand me the payload back in the same form." Each one flattens, does work at the element grain, then climbs back to the parent grain with the elements gathered up. The climb is a GROUP BY on the parent key with an array aggregate. The code below reads the earlier flatten materialised as order_lines, one row per order line.
The first step collapses repeated SKUs within an order, which real carts produce whenever a client posts one line per tap. The second folds those rows into one per order. Narrate them as "first per order and SKU, then per order": that sentence proves you know which grain each aggregate runs at, and it is exactly where a single GROUP BY order_id over the raw lines goes wrong, because a client that posted A-12 as 2 taps in order 1001 would get an array listing it twice.
Arrays come back in no fixed order
GROUP BY promises nothing about the sequence in which rows reach the aggregate, so a bare ARRAY_AGG(sku) can return [C-33, A-12, B-07] today and a different arrangement after the cluster is resized. When position matters, the ORDER BY goes inside the aggregate call, and the ordinality captured during the flatten is the only reliable record of the source order. Interviewers who have chased a flaky pipeline test ask it directly: "will that array be identical on every run?" It is identical only when the sort key is deterministic, which is why the query above adds sku as a tie-breaker.
The parent column that gets summed
Every element row carries its parent's columns, so an aggregate over a parent-level column at the element grain counts that value once per element. Here are the order_lines rows for orders 1002 and 1004, with shipping_fee copied onto each line, folded by order_id.
SELECTorder_id,SUM(qty) AS units,SUM(shipping_fee) AS shipping_summed,MAX(shipping_fee) AS shipping_chargedFROM order_linesGROUP BY order_idORDER BY order_id
Order 1004 paid 6.00 for shipping and the summed column reports 12.00, because it has 2 lines. Order 1002 comes out right only because it has a single line. units comes out right because qty lives at the element grain; shipping comes out wrong because it lives at the order grain. The fixes, in order of preference: take parent-level columns from the parent table and join the element aggregate to it; use MAX or ANY_VALUE when the value is constant within each group; and treat any SUM or AVG over a column coarser than the row as a bug on sight. This is the fan-out a duplicate join key causes, and naming it that way ties the flatten to a failure every interviewer already scores.
Structs, objects and NULLs by engine
| Engine | Re-nest as | If the value itself is NULL |
|---|---|---|
| Trino | `array_agg(CAST(ROW(...) AS ROW(...)) ORDER BY ...)` | kept in the array |
| BigQuery | `ARRAY_AGG(STRUCT(sku, qty) ORDER BY ...)` | error if one reaches the result; `IGNORE NULLS` drops them |
| Snowflake | `ARRAY_AGG(OBJECT_CONSTRUCT('sku', sku)) WITHIN GROUP (ORDER BY ...)` | skipped |
| Postgres | `jsonb_agg(jsonb_build_object('sku', sku) ORDER BY ...)` | kept as JSON null |
That last column applies only when the aggregated value itself is NULL, as with a bare ARRAY_AGG(sku). A struct or object built from an outer flatten's placeholder row is a non-null record of nulls on every engine: a ROW of nulls in Trino, a STRUCT of nulls in BigQuery, {"sku": null} in Postgres and {} in Snowflake. No rule in the table removes it, and IGNORE NULLS passes it straight through. Exclude the placeholder before the aggregate: FILTER (WHERE sku IS NOT NULL) on Trino and Postgres, IF(sku IS NULL, NULL, STRUCT(sku, qty)) with IGNORE NULLS on BigQuery, IFF(sku IS NULL, NULL, OBJECT_CONSTRUCT(...)) on Snowflake, then apply the empty-array fallback from the next section.
Transform in place when the grain stays put
When the work is per element and the answer is still one row per order (prices to cents, cancelled lines removed, lines sorted by price), flattening and regrouping sends every element through a shuffle only to land where it started. A correlated array subquery rebuilds the array inside each row and never changes the grain. BigQuery writes it as ARRAY(SELECT ... FROM UNNEST(o.items)), Trino offers the transform and filter lambdas, and Snowflake has TRANSFORM and FILTER higher-order functions. Offering this when the interviewer asks for "the payload back" shows you weighed the cost of the regroup rather than reaching for GROUP BY out of habit.
No GROUP BY, no ordering risk, and order 1003 comes back holding an empty array, because the subquery runs inside its row instead of joining against it.
This one usually arrives as a reconciliation. "Yesterday orders_raw had 42,000 orders and the order-lines model has 41,880. Where did 120 go?" Sometimes it hides inside a requirement instead: "line count per order, every order included." Both describe one mechanic. CROSS JOIN UNNEST is an inner join between a row and its own elements, and an array with zero elements leaves nothing to join, so the parent disappears. Order 1003, a gift-card purchase whose payload holds "items": [], sits in the input and is absent from every flattened result so far.
Three payload shapes cause the drop: an empty array, a missing items key, and "items": null. The first 2 yield zero element rows on every major engine. The third depends on the engine: Trino casts the JSON null to SQL NULL and quietly returns nothing, while Postgres stops the query with cannot extract elements from a scalar, which at least fails loudly. On Trino the gap of 120 can hold all 3 shapes, and the interviewer may ask how you would tell them apart before fixing anything.
The outer flatten
| order_id | line_count |
|---|---|
| 1001 | 3 |
| 1002 | 1 |
| 1003 | 0 |
| 1004 | 2 |
LEFT JOIN ... ON TRUE keeps every order and fills the element columns with NULL when there is nothing to pair. The condition is TRUE because the lateral reference has already decided which elements belong to which row; the outer join's only job is to keep the parent that found none. The result is back to 4 rows for 4 orders, with 1003 reporting a line count of 0.
Every aggregate over 1003's element columns now sees only NULL. COUNT(i.item) returns 0, but a SUM over an element field such as qty returns NULL, because a sum over no values has no value. Wrap it in COALESCE(SUM(qty), 0) when the consumer expects a number, and say why in the room: NULL units in a revenue extract reads as missing data, while 0 reads as an order with nothing in it, which is exactly what 1003 is.
| Engine | Outer flatten |
|---|---|
| Trino / Presto | `LEFT JOIN UNNEST(arr) AS t(x) ON TRUE` |
| BigQuery | `LEFT JOIN UNNEST(arr) AS x` |
| Snowflake | `LATERAL FLATTEN(input => payload:items, OUTER => TRUE)` |
| Postgres | `LEFT JOIN LATERAL jsonb_array_elements(payload->'items') AS i(item) ON TRUE` |
| Spark SQL | `LATERAL VIEW OUTER explode(items)`, or `explode_outer` |
Three ways to lose the row again
| Later step | What happens to 1003 | Fix |
|---|---|---|
| `COUNT(*)` | Reports 1 line, because the NULL placeholder is a row | Count an element column: `COUNT(i.item)` |
| `WHERE qty > 0` | NULL > 0 is unknown, the row is filtered, 1003 vanishes | Move the test into the aggregate: `COUNT(CASE WHEN qty > 0 THEN 1 END)` |
| `ARRAY_AGG(sku)` | Trino and Postgres build `[NULL]`, a 1-element array for an order with none | `ARRAY_AGG(sku) FILTER (WHERE sku IS NOT NULL)`, then `COALESCE` the NULL result to an empty array |
The WHERE row is the trap interviewers set on purpose. They accept the LEFT JOIN, nod, and then ask for "line counts per order, ignoring zero-quantity lines." A predicate on an element column in WHERE runs after the join, the placeholder row fails it, and the outer flatten has become an inner one again, with no error and 120 orders missing. Moving the condition into a conditional aggregate keeps the parent and still excludes the lines.
When to skip the flatten
If the only output is a number per order, the safest way to keep 1003 is to never produce element rows at all. A scalar subquery over the array runs inside each parent row, returns exactly one value per order, and has no join that could drop anything. The COALESCE stays, because an aggregate over zero rows still returns NULL.
COUNT(DISTINCT order_id) on the output with COUNT(*) on the parent, and offer to keep that comparison as a test on the model. It is the line that sounds like a pipeline you have run, and it catches the next 120 the day a client starts sending empty carts.This one shows up as a number that is slightly too small. "Revenue rebuilt from the items array is 3% under the finance ledger." "This join from events to users loses about a fifth of rows." The JSON parses fine; the same key is typed differently by different writers. Order 1004 came from an older Android build that serialises price as "19.99", while the iOS app sends 12.5. To a JSON parser those are a string and a number, and a function that expects one misreads, rejects or quietly skips the other.
Which function breaks depends on what it expects. Casting the JSON value directly, (item->'price')::numeric in Postgres, works on the number and fails the whole query on the string with cannot cast jsonb string to type numeric. Comparisons on the raw JSON value fail silently: Postgres sorts jsonb by type before value, with every Number above every String, so item->'price' > '15'::jsonb is false for "19.99". Equality has the same flaw, because the number 123 and the string "123" are different JSON values, so a join on payload->'user_id' never matches across the 2 writers.
Profile the types before casting
| price_type | line_rows |
|---|---|
| number | 5 |
| string | 1 |
This is the query to open with when a type question lands, before any cast is written. Every engine has the probe: jsonb_typeof in Postgres, JSON_TYPE in BigQuery, TYPEOF on a VARIANT in Snowflake. The output says which branches the conversion needs, and on a real feed the long tail is where the surprises sit: an object where a scalar was expected, a boolean, an empty string. Six rows here; across 42,000 orders a day the third type might be 14 rows, and those 14 are what the follow-up will ask about.
Normalise through text, cast defensively
Text extraction is the common ground across types. ->> in Postgres, JSON_VALUE in BigQuery, JSON_EXTRACT_SCALAR in Trino and ::STRING in Snowflake all return 12.5 for the number and 19.99 for the string, both as unquoted text. Cast that text to a decimal once, with the safe variant so a bad value becomes NULL instead of failing the run, and count those NULLs so the loss is visible.
| revenue | prices_present | prices_unparsed |
|---|---|---|
| 141.98 | 6 | 0 |
The last column answers the follow-up before it is asked. TRY_CAST turns "N/A", "12,50" and "" into NULL, and SUM skips NULLs, so a defensive cast without a counter swaps a crash for an undercount nobody notices. Report the unparsed count beside the total, or fail the job when it crosses a threshold such as 0.1% of lines. BigQuery spells the safe cast SAFE_CAST; Snowflake uses TRY_TO_DECIMAL on the text.
When text is the wrong common ground
- ▸Large integer IDs: a 64-bit ID written as a JSON number can be rounded by any parser that reads it as a double. Prefer the string copy.
- ▸Booleans:
trueand"true"both become the text true, but"yes","1"and"Y"need a mapping in a CASE. - ▸Timestamps: epoch seconds as a number and an ISO 8601 string need different parse functions.
- ▸Nested values: an object where a scalar belongs comes back NULL from
JSON_EXTRACT_SCALARandJSON_VALUE, and as serialized JSON text from Postgres->>. The type profile catches both.
"How many orders used no coupon?" The quick query, WHERE JSON_EXTRACT_SCALAR(payload, '$.coupon') IS NULL, returns 3 of the 4 orders, and it is answering a different question. Order 1002 sent "coupon": null: checkout ran the coupon step and the customer applied nothing. Orders 1003 and 1004 never sent the key, because the app builds that wrote them predate the coupon feature. Scalar extraction turns both into the same SQL NULL, so "no coupon" and "we have no idea" land in one bucket.
- The writer never produced the field
- Usual cause: an older client or a skipped optional section
- For "used no coupon?" the answer is unknown
- The writer produced the field with no value
- Usual cause: the user cleared it, or the step ran and found nothing
- For "used no coupon?" the answer is yes
| payload fragment | payload->'coupon' | payload->>'coupon' | payload ? 'coupon' |
|---|---|---|---|
| `{"coupon": "SPRING10"}` | `"SPRING10"` (jsonb) | `SPRING10` | true |
| `{"coupon": null}` | `null` (jsonb) | NULL | true |
| `{}` | NULL | NULL | false |
The JSON-returning operator keeps the distinction and the text operator destroys it. Postgres's -> gives back a jsonb null for the explicit null and SQL NULL for the absent key, while ->> maps both to SQL NULL. BigQuery's JSON_VALUE, Trino's JSON_EXTRACT_SCALAR and Snowflake's ::STRING cast all behave like ->>. Once the value is text, the information is gone, so the check runs on the JSON value or on the key itself, never on the extracted string.
Classify into 3 states first
| coupon_state | orders |
|---|---|
| applied | 1 |
| not_sent | 2 |
| sent_null | 1 |
The same split applies to array elements. If a client posts "items": [{"sku": "A-12", "qty": 1}, null], the flatten returns 2 element rows and the second holds a JSON null. In Postgres COUNT(e) counts it, since a jsonb null is a value, while COUNT(e->>'sku') does not, so line counts disagree depending on which column is counted. Decide whether a null element is a line, then filter with jsonb_typeof(e) <> 'null' where it is not.
The probe in other engines
| Engine | Key present | Explicit JSON null |
|---|---|---|
| Postgres | `payload ? 'coupon'` | `jsonb_typeof(payload->'coupon') = 'null'` |
| Snowflake | `payload:coupon IS NOT NULL` | `IS_NULL_VALUE(payload:coupon)` |
| BigQuery | `JSON_QUERY(payload, '$.coupon') IS NOT NULL` | `JSON_TYPE(JSON_QUERY(payload, '$.coupon')) = 'null'` |
Snowflake turns the trap around. A VARIANT holding JSON null is a value, so payload:coupon IS NULL is false for order 1002, and a filter written to catch every no-coupon order catches only the missing keys. The BigQuery row assumes payload uses the native JSON type. Loading can also erase the difference before any query runs: STRIP_NULL_VALUES = TRUE on a Snowflake JSON file format removes every key whose value is null, turning sent_null orders into not_sent orders permanently.
? operator collides with the JDBC parameter placeholder, so a BI tool or Java service may reject the query. Write ?? in JDBC query strings, or call jsonb_exists(payload, 'coupon'), the function behind the operator.Where the difference changes the outcome
Partial-update feeds are the follow-up that tests production experience. Under JSON Merge Patch (RFC 7396), {"coupon": null} means remove the coupon and an absent coupon means leave it as it was. A change-data consumer that extracts with ->> and writes the result into a target column applies a NULL in both cases, wiping fields the source never touched. The same split decides feature flags (switched off versus never configured) and survey answers (declined versus never shown).
COALESCE(payload->>'coupon', 'none') is the line that looks tidy in a dashboard and merges 2 meanings under one label. Classify into the 3 states first, then pick a default inside each state, and keep not_sent visible as its own value so the unknowns stay counted rather than relabelled.
- Classify present, JSON null and missing before any COALESCE or IS NULL
- Test for the key on the JSON value: ?, JSON_QUERY or the VARIANT itself
- Report not_sent as its own bucket so unknowns stay counted
- Check the loader for STRIP_NULL_VALUES before trusting a null count
- Don't extract to text and then ask whether the key existed
- Don't COALESCE a missing key and a JSON null into one default label
- Don't write ->> output into a target column from a merge-patch feed
- Don't assume IS NULL catches a JSON null held in a Snowflake VARIANT
> A marketplace's finance team reconciles daily order revenue, rebuilt from the raw JSON order feed in the warehouse, against the payments ledger. The rebuilt figure runs 3% low, the revenue model holds fewer orders than the source table, and a coupon-uptake chart split by app release shows older releases with implausibly low uptake. The data team owes finance a corrected model: one row per order with its lines nested inside, revenue that matches the ledger, and a coupon field that says only what the data actually knows.
items drop from a cross join flatten; the outer flatten restores them and a reconciliation test keeps them restored.ARRAY_AGG returns lines in source order, and the NULL placeholder from empty orders is filtered so they carry an empty array.TRY_CAST, and the unparsed count is reported beside revenue.UNNEST, one row is one element: predict the row count as the sum of array lengths and never SUM a parent column at that grain.ARRAY_AGG per parent with an ORDER BY inside the aggregate.LEFT JOIN UNNEST ... ON TRUE keeps it, then count an element column.TRY_CAST, and count what failed to convert.COALESCE.Flatten and re-nest JSON arrays without dropping parents, inflating sums, or mixing up null and missing.
- Category
- SQL
- Difficulty
- intermediate
- Duration
- 25 minutes
- Challenges
- 4 hands-on challenges
Topics covered: Flattening Arrays with UNNEST, Re-Nesting After Aggregation, Keeping Parents with Empty Arrays, A Key Whose Type Varies by Row, Missing Keys and NULL Extraction
Lesson Sections
- Flattening Arrays with UNNEST (concepts: Array Flattening, Lateral Joins, Join Cardinality, JSON Extraction, Arrays, Existence Checks)
What one output row means The same move in every engine The keyword changes and the operation does not. Calling it "a lateral join that fans each order out by its item count" lands on whichever engine the interviewer runs, and it shows you know what the syntax does rather than which keyword spells it. Watch the position base when translating: offset 0 in BigQuery is ordinality 1 in Trino, and an off-by-one there misaligns every later join on position. Two arrays on one row Counting parents after
- Re-Nesting After Aggregation (concepts: Array Aggregation, Grouping, Join Cardinality, Sums & Averages, Array Transforms, Common Table Expressions)
Arrays come back in no fixed order The parent column that gets summed Structs, objects and NULLs by engine Transform in place when the grain stays put
- Keeping Parents with Empty Arrays (concepts: Outer Joins, Array Flattening, Lateral Joins, Counting, Null Defaulting)
The outer flatten Three ways to lose the row again Switching to the outer flatten is half the answer. The NULL placeholder row it creates for 1003 is fragile, and 3 ordinary later steps distort or delete it. The follow-ups live here. When to skip the flatten
- A Key Whose Type Varies by Row (concepts: Type Casting, Safe Casting, Conditional Logic, JSON Extraction)
Profile the types before casting Normalise through text, cast defensively When text is the wrong common ground Routing through text works for prices. Four other cases need the type probe and an explicit branch, and naming them is what turns a correct answer into a thorough one. The full answer the interviewer is grading has 4 parts: the profile query, the conversion through text or a type branch, the counter for what failed to convert, and one sentence noting that a typed column written once ups
- Missing Keys and NULL Extraction (concepts: Null Handling, JSON Extraction, Conditional Logic)
Classify into 3 states first The answer to "how many used no coupon" is 1 confirmed, with 2 unknown, and saying that split out loud is the moment the interviewer is waiting for. Reporting 3 makes a confident claim about orders the data says nothing about, and it is the version that ends up on a dashboard comparing coupon uptake across app releases, where older releases look like customers who turned coupons down. The probe in other engines Where the difference changes the outcome