12 Extraction and Heterogeneous Sources
This chapter assumes you have Databricks Community Edition set up and datasets loaded in DBFS according to the previous chapter.
12.1 1. Extraction (Extract)
12.1.1 1.1 Reading Common Formats
# CSV
df_csv = spark.read.csv("data/trips.csv", header=True, schema=schema)
# Parquet (columnar, compressed — the preferred format for DW storage)
df_pq = spark.read.parquet("data/trips.parquet")
# JSON (JSON Lines format)
df_json = spark.read.json("data/reviews.jsonl")
# Entire directory of Parquet files (Spark automatically merges partitions)
df_all = spark.read.parquet("data/trips/year=2023/")
# JDBC (relational database connection)
df_jdbc = (spark.read
.format("jdbc")
.option("url", "jdbc:postgresql://host:5432/sales")
.option("dbtable", "public.orders")
.option("user", "etl_user")
.option("password", "secret")
.option("numPartitionColumns", "order_id") # parallelism
.option("lowerBound", "1")
.option("upperBound", "10000000")
.option("numPartitions", "16")
.load())12.1.2 1.2 Partitioning and Read Parallelism
# Check the number of partitions in a DataFrame
print(f"Partitions: {df_pq.rdd.getNumPartitions()}")
# Repartition to rebalance cluster workload
df_balanced = df_pq.repartition(32)
# Coalesce — reduces partitions without full shuffle (useful for writing fewer output files)
df_small = df_balanced.coalesce(4)12.1.3 1.3 Exercise E-1 — Ingesting NYC Taxi
12.2 2. Heterogeneous Source Integration: Data Lake → DW
In practice, raw data arrives in diverse formats, with inconsistent column names, incompatible data types, and varying levels of nesting. This section covers techniques to standardize and homogenize these sources before loading into the Data Warehouse.
Click on a layer to inspect its contents and the ETL problem it solves.
12.2.1 2.1 The Problem: Heterogeneous Sources in the RAW Zone
When a Data Lake accumulates data across multiple systems, common integration hurdles arise:
| Issue | Real-World Example |
|---|---|
| Differing Formats | Sales in CSV, app events in JSON, product catalog in XML |
| Inconsistent Naming | customer_id vs. cust_id vs. clienteId |
| Incompatible Data Types | "2024-01-15" (string) vs. 1705276800 (epoch) vs. 20240115 (integer) |
| Nested JSON Structures | {"user": {"name": "Ana", "address": {"city": "CDMX"}}} |
| Conflicting Encodings | UTF-8 vs. Latin-1 vs. CP1252 |
| Non-Standard Delimiters | Semicolons ; in European CSVs, pipes | in database dumps |
| Heterogeneous Null Representations | "", "NULL", "N/A", "\\N", None |
| Measurement Unit Discrepancies | Distances in km vs. miles, monetary amounts in MXN vs. USD |
12.2.2 2.2 Nested JSON: Flattening for the DW
JSON is ubiquitous in REST APIs and event streams, but Data Warehouses require flattened tables (rows × scalar columns). PySpark provides three primary tools for flattening:
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
col, get_json_object, from_json, to_json,
explode, explode_outer, inline, flatten,
struct, to_timestamp
)
from pyspark.sql.types import *
spark = SparkSession.builder.appName("ETL-JSON").master("local[*]").getOrCreate()
# ── Example: Purchase Events (JSON lines) ───────────────────────────────
# Each line contains nested JSON objects and arrays:
# {"event_id":"e1","ts":"2024-01-15T10:30:00Z",
# "user":{"id":42,"city":"CDMX","segment":"premium"},
# "items":[{"sku":"A01","qty":2,"price":199.0},
# {"sku":"B03","qty":1,"price":89.5}]}
df_raw = spark.read.json("data/lake/eventos_compra.jsonl")
df_raw.printSchema()Technique 1: Accessing Nested Fields via Dot Notation
# Struct → individual columns via dot notation
df_flat = df_raw.select(
col("event_id"),
to_timestamp(col("ts")).alias("event_ts"),
col("user.id").alias("user_id"),
col("user.city").alias("city"),
col("user.segment").alias("segment"),
col("items") # array column remains as an array type
)Technique 2: explode — Unnesting Arrays to Rows
# Explodes array elements into separate rows
df_items = (df_raw
.select(
col("event_id"),
to_timestamp(col("ts")).alias("event_ts"),
col("user.id").alias("user_id"),
explode(col("items")).alias("item") # <- unnest array
)
.select(
col("event_id"),
col("event_ts"),
col("user_id"),
col("item.sku").alias("sku"),
col("item.qty").alias("quantity"),
col("item.price").alias("unit_price"),
(col("item.qty") * col("item.price")).alias("subtotal")
)
)
df_items.show(5)explode drops rows where the array is empty or null. Use explode_outer if you want to preserve those rows with item = null.
Technique 3: Embedded Stringified JSON → Parsing with Explicit Schemas
In many data pipelines, JSON payloads arrive as string columns inside CSV files or database tables:
# CSV with a string column "payload" containing stringified JSON
df_csv = spark.read.csv("data/lake/events_flat.csv", header=True)
# Define explicit schema for embedded JSON
payload_schema = StructType([
StructField("user_id", LongType(), True),
StructField("amount", DoubleType(), True),
StructField("currency", StringType(), True),
])
# Parse string column → typed struct
df_parsed = (df_csv
.withColumn("payload_struct",
from_json(col("payload"), payload_schema))
.select(
col("event_id"),
col("payload_struct.user_id").alias("user_id"),
col("payload_struct.amount").alias("amount"),
col("payload_struct.currency").alias("currency")
)
)Technique 4: get_json_object — Selective JSONPath Extraction
When extracting a few fields from deeply nested JSON without parsing the entire schema:
df_select = df_csv.select(
col("event_id"),
get_json_object(col("payload"), "$.user_id").cast("long").alias("user_id"),
get_json_object(col("payload"), "$.amount").cast("double").alias("amount"),
get_json_object(col("payload"), "$.currency").alias("currency"),
)12.2.3 2.3 Heterogeneous CSV: Schema Normalization Across Sources
A common scenario: the same entity type originates from three separate systems with varying column names and units. The ETL pipeline must unify them into a single schema.
from pyspark.sql.functions import (
col, to_date, to_timestamp, lit, round as spark_round
)
MXN_USD_RATE = 17.0 # sample exchange rate
# Source 1: ERP (Mexico)
df_erp = (spark.read
.option("header", True)
.option("encoding", "UTF-8")
.csv("data/lake/erp_pedidos.csv")
.select(
col("cust_id").cast("long").alias("customer_id"),
to_date(col("fecha_pedido"), "dd/MM/yyyy").alias("order_date"),
col("monto_mxn").cast("double").alias("amount_mxn"),
spark_round(col("monto_mxn").cast("double") / MXN_USD_RATE, 2).alias("amount_usd"),
col("desc_pct").cast("double").alias("discount"),
lit("ERP").alias("source_system")
)
)
# Source 2: CRM (USA)
df_crm = (spark.read
.option("header", True)
.option("encoding", "Latin-1") # different encoding
.csv("data/lake/crm_orders.csv")
.select(
col("CustomerID").cast("long").alias("customer_id"),
to_date(col("OrderDate"), "yyyy-MM-dd").alias("order_date"),
spark_round(col("AmountUSD").cast("double") * MXN_USD_RATE, 2).alias("amount_mxn"),
col("AmountUSD").cast("double").alias("amount_usd"),
(col("Discount").cast("double") / 100).alias("discount"), # percentage → fraction
lit("CRM").alias("source_system")
)
)
# Source 3: Event Log JSON
payload_schema_ev = StructType([
StructField("cliente", LongType(), True),
StructField("ts", StringType(), True),
StructField("total", StructType([
StructField("mxn", DoubleType(), True),
StructField("usd", DoubleType(), True),
]), True),
StructField("dcto", DoubleType(), True),
])
df_events = (spark.read.schema(payload_schema_ev).json("data/lake/events.jsonl")
.select(
col("cliente").alias("customer_id"),
to_date(to_timestamp(col("ts"))).alias("order_date"),
col("total.mxn").alias("amount_mxn"),
col("total.usd").alias("amount_usd"),
col("dcto").alias("discount"),
lit("EVENT").alias("source_system")
)
)
# ── Union of all 3 sources into a single standardized schema ──────────────
df_unified = df_erp.unionByName(df_crm).unionByName(df_events)
print(f"ERP: {df_erp.count():>8,} rows")
print(f"CRM: {df_crm.count():>8,} rows")
print(f"Events: {df_events.count():>8,} rows")
print(f"Total: {df_unified.count():>8,} rows")
df_unified.printSchema()
df_unified.groupBy("source_system").count().show()unionByName merges columns by name, not positional order. If a source dataset contains extra columns missing from others, use unionByName(df2, allowMissingColumns=True) to populate missing columns with null.
12.2.4 2.4 Data Type Normalization and Heterogeneous Null Values
from pyspark.sql.functions import (
when, trim, upper, lower, regexp_replace,
coalesce, nanvl, col
)
# ── Strings representing null values ─────────────────────────────────────
STRING_NULLS = ["NULL", "N/A", "\\N", "None", "nan", "", " "]
def clean_null_strings(df):
for c in df.columns:
if dict(df.dtypes)[c] == "string":
df = df.withColumn(c,
when(trim(col(c)).isin(STRING_NULLS), None)
.otherwise(trim(col(c)))
)
return df
df_clean = clean_null_strings(df_unified)
# ── Text Normalization ────────────────────────────────────────────────
df_text = (df_clean
.withColumn("city", upper(trim(col("city"))))
.withColumn("segment", lower(trim(col("segment"))))
)
# ── Heterogeneous Unit Conversion ──────────────────────────────────
df_distance = df_raw.withColumn("distance_km",
when(col("unit") == "mi", col("distance") * 1.60934)
.when(col("unit") == "km", col("distance"))
.when(col("unit") == "m", col("distance") / 1000.0)
.otherwise(None)
)
# ── Multi-Format Date Parsing → Standard Timestamp ────────────────────
from pyspark.sql.functions import coalesce, to_timestamp
def parse_multi_format_date(col_name):
formats = [
"yyyy-MM-dd'T'HH:mm:ssX", # ISO 8601 with timezone
"yyyy-MM-dd HH:mm:ss", # datetime without timezone
"dd/MM/yyyy", # European format
"MM/dd/yyyy", # US format
"yyyyMMdd", # Compact integer date
]
return coalesce(*[to_timestamp(col(col_name), fmt) for fmt in formats])
df_dates = df_raw.withColumn("fecha_std", parse_multi_format_date("fecha_raw"))12.2.5 2.5 Non-Standard Delimiters and Encodings in CSV
# Pipe-delimited file with ISO-8859-1 encoding (common in SAP/Oracle exports)
df_sap = (spark.read
.option("sep", "|")
.option("encoding", "ISO-8859-1")
.option("header", True)
.option("quote", '"')
.option("escape", "\\")
.option("multiLine", False)
.option("nullValue", "")
.csv("data/lake/sap_export.txt")
)
# XML File — requires spark-xml dependency
df_xml = (spark.read
.format("com.databricks.spark.xml")
.option("rowTag", "order") # row root tag
.load("data/lake/orders.xml")
)
# Avro — natively supported in Spark 3.x
df_avro = spark.read.format("avro").load("data/lake/events.avro")12.2.6 2.6 Converting JSON (Data Lake) → Parquet (DW): End-to-End Pipeline
This pattern represents a standard enterprise pipeline: the Data Lake stores raw JSON events, and the Data Warehouse ingests them as typed, partitioned Parquet tables.
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
col, explode, to_timestamp, to_date,
lit, trim, when, coalesce, year, month, dayofmonth
)
from pyspark.sql.types import *
spark = (SparkSession.builder
.appName("lake-to-dw")
.master("local[*]")
.config("spark.sql.shuffle.partitions", "8")
.getOrCreate())
# ─── EXTRACT ──────────────────────────────────────────────────────────────
# Raw Data Lake: Nested semi-structured JSON
schema_raw = StructType([
StructField("event_id", StringType(), False),
StructField("ts", StringType(), True),
StructField("version", IntegerType(), True),
StructField("user", StructType([
StructField("id", LongType(), True),
StructField("city", StringType(), True),
StructField("segment", StringType(), True),
StructField("country", StringType(), True),
]), True),
StructField("items", ArrayType(StructType([
StructField("sku", StringType(), True),
StructField("category", StringType(), True),
StructField("qty", IntegerType(), True),
StructField("price_usd",DoubleType(), True),
])), True),
StructField("payment", StructType([
StructField("method", StringType(), True),
StructField("currency", StringType(), True),
StructField("fx_rate", DoubleType(), True),
]), True),
])
df_raw = spark.read.schema(schema_raw).json("data/lake/raw/events/")
print(f"Data Lake record count: {df_raw.count():,}")
# ─── TRANSFORM: Flatten + Normalize + Explode ────────────────────────────
df_items = (df_raw
.withColumn("item", explode(col("items")))
.select(
col("event_id"),
to_timestamp(col("ts")).alias("event_ts"),
to_date(to_timestamp(col("ts"))).alias("order_date"),
col("user.id").cast("long").alias("user_id"),
trim(col("user.city")).alias("city"),
trim(col("user.segment")).alias("segment"),
coalesce(col("user.country"), lit("MX")).alias("country"),
col("item.sku").alias("sku"),
col("item.category").alias("category"),
col("item.qty").cast("integer").alias("quantity"),
col("item.price_usd").alias("price_usd"),
(col("item.price_usd") * col("payment.fx_rate")).alias("local_price"),
col("payment.method").alias("payment_method"),
col("payment.currency").alias("currency"),
(col("item.qty") * col("item.price_usd")).alias("subtotal_usd"),
(col("item.qty") * col("item.price_usd") *
col("payment.fx_rate")).alias("local_subtotal"),
)
.filter(col("quantity") > 0)
.filter(col("price_usd") > 0)
.filter(col("event_ts").isNotNull())
)
df_final = (df_items
.withColumn("year", year("order_date"))
.withColumn("month", month("order_date"))
.withColumn("day", dayofmonth("order_date"))
)
# ─── LOAD: Partitioned Parquet to Data Warehouse ─────────────────────────
DW_PATH = "output/dw/fact_sales_items/"
(df_final
.write
.mode("overwrite")
.partitionBy("year", "month", "day")
.parquet(DW_PATH))
# Verification
df_dw = spark.read.parquet(DW_PATH)
print(f"\nDW record count: {df_dw.count():,}")
print(f"Column count: {len(df_dw.columns)}")
df_dw.printSchema()
df_dw.createOrReplaceTempView("facts")
spark.sql("""
SELECT
country, segment, currency,
COUNT(*) AS item_count,
SUM(subtotal_usd) AS total_usd_revenue,
AVG(price_usd) AS avg_usd_price
FROM facts
GROUP BY country, segment, currency
ORDER BY total_usd_revenue DESC
""").show(truncate=False)
spark.stop()12.2.7 2.7 Schema Evolution and Reconciliation with mergeSchema
When source schemas evolve across files (e.g., a new column appears in February), Spark reconciles schemas dynamically:
# Read historical Parquet files with evolving schemas
df_historical = (spark.read
.option("mergeSchema", "true") # merges superset of columns
.parquet("data/lake/parquet/historical/"))
df_historical.printSchema()
# Delta Lake: Native schema evolution
(df_new.write
.format("delta")
.option("mergeSchema", "true") # enables adding new columns on append
.mode("append")
.save("output/delta/facts/"))