Semi-Structured Data: Beginner
When the Schema Is Inside the Data
Recognize semi-structured data questions: JSON payloads, nested arrays, event properties, API responses.
The question that recurs in interviews involving event data: 'we have an events table with a JSON payload column. Pull out the user_id and event_type from the payload and count events per user.' The candidate who knows the right extraction syntax for the engine produces a clean six-line query; the candidate who does not fumbles between the various JSON functions. The patterns are small but engine-specific; knowing which one applies is the move.
The mental model: semi-structured is opaque until you extract
- ▸Semi-structured columns are opaque to SQL until extracted
- ▸Extraction returns a typed scalar (STRING, INT, BOOLEAN)
- ▸Downstream SQL operates on the extracted value as native
- ▸The CTE that does the extraction is the boundary
JSON_EXTRACT and Path Navigation
Extract scalar values from nested JSON using dot-notation and bracket-notation path expressions.
- payload->>'field' for text; payload->'field' for nested navigation.
- payload:field with optional ::TYPE cast.
- JSON_VALUE(payload, '$.field') for scalars; JSON_QUERY for nested.
- get_json_object(payload, '$.field') for any depth.
Engine-specific extraction syntax
Path syntax
Casting the result
The query
Why the CTE
- Extraction syntax repeated wherever the field is used
- Hard to read; engine-specific syntax interleaved with analytic logic
- Schema changes require finding every reference
- Hard to test; can only run the full query
- Extraction is named in a CTE; references use the column name
- Easy to read; analytic logic operates on column names
- Schema changes touch one CTE
- Can test the CTE independently by SELECTing from it
Pulling a Scalar Field Out of JSON
Use UNNEST/LATERAL FLATTEN to explode array columns into rows and join back to the parent.
Nested paths
Two operators in Postgres: -> vs ->>
- ▸-> returns a JSON value (further navigable)
- ▸->> returns a text value (final extraction)
- ▸Navigate intermediate paths with ->
- ▸Extract scalar with ->> only at the final step
Array elements by index
Strict vs lax extraction
JSON Columns vs Flat Columns
Aggregate over unnested data and reconstruct arrays/structs using ARRAY_AGG and STRUCT.
When the JSON payload contains an array (an order with multiple items), extracting a single element only gets you one item. The right pattern is to UNNEST the array into rows so each item becomes its own row. Then the standard relational operations (join, aggregate, filter) work as usual on the unnested rows.
The UNNEST pattern
Reading the pattern
- LATERAL jsonb_array_elements(payload->'items') AS item.
- LATERAL FLATTEN(input => payload:items) f; reference f.value:field.
- UNNEST(JSON_QUERY_ARRAY(payload, '$.items')) AS item.
Why LATERAL
Per-event aggregates after UNNEST
Reading a Nested Path Expression
Discuss with the interviewer when to parse JSON at query time vs materializing into typed columns during ETL.
Gotcha 1: missing keys return NULL silently
Gotcha 2: type coercion errors
Gotcha 3: array length and missing arrays
The closing thought
> You are in a data engineering interview at an analytics platform. The interviewer asks: 'Our events table has a JSON payload column. Pull out user_id and event_type from the payload and count events per user.'
-> returns JSON you can keep navigating and ->> returns text you cannot, so a nested path uses -> for intermediate steps and ->> only at the last one.AVG and undercounts COUNT on that column. Check whether the path is consistently populated and reach for COALESCE when it is not.TRY_CAST or SAFE_CAST on dirty payloads. A raw cast on an 'N/A' or empty string throws in Postgres, and the safe variants return NULL instead of failing the whole query.GROUP BY on the parent key, and a missing array yields zero rows for that parent unless you LEFT JOIN against the unnest.JSON columns in SQL interviews separate data engineers from analysts
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: When the Schema Is Inside the Data, JSON_EXTRACT and Path Navigation, Pulling a Scalar Field Out of JSON, JSON Columns vs Flat Columns, Reading a Nested Path Expression
Lesson Sections
- When the Schema Is Inside the Data (concepts: sqlJsonExtract)
The mental model: semi-structured is opaque until you extract A JSON column holds a value the database does not interpret as relational. Until you extract a path, you cannot filter, group, or aggregate on the content. Extraction returns a typed scalar (a STRING, INT, BOOLEAN) that downstream SQL operates on normally. The extraction step is the bridge between the semi-structured world and the relational world. State this when designing: 'I'll extract the path I need into named columns in a CTE, t
- JSON_EXTRACT and Path Navigation (concepts: sqlJsonExtract)
Every engine has functions for extracting a scalar from a JSON column. The syntax differs, but the semantics are the same: given a JSON column and a path, return the value at that path as a typed scalar. Knowing the right syntax for the engine you're on is the prerequisite to writing the query. Engine-specific extraction syntax Path syntax Most engines use a $.field path syntax for JSON paths (the $ refers to the root of the document; .field navigates into a property). Postgres's ->> operator ta
- Pulling a Scalar Field Out of JSON (concepts: sqlJsonExtract)
JSON payloads are nested. A user object contains a profile object which contains an address object. Events have nested device, session, and context structures. The extraction path walks the nesting; each step is one property navigation. Arrays inside the path are referenced by index. Nested paths Two operators in Postgres: -> vs ->> Postgres has two extraction operators. -> returns a JSON value (which can be further navigated). ->> returns a text value (which cannot be further navigated). When w
- JSON Columns vs Flat Columns (concepts: sqlUnnest)
The UNNEST pattern Reading the pattern UNNEST (or FLATTEN, or jsonb_array_elements) takes an array and produces one row per element. The result is joined back to the parent row (via LATERAL or implicit cross join) so each unnested row has the parent's context. The output has one row per (event, item) pair; aggregates on the result are per-item-level. Why LATERAL LATERAL lets the UNNEST reference columns from the parent table (events). Without LATERAL, the UNNEST is independent and cannot see the
- Reading a Nested Path Expression (concepts: sqlJsonExtract)
Three gotchas trip people up when working with semi-structured columns. Each has a specific failure mode and a known mitigation. Gotcha 1: missing keys return NULL silently If the path doesn't exist on a row (the device field is only present on mobile events, not web), extraction returns NULL. Aggregates that don't handle NULL produce wrong numbers (AVG skips NULL rows; COUNT(field) counts non-NULL only). Always check whether the path is consistently populated; if not, use COALESCE for defaults