10.1 Semi-Structured Data: VARIANT, OBJECT & ARRAY Processing
Key Takeaways
- VARIANT, OBJECT, and ARRAY store semi-structured data; a single VARIANT value can hold up to 128 MB of uncompressed data (the same limit applies to OBJECT and ARRAY values).
- Colon and dot notation (col:field.subfield), bracket notation (col['field']), or double-quoted element names return VARIANT values; explicit casts (::TYPE) produce typed relational values for predictable comparisons, joins, and output.
- Snowflake differentiates between SQL NULL (indicating a missing key or non-existent path) and JSON null (an explicit 'null' literal value), requiring IS_NULL_VALUE() or PARSE_JSON('null') to evaluate correctly.
- The FLATTEN lateral table function converts nested arrays and objects into relational rows (outputting seq, key, path, index, value, this), using outer => TRUE to preserve parent rows containing empty arrays.
- During write operations, Snowflake's columnar engine automatically detects repeating paths and data types, shredding them into physical columnar sub-columns within micro-partitions to enable min/max metadata pruning.
10.1 Semi-Structured Data: VARIANT, OBJECT & ARRAY Processing
Modern cloud data platforms must ingest, query, and transform vast quantities of semi-structured data—such as JSON event streams, IoT sensor payloads, Avro event logs, and Parquet data lake files—without requiring slow and rigid upfront schema definitions. Snowflake was architected from the ground up to provide native, first-class support for semi-structured data, combining the flexibility of schema-on-read with the query performance, compression, and micro-partition pruning of a columnar relational database.
For the SnowPro Advanced: Architect (ARA-C01) exam, candidates must master semi-structured data types, traversal syntax, casting subtleties, lateral flattening mechanics, null-value discrimination, and the underlying physical storage architecture that enables sub-column columnarization and micro-partition pruning.
Semi-Structured Data Types: VARIANT, OBJECT & ARRAY
Snowflake provides three specialized native data types specifically designed to store and manipulate semi-structured hierarchical structures:
┌────────────────────────────────────────┐
│ VARIANT │
│ Universal Semi-Structured Container │
│ Holds any primitive or complex type │
│ (Max 128 MB uncompressed) │
└──────────────────┬─────────────────────┘
│
┌────────────────────────┴────────────────────────┐
▼ ▼
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ OBJECT │ │ ARRAY │
│ Key-Value Dictionary Collection │ │ Ordered, Dense List of Elements │
│ Keys: String | Values: VARIANT │ │ Index: 0 to N-1 | Values: VARIANT│
│ Example: {"city": "San Mateo"} │ │ Example: ["US", "CA", "UK"] │
└─────────────────────────────────┘ └─────────────────────────────────┘
1. VARIANT
The VARIANT type is Snowflake's universal semi-structured container. A VARIANT column can store any Snowflake primitive value (e.g., VARCHAR, NUMBER, BOOLEAN, TIMESTAMP_NTZ) or complex hierarchical structure (OBJECT, ARRAY).
- Size Limit: An individual
VARIANTvalue can hold up to 128 MB of uncompressed data. Very large documents (for example, one huge JSON array) should be split into rows during loading —STRIP_OUTER_ARRAY = TRUEin the JSON file format turns top-level array elements into separate rows. - Universal Ingestion: Formats including JSON, Avro, ORC, Parquet, and XML can be loaded directly into a
VARIANTcolumn without prior parsing.
2. OBJECT
An OBJECT represents an unordered collection of key-value pairs (analogous to a JSON object, Python dictionary, or hash map).
- Keys: Always
VARCHARstrings. - Values: Always of type
VARIANT, allowing nested values to be primitives, arrays, or further nested objects. - Size Limit: An OBJECT value can be up to 128 MB.
3. ARRAY
An ARRAY represents an ordered, zero-indexed sequence of elements (analogous to a JSON array or Python list).
- Indexing: Zero-based (
array_col[0]accesses the first element). - Element Types: Each element is stored as a
VARIANT, enabling heterogeneous arrays containing mixed data types (e.g.,[100, 'Gold', true, {'discount': 0.15}]). - Size Limit: The combined size of all values in an ARRAY can be up to 128 MB.
Semi-Structured Data Types Comparison
| Data Type | Structural Model | Indexing Mechanism | Maximum Size | Common Usage |
|---|---|---|---|---|
VARIANT | Universal polymorphic container | Key path or numerical index | 128 MB (uncompressed) | Raw ingestion landing zones, flexible payload storage, poly-schema staging |
OBJECT | Key-value dictionary ({k: v}) | String key lookup (obj['key']) | 128 MB | Structured key-value lookup tables, config metadata, mapped entity attributes |
ARRAY | Ordered sequence ([v0, v1, ...]) | Integer offset index (arr[0]) | 128 MB (combined values) | Order line items, tag collections, timestamp series, repeating sub-records |
Traversal Syntax, Path Extraction & Type Casting
Querying nested attributes within semi-structured data relies on two primary traversal conventions: colon notation and bracket notation.
Colon Traversal vs. Bracket Traversal
Consider an ingestion table containing raw customer event logs:
CREATE OR REPLACE TABLE raw_events (
event_id VARCHAR(64),
ingested_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(),
payload VARIANT
);
Sample payload JSON value:
{
"customer": {
"account-id": "ACC-98421",
"first name": "Jane",
"contact": {
"email": "jane.doe@enterprise.com",
"phones": ["+1-555-0199", "+1-555-0144"]
}
},
"transaction": {
"amount": 450.75,
"currency": "USD",
"tax_exempt": false
}
}
1. Colon Notation (:) and Dot Syntax
Used for traversing standard alphanumeric object keys:
SELECT
payload:customer.contact.email AS contact_email,
payload:transaction.amount AS raw_amount
FROM raw_events;
2. Bracket Notation (['<key>'] or [<index>])
Mandatory when object keys contain special characters, hyphens, spaces, dots, or start with digits, as well as for array index access:
SELECT
-- Hyphenated key requires bracket notation
payload:customer['account-id'] AS account_id,
-- Key containing spaces requires bracket notation
payload:customer['first name'] AS first_name,
-- Array zero-based element access
payload:customer.contact.phones[0] AS primary_phone
FROM raw_events;
Exam Trap: If you write
payload:customer.account-id, Snowflake interprets-idas a subtraction operator rather than part of the key, giving an error or an unexpected result. When a key contains a hyphen, space, or other character that is not valid in an identifier, either use bracket notation (payload:customer['account-id']) or enclose the element name in double quotes (payload:customer."account-id"). Key names are case-sensitive.
Path Extraction Returns VARIANT: The Double-Quote Dilemma
A critical concept on the ARA-C01 exam is that all path extractions using colon or bracket syntax return a VARIANT data type, not a SQL primitive:
-- Query returning uncast VARIANT
SELECT payload:customer.contact.email FROM raw_events;
-- Output: "jane.doe@enterprise.com" (includes literal enclosing double quotes!)
The double quotes appear because the output is a VARIANT that contains a string, not a VARCHAR. Snowflake can still compare it with a literal through implicit conversion, but relying on implicit conversion is fragile:
- Comparisons and sorts between VARIANT values follow VARIANT rules, which can differ from the typed behavior you expect (for example, numbers stored as strings in one file and as numbers in another).
- Joins and
GROUP BYon uncast paths carry VARIANT values into results, BI tools, and downstream tables. - Explicit casts document the intended type and fail loudly (or return NULL with
TRY_functions) when data does not match.
-- Works through implicit conversion, but the intent and type are unclear
SELECT * FROM raw_events WHERE payload:customer.contact.email = 'jane.doe@enterprise.com';
-- Preferred: explicit, typed comparison
SELECT * FROM raw_events WHERE payload:customer.contact.email::VARCHAR = 'jane.doe@enterprise.com';
Explicit Casting (:: and Conversion Functions)
To strip the enclosing JSON quotes and convert semi-structured attributes into first-class relational types, apply explicit casting with the double colon (::) operator or conversion functions:
SELECT
payload:customer['account-id']::VARCHAR(32) AS account_id,
payload:customer['first name']::VARCHAR(100) AS first_name,
payload:customer.contact.email::VARCHAR(256) AS email,
payload:customer.contact.phones[0]::VARCHAR(32) AS primary_phone,
payload:transaction.amount::NUMBER(12,2) AS txn_amount,
payload:transaction.tax_exempt::BOOLEAN AS is_tax_exempt
FROM raw_events
WHERE payload:customer.contact.email::VARCHAR = 'jane.doe@enterprise.com';
Safe Type Casting: TRY_CAST and TRY_TO_*
In real-world ingestion feeds, semi-structured fields often contain corrupted or unexpected data (e.g., an alphabetical string in an amount field). A standard cast (payload:transaction.amount::NUMBER) aborts the entire query upon encountering an invalid conversion. Use TRY_CAST or TRY_TO_NUMBER to return NULL on conversion errors without failing the query:
SELECT
event_id,
-- Aborts on malformed numeric data:
-- payload:transaction.amount::NUMBER(10,2) AS unsafe_amt,
-- Safely returns NULL on malformed data:
TRY_CAST(payload:transaction.amount::VARCHAR AS NUMBER(10,2)) AS safe_amount,
TRY_TO_TIMESTAMP_NTZ(payload:event_timestamp::VARCHAR) AS safe_timestamp
FROM raw_events;
The Null Dilemma: SQL NULL vs. JSON null
One of the most frequently tested semi-structured concepts on the SnowPro Advanced: Architect exam is the distinction between a SQL NULL and an explicit JSON null literal (IS_NULL_VALUE).
Understanding the Two Types of Nulls
- SQL
NULL(Missing Key or Undefined Path):- Occurs when a queried key or path does not exist in the semi-structured object, or when the entire relational column is
NULL. - Evaluates to
TRUEfor standard SQLIS NULLpredicates.
- Occurs when a queried key or path does not exist in the semi-structured object, or when the entire relational column is
- JSON
null(nullLiteral Value):- Occurs when a key explicitly exists in the JSON payload with a value of
null(e.g.,{"secondary_email": null}). - In Snowflake, an explicit JSON
nullis a valid, non-nullVARIANTvalue that represents the JSON literalnull. - Crucial: A JSON
nullliteral evaluates toFALSEwhen tested withIS NULL!
- Occurs when a key explicitly exists in the JSON payload with a value of
Demonstrating the Null Behavior
WITH sample_data AS (
SELECT PARSE_JSON('{
"user_id": 101,
"primary_email": "user@corp.com",
"secondary_email": null
-- "phone" key is completely missing
}') AS v
)
SELECT
-- Key is completely missing: SQL NULL
v:phone IS NULL AS phone_is_sql_null, -- Returns TRUE
IS_NULL_VALUE(v:phone) AS phone_is_json_null, -- Returns FALSE
-- Key exists with explicit null literal: JSON null
v:secondary_email IS NULL AS email2_is_sql_null, -- Returns FALSE (Exam Trap!)
IS_NULL_VALUE(v:secondary_email) AS email2_is_json_null, -- Returns TRUE
v:secondary_email = PARSE_JSON('null') AS email2_equals_json_null, -- Returns TRUE
-- Casting JSON null to VARCHAR strips the variant wrapper and yields a SQL NULL
v:secondary_email::VARCHAR IS NULL AS cast_email2_is_sql_null -- Returns TRUE
FROM sample_data;
Null Handling Decision Matrix
| Expression Scenario | Value in Document | col:path IS NULL | IS_NULL_VALUE(col:path) | col:path::VARCHAR IS NULL |
|---|---|---|---|---|
| Key does not exist | {"a": 1} -> query col:b | TRUE | FALSE | TRUE |
| Key has explicit null | {"a": 1, "b": null} -> query col:b | FALSE (Trap!) | TRUE | TRUE |
| Key has valid string | {"a": 1, "b": "text"} -> query col:b | FALSE | FALSE | FALSE |
| Entire column is NULL | SQL NULL row | TRUE | NULL | TRUE |
Architect Exam Takeaway: To reliably filter out records where an attribute is either omitted OR explicitly set to JSON null, cast the attribute to a SQL primitive (
col:path::VARCHAR IS NOT NULL) or use compound logic:WHERE col:path IS NOT NULL AND NOT IS_NULL_VALUE(col:path).
Relational Transformation Functions
Snowflake provides a suite of specialized functions for parsing, validating, constructing, and decomposing semi-structured data.
Parsing and Validation Functions
PARSE_JSON(string_expr):- Converts a valid JSON-formatted string into a
VARIANTobject. - If the input string contains invalid JSON syntax,
PARSE_JSONaborts the query execution with a parsing error.
- Converts a valid JSON-formatted string into a
TRY_PARSE_JSON(string_expr):- Safely parses a JSON string into a
VARIANT. If parsing fails due to malformed JSON, it returns SQLNULLinstead of aborting the query. - Architect Recommendation: Always use
TRY_PARSE_JSONin ingestion ELT pipelines to isolate corrupted records into an error handling quarantine table.
- Safely parses a JSON string into a
CHECK_JSON(string_expr):- Validates a string expression for JSON compliance.
- Returns
NULLif the string is 100% syntactically valid JSON. - Returns a detailed error message string describing the exact syntax defect if invalid.
-- Quarantining invalid payloads during staging
INSERT INTO dead_letter_queue (raw_string, error_message)
SELECT
raw_payload,
CHECK_JSON(raw_payload) AS error_reason
FROM landing_stage_raw
WHERE CHECK_JSON(raw_payload) IS NOT NULL;
Construction Functions: OBJECT_CONSTRUCT & ARRAY_AGG
OBJECT_CONSTRUCT(k1, v1, k2, v2, ...):- Constructs an
OBJECTfrom key-value pairs. - Omission Rule: Any key whose corresponding value evaluates to a SQL
NULLis completely omitted from the resulting object.
- Constructs an
OBJECT_CONSTRUCT_KEEP_NULL(k1, v1, ...):- Retains keys whose values evaluate to SQL
NULL, explicitly storing them as JSONnullliterals ("key": null).
- Retains keys whose values evaluate to SQL
ARRAY_CONSTRUCT(v1, v2, ...):- Constructs an
ARRAYcontaining the provided arguments.
- Constructs an
ARRAY_AGG(expr):- Aggregate function that rolls multiple relational rows into a single ordered
ARRAYgrouped by a dimensional key.
- Aggregate function that rolls multiple relational rows into a single ordered
-- Aggregating relational order lines into a nested JSON customer document
SELECT
c.customer_id,
OBJECT_CONSTRUCT(
'customer_id', c.customer_id,
'customer_name', c.name,
'orders', ARRAY_AGG(OBJECT_CONSTRUCT(
'order_id', o.order_id,
'total_amount', o.total,
'order_date', o.order_date
)) WITHIN GROUP (ORDER BY o.order_date DESC)
) AS customer_profile_json
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name;
Unnesting Data with the FLATTEN Table Function
The FLATTEN function is a lateral table function used to explode an ARRAY or OBJECT into individual relational rows. It is almost always paired with the LATERAL join operator.
FLATTEN Syntax and Parameters
TABLE(FLATTEN(
input => <variant_array_or_object>,
path => '<string_path_inside_input>',
outer => TRUE | FALSE,
recursive => TRUE | FALSE,
mode => 'BOTH' | 'ARRAY' | 'OBJECT'
))
| Parameter | Type | Default | Architectural Behavior |
|---|---|---|---|
input | VARIANT, OBJECT, ARRAY | (Required) | The semi-structured expression to explode. |
path | VARCHAR | '' (root) | Optional path inside the input to target for flattening. |
outer | BOOLEAN | FALSE | When FALSE (inner join), parent rows with empty arrays ([]) or NULL values are excluded. When TRUE (left outer join), parent rows are preserved with NULL in the exploded columns. |
recursive | BOOLEAN | FALSE | When TRUE, recursively explodes all nested arrays and sub-objects within the input hierarchy. |
mode | VARCHAR | 'BOTH' | Restricts explosion to 'ARRAY' elements only, 'OBJECT' keys only, or 'BOTH'. |
Columns Produced by FLATTEN
Every invocation of FLATTEN outputs a deterministic virtual table with six standard columns:
seq(INT): A unique sequence number corresponding to the source parent row. All exploded rows derived from the same source row share identicalseqnumbers.key(VARCHAR): For objects, the key name. For arrays, this is alwaysNULL.path(VARCHAR): The full path to the element being extracted.index(INT): For arrays, the zero-based element index (0, 1, 2...). For objects, this is alwaysNULL.value(VARIANT): The unnested value of the array element or object key. Because it is returned asVARIANT, it requires casting (value::STRING).this(VARIANT): The parent container object or array currently being exploded.
Comprehensive FLATTEN Example: Order Processing
-- Table containing orders with an embedded array of items
CREATE OR REPLACE TABLE raw_orders (
order_id VARCHAR(32),
order_date DATE,
order_json VARIANT
);
-- Sample Query unnesting line items
SELECT
r.order_id,
r.order_date,
f.index AS item_line_number,
f.value:item_sku::VARCHAR(64) AS sku,
f.value:quantity::INT AS quantity,
f.value:price::NUMBER(10,2) AS unit_price,
(f.value:quantity::INT * f.value:price::NUMBER(10,2)) AS line_total
FROM raw_orders r,
LATERAL FLATTEN(input => r.order_json:line_items, outer => TRUE) f
WHERE f.value:price::NUMBER(10,2) > 50.00;
Exam Trap on
outer => TRUE: If an order contains zero items (line_items: []) or theline_itemskey is missing, standardLATERAL FLATTEN(input => ...)acts as an inner join and completely eliminates the order from the result set. To guarantee that orders without line items appear in reporting (e.g., cancelled or pending orders), you must specifyouter => TRUE.
Sub-Column Columnarization, Automatic Pruning & Performance
One of the most impressive technical feats of Snowflake's database engine is how it optimizes VARIANT storage under the hood. While developers interact with VARIANT data using schema-on-read JSON semantics, Snowflake physically stores and scans the data using high-performance columnar execution.
Physical Sub-Column Extraction Mechanics
When semi-structured documents are ingested into a VARIANT column within a Snowflake table:
- Path Frequency Analysis: Snowflake's background micro-partition write engine analyzes the structure of each ingested document across the rows belonging to that micro-partition.
- Type Detection & Extraction: For paths that appear frequently and demonstrate consistent primitive data types (e.g.,
payload:customer.idalways containing an integer, orpayload:device.tempalways containing a float), Snowflake automatically extracts those sub-paths into distinct physical columnar sub-columns within the micro-partition file. - Columnar Encoding & Compression: The extracted sub-columns are compressed using standard columnar compression algorithms (e.g., dictionary encoding, run-length encoding, bit-packing), identical to first-class relational columns.
- Fallback Representation: Irregular, highly sparse, or deeply nested dynamic paths remain encoded in Snowflake's optimized internal binary semi-structured format within the micro-partition.
Logical View (VARIANT Column): Physical Micro-Partition Storage:
┌──────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ Row 1: {"id": 10, "geo": "US"} │ │ Sub-Col: payload:id -> [10, 11, 12] (INT) │
│ Row 2: {"id": 11, "geo": "CA"} │──►│ Sub-Col: payload:geo -> ['US','CA','US'] │
│ Row 3: {"id": 12, "geo": "US"} │ │ Metadata: min/max per sub-column registered │
└──────────────────────────────────────┘ └──────────────────────────────────────────────┘
Micro-Partition Metadata & Query Pruning
Because frequently queried sub-paths are physically stored as discrete sub-columns, Snowflake's metadata layer registers min/max statistics for these sub-columns in the micro-partition header.
When a query executes with a filter on a nested attribute:
SELECT * FROM raw_events
WHERE payload:customer.country::VARCHAR = 'DE';
- The Snowflake Cloud Services query optimizer evaluates the query predicate against the min/max statistics stored for the sub-column
payload:customer.countryacross all micro-partitions. - Micro-partitions whose sub-column min/max range does not include
'DE'are pruned entirely, without the virtual warehouse reading the micro-partitions from cloud object storage. - Furthermore, during query execution, the virtual warehouse scans only the extracted sub-column rather than parsing the entire
VARIANTpayload for every row.
Architectural Decision: Schema-on-Read vs. Flattened Relational Columns
While sub-column extraction provides exceptional out-of-the-box performance, enterprise architects must make deliberate physical modeling choices for high-scale analytical environments:
| Architectural Factor | Native VARIANT Storage | Extracted Relational Table (Flattened) |
|---|---|---|
| Schema Flexibility | Infinite. Adapts instantly to upstream JSON schema drift, new fields, and type polymorphism without DDL changes. | Rigid. Requires DDL migrations (ALTER TABLE ADD COLUMN) and pipeline updates when schema evolves. |
| Storage Overhead | Slightly higher due to path identifier metadata and variable structure representations. | Minimal. Maximum compression achieved through pure homogeneous data types. |
| Query Performance | Near-relational for extracted paths. Slower for irregular, sparse, or non-columnarized paths. | Deterministic and maximum performance for all columns. Full support for search optimization. |
| Clustering Keys | Direct uncast VARIANT columns cannot be used as clustering keys. Materialized expressions must be cast (payload:id::INT). | First-class relational columns can be directly assigned to table clustering keys. |
| Governance & Masking | Dynamic Data Masking policies on VARIANT require complex JSON manipulation or apply only to the whole blob. | Granular column-level Dynamic Data Masking and Row Access Policies can be applied directly to individual fields. |
Exam Best Practice Guidelines for Semi-Structured Modeling
- Land Raw in
VARIANT: Always ingest raw event payloads directly into an ELT landing table with aVARIANTcolumn to ensure 100% data fidelity and avoid load failures caused by schema changes. - Materialize High-Frequency Filters and Joins: If specific nested attributes (such as
tenant_id,event_date, oruser_id) are repeatedly used inJOINconditions,WHEREclauses, orGROUP BYoperations across heavy analytical queries, extract them into explicit relational columns via a transformation pipeline or defined as deterministic virtual columns. - Clustering on Semi-Structured Data: If a table must be clustered on an attribute inside a
VARIANT, you must explicitly cast the expression in the clustering key definition:ALTER TABLE raw_events CLUSTER BY (payload:tenant_id::VARCHAR, payload:event_date::DATE);
A data engineer queries an IoT device telemetry table where the payload column is of type VARIANT. The JSON payload occasionally contains an explicit null value for the error_code field (e.g., {"device_id": 99, "error_code": null}). When executing the query: SELECT * FROM iot_telemetry WHERE payload:error_code IS NULL; Why do the records with explicit {"error_code": null} fail to appear in the query result?
An enterprise analytics team processes e-commerce orders stored in a VARIANT column. Some orders contain multiple items in an array (order_json:items), while newly created draft orders contain an empty array (items: []). The data architect executes: SELECT o.order_id, f.value:item_id::VARCHAR AS item_id FROM orders o, LATERAL FLATTEN(input => o.order_json:items) f; Business users complain that draft orders are missing from the output. What is the root cause and the required architectural remedy?
An architect is evaluating the performance of a multi-terabyte table containing raw JSON payloads in a VARIANT column. Analytical queries frequently filter on payload:customer.country_code::VARCHAR = 'US'. How does Snowflake achieve high query performance and partition pruning on this query without full table scans?