7.7 JSON (VARIANT)¶
Concept¶
VARIANT — JSON in a column¶
Snowflake stores semi-structured data (JSON, Avro, Parquet, ORC, XML) in a column of type
VARIANT. You don't parse it at query time like in Trino — Snowflake stores it in a
columnar way inside the micro-partitions, so querying doc:status is fast.
| Syntax | Means |
|---|---|
doc:status |
the status key of doc (still a VARIANT) |
doc:customer.id |
a nested key |
doc:items[0] |
the first element of an array |
doc:status::varchar |
cast to a real type — always cast before comparing or joining |
lateral flatten(input => doc:items) |
one row per array element |
You did the same in the lab with Trino's json_extract_scalar and UNNEST in
Semi-structured data (JSON).
Lab¶
1 · Build JSON documents from ShopFlow¶
One JSON document per order, with its lines as an array — the shape an orders API would send:
create or replace table ORDERS_JSON as
select o.order_id,
object_construct(
'order_id', o.order_id,
'order_ts', o.order_ts,
'channel', o.channel,
'status', o.status,
'customer', object_construct('id', c.customer_id, 'country', c.country),
'items', array_agg(object_construct(
'product_id', i.product_id,
'qty', i.quantity,
'price', i.unit_price))
) as doc
from SHOPFLOW_DEV.BRONZE.ORDERS o
join SHOPFLOW_DEV.BRONZE.CUSTOMERS c on c.customer_id = o.customer_id
join SHOPFLOW_DEV.BRONZE.ORDER_ITEMS i on i.order_id = o.order_id
group by o.order_id, o.order_ts, o.channel, o.status, c.customer_id, c.country;
select doc from ORDERS_JSON limit 1;
object_construct(key, value, …)— builds a JSON object.array_agg(x)— collects the group's values into a JSON array.
Example document:
{
"channel": "web",
"customer": { "country": "FR", "id": 72 },
"items": [
{ "price": 412.18, "product_id": 37, "qty": 2 },
{ "price": 18.5, "product_id": 122, "qty": 1 }
],
"order_id": 2,
"order_ts": "2023-06-18 14:46:32.000",
"status": "delivered"
}
2 · Read paths and cast¶
select doc:order_id::int as order_id,
doc:customer.country::varchar as country,
doc:status::varchar as status,
array_size(doc:items) as n_items
from ORDERS_JSON
limit 5;
array_size(array)— number of elements.
Without ::varchar, doc:status shows as "delivered" with quotes — it's still a VARIANT.
3 · One row per item: lateral flatten¶
select doc:order_id::int as order_id,
item.index as position,
item.value:product_id::int as product_id,
item.value:qty::int as qty,
item.value:price::number(10,2) as price
from ORDERS_JSON,
lateral flatten(input => doc:items) item
order by order_id, position
limit 6;
lateral flatten(input => array)— a row per element. Each row hasvalue(the element),index(its position) and more.
Count the rows: 100000 minus the cancelled orders' lines you deleted in the clone — back to the
grain of ORDER_ITEMS.
4 · Write the documents as JSON files¶
APIs and other systems hand JSON over as files. Write ORDERS_JSON out as files into a
stage — a place for files inside Snowflake (the next lesson covers stages
in full):
create or replace file format JSON_FMT type = json;
create or replace stage ORDERS_STAGE file_format = JSON_FMT;
copy into @ORDERS_STAGE/orders/
from (select doc from ORDERS_JSON)
file_format = (type = json);
list @ORDERS_STAGE;
@ORDERS_STAGE/orders/— a path inside the stage;@marks a stage.copy into @stage from (query)— unload: writes the query result as files (gzip-compressed by default).list @stage— the files in it, with size and checksum.
5 · Query JSON files without loading them¶
$1— the first column of each file row; for JSON, the whole document.
What the file looks like. The unload in step 4 wrote one JSON document per line (called NDJSON; your values differ):
{"channel":"web","customer":{"country":"FR","id":72},"items":[…],"order_id":2,"order_ts":"2023-06-18 14:46:32.000","status":"delivered"}
{"channel":"app","customer":{"country":"US","id":4120},"items":[…],"order_id":3,"order_ts":"2023-06-18 15:02:11.000","status":"shipped"}
…
When Snowflake reads a file, it splits it into rows and columns using the file format:
| File format | One row is | Columns |
|---|---|---|
type = json |
one JSON document (here: one line) | only one: $1 = the whole document, as a VARIANT |
type = csv |
one line | one per comma-separated field: $1, $2, $3 … — as text |
So for a JSON file there's never a $2 — the document is the one column, and
$1:order_id reaches inside it. See which file and line each row came from:
select metadata$filename, metadata$file_row_number, $1:order_id::int as order_id
from @ORDERS_STAGE/orders/
limit 3;
metadata$filename,metadata$file_row_number— the file and the row number in it; handy to trace a bad row back to its file.
6 · Load JSON files into a VARIANT column¶
create or replace table ORDERS_RAW (
doc variant,
loaded_at timestamp_ltz default current_timestamp()
);
copy into ORDERS_RAW (doc)
from (select $1 from @ORDERS_STAGE/orders/)
file_format = (format_name = JSON_FMT);
select count(*) from ORDERS_RAW;
copy into table (doc) from (select $1 …)— load each file row's document into thedoccolumn;loaded_atfills itself from itsdefault.
One row per order again — the same documents as ORDERS_JSON, now loaded from files. What
COPY does when it meets the same file twice is in the next lesson.
7 · JSON inside a CSV column¶
Files from other systems often mix both: a CSV whose 2nd column holds a JSON object, or
two columns with two different JSON objects. Make one from ORDERS_JSON — order_id, the
customer object and the items array, as three CSV columns:
create file format if not exists CSV_FMT
type = csv skip_header = 1 field_optionally_enclosed_by = '"';
copy into @ORDERS_STAGE/orders_mixed.csv
from (select doc:order_id::int as order_id,
to_json(doc:customer) as customer,
to_json(doc:items) as items
from ORDERS_JSON
order by order_id
limit 3)
file_format = (type = csv field_optionally_enclosed_by = '"' compression = none)
header = true single = true;
create file format … type = csv— how to read a CSV:skip_header = 1skips the header line,field_optionally_enclosed_by = '"'handles values wrapped in quotes.to_json(variant)— the JSON as text, so it can go into a CSV field.
The file looks like this — each JSON is wrapped in "…", and every " inside it is doubled
("") so the commas in the JSON don't split the field (your values differ):
ORDER_ID,CUSTOMER,ITEMS
2,"{""country"":""FR"",""id"":72}","[{""price"":412.18,""product_id"":37,""qty"":2},{""price"":18.5,""product_id"":122,""qty"":1}]"
3,"{""country"":""US"",""id"":4120}","[{""price"":99.9,""product_id"":5,""qty"":1}]"
…
Now read it as CSV, so the columns are $1, $2, $3:
select $1::int as order_id,
parse_json($2):country::varchar as country, -- JSON in column 2
parse_json($2):id::int as customer_id,
array_size(parse_json($3)) as n_items, -- another JSON in column 3
parse_json($3)[0]:product_id::int as first_product
from @ORDERS_STAGE/orders_mixed.csv (file_format => 'CSV_FMT');
-
(file_format => 'CSV_FMT')— read this file withCSV_FMTinstead of the stage's own JSON format. -
$2,$3— the 2nd and 3rd CSV columns. In a CSV every column is text, so$2:countrydoesn't work yet. parse_json(text)— turns JSON text into a VARIANT; after that:country,[0]and::intwork as on any VARIANT.[0]— the first element of an array (counting from 0).
To keep the JSON when loading, store it in VARIANT columns:
create or replace table ORDERS_MIXED (order_id int, customer variant, items variant);
copy into ORDERS_MIXED
from (select $1::int, parse_json($2), parse_json($3)
from @ORDERS_STAGE/orders_mixed.csv)
file_format = (format_name = CSV_FMT);
select order_id, customer:country::varchar as country, array_size(items) as n_items
from ORDERS_MIXED;
| File | Where the JSON is | Read it with |
|---|---|---|
JSON file (type = json) |
the whole row | $1:field |
| CSV, JSON in the 2nd column | column 2, as text | parse_json($2):field |
| CSV, two JSON columns | columns 2 and 3 | parse_json($2):…, parse_json($3):… |
Next
Uploading your own files and loading them into tables — from inside Snowflake and from AWS S3 — in Stages: upload & load files.
Check yourself¶
- Why cast
doc:status::varcharbefore comparing it? (It's a VARIANT — a JSON string with quotes. Cast to a real type first.) - A JSON file is read with
$1. When do you use$2? (Never for a JSON file — the whole document is$1.$2is the 2nd column of a CSV; if it holds JSON text, useparse_json($2).)