IntermediateSQL · 26 min · 14 challenges

Complex Data: Intermediate

Picture a guest on Airbnb turning on a digital-detox filter: cabins that list no wifi. Two cabins qualify, but each has a blank amenity slot stored as NULL. The filter negates a contains check on wifi, every unknown answer drops out, and the guest gets an empty page. Fixing that means searching, cleaning and converting the arrays, maps and JSON behind each listing.

Array Operations

Daily Life
Interviews

An array column holds an ordered list in each row, so a single cell can carry every amenity a listing offers. The first 2 sections run on 5 listings whose arrays come from 3 amenity slots on the listing_amenities table. Hosts fill those slots by hand, which leaves listing 102 with wifi entered twice and listings 103 and 104 with an empty slot stored as NULL. Most examples build the array once, in a CTE named listings.

listing_idcityamenities
101Austin[wifi, hot_tub, parking]
102Denver[parking, wifi, wifi]
103Austin[kitchen, NULL, parking]
104Denver[hot_tub, kitchen, NULL]
105Austin[wifi, kitchen, hot_tub]

Searching with contains

contains(amenities, 'wifi') asks whether a value appears anywhere in the array. A match returns true. A miss returns false only when every element is known: if the array holds a NULL, the blank slot might have been wifi, so the answer is NULL. That is three-valued logic applied to each element in turn. The animation runs the check over the raw slots, building the array inline.

SELECT
listing_id,
ARRAY[amenity_1, amenity_2, amenity_3] AS amenities,
CONTAINS(ARRAY[amenity_1, amenity_2, amenity_3], 'wifi') AS has_wifi
FROM listing_amenities
listing_amenities5 rows
listing_id
city
amenity_1
amenity_2
amenity_3
101
Austin
wifi
hot_tub
parking
102
Denver
parking
wifi
wifi
103
Austin
kitchen
NULL
parking
104
Denver
hot_tub
kitchen
NULL
105
Austin
wifi
kitchen
hot_tub
Preparing the animation

Listings 103 and 104 get NULL for has_wifi, and that NULL causes trouble once the question flips to listings without wifi. NOT applied to NULL is still NULL, and WHERE keeps only rows where the condition is true. The first tab returns zero rows even though 2 listings have no wifi entry. The second tab turns the unknown answer into false before negating, so a blank slot counts as no wifi.

Negates contains directly. Both listings without wifi hold a NULL slot, both conditions are NULL, and the result is empty.
WITH listings AS (
SELECT
listing_id,
ARRAY[amenity_1, amenity_2, amenity_3] AS amenities
FROM listing_amenities
)
SELECT
listing_id,
amenities
FROM listings
WHERE NOT CONTAINS(amenities, 'wifi')
Result
listing_idamenities
103["kitchen",null,"parking"]
104["hot_tub","kitchen",null]
2 rows

Positions: array_position and element_at

array_position(amenities, 'hot_tub') returns the 1-based position of the first match and 0 on a miss, even when the array holds NULLs. To go the other way, from position to value, square brackets work until the position runs past the end: amenities[4] on a 3-element array stops the query with an error. element_at(amenities, 4) returns NULL there, and a negative position counts back from the end, so element_at(amenities, -1) reads the last slot.

WITH listings AS (
SELECT
listing_id,
ARRAY[amenity_1, amenity_2, amenity_3] AS amenities
FROM listing_amenities
)
SELECT
listing_id,
ARRAY_POSITION(amenities, 'hot_tub') AS hot_tub_pos,
ELEMENT_AT(amenities, 4) AS fourth_slot,
ELEMENT_AT(amenities, - 1) AS last_slot
FROM listings
Result
listing_idhot_tub_posfourth_slotlast_slot
1012NULLparking
1020NULLwifi
1030NULLparking
1041NULLNULL
1053NULLhot_tub
5 rows

Sorting with array_sort and reverse

array_sort orders the elements ascending and places every NULL at the end. The 1-argument form has no descending option, so descending order comes from reverse(array_sort(...)). Reverse flips the entire array, NULLs included, which moves them from the end to the front. Pair the sort with element_at and the NULL placement decides what the alphabetically last amenity turns out to be.

WITH listings AS (
SELECT
listing_id,
ARRAY[amenity_1, amenity_2, amenity_3] AS amenities
FROM listing_amenities
)
SELECT
listing_id,
ARRAY_SORT(amenities) AS sorted_asc,
REVERSE(ARRAY_SORT(amenities)) AS sorted_desc,
ELEMENT_AT(ARRAY_SORT(amenities), - 1) AS last_sorted
FROM listings
Result
listing_idsorted_ascsorted_desclast_sorted
101["hot_tub","parking","wifi"]["wifi","parking","hot_tub"]wifi
102["parking","wifi","wifi"]["wifi","wifi","parking"]wifi
103[null,"kitchen","parking"]["parking","kitchen",null]parking
104[null,"hot_tub","kitchen"]["kitchen","hot_tub",null]kitchen
105["hot_tub","kitchen","wifi"]["wifi","kitchen","hot_tub"]wifi
5 rows

Listings 103 and 104 report NULL as last_sorted, because their blank slot sorted to the back. Removing NULLs before sorting, the job of the next section, makes the last element a real amenity.

Modifying: append, remove, slice

No array function changes the stored column. Each one returns a new array for that row of the result. amenities || 'smoke_alarm' appends one element, and || between 2 arrays joins them end to end, as concat(a, b) does. array_remove(amenities, 'wifi') drops every element equal to wifi, so both copies on listing 102 go. slice(amenities, 1, 2) returns 2 elements starting at position 1.

WITH listings AS (
SELECT
listing_id,
ARRAY[amenity_1, amenity_2, amenity_3] AS amenities
FROM listing_amenities
)
SELECT
listing_id,
amenities || 'smoke_alarm' AS appended,
SLICE(amenities, 1, 2) AS first_two
FROM listings
Result
listing_idappendedfirst_two
101["wifi","hot_tub","parking"]smoke_alarm["wifi","hot_tub"]
102["parking","wifi","wifi"]smoke_alarm["parking","wifi"]
103["kitchen",null,"parking"]smoke_alarm["kitchen",null]
104["hot_tub","kitchen",null]smoke_alarm["hot_tub","kitchen"]
105["wifi","kitchen","hot_tub"]smoke_alarm["wifi","kitchen"]
5 rows

Run on the 5 listings, array_remove leaves listing 102 with [parking] and keeps the NULL slots on 103 and 104. It drops only elements equal to wifi, and a NULL is not equal to wifi, so the blank slot stays in the result.

WITH listings AS (
SELECT
listing_id,
ARRAY[amenity_1, amenity_2, amenity_3] AS amenities
FROM listing_amenities
)
SELECT
listing_id,
amenities,
ARRAY_REMOVE(amenities, 'wifi') AS no_wifi
FROM listings
listing_idamenitiesno_wifi
101[wifi, hot_tub, parking][hot_tub, parking]
102[parking, wifi, wifi][parking]
103[kitchen, NULL, parking][kitchen, NULL, parking]
104[hot_tub, kitchen, NULL][hot_tub, kitchen, NULL]
105[wifi, kitchen, hot_tub][kitchen, hot_tub]
TIP
The search value must match the array's element type. contains(ARRAY[1, 2], '1') fails with a type error because '1' is VARCHAR and the elements are INTEGER. Cast the search value, the same rule that applies to join keys of different types.
Multiple Choice

Listing 106 has amenities [tv, NULL, wifi]. What does this query return for it?

SELECT
CONTAINS(amenities, 'pool') AS has_pool,
ARRAY_POSITION(amenities, 'wifi') AS wifi_pos
FROM listings
WHERE listing_id = 106

Array Cleanup

Daily Life
Interviews

Listing 102 counts 3 amenities but offers 2, and the NULL slots on 103 and 104 turn every search on those rows into NULL. Both defects trace back to collection: a host taps the same checkbox twice, and an unused slot is stored as NULL. Cleaning the list once means every function after it works on real, unique values. The examples here name the uncleaned column raw_amenities.

array_distinct removes repeats

array_distinct(raw_amenities) keeps the first occurrence of each value and drops later copies, so the survivors stay in their original order. NULL counts as one more value: a single NULL survives, and an array holding 3 NULLs keeps 1. cardinality counts every element, NULLs included, which is why listing 103 still reports 3 after deduplication.

WITH raw AS (
SELECT
ARRAY[amenity_1, amenity_2, amenity_3] AS raw_amenities,
listing_id
FROM listing_amenities
)
SELECT
listing_id,
CARDINALITY(raw_amenities) AS raw_count,
ARRAY_DISTINCT(raw_amenities) AS deduped,
CARDINALITY(ARRAY_DISTINCT(raw_amenities)) AS deduped_count
FROM raw
Result
listing_idraw_countdedupeddeduped_count
1013["wifi","hot_tub","parking"]3
1023["parking","wifi"]2
1033["kitchen",null,"parking"]3
1043["hot_tub","kitchen",null]3
1053["wifi","kitchen","hot_tub"]3
5 rows

Removing NULLs: the call that backfires

The tempting call is array_remove(raw_amenities, NULL), and it returns NULL for every row: array_remove hands back NULL whenever its second argument is NULL, before it looks at the array. Even with that rule lifted, it removes elements equal to the argument, and NULL = NULL is never true.

The call that works is array_except(raw_amenities, ARRAY[CAST(NULL AS VARCHAR)]). array_except(x, y) returns the elements of x that are absent from y, and as a set operation it lets a NULL in x match the NULL in y. The CAST gives the 1-element array the VARCHAR type the amenities have.

WITH raw AS (
SELECT
ARRAY[amenity_1, amenity_2, amenity_3] AS raw_amenities,
listing_id
FROM listing_amenities
)
SELECT
listing_id,
ARRAY_REMOVE(raw_amenities, NULL) AS removed,
ARRAY_EXCEPT(raw_amenities, ARRAY[CAST(NULL AS VARCHAR)]) AS excepted
FROM raw
listing_idremovedexcepted
101NULL[wifi, hot_tub, parking]
102NULL[parking, wifi]
103NULL[kitchen, parking]
104NULL[hot_tub, kitchen]
105NULL[wifi, kitchen, hot_tub]

Listing 102 in the excepted column reads [parking, wifi]. Set functions always return distinct elements, so array_except dropped the second wifi along with the NULLs. For amenities that is the goal, since a listing either has wifi or lacks it. For sequences where repeats carry information, such as the pages a guest viewed in order, the same call erases data; a later lesson covers filtering that keeps repeats.

call on the raw arraylisting 102listing 103
array_distinct(x)[parking, wifi][kitchen, NULL, parking]
array_remove(x, NULL)NULLNULL
array_except(x, ARRAY[NULL])[parking, wifi][kitchen, parking]
The set functions, all deduplicating
  • array_union(x, y): every distinct element found in either array.
  • array_intersect(x, y): the distinct elements present in both arrays.
  • array_except(x, y): the distinct elements of x that y does not hold.

Clean once, then search

With NULLs gone, contains sees every element and returns false on a miss. The CTE below cleans in one place, and the no-wifi filter, written here as a comparison with false, now returns listings 103 and 104 with no COALESCE.

WITH clean AS (
SELECT
ARRAY_EXCEPT(
ARRAY[amenity_1, amenity_2, amenity_3],
ARRAY[CAST(NULL AS VARCHAR)]
) AS amenities,
listing_id
FROM listing_amenities
)
SELECT
listing_id,
amenities
FROM clean
WHERE CONTAINS(amenities, 'wifi') = FALSE
listing_idamenities
103[kitchen, parking]
104[hot_tub, kitchen]
The gap between raw and clean sizes doubles as a data-quality measure. Summed across listings, it counts the junk slots a cleanup step removed, a figure worth tracking as new hosts arrive.
WITH raw AS (
SELECT
ARRAY[amenity_1, amenity_2, amenity_3] AS raw_amenities,
listing_id
FROM listing_amenities
)
SELECT
COUNT(*) AS listings,
SUM(
CARDINALITY(raw_amenities) - CARDINALITY(
ARRAY_EXCEPT(raw_amenities, ARRAY[CAST(NULL AS VARCHAR)])
)
) AS junk_slots
FROM raw
listingsjunk_slots
53
The 3 junk slots are the second wifi on listing 102 and the NULL slots on 103 and 104. The raw arrays hold 15 elements and the cleaned ones 12, so 1 slot in 5 was junk. A jump in that ratio after a release points at the form that collects amenities.
✓Do
  • Clean arrays in one CTE and point every later step at the clean column
  • Drop NULLs from set-like lists with array_except and a 1-element NULL array
  • Compare cardinality before and after cleanup to measure how much changed
✗Don't
  • Don't pass NULL to array_remove; it returns a NULL array
  • Don't deduplicate sequences where repeats carry meaning, like page views
  • Don't search arrays with contains while NULL slots remain in them
Multiple Choice

Listing 107's raw array is [pool, NULL, pool, NULL]. What do the 2 cleanup columns return for it?

SELECT
ARRAY_DISTINCT(raw_amenities) AS deduped,
ARRAY_EXCEPT(raw_amenities, ARRAY[CAST(NULL AS VARCHAR)]) AS cleaned
FROM raw
WHERE listing_id = 107

Map Operations

Daily Life
Interviews

A map stores key-value pairs in one cell. Each key appears once, and every value shares one type. Listing settings fit that shape: each host sets a check-in time, a pet policy and a guest limit. The examples build a settings map per listing from 3 columns of listing_settings, keeping every value as VARCHAR so times, words and numbers can live in the same map.

listing_idsettings
101{checkin=16:00, pets=yes, max_guests=4}
102{checkin=15:00, pets=no, max_guests=10}
103{checkin=14:00, pets=yes, max_guests=2}

element_at(settings, 'pets') reads a value by key, the same function that read arrays by position. A key the map lacks returns NULL. The bracket form settings['pets'] fails the query on a missing key, so it belongs only where every row is known to carry that key.

Merging with map_concat

map_concat(a, b) combines 2 maps. A key found in only one map passes through; when both hold a key, the value from the later argument wins. That rule turns argument order into a policy. Put platform defaults first and host settings second, and hosts override the defaults. Reverse them, and the defaults overwrite what hosts chose, with no error to warn you.

WITH with_settings AS (
SELECT
listing_id,
MAP(
ARRAY['checkin', 'pets', 'max_guests'],
ARRAY[checkin, pets, max_guests]
) AS settings,
MAP(
ARRAY['pets', 'quiet_hours', 'checkout'],
ARRAY['no', '22:00', '11:00']
) AS defaults
FROM listing_settings
)
SELECT
listing_id,
ELEMENT_AT(MAP_CONCAT(defaults, settings), 'pets') AS host_wins,
ELEMENT_AT(MAP_CONCAT(settings, defaults), 'pets') AS default_wins,
ELEMENT_AT(MAP_CONCAT(defaults, settings), 'quiet_hours') AS quiet_hours,
CARDINALITY(MAP_CONCAT(defaults, settings)) AS rule_count
FROM with_settings
listing_idhost_winsdefault_winsquiet_hoursrule_count
101yesno22:005
102nono22:005
103yesno22:005
Listing 102's host chose no, so both orders agree on that row, and only 101 and 103 reveal which order ran. Quiet hours come from the defaults in either order, since no host set them. Each merged map holds 5 keys: the 3 host settings plus quiet_hours and checkout, with pets counted once.
TIP
A key paired with NULL in the later map still wins. If an override column is NULL and goes into the map anyway, the merge replaces a useful default with NULL. Check override values for NULL before building the map that goes second.

Filtering rows and entries

Two kinds of filter apply to maps. A row filter keeps rows whose map holds some entry: WHERE element_at(settings, 'pets') = 'yes'. A row without a pets key gets NULL from element_at, the comparison is NULL, and the row drops out. An entry filter keeps chosen keys and discards the rest, and one way that needs no new function is to rebuild the map from the keys you want, reading each value with element_at.

WITH with_settings AS (
SELECT
listing_id,
MAP(
ARRAY['checkin', 'pets', 'max_guests'],
ARRAY[checkin, pets, max_guests]
) AS settings
FROM listing_settings
)
SELECT
listing_id,
MAP(
ARRAY['checkin', 'pets'],
ARRAY[ELEMENT_AT(settings, 'checkin'), ELEMENT_AT(settings, 'pets')]
) AS guest_view
FROM with_settings
WHERE ELEMENT_AT(settings, 'pets') = 'yes'
Result
listing_idguest_view
0 rows

The rebuilt map always carries both keys. If a listing had no checkin entry, its guest_view would still list the key, paired with NULL, which a consumer may read differently from an absent key. To test whether a key exists, search the key array: contains(map_keys(settings), 'checkin').

Transforming through keys and values

map_keys(settings) and map_values(settings) return the map's contents as 2 arrays in matching order, which opens up every array function from the first 2 sections. Entry order inside a map is not something to depend on, so wrap map_keys in array_sort before displaying or comparing key lists. A value pulled out by key can be cast to its real type, which lets guest limits sort as numbers.

WITH with_settings AS (
SELECT
listing_id,
MAP(
ARRAY['checkin', 'pets', 'max_guests'],
ARRAY[checkin, pets, max_guests]
) AS settings
FROM listing_settings
)
SELECT
listing_id,
ARRAY_SORT(MAP_KEYS(settings)) AS setting_names,
CARDINALITY(settings) AS setting_count,
CAST(ELEMENT_AT(settings, 'max_guests') AS INTEGER) AS max_guests
FROM with_settings
ORDER BY max_guests DESC
Result
listing_idsetting_namessetting_countmax_guests
101["[\"checkin\",\"pets\",\"max_guests\"]"]0NULL
102["[\"checkin\",\"pets\",\"max_guests\"]"]0NULL
103["[\"checkin\",\"pets\",\"max_guests\"]"]0NULL
3 rows
✓Arrays
  • Read by position: element_at(arr, 2)
  • Search values: contains(arr, v)
  • Repeats allowed
  • Order carries meaning
✗Maps
  • Read by key: element_at(m, 'pets')
  • Search keys: contains(map_keys(m), k)
  • Each key once
  • Entry order not guaranteed
Multiple Choice

Listing 103's settings map is {checkin=14:00, pets=yes, max_guests=2}. What do pets and n come back as?

WITH with_settings AS (
SELECT
listing_id,
MAP(
ARRAY['checkin', 'pets', 'max_guests'],
ARRAY[checkin, pets, max_guests]
) AS settings
FROM listing_settings
),
merged AS (
SELECT
listing_id,
MAP_CONCAT(
settings,
MAP(ARRAY['pets', 'checkout'], ARRAY['no', '11:00'])
) AS rules
FROM with_settings
)
SELECT
ELEMENT_AT(rules, 'pets') AS pets,
CARDINALITY(rules) AS n
FROM merged
WHERE listing_id = 103

JSON_EXTRACT_SCALAR()

Daily Life
Interviews

The listing editor logs each save as a JSON document in a VARCHAR column named payload. json_extract pulls a fragment out by path and returns it as JSON, so a string comes back wrapped in its quotes and an object comes back whole. That suits a fragment headed for more JSON processing, and it gets in the way when you want a plain value to filter, group or join on.

json_extract_scalar(payload, '$.address.city') returns the value as VARCHAR with the quotes removed. It handles strings, numbers and booleans. A path that lands on an object or an array, a path that does not exist, and a JSON null all give SQL NULL.

json_extract returns JSON
pathresult
$.address.city"Austin"
$.max_guests4
$.superhosttrue
$.address{"city":"Austin","zip":"78701"}
$.poolNULL
json_extract_scalar returns VARCHAR
pathresult
$.address.cityAustin
$.max_guests4
$.superhosttrue
$.addressNULL
$.poolNULL
SELECT
event_id,
JSON_EXTRACT_SCALAR(payload, '$.listing_id') AS listing_id,
JSON_EXTRACT_SCALAR(payload, '$.address.city') AS city,
JSON_EXTRACT_SCALAR(payload, '$.address') AS address,
JSON_EXTRACT_SCALAR(payload, '$.nightly_price') AS price,
JSON_EXTRACT_SCALAR(payload, '$.superhost') AS superhost
FROM listing_events
Result
event_idlisting_idcityaddresspricesuperhost
1101AustinNULL129true
2102DenverNULL95false
3103AustinNULLNULLtrue
4104DenverNULLTBDNULL
4 rows
Event 3's price is NULL because its JSON holds null, and event 4's superhost is NULL because the key is absent. The column cannot tell those 2 cases apart, and the next section shows how to list the keys each document carries. Event 4's price arrives as the text TBD, which matters as soon as the column is cast.

Paths into nested fields and arrays

Path syntax in this lesson
  • $.max_guests: a top-level key.
  • $.address.city: a key inside a nested object, one dot per level.
  • $.amenities[0]: the first element of a JSON array. Path indexes start at 0, while SQL array positions start at 1.
  • $.amenities: a whole array, so json_extract_scalar returns NULL for it.
Applied to the 4 save events, those paths reach into the address object and pick amenities by index. Event 4 returns NULL twice, for 2 different reasons: its address carries no zip key, and its third amenity is a JSON null. Index 2 is the third element, so event 1 shows parking in that column.
SELECT
event_id,
JSON_EXTRACT_SCALAR(payload, '$.address.zip') AS zip,
JSON_EXTRACT_SCALAR(payload, '$.amenities[0]') AS first_amenity,
JSON_EXTRACT_SCALAR(payload, '$.amenities[2]') AS third_amenity
FROM listing_events
Result
event_idzipfirst_amenitythird_amenity
178701wifiparking
280202parkingwifi
378704kitchenparking
4NULLhot_tubNULL
4 rows

Every result is VARCHAR

Numbers come out as text, and text compares character by character. Sorting guest limits of 4, 10, 2 and 6 in descending order as text gives 6, 4, 2, 10, because the character 6 outranks the character 1. The first tab sorts the raw strings. The second casts to INTEGER inside the SELECT, so the sort sees numbers.
Sorts VARCHAR values: listing 102 with 10 guests lands last.
SELECT
JSON_EXTRACT_SCALAR(payload, '$.listing_id') AS listing_id,
JSON_EXTRACT_SCALAR(payload, '$.max_guests') AS max_guests
FROM listing_events
ORDER BY max_guests DESC
Result
listing_idmax_guests
1046
1014
1032
10210
4 rows

CAST stops the whole query when one value will not convert, and event 4's price is TBD. TRY_CAST returns NULL for that row and lets the others through. Wrapped around the extraction, it gives a column that aggregates cleanly, and COUNT over that column reports how many rows had a usable price.

SELECT
JSON_EXTRACT_SCALAR(payload, '$.address.city') AS city,
COUNT(*) AS listings,
COUNT(
TRY_CAST(
JSON_EXTRACT_SCALAR(payload, '$.nightly_price')
AS INTEGER
)
) AS priced,
AVG(
TRY_CAST(
JSON_EXTRACT_SCALAR(payload, '$.nightly_price')
AS INTEGER
)
) AS avg_price
FROM listing_events
GROUP BY JSON_EXTRACT_SCALAR(payload, '$.address.city')
ORDER BY city
Result
citylistingspricedavg_price
Austin21129
Denver2247.5
2 rows
TIP
Booleans arrive as the strings 'true' and 'false'. Comparing to the string 'true' works in a WHERE clause, and CAST(... AS BOOLEAN) converts them to real BOOLEAN values for AND, OR and NOT logic.
Choosing the extractor
  • ▸json_extract: the value stays JSON, for another JSON function or a cast to ARRAY or MAP.
  • ▸json_extract_scalar: a string, number or boolean as VARCHAR, ready to filter, group or join.
  • ▸CAST or TRY_CAST around json_extract_scalar: whenever the value is a number or a boolean.
Multiple Choice

Event 1's payload holds "amenities": ["wifi", "hot_tub", "parking"]. What does this query return for it?

SELECT
JSON_EXTRACT_SCALAR(payload, '$.amenities[1]') AS amenity
FROM listing_events
WHERE event_id = 1

CAST JSON to Array/Map

Daily Life
Interviews

json_extract_scalar returns NULL for $.amenities and $.address, which leaves the payload's arrays and objects out of reach. To search, sort or clean them with the functions from earlier sections, convert the JSON into native types: CAST(json_value AS ARRAY(VARCHAR)) or CAST(json_value AS MAP(VARCHAR, VARCHAR)).

The cast needs a JSON-typed input. json_extract(payload, '$.amenities') supplies one, since it returns JSON. When a whole VARCHAR column is one JSON document, json_parse(payload) turns the text into JSON. Casting the VARCHAR straight to ARRAY fails, because a string has no conversion to an array; the text has to be parsed first.

VARCHAR text
payload
one path to JSON
json extract
whole text to JSON
json parse
JSON to ARRAY or MAP
CAST
contains
array and map functions
element_atmap_keys

Text becomes JSON through a path or a full parse, and only JSON can be cast to ARRAY or MAP.

JSON arrays into SQL arrays

Each JSON array element becomes one SQL element, and a JSON null element becomes a SQL NULL. Event 3's amenities arrive as [kitchen, NULL, parking], the same blank slot the first 2 sections handled, and the same cleanup applies once the cast is done. The has_hot_tub column reads the uncleaned array on purpose: listing 103 gets NULL, and pointing contains at clean would give false.

WITH parsed AS (
SELECT
JSON_EXTRACT_SCALAR(payload, '$.listing_id') AS listing_id,
CAST(
JSON_EXTRACT(payload, '$.amenities')
AS ARRAY(VARCHAR)
) AS amenities
FROM listing_events
)
SELECT
listing_id,
amenities,
ARRAY_EXCEPT(amenities, ARRAY[CAST(NULL AS VARCHAR)]) AS clean,
CONTAINS(amenities, 'hot_tub') AS has_hot_tub
FROM parsed
listing_idamenitiescleanhas_hot_tub
101[wifi, hot_tub, parking][wifi, hot_tub, parking]true
102[parking, wifi, wifi][parking, wifi]false
103[kitchen, NULL, parking][kitchen, parking]NULL
104[hot_tub, kitchen, NULL][hot_tub, kitchen]true
Multiple Choice

Event 7's payload holds "amenities": ["wifi", null, "wifi"]. What does this query return?

SELECT
CARDINALITY(
ARRAY_DISTINCT(
CAST(
JSON_EXTRACT(payload, '$.amenities')
AS ARRAY(VARCHAR)
)
)
) AS n
FROM listing_events
WHERE event_id = 7

JSON objects into maps

An object casts to a map with its field names as keys. JSON object keys are always strings, so the key type is VARCHAR. The value type is yours to pick, and every value has to convert to it. All fields of address are strings, so MAP(VARCHAR, VARCHAR) fits, and element_at returns NULL for the zip that event 4 never sent.

SELECT
JSON_EXTRACT_SCALAR(payload, '$.listing_id') AS listing_id,
ELEMENT_AT(
CAST(
JSON_EXTRACT(payload, '$.address')
AS MAP(VARCHAR, VARCHAR)
),
'zip'
) AS zip,
CARDINALITY(
CAST(
JSON_EXTRACT(payload, '$.address')
AS MAP(VARCHAR, VARCHAR)
)
) AS address_fields
FROM listing_events
Result
listing_idzipaddress_fields
101787010
102802020
103787040
104NULL0
4 rows

Mixed values: MAP(VARCHAR, JSON)

The full payload mixes numbers, booleans, an object and an array, and no single SQL type holds them all. JSON does: casting to MAP(VARCHAR, JSON) keeps each value as a JSON fragment, so the cast succeeds on any object. map_keys of that map lists the fields each document carries. Event 4 has no superhost key, while event 3 carries a nightly_price key whose value is null.

SELECT
event_id,
ARRAY_SORT(
MAP_KEYS(CAST(JSON_PARSE(payload) AS MAP(VARCHAR, JSON)))
) AS fields,
CONTAINS(
MAP_KEYS(CAST(JSON_PARSE(payload) AS MAP(VARCHAR, JSON))),
'superhost'
) AS has_superhost
FROM listing_events
Result
event_idfieldshas_superhost
1["address","amenities","listing_id","max_guests","nightly_price","superhost"]1
2["address","amenities","listing_id","max_guests","nightly_price","superhost"]1
3["address","amenities","listing_id","max_guests","nightly_price","superhost"]1
4["address","amenities","listing_id","max_guests","nightly_price"]0
4 rows

When a cast meets the wrong data

CAST converts all or nothing. If one element in one row cannot become the target type, the query stops with an error. TRY_CAST returns NULL for that row's entire value, never a partial array. The table shows what each input becomes as ARRAY(INTEGER).

JSON inputCAST AS ARRAY(INTEGER)TRY_CAST AS ARRAY(INTEGER)
[1, 2, 3][1, 2, 3][1, 2, 3]
[1, null, 3][1, NULL, 3][1, NULL, 3]
[1, "two", 3]query failsNULL
{"a": 1}query failsNULL

Take the whole payload. TRY_CAST(json_parse(payload) AS MAP(VARCHAR, INTEGER)) returns NULL on all 4 events: every event carries an address object and an amenities array, and neither converts to an integer, so each guest limit is lost with them. Cast to MAP(VARCHAR, JSON) instead and all 4 succeed, while TRY_CAST(json_extract_scalar(payload, '$.max_guests') AS INTEGER) still reads 6 for event 4.

TIP
Start with the loosest type the data allows. ARRAY(JSON) and MAP(VARCHAR, JSON) accept any values of the right outer shape, and each value can be extracted and cast on its own afterwards, so one bad field costs one NULL.
Complete the SQL

> Return the listing_id of every event in listing_events whose amenities array contains 'hot_tub'. The amenities live inside the VARCHAR column payload. Two tiles are distractors.

1
2
3
4
5
❯❯❯PUTTING IT ALL TOGETHER

> Picture a search team at Airbnb building a nightly listings table from the editor's JSON save events, so that a guest's amenity filter matches the right homes and each listing page shows complete house rules.

Paths read listing_id, city and max_guests with json_extract_scalar, and each is cast to its real type so guest limits filter and sort as numbers.
CAST(json_extract(payload, '$.amenities') AS ARRAY(VARCHAR)) turns the amenity list into an array, with JSON nulls arriving as NULL slots.
array_except with a 1-element NULL array clears those slots and repeats once, so every wifi or hot tub filter gets a true or false from contains.
The house-rules object casts to MAP(VARCHAR, VARCHAR), and map_concat with platform defaults first fills in every rule a host never set.
map_keys over MAP(VARCHAR, JSON) audits which fields each editor version sends before the other steps depend on them.
KEY TAKEAWAYS
contains returns NULL when the value is missing and the array holds a NULL, so NOT contains drops those rows without any error.
SQL array positions start at 1: array_position returns 0 on a miss, and element_at returns NULL past the end and counts back from a negative position.
array_sort puts NULLs last and reverse moves them first, and every array function returns a new array while the column stays as stored.
array_distinct keeps one NULL, array_remove(x, NULL) returns NULL, and array_except with a 1-element NULL array drops NULLs and repeats together.
map_concat lets the later map win on a shared key, so the order of defaults and overrides is a decision about which survives.
json_extract_scalar returns VARCHAR or NULL, so numbers are cast before sorting or summing, with TRY_CAST wherever text can appear.
JSON arrays and objects become ARRAY and MAP through CAST from a JSON input, and MAP(VARCHAR, JSON) accepts any object.

Search, sort and clean arrays, merge maps, and turn raw JSON into values and types a query can use.

Arrays, maps, and finding needles

Category
SQL
Difficulty
intermediate
Duration
26 minutes
Challenges
14 hands-on challenges

Topics covered: Array Operations, Array Cleanup, Map Operations, JSON_EXTRACT_SCALAR(), CAST JSON to Array/Map

Lesson Sections

  1. Array Operations (concepts: Array Operations)

    Searching with contains Positions: array_position and element_at Sorting with array_sort and reverse Modifying: append, remove, slice

  2. Array Cleanup (concepts: Array Operations)

    array_distinct removes repeats Removing NULLs: the call that backfires Clean once, then search The gap between raw and clean sizes doubles as a data-quality measure. Summed across listings, it counts the junk slots a cleanup step removed, a figure worth tracking as new hosts arrive. The 3 junk slots are the second wifi on listing 102 and the NULL slots on 103 and 104. The raw arrays hold 15 elements and the cleaned ones 12, so 1 slot in 5 was junk. A jump in that ratio after a release points at

  3. Map Operations

    Merging with map_concat Listing 102's host chose no, so both orders agree on that row, and only 101 and 103 reveal which order ran. Quiet hours come from the defaults in either order, since no host set them. Each merged map holds 5 keys: the 3 host settings plus quiet_hours and checkout, with pets counted once. Filtering rows and entries Transforming through keys and values

  4. JSON_EXTRACT_SCALAR() (concepts: JSON Extraction)

    Event 3's price is NULL because its JSON holds null, and event 4's superhost is NULL because the key is absent. The column cannot tell those 2 cases apart, and the next section shows how to list the keys each document carries. Event 4's price arrives as the text TBD, which matters as soon as the column is cast. Paths into nested fields and arrays Applied to the 4 save events, those paths reach into the address object and pick amenities by index. Event 4 returns NULL twice, for 2 different reason

  5. CAST JSON to Array/Map (concepts: Type Casting)

    JSON arrays into SQL arrays JSON objects into maps Mixed values: MAP(VARCHAR, JSON) When a cast meets the wrong data