Select Page

VARIANT Data Type: Working with Semi-Structured Data in Databricks

What is VARIANT?

VARIANT is Databricks’ data type for storing semi-structured data (JSON, XML, Avro) in a single column. It provides:

  • Schema flexibility: Handle evolving JSON schemas
  • Native performance: Optimized storage and querying
  • SQL/Python access: Extract nested fields easily

Think of VARIANT as a “smart JSON column” that understands structure.


Why Use VARIANT?

Traditional Approach (Flattening)

{"id": 1, "user": {"name": "Alice", "email": "alice@email.com", "address": {"city": "Sydney"}}}

Must flatten into:

id INT, user_name STRING, user_email STRING, user_address_city STRING

Problems:

  • Schema changes require ALTER TABLE
  • Nested arrays difficult to handle
  • Loses original structure

VARIANT Approach

CREATE TABLE events (
    id INT,
    payload VARIANT  -- Stores entire JSON
);

Benefits:

  • Schema evolution without ALTER TABLE
  • Query nested fields with : operator
  • Preserve original JSON structure

VARIANT vs Alternatives Decision Tree

graph TD
    Start["Need to Store JSON Data?"]
    
    Start -->|Yes| Q1["Schema Stable & Known?"]
    Start -->|No| End1["Use Strongly-Typed Columns"]
    
    Q1 -->|Yes| Q2["Need to Query Fields?"]
    Q1 -->|No| UseVariant["Use VARIANT"]
    
    Q2 -->|Yes| Q3["Performance Critical?"]
    Q2 -->|No| UseString["Use STRING Column"]
    
    Q3 -->|Very Critical| UseTyped["Use Strongly-Typed
(Flatten to Columns)"] Q3 -->|Normal| Q4["Schema Changes Expected?"] Q4 -->|Yes| UseVariant Q4 -->|No| UseTyped UseVariant -->|"Best For"| V1["- Evolving schemas
- Exploratory analysis
- Mixed event types
- Rapid prototyping"] UseTyped -->|"Best For"| T1["- Production analytics
- Type safety required
- Complex joins
- Stable schemas"] UseString -->|"Best For"| S1["- Archival only
- Passthrough data
- Pure archival access"] style UseVariant fill:#a8e6cf style UseTyped fill:#ffd3b6 style UseString fill:#ffaaa5 style V1 fill:#e8f5e9 style T1 fill:#fff3e0 style S1 fill:#ffebee

VARIANT Storage Format

graph LR
    subgraph JSON_Input["JSON Input"]
        J1["JSON object
id: 100
name: Alice
tags: premium, verified"] end subgraph VARIANT_Storage["VARIANT Internal Structure"] V1["Binary-Encoded Tree"] V2["πŸ”’ id: 100
(INT)"] V3["πŸ”€ name: 'Alice'
(STRING)"] V4["πŸ“ƒ tags: ARRAY"] V5["πŸ”€ 'premium'"] V6["πŸ”€ 'verified'"] V1 --> V2 V1 --> V3 V1 --> V4 V4 --> V5 V4 --> V6 end subgraph Benefits["Storage Benefits"] B1["βœ… Numbers kept as numbers"] B2["βœ… Paths indexed
for fast access"] B3["βœ… Compression
applied"] end J1 -->|"parse_json()"| V1 V1 -.benefits.-> B1 V1 -.benefits.-> B2 V1 -.benefits.-> B3 style JSON_Input fill:#e3f2fd style VARIANT_Storage fill:#f3e5f5 style Benefits fill:#e8f5e9

Delta Lake stores VARIANT efficiently:

  • Numbers stored with their native types preserved
  • Paths indexed for fast access
  • Compression applied

JSON Structure Visualization

graph TB
    subgraph Event["Customer Event JSON"]
        Root["Root Object"]
        
        Root --> ID["id: 1"]
        Root --> Time["timestamp: '2025-01-15'"]
        Root --> Event_Obj["event: object"]
        
        Event_Obj --> Type["type: 'purchase'"]
        Event_Obj --> Customer["customer: object"]
        Event_Obj --> Trans["transaction: object"]
        
        Customer --> CID["id: 100"]
        Customer --> CName["name: 'Alice'"]
        Customer --> Tier["tier: 'gold'"]
        
        Trans --> Amount["amount: 150.50"]
        Trans --> Currency["currency: 'AUD'"]
        Trans --> Items["items: array"]
        
        Items --> Item1["sku: LAP-001
qty: 1"] Items --> Item2["sku: MOU-042
qty: 2"] end subgraph Access["Access Patterns"] A1["event:type
β†’ 'purchase'"] A2["event:customer.id
β†’ 100"] A3["event:customer.tier
β†’ 'gold'"] A4["event:transaction.amount
β†’ 150.50"] A5["event:transaction.items.0.sku
β†’ LAP-001"] end Type -.->|colon operator| A1 CID -.->|colon operator| A2 Tier -.->|colon operator| A3 Amount -.->|colon operator| A4 Item1 -.->|array indexing| A5 style Event fill:#e3f2fd style Access fill:#fff3e0

Basic VARIANT Operations

Creating Tables with VARIANT

CREATE TABLE learning_catalog.sales.customer_events (
    event_id BIGINT,
    event_time TIMESTAMP,
    event_data VARIANT  -- Semi-structured payload
) USING DELTA;

Inserting JSON Data

-- Direct JSON insertion
INSERT INTO learning_catalog.sales.customer_events
VALUES (
    1,
    current_timestamp(),
    parse_json('{"customer_id": 100, "action": "purchase", "amount": 150.50, "items": ["laptop", "mouse"]}')
);

-- From file (common pattern)
COPY INTO learning_catalog.sales.customer_events
FROM '/Volumes/learning_catalog/sales/landing_files/events/'
FILEFORMAT = JSON;

Extracting Fields with : Operator

-- Extract top-level fields
SELECT 
    event_id,
    event_data:customer_id AS customer_id,
    event_data:action AS action,
    event_data:amount AS amount
FROM learning_catalog.sales.customer_events;

-- Extract nested fields
SELECT 
    event_data:user.name AS user_name,
    event_data:user.address.city AS city
FROM learning_catalog.sales.customer_events;

-- Extract from arrays (zero-indexed)
SELECT 
    event_data:items[0] AS first_item,
    event_data:items[1] AS second_item
FROM learning_catalog.sales.customer_events;

Hands-On Exercise 1: Basic VARIANT Operations

Objective

Learn to store, query, and extract data from VARIANT columns.

Step 1: Create Test Data

Create customer_events.json:

{"event_id": 1, "timestamp": "2025-01-15T10:00:00", "event": {"type": "login", "customer": {"id": 100, "name": "Alice Johnson", "tier": "gold"}, "device": {"type": "mobile", "os": "iOS"}}}
{"event_id": 2, "timestamp": "2025-01-15T10:05:00", "event": {"type": "purchase", "customer": {"id": 100, "name": "Alice Johnson", "tier": "gold"}, "transaction": {"amount": 150.50, "currency": "AUD", "items": [{"sku": "LAP-001", "quantity": 1}, {"sku": "MOU-042", "quantity": 2}]}}}
{"event_id": 3, "timestamp": "2025-01-15T10:10:00", "event": {"type": "support_ticket", "customer": {"id": 101, "name": "Bob Smith", "tier": "silver"}, "ticket": {"category": "billing", "priority": "high", "description": "Issue with invoice"}}}
{"event_id": 4, "timestamp": "2025-01-15T10:15:00", "event": {"type": "logout", "customer": {"id": 100, "name": "Alice Johnson", "tier": "gold"}, "session_duration": 900}}

Step 2: Create Table and Load Data

-- Create table with VARIANT column
CREATE TABLE IF NOT EXISTS learning_catalog.sales.customer_events (
    event_id BIGINT,
    timestamp TIMESTAMP,
    event VARIANT
) USING DELTA;

-- Load from file (first upload to volume)
COPY INTO learning_catalog.sales.customer_events
FROM '/Volumes/learning_catalog/sales/landing_files/customer_events/'
FILEFORMAT = JSON
FORMAT_OPTIONS ('inferSchema' = 'true');

-- Verify
SELECT * FROM learning_catalog.sales.customer_events;

Step 3: Query VARIANT Data

-- Extract customer information
SELECT 
    event_id,
    timestamp,
    event:type AS event_type,
    event:customer.id AS customer_id,
    event:customer.name AS customer_name,
    event:customer.tier AS customer_tier
FROM learning_catalog.sales.customer_events;

-- Purchase events only with transaction details
SELECT 
    event_id,
    timestamp,
    event:customer.id AS customer_id,
    event:transaction.amount AS amount,
    event:transaction.currency AS currency,
    event:transaction.items AS items  -- Returns ARRAY
FROM learning_catalog.sales.customer_events
WHERE event:type = 'purchase';

-- Extract individual items from array
SELECT 
    event_id,
    event:customer.id AS customer_id,
    event:transaction.items[0].sku AS item1_sku,
    event:transaction.items[0].quantity AS item1_qty,
    event:transaction.items[1].sku AS item2_sku,
    event:transaction.items[1].quantity AS item2_qty
FROM learning_catalog.sales.customer_events
WHERE event:type = 'purchase';

Step 4: Convert to Strongly-Typed Columns

-- Create typed view for analytics
CREATE OR REPLACE VIEW learning_catalog.sales.customer_events_typed AS
SELECT 
    event_id,
    timestamp,
    CAST(event:type AS STRING) AS event_type,
    CAST(event:customer.id AS BIGINT) AS customer_id,
    CAST(event:customer.name AS STRING) AS customer_name,
    CAST(event:customer.tier AS STRING) AS customer_tier,
    event  -- Keep original for flexibility
FROM learning_catalog.sales.customer_events;

-- Query typed view
SELECT * FROM learning_catalog.sales.customer_events_typed
WHERE customer_tier = 'gold';

Hands-On Exercise 2: Array Explosion with VARIANT

Objective

Flatten nested arrays within VARIANT columns for analytics.

Array Explosion Flow

graph LR
    subgraph Input["Input: Order with Items Array"]
        I1["order_id: 1001
items: [{sku: 'LAP-001', price: 300},
{sku: 'MOU-042', price: 25}]"] end subgraph Explode["LATERAL VIEW explode()"] E1["Explode items array
into separate rows"] end subgraph Output["Output: One Row Per Item"] O1["Row 1: order_id: 1001, sku: 'LAP-001', price: 300"] O2["Row 2: order_id: 1001, sku: 'MOU-042', price: 25"] end I1 -->|"from_json() + explode()"| E1 E1 --> O1 E1 --> O2 style Input fill:#e3f2fd style Explode fill:#fff3e0 style Output fill:#e8f5e9

Step 1: Create Order Data

{"order_id": 1001, "customer_id": 100, "order_date": "2025-01-15", "order": {"total": 350.00, "items": [{"sku": "LAP-001", "name": "Laptop", "price": 300.00, "quantity": 1}, {"sku": "MOU-042", "name": "Mouse", "price": 25.00, "quantity": 2}]}}
{"order_id": 1002, "customer_id": 101, "order_date": "2025-01-16", "order": {"total": 75.00, "items": [{"sku": "KEY-100", "name": "Keyboard", "price": 75.00, "quantity": 1}]}}
{"order_id": 1003, "customer_id": 102, "order_date": "2025-01-17", "order": {"total": 450.00, "items": [{"sku": "MON-200", "name": "Monitor", "price": 350.00, "quantity": 1}, {"sku": "CAB-050", "name": "HDMI Cable", "price": 20.00, "quantity": 5}]}}

Step 2: Load and Explode

-- Create orders table
CREATE TABLE IF NOT EXISTS learning_catalog.sales.orders_raw (
    order_id BIGINT,
    customer_id BIGINT,
    order_date DATE,
    order VARIANT
) USING DELTA;

-- Load data (upload JSON to volume first)
COPY INTO learning_catalog.sales.orders_raw
FROM '/Volumes/learning_catalog/sales/landing_files/orders/'
FILEFORMAT = JSON;

-- Explode items array into rows
SELECT 
    order_id,
    customer_id,
    order_date,
    CAST(order:total AS DECIMAL(10,2)) AS order_total,
    item.sku AS item_sku,
    item.name AS item_name,
    CAST(item.price AS DECIMAL(10,2)) AS item_price,
    CAST(item.quantity AS INT) AS item_quantity,
    CAST(item.price AS DECIMAL(10,2)) * CAST(item.quantity AS INT) AS line_total
FROM learning_catalog.sales.orders_raw
LATERAL VIEW explode(from_json(order:items, 'array<struct<sku:string,name:string,price:double,quantity:int>>')) AS item;

Step 3: Create Flattened Table

-- Materialize flattened data
CREATE OR REPLACE TABLE learning_catalog.sales.order_items AS
SELECT 
    order_id,
    customer_id,
    order_date,
    CAST(order:total AS DECIMAL(10,2)) AS order_total,
    posexplode(from_json(order:items, 'array<struct<sku:string,name:string,price:double,quantity:int>>')) AS (item_index, item)
FROM learning_catalog.sales.orders_raw;

SELECT 
    order_id,
    customer_id,
    item_index,
    item.sku,
    item.name,
    item.price,
    item.quantity,
    item.price * item.quantity AS line_total
FROM learning_catalog.sales.order_items;

Hands-On Exercise 3: Schema Evolution with VARIANT

Objective

Handle evolving JSON schemas without table migrations.

Schema Evolution Timeline

graph TD
    subgraph V1["Version 1 Schema (2025-01)"]
        V1_Fields["Fields:
- type
- customer.id
- customer.name
- page"] end subgraph V2["Version 2 Schema (2025-02)"] V2_Fields["Fields:
- type
- customer.id
- customer.name
- customer.email NEW
- page
- metadata NEW
- referrer, user_agent
- geolocation NEW
- country, city, lat/lon"] end subgraph Compatibility["Backward Compatibility"] C1["βœ… Old queries still work"] C2["βœ… Schema evolves in place"] C3["βœ… NULL for missing fields"] end V1 -->|"Schema Evolution"| V2 V2 --> Compatibility style V1 fill:#e3f2fd style V2 fill:#e8f5e9 style Compatibility fill:#fff3e0

Step 1: Initial Schema

-- V1 events (simple schema)
INSERT INTO learning_catalog.sales.customer_events VALUES
(5, current_timestamp(), parse_json('{"type": "page_view", "customer": {"id": 103, "name": "Carol White"}, "page": "/products"}'));

-- Query works fine
SELECT 
    event:type AS event_type,
    event:customer.id AS customer_id,
    event:page AS page
FROM learning_catalog.sales.customer_events
WHERE event_id = 5;

Step 2: Schema Evolution (Add New Fields)

-- V2 events (added metadata, geolocation)
INSERT INTO learning_catalog.sales.customer_events VALUES
(6, current_timestamp(), parse_json('{
    "type": "page_view",
    "customer": {"id": 103, "name": "Carol White", "email": "carol@email.com"},
    "page": "/products",
    "metadata": {"referrer": "google.com", "user_agent": "Chrome/98"},
    "geolocation": {"country": "AU", "city": "Brisbane", "lat": -27.4698, "lon": 153.0251}
}'));

-- Old queries still work!
SELECT 
    event:type AS event_type,
    event:customer.id AS customer_id,
    event:page AS page
FROM learning_catalog.sales.customer_events
WHERE event_id = 6;

-- New queries can access new fields
SELECT 
    event:type AS event_type,
    event:customer.id AS customer_id,
    event:customer.email AS customer_email,  -- New field
    event:metadata.referrer AS referrer,     -- New nested object
    event:geolocation.city AS city           -- New nested object
FROM learning_catalog.sales.customer_events
WHERE event_id = 6;

Step 3: Handle Missing Fields Gracefully

-- Query both old and new events (handles nulls automatically)
SELECT 
    event_id,
    event:type AS event_type,
    event:customer.id AS customer_id,
    COALESCE(event:customer.email, 'N/A') AS customer_email,  -- NULL for old events
    COALESCE(event:geolocation.city, 'Unknown') AS city      -- NULL for old events
FROM learning_catalog.sales.customer_events
WHERE event:type = 'page_view'
ORDER BY event_id;

Advanced VARIANT Techniques

1. Complex Filtering

-- Filter by nested field
SELECT * 
FROM customer_events
WHERE event:transaction.amount > 100;

-- Filter by array element
SELECT *
FROM customer_events
WHERE array_contains(event:transaction.items, 'LAP-001');

-- Filter by nested boolean
SELECT *
FROM customer_events
WHERE event:metadata.is_premium = true;

2. Aggregations on VARIANT

-- Sum amounts from VARIANT
SELECT 
    CAST(event:customer.id AS BIGINT) AS customer_id,
    SUM(CAST(event:transaction.amount AS DECIMAL(10,2))) AS total_spent
FROM customer_events
WHERE event:type = 'purchase'
GROUP BY customer_id
ORDER BY total_spent DESC;

-- Count distinct values
SELECT 
    CAST(event:customer.tier AS STRING) AS tier,
    COUNT(DISTINCT CAST(event:customer.id AS BIGINT)) AS customer_count
FROM customer_events
GROUP BY tier;

3. Convert VARIANT to JSON String

-- Back to JSON string for export
SELECT 
    event_id,
    to_json(event) AS event_json
FROM customer_events;

4. Schema Introspection

-- Get all unique event types
SELECT DISTINCT event:type AS event_type
FROM customer_events;

-- Find events with specific field
SELECT event_id, event
FROM customer_events
WHERE event:metadata IS NOT NULL;

Terraform: VARIANT Data Pipeline

# Volume for semi-structured data
resource "databricks_volume" "json_landing" {
  catalog_name = "learning_catalog"
  schema_name  = "sales"
  name         = "json_landing"
  volume_type  = "MANAGED"
  comment      = "Landing zone for JSON/semi-structured data"
}

# Create table with VARIANT
resource "databricks_sql_table" "events_raw" {
  catalog_name = "learning_catalog"
  schema_name  = "sales"
  name         = "events_raw"
  table_type   = "MANAGED"

  column {
    name = "event_id"
    type = "BIGINT"
  }

  column {
    name = "ingestion_time"
    type = "TIMESTAMP"
  }

  column {
    name = "event_payload"
    type = "VARIANT"
  }

  column {
    name = "source_file"
    type = "STRING"
  }

  comment = "Raw events with VARIANT payload for schema flexibility"
}

# DLT pipeline for VARIANT processing
resource "databricks_notebook" "variant_pipeline" {
  path     = "/Shared/variant_pipeline"
  language = "PYTHON"
  content_base64 = base64encode(<<-EOT
import dlt
from pyspark.sql.functions import *

@dlt.table(
    comment="Raw JSON events loaded with Auto Loader"
)
def events_raw():
    return (
        spark.readStream
            .format("cloudFiles")
            .option("cloudFiles.format", "json")
            .option("cloudFiles.schemaLocation", "/tmp/schema/events")
            .load("/Volumes/learning_catalog/sales/json_landing/")
            .select(
                col("event_id"),
                current_timestamp().alias("ingestion_time"),
                to_json(struct("*")).cast("VARIANT").alias("event_payload"),
                input_file_name().alias("source_file")
            )
    )

@dlt.table(
    comment="Typed view of events extracted from VARIANT"
)
def events_typed():
    return (
        dlt.read_stream("events_raw")
            .select(
                col("event_id"),
                col("event_payload:type").cast("string").alias("event_type"),
                col("event_payload:customer.id").cast("bigint").alias("customer_id"),
                col("event_payload:customer.name").cast("string").alias("customer_name"),
                col("event_payload:timestamp").cast("timestamp").alias("event_time"),
                col("event_payload").alias("raw_payload")
            )
    )
EOT
  )
}

resource "databricks_pipeline" "variant_pipeline" {
  name = "variant_processing_pipeline"

  library {
    notebook {
      path = databricks_notebook.variant_pipeline.path
    }
  }

  cluster {
    label       = "default"
    num_workers = 1
  }

  target       = "learning_catalog.sales"
  storage      = "/pipelines/variant"
  continuous   = false
  development  = true
}

VARIANT vs. Alternatives

Approach When to Use Pros Cons
VARIANT Evolving schemas, exploratory Schema flexibility, performance Loose typing
Strongly-Typed Stable schemas, production Type safety, optimization Rigid schema
JSON String Storage only, no queries Simple Text-only access
Parquet Nested Complex nesting, analytics Efficient storage Inflexible schema

Best Practice: Start with VARIANT for ingestion, create typed views for analytics.


Performance Considerations

1. Indexing

VARIANT columns benefit from Bloom filters:

CREATE TABLE events (
    event_id BIGINT,
    event VARIANT
) USING DELTA
TBLPROPERTIES (
    'delta.bloomFilter.enabled' = 'true',
    'delta.bloomFilter.columns' = 'event'
);

2. Partition by Extracted Field

CREATE TABLE events (
    event_id BIGINT,
    event VARIANT,
    event_date DATE GENERATED ALWAYS AS (CAST(event:timestamp AS DATE))
) USING DELTA
PARTITIONED BY (event_date);

3. Materialize Common Queries

-- Create typed table for frequent queries
CREATE TABLE events_typed AS
SELECT 
    event_id,
    CAST(event:type AS STRING) AS event_type,
    CAST(event:customer.id AS BIGINT) AS customer_id,
    event
FROM events_raw;

Troubleshooting

Issue: NULL values when extracting fields

Cause: Incorrect path or field doesn’t exist

Solution:

-- Check if field exists
SELECT event_id, event
FROM events
WHERE event:nonexistent_field IS NULL;

-- Use COALESCE for defaults
SELECT COALESCE(event:customer.email, 'N/A') FROM events;

Issue: Type mismatch errors

Cause: Incorrect CAST

Solution:

-- Safe casting with TRY_CAST
SELECT TRY_CAST(event:amount AS DECIMAL(10,2)) FROM events;

Issue: Poor query performance

Cause: Delta optimization properties left at defaults

Solution: Enable optimization

ALTER TABLE events 
SET TBLPROPERTIES (
    'delta.autoOptimize.optimizeWrite' = 'true',
    'delta.autoOptimize.autoCompact' = 'true'
);

Best Practices

  1. Use VARIANT for ingestion, typed columns for analytics
  2. Create views to expose commonly-accessed fields as typed columns
  3. Add generated columns for frequently-filtered fields
  4. Partition by extracted date for time-series data
  5. Enable Change Data Feed for VARIANT tables to track schema evolution
  6. Document schema versions in table comments
  7. Use try_cast() for defensive type conversions

Next Steps

  1. βœ… Complete Exercise 1: Basic VARIANT operations
  2. βœ… Complete Exercise 2: Array explosion
  3. βœ… Complete Exercise 3: Schema evolution
  4. πŸ”„ Build real-time JSON ingestion pipeline
  5. πŸ”„ Implement schema registry for VARIANT data
  6. πŸ”„ Optimize VARIANT queries with statistics
  7. πŸ”„ Integrate with CDC pipeline

Related Concepts

  • Delta Live Tables: Pipelines for VARIANT processing
  • Unity Catalog: Metadata for VARIANT columns
  • Auto Loader: Ingestion of JSON into VARIANT
  • CDC with apply_changes(): SCD Type 2 with VARIANT payloads

Last Updated: December 12, 2025

Distributed Data Scenarios: Handling Skew in Spark’s groupBy Operation

Performance issues in Spark sometimes trace back to the data structure itself, even when the code and the cluster are configured correctly. I discovered this firsthand when a seemingly simple groupBy("city") operation exploded into over 200 shuffle tasks. While most tasks completed in seconds, one dragged on for minutes. My job ran slowly and created a bottleneck for an entire pipeline. Behind an ordinary-looking transformation sat a fundamental system imbalance. In this article, I’ll share a real-world example of shuffle skew in distributed data processing and how I resolved it.

Problem Overview: Skewed Shuffle from a groupBy Operation

flowchart TD
    A["Input DataFrame
(billions of rows)"] --> B["groupBy(city)"] B --> C["Shuffle
200+ partitions created"] C --> D1["Partition
a few MB
finishes in seconds"] C --> D2["Partition
a few MB
finishes in seconds"] C --> D3["..."] C --> DN["One partition ~1.2 GB
millions of New York rows"] DN --> E["Longest task ~12 min
spill to disk, OOM risk
job stalls at ~25 min"]

During a large Spark job, a simple transformation using groupBy("city") led to significant performance issues:

  • 200+ shuffle partitions were created by Spark to execute the groupBy operation.
  • Due to data skew, one or two cities (e.g., “New York”) contained millions of records, while most others had only a few.
  • This resulted in:
    • One task handling a massive partition (1+ GB),
    • Most tasks finishing quickly, and
    • One or two tasks taking much longer, ultimately stalling or failing the entire job.
  • The Spark UI showed clear task time imbalance, frequent spill to disk, and occasionally executor memory errors (OOMs).
  • The root cause lay in how groupBy and shuffle operations distribute tasks across a distributed system.

Solution: Mitigating Skew with Salting + Repartitioning

The core issue sat in how Spark distributed the data during the shuffle caused by groupBy("city"). High-cardinality cities like “New York” created data skew, overwhelming single tasks while others finished almost instantly.

We solved this using two complementary techniques:

flowchart TD
    A["Skewed DataFrame"] --> S1["Salting
salt = floor(rand() x 10)"] S1 --> S2["city_salted =
city plus salt"] S2 --> S3["Shuffle spreads the hot key
across 10 sub-partitions"] S3 --> S4["Aggregate per salted key"] S4 --> S5["Strip salt, re-aggregate
to correct per-city counts"] A --> R["Repartition
df.repartition(100, city)"] R --> S3

1. Salting the Skewed Key

Salting divides a hot key (like “New York”) into multiple artificial sub-keys, distributing the load more evenly.

from pyspark.sql.functions import concat, col, lit, floor, rand

# Step 1: Add a salt column
salted_df = df.withColumn("salt", floor(rand() * 10))  # 10 buckets

# Step 2: Salt the groupBy key
salted_df = salted_df.withColumn("city_salted", concat(col("city"), lit("_"), col("salt")))

# Step 3: Perform groupBy on salted key
grouped = salted_df.groupBy("city_salted").count()

# Step 4: Unsalt (combine buckets)
from pyspark.sql.functions import split
grouped_final = grouped.withColumn("city", split(col("city_salted"), "_")[0]) \
                       .groupBy("city").sum("count")

2. Repartition Before Shuffle

We also balanced the workload by repartitioning by the skewed key to control task distribution:

df = df.repartition(100, "city")

This ensures Spark pre-distributes the data, preventing hotspot partitions.


Before vs After: Performance Comparison

Metric Before (Skewed) After (Salted + Repartitioned)
# Shuffle Partitions 200+ (default) 100 (controlled)
Skewed Task Size ~1.2 GB ~110 MB
Longest Task Duration ~12 minutes ~45 seconds
Spill to Disk Frequent Minimal
OOM / Failures Yes (intermittent) None
Cluster Utilization Poor (underutilized) High (parallelism restored)
Overall Job Duration ~25 mins ~6 mins

Creating DataFrame in pySpark: A Visual Guide

For any data operations in Apache spark, one of the first tasks is creating a DataFrame. This is done using the createDataFrame() API. There are two main things to consider:

  1. Data Source – Data can come from a list of dictionaries, a list of lists or tuples, or even directly from a Pandas DataFrame.
  2. Schema – The structure of your data. This can be:
    • Auto-Inferred: Spark automatically detects column names and data types.
    • Explicit: You define the schema yourself. For simple cases, a string schema works (e.g. 'name STRING, age INT'). For more complex cases, a StructType gives you full control, including nullability.

Writing data with PySpark: A Visual Guide

PySpark in Apache Spark uses the DataFrameWriter to manage how data is saved.

To write data in PySpark, you start with the .write attribute of a DataFrame, which gives you a DataFrameWriter to manage the save process.

Basic Approach to Writing Data

There are two main syntax styles in PySpark:

  1. Generic API
    Uses the chain: .format().option().save()
  2. Format-specific shortcuts
    Uses direct methods like .csv(), .json(), .parquet(), etc.

Key Components

  • .write: Returns a DataFrameWriter object
  • .format(): Defines the output format (e.g., CSV, JSON, Parquet)
  • .option(): Controls file-specific settings like headers, delimiters, quotes
  • .save(): Triggers the actual data write

Mode Behaviour with .mode()

The .mode() method controls how PySpark behaves when data already exists at the output location:

  • .overwrite: Deletes and replaces existing data
  • .append: Adds new rows to the existing file
  • .ignore: Skips writing if data already exists
  • .error: (Default) Throws an error if data exists

Choose the mode based on whether you’re doing a full refresh or incremental load.

Partitioning Output Files

  • .partitionBy("column"): Splits the output into folders based on column values
  • Improves query performance when filtering on partitioned columns
  • Avoid partitioning on columns with high cardinality to prevent small files

Reading data in pySpark: A Visual Guide

Embedded:

PySpark makes it easy to load data from different sources into DataFrames. At first, the process can seem a little overwhelming, but this guide is designed as a visual walkthrough to simplify the concepts.

The data reading process begins with the .read attribute of a SparkSession, which provides access to a DataFrameReader object.

Basic approach to reading data

There are two main syntax styles for reading data in PySpark:

  1. Generic API using format().option().load() chain
  2. Convenience methods using wrapper methods like csv(), json(), or parquet()

Key components of data reading

  • DataFrameReader object: created by accessing the .read attribute of a SparkSession
  • Format specification: defines the file type such as CSV, JSON, or Parquet
  • Options: format-specific settings that control how data is interpreted
  • Schema definition: defines the structure of the resulting DataFrame (column names and types)
  • Loading: calling .load() or a method like .csv() performs the read and returns a DataFrame

Schema handling approaches

  • Explicit schema definition: use .schema() for precise control over data types
  • Schema inference: use inferSchema=True to automatically detect data types

Tip: For large datasets or production environments, explicit schema definition is recommended for better performance and data consistency.