Free & open source — no account required
Free & open source — no account required
This technical guide provides an in-depth analysis of the json to snowflake table engine, best practices for implementation, and data security standards.
Snowflake's semi-structured data handling separates it from traditional data warehouses. The VARIANT type stores JSON, Avro, Parquet, and ORC natively without predefined schema — you query fields with colon notation ($1:user.email::STRING) and Snowflake's query optimizer prunes columns within the binary-encoded VARIANT even without explicit indexes. The professional pattern for JSON ingestion is a two-layer architecture: a raw landing table with a single VARIANT column (load everything, validate nothing), followed by a structured refined table built by a COPY/SELECT transform that casts fields to typed columns. This keeps ingestion fast and schema evolution safe.
-- Input JSON (user event)
{
"user_id": "usr_001",
"email": "alice@example.com",
"event_type": "purchase",
"amount": "149.99",
"currency": "USD",
"metadata": {
"device": "mobile",
"campaign": "summer_sale",
"items": [
{ "sku": "WIDGET-L", "quantity": 2, "price": "59.99" },
{ "sku": "CABLE-USB", "quantity": 1, "price": "29.99" }
]
},
"occurred_at": "2024-01-15T10:30:00Z"
}
-- Layer 1: Raw landing table (VARIANT — no schema enforcement)
CREATE TABLE raw.events_landing (
raw_data VARIANT NOT NULL,
file_name VARCHAR(500),
loaded_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
);
-- Load JSON from S3 stage (COPY INTO)
COPY INTO raw.events_landing (raw_data, file_name)
FROM (
SELECT $1, METADATA$FILENAME
FROM @my_s3_stage/events/
)
FILE_FORMAT = (TYPE = 'JSON' STRIP_OUTER_ARRAY = TRUE);
-- Layer 2: Structured refined table
CREATE TABLE refined.events (
event_id VARCHAR(36) NOT NULL DEFAULT gen_random_uuid(),
user_id VARCHAR(50) NOT NULL,
email VARCHAR(255),
event_type VARCHAR(50) NOT NULL,
amount NUMBER(12, 4),
currency CHAR(3),
device VARCHAR(50),
campaign VARCHAR(100),
occurred_at TIMESTAMP_NTZ NOT NULL,
loaded_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(),
raw_data VARIANT, -- preserve original for debugging
PRIMARY KEY (event_id)
);
-- Populate refined from raw using colon notation
INSERT INTO refined.events (user_id, email, event_type, amount, currency,
device, campaign, occurred_at, raw_data)
SELECT
r.raw_data:user_id::VARCHAR(50),
r.raw_data:email::VARCHAR(255),
r.raw_data:event_type::VARCHAR(50),
r.raw_data:amount::NUMBER(12,4),
r.raw_data:currency::CHAR(3),
r.raw_data:metadata:device::VARCHAR(50),
r.raw_data:metadata:campaign::VARCHAR(100),
r.raw_data:occurred_at::TIMESTAMP_NTZ,
r.raw_data
FROM raw.events_landing r
WHERE r.raw_data:event_type IS NOT NULL;
-- Snowflake Types vs JSON
-- JSON string → VARCHAR(N) or TEXT (≤16MB)
-- JSON number → NUMBER(p, s), FLOAT, or INTEGER
-- JSON boolean → BOOLEAN
-- JSON null → NULL (any nullable column)
-- JSON object → VARIANT or OBJECT
-- JSON array → VARIANT or ARRAY
-- Timestamp types (choose carefully):
-- TIMESTAMP_NTZ — "No TimeZone": stored as-is, recommended for most data
-- TIMESTAMP_TZ — stores UTC + timezone offset: good for user-timezone-aware data
-- TIMESTAMP_LTZ — "Local TimeZone": converts to session timezone on display
-- Numeric precision:
-- NUMBER(p, s) — exact decimal, p=total digits, s=decimal places
-- NUMBER(10, 2) — max 99,999,999.99 (use for money)
-- FLOAT/FLOAT8 — 64-bit IEEE 754 (avoid for financial amounts)
-- INTEGER/INT — 38-digit integer (Snowflake integers are NUMBER)
-- Colon notation for extraction:
raw_data:user_id -- returns VARIANT
raw_data:user_id::VARCHAR -- cast to string
raw_data:amount::NUMBER(12,4) -- cast to decimal
raw_data:occurred_at::TIMESTAMP_NTZ -- cast timestamp string
raw_data:is_active::BOOLEAN -- cast boolean
raw_data:metadata:device::VARCHAR -- nested path
raw_data:items[0]:sku::VARCHAR -- array element by index (0-indexed!)
-- FLATTEN turns a JSON array into multiple rows (like PostgreSQL UNNEST)
-- Useful for extracting order line items from nested arrays
-- events.raw_data:metadata.items = [{"sku":"WIDGET-L","quantity":2},...]
SELECT
e.user_id,
e.occurred_at,
f.value:sku::VARCHAR AS item_sku,
f.value:quantity::INT AS item_qty,
f.value:price::NUMBER AS item_price,
f.index AS item_position -- 0-based array index
FROM refined.events e,
LATERAL FLATTEN(input => e.raw_data:metadata:items) f
WHERE e.event_type = 'purchase';
-- Each row becomes one item × one event
-- FLATTEN options:
-- PATH — which sub-path to flatten (default: entire column)
-- OUTER — include rows with NULL/empty arrays (like LEFT JOIN)
-- RECURSIVE — flatten nested arrays recursively
-- MODE — 'BOTH', 'ARRAY', 'OBJECT' (what to expand)
SELECT
f.key, -- key name (for OBJECT mode)
f.value, -- value (VARIANT)
f.index, -- position (for ARRAY mode)
f.path, -- full path from root
f.this -- current element (context)
FROM TABLE(FLATTEN(input => PARSE_JSON('{"a":1,"b":2}'), MODE => 'OBJECT')) f;
-- Snowflake doesn't use traditional indexes — it uses micro-partitioning
-- Each micro-partition holds 50-500MB of compressed data
-- Clustering keys control how rows are sorted across micro-partitions
-- Good clustering keys:
-- Columns frequently used in WHERE, GROUP BY, JOIN
-- High-cardinality columns with range queries (dates, IDs)
-- Apply clustering to large tables (>1TB recommended)
ALTER TABLE refined.events
CLUSTER BY (occurred_at, event_type);
-- Automatic clustering maintains the ordering as new data arrives
-- Monitor clustering efficiency:
SELECT SYSTEM$CLUSTERING_INFORMATION('refined.events', '(occurred_at, event_type)');
-- Query that benefits from clustering (prunes micro-partitions):
SELECT user_id, SUM(amount) AS total
FROM refined.events
WHERE occurred_at >= '2024-01-01'
AND occurred_at < '2024-02-01'
AND event_type = 'purchase'
GROUP BY user_id;
-- Snowflake scans only the micro-partitions within the date/type range
-- Stream: tracks INSERT/UPDATE/DELETE changes to a table (CDC)
CREATE STREAM raw.events_stream ON TABLE raw.events_landing;
-- Task: scheduled SQL that processes stream records
CREATE TASK transform_events
WAREHOUSE = my_warehouse
SCHEDULE = '5 minute'
WHEN SYSTEM$STREAM_HAS_DATA('raw.events_stream')
AS
INSERT INTO refined.events (user_id, email, event_type, amount, currency, occurred_at)
SELECT
s.raw_data:user_id::VARCHAR,
s.raw_data:email::VARCHAR,
s.raw_data:event_type::VARCHAR,
s.raw_data:amount::NUMBER(12,4),
s.raw_data:currency::CHAR(3),
s.raw_data:occurred_at::TIMESTAMP_NTZ
FROM raw.events_stream s
WHERE s.METADATA$ACTION = 'INSERT'; -- only process new rows
-- Start the task
ALTER TASK transform_events RESUME;
-- Check if a key exists
SELECT COUNT(*)
FROM raw.events_landing
WHERE raw_data:metadata:campaign IS NOT NULL;
-- Use TYPEOF() to inspect types at query time
SELECT TYPEOF(raw_data:amount) AS amount_type
FROM raw.events_landing
LIMIT 5;
-- → "TEXT" (JSON numbers may come as strings — always cast)
-- Handle missing keys gracefully with TRY_CAST
SELECT
raw_data:user_id::VARCHAR AS user_id,
TRY_CAST(raw_data:amount::VARCHAR AS NUMBER) AS amount, -- NULL if malformed
COALESCE(raw_data:currency::VARCHAR, 'USD') AS currency
FROM raw.events_landing;
-- Aggregate directly on VARIANT (works but slower than structured column)
SELECT
raw_data:event_type::VARCHAR AS event_type,
COUNT(*) AS cnt,
SUM(raw_data:amount::NUMBER) AS total
FROM raw.events_landing
GROUP BY 1
ORDER BY cnt DESC;
::VARCHAR, ::NUMBER) before JOIN conditions and WHERE filters. Uncasted VARIANT in a JOIN is a full table scan.PARSE_JSON('{"key":"value"}') rather than string literals. Snowflake validates the JSON syntax and stores it in the efficient binary format rather than as a raw string.raw schema, transformed data in refined or analytics. This separates the concerns of ingestion reliability (raw: load everything fast) from query performance (refined: typed, clustered, cleaned).Q: When should I use VARIANT vs individual columns?
A: VARIANT for exploratory data, frequently-changing schemas, or attributes queried rarely. Individual typed columns for dimensions and metrics used in regular WHERE clauses, GROUP BY, and JOINs. A common pattern: key analytical fields (user_id, amount, timestamp) as columns, everything else in a VARIANT properties column.
Q: How do Snowflake micro-partitions compare to BigQuery partitions?
A: BigQuery uses explicit DATE/TIMESTAMP partition columns that you define — queries without a partition filter scan everything. Snowflake automatically partitions every table into micro-partitions (~100MB compressed); clustering keys tell Snowflake how to order rows across micro-partitions. Both reduce data scanned; Snowflake's approach is more automatic but BigQuery's explicit partitions are more predictable.
Q: How do I handle schema evolution (new JSON keys added over time)?
A: The raw VARIANT landing table never needs migration — new keys just appear in the VARIANT. The refined table needs ALTER TABLE ADD COLUMN for new fields, plus a backfill. This is the advantage of the two-layer approach: you can always re-derive the refined table from the preserved raw data.
Is the processing local-only?
Absolutely. TypeMorph operates entirely within your browser's sandbox. We use Web Workers for high-performance computation without ever transmitting your JSON, SQL, or API data to a remote server.
Can I use this for enterprise projects?
Yes. The tool is designed for professional software engineers who require GDPR compliance and data privacy. It is trusted by developers at top-tier startups and financial institutions.