pysparkintermediate45 minPySpark
Lakehouse Medallion Bronze-to-Silver ETL
Validate raw IoT sensor bronze data, quarantine corrupted records with dead-letter routing, and promote clean silver telematics.
The incident
- Company
- TeleMetric IoT Systems
- Engineer
- Priya Nair, Data Platform Engineer
- Trigger
- S3 bronze telemetry landing zone accumulated unvalidated device pings with corrupted null device IDs and negative battery percentages.
- Stakes
- Corrupted device telemetry injected invalid readings into executive energy monitoring dashboards and breached contractual IoT uptime SLAs.
At TeleMetric IoT Systems, Priya Nair oversaw the lakehouse data ingestion platform processing environmental sensor telematics for 12,000 smart building installations across commercial real estate portfolios. Every fifteen minutes, edge gateways transmitted millions of raw sensor readings into an S3 bronze bucket. Telemetry pings captured device identifiers, ambient temperatures, battery percentages, and reading timestamps. The downstream silver and gold analytical layers powered automated HVAC climate optimization algorithms, predictive maintenance systems, and tenant energy billing summaries.
Due to intermittent firmware glitches and power fluctuations at older edge gateways, incoming sensor streams were contaminated with malformed records. Approximately 6 percent of incoming records arrived with null device IDs, uncalibrated negative battery percentages, or corrupted temperature sensor values exceeding physical limits. When raw telemetry flowed unchecked into downstream silver tables, business analytics dashboards reported negative battery levels and triggered false device outage alerts. Building facility managers submitted critical incident tickets complaining of erratic thermostat adjustments.
The team initially deployed a basic filtering script that called dropna on device ID. However, this naive filtering strategy had severe deficiencies: silently dropping records without logging rejection reasons left the IoT hardware engineering team blind to ongoing edge firmware failures. Furthermore, the simple dropna rule failed to catch negative battery readings (-15 percent) or out-of-range sensor spikes. Field technicians were dispatched to healthy sensor nodes while genuinely malfunctioning units went unnoticed.
Priya designed a resilient bronze-to-silver medallion architecture that separated data cleaning from quarantine dead-letter auditing. She formulated explicit validation expressions in PySpark: verifying that device_id was non-null, battery was bounded between 0 and 100, and temperature fell within realistic operating ranges (-20 to 60 degrees Celsius). Records satisfying all rules were promoted into certified silver storage, while failing records were directed to a quarantine dead-letter table with failure flags. Dual routing preserved clean analytical streams without losing diagnostic visibility.
Priya implemented the dual-routing medallion pipeline in PySpark. Validating against test sensor batches, the pipeline processed 6 raw telemetry events, promoting 4 clean records to the silver table while routing 2 corrupted records (one with null device ID and one with negative battery percentage) into the quarantine table. The pipeline calculated certified average building temperatures and battery health across active devices without data pollution. Gold-layer heating and cooling models received strictly verified physical readings.
Priya presented the medallion validation pipeline to the Vice President of IoT Infrastructure and the Lead Firmware Architect. The architecture reduced silver data defects to zero. The bronze-to-silver quarantine pattern was formally adopted across all TeleMetric smart building installations nationwide, establishing an audit-ready ingestion benchmark for enterprise IoT operations.
What you will learn
- Construct medallion pipeline validation boundaries between Bronze and Silver layers
- Formulate multi-condition PySpark boolean validation rules with F.col() expressions
- Route invalid records into a dead-letter quarantine table with descriptive error reasons
- Aggregate certified silver metrics without risk of null or invalid data corruption
What you need first
Medallion Architecture LayersLakehouse medallion pipeline
Bronze stores raw unvalidated ingestion feeds, Silver enforces schema and domain validity, and Gold delivers aggregated marts.
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = SparkSession.builder.master('local[1]').getOrCreate()
df = spark.createDataFrame([(1, None), (2, 50)], ['id', 'val'])
print(df.filter(F.col('val').isNotNull()).count())1
Applying business aggregations directly to raw Bronze tables without intermediate Silver cleansing and validation.
Dead-Letter Quarantine RoutingColumn operations & functions
Rather than silently dropping invalid records, quarantine routing stores corrupt rows with error audit flags for debugging.
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = SparkSession.builder.master('local[1]').getOrCreate()
df = spark.createDataFrame([(1, -5), (2, 80)], ['id', 'pct'])
quarantine = df.filter(F.col('pct') < 0)
print(quarantine.first()['id'])1
Silently discarding bad rows with dropna, hiding upstream sensor firmware regressions from engineering teams.
Architecture and data flow
The data
Raw IoT sensor telemetry containing valid pings alongside null device IDs and negative battery values.
raw_bronze_telemetry: Raw sensor pings landed in S3 bronze bucket.
| ping_id | device_id | temp_c | battery_pct | timestamp |
|---|---|---|---|---|
| p1 | dev_101 | 22.5 | 85 | 2024-03-01 10:00:00 |
| p2 | dev_102 | 24.1 | 90 | 2024-03-01 10:00:05 |
| p3 | None | 21.0 | 75 | 2024-03-01 10:00:10 |
| p4 | dev_103 | 19.8 | -15 | 2024-03-01 10:00:15 |
| p5 | dev_101 | 23.0 | 84 | 2024-03-01 10:05:00 |
| p6 | dev_102 | 24.5 | 89 | 2024-03-01 10:05:05 |
Step by step
Read each step, run its code, then check your output against the one shown.
Ingest raw bronze IoT sensor telematics DataFrame
We initialize the raw bronze telemetry DataFrame representing incoming IoT sensor pings. In smart building installations, thousands of edge sensors stream temperature and battery readings every few seconds. Notice that ping p3 contains a null device_id, while ping p4 contains an uncalibrated negative battery percentage of -15. We inspect the raw DataFrame to establish a baseline before implementing validation filters. In lakehouse architectures, Bronze data is preserved immutably in its raw incoming state to maintain a complete historical audit trail.
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder.master("local[1]").appName("medallion_etl").getOrCreate() raw_data = [ ("p1", "dev_101", 22.5, 85, "2024-03-01 10:00:00"), ("p2", "dev_102", 24.1, 90, "2024-03-01 10:00:05"), ("p3", None, 21.0, 75, "2024-03-01 10:00:10"), ("p4", "dev_103", 19.8, -15, "2024-03-01 10:00:15"), ("p5", "dev_101", 23.0, 84, "2024-03-01 10:05:00"), ("p6", "dev_102", 24.5, 89, "2024-03-01 10:05:05"), ] df_bronze = spark.createDataFrame(raw_data, ["ping_id", "device_id", "temp_c", "battery_pct", "timestamp"]) df_bronze.show(truncate=False)+-------+---------+------+-----------+-------------------+ |ping_id|device_id|temp_c|battery_pct|timestamp | +-------+---------+------+-----------+-------------------+ |p1 |dev_101 |22.5 |85 |2024-03-01 10:00:00| |p2 |dev_102 |24.1 |90 |2024-03-01 10:00:05| |p3 |NULL |21.0 |75 |2024-03-01 10:00:10| |p4 |dev_103 |19.8 |-15 |2024-03-01 10:00:15| |p5 |dev_101 |23.0 |84 |2024-03-01 10:05:00| |p6 |dev_102 |24.5 |89 |2024-03-01 10:05:05| +-------+---------+------+-----------+-------------------+
Common mistakeModifying raw bronze data in place rather than treating Bronze tables as append-only immutable logs.
How you will knowYou will see 6 bronze records with null device_id on p3 and -15 battery on p4.
Demonstrate silent data corruption with naive dropna filtering
Wrong turn first: this step shows the mistake before the fix
Here is the naive approach developers often attempt first: calling df_bronze.dropna(subset=['device_id']). While dropna successfully removes the null device ping p3, it completely misses the negative battery reading of -15 percent on ping p4. The corrupted battery reading is passed directly into silver tables, polluting average battery metrics and triggering false hardware alarm thresholds. Furthermore, discarded records are lost completely without. We demonstrate this failed attempt to highlight why domain-level validation rules are required.
from pyspark.sql import SparkSession spark = SparkSession.builder.master("local[1]").appName("medallion_etl").getOrCreate() raw_data = [ ("p1", "dev_101", 22.5, 85, "2024-03-01 10:00:00"), ("p2", "dev_102", 24.1, 90, "2024-03-01 10:00:05"), ("p3", None, 21.0, 75, "2024-03-01 10:00:10"), ("p4", "dev_103", 19.8, -15, "2024-03-01 10:00:15"), ("p5", "dev_101", 23.0, 84, "2024-03-01 10:05:00"), ("p6", "dev_102", 24.5, 89, "2024-03-01 10:05:05"), ] df_bronze = spark.createDataFrame(raw_data, ["ping_id", "device_id", "temp_c", "battery_pct", "timestamp"]) # Naive approach: dropna allows negative battery values through naive_silver = df_bronze.dropna(subset=["device_id"]) print("Naive silver rows (negative battery passed through):") naive_silver.select("ping_id", "device_id", "battery_pct").show(truncate=False)Naive silver rows (negative battery passed through): +-------+---------+-----------+ |ping_id|device_id|battery_pct| +-------+---------+-----------+ |p1 |dev_101 |85 | |p2 |dev_102 |90 | |p4 |dev_103 |-15 | |p5 |dev_101 |84 | |p6 |dev_102 |89 | +-------+---------+-----------+
Common mistakeRelying strictly on null checks while ignoring numerical domain validity constraints like range limits.
How you will knowObserve that naive filtering retains p4 with battery_pct -15.
Define comprehensive boolean validation rule expressions
We formulate comprehensive boolean validation expressions that verify structural integrity and domain validity. We require device_id to be non-null, battery_pct to be bounded between 0 and 100 inclusive, and temp_c to fall within reasonable operating limits (-20 to 60 degrees Celsius). We evaluate this rule by adding an 'is_valid' boolean column to the DataFrame. Combining multiple column expressions using bitwise logical operators (& and |) allows Spark to execute vectorized validation checks across all worker partitions simultaneously without Python runtime overhead.
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder.master("local[1]").appName("medallion_etl").getOrCreate() raw_data = [ ("p1", "dev_101", 22.5, 85, "2024-03-01 10:00:00"), ("p2", "dev_102", 24.1, 90, "2024-03-01 10:00:05"), ("p3", None, 21.0, 75, "2024-03-01 10:00:10"), ("p4", "dev_103", 19.8, -15, "2024-03-01 10:00:15"), ("p5", "dev_101", 23.0, 84, "2024-03-01 10:05:00"), ("p6", "dev_102", 24.5, 89, "2024-03-01 10:05:05"), ] df_bronze = spark.createDataFrame(raw_data, ["ping_id", "device_id", "temp_c", "battery_pct", "timestamp"]) valid_rule = ( F.col("device_id").isNotNull() & (F.col("battery_pct") >= 0) & (F.col("battery_pct") <= 100) & (F.col("temp_c") >= -20) & (F.col("temp_c") <= 60) ) flagged_df = df_bronze.withColumn("is_valid", valid_rule) flagged_df.select("ping_id", "device_id", "battery_pct", "is_valid").show(truncate=False)+-------+---------+-----------+--------+ |ping_id|device_id|battery_pct|is_valid| +-------+---------+-----------+--------+ |p1 |dev_101 |85 |true | |p2 |dev_102 |90 |true | |p3 |NULL |75 |false | |p4 |dev_103 |-15 |false | |p5 |dev_101 |84 |true | |p6 |dev_102 |89 |true | +-------+---------+-----------+--------+
Common mistakeUsing Python 'and' keyword instead of the PySpark bitwise operator '&' when chaining column expressions.
How you will knowOutput shows an is_valid column where p3 and p4 are marked False while others are True.
Route records to Silver table and Quarantine dead-letter log
We separate the flagged dataset into two distinct DataFrames: certified silver records where is_valid is True, and quarantine dead-letter records where is_valid is False. For quarantine records, we construct an explicit rejection_reason column using F.when expressions to document whether the failure was caused by a null device ID or an invalid battery reading. Dead-letter routing ensures that invalid records are preserved for firmware engineering triage while keeping the production silver table strictly clean, defect-free, and audit-ready across all tenant buildings.
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder.master("local[1]").appName("medallion_etl").getOrCreate() raw_data = [ ("p1", "dev_101", 22.5, 85, "2024-03-01 10:00:00"), ("p2", "dev_102", 24.1, 90, "2024-03-01 10:00:05"), ("p3", None, 21.0, 75, "2024-03-01 10:00:10"), ("p4", "dev_103", 19.8, -15, "2024-03-01 10:00:15"), ("p5", "dev_101", 23.0, 84, "2024-03-01 10:05:00"), ("p6", "dev_102", 24.5, 89, "2024-03-01 10:05:05"), ] df_bronze = spark.createDataFrame(raw_data, ["ping_id", "device_id", "temp_c", "battery_pct", "timestamp"]) valid_rule = ( F.col("device_id").isNotNull() & (F.col("battery_pct") >= 0) & (F.col("battery_pct") <= 100) & (F.col("temp_c") >= -20) & (F.col("temp_c") <= 60) ) flagged_df = df_bronze.withColumn("is_valid", valid_rule) silver_df = flagged_df.filter(F.col("is_valid")).drop("is_valid") quarantine_df = ( flagged_df .filter(~F.col("is_valid")) .withColumn( "rejection_reason", F.when(F.col("device_id").isNull(), "NULL_DEVICE_ID") .when((F.col("battery_pct") < 0) | (F.col("battery_pct") > 100), "INVALID_BATTERY_RANGE") .otherwise("CORRUPT_TEMP_READING") ) .drop("is_valid") ) print("Quarantine dead-letter log:") quarantine_df.select("ping_id", "device_id", "battery_pct", "rejection_reason").show(truncate=False)Quarantine dead-letter log: +-------+---------+-----------+---------------------+ |ping_id|device_id|battery_pct|rejection_reason | +-------+---------+-----------+---------------------+ |p3 |NULL |75 |NULL_DEVICE_ID | |p4 |dev_103 |-15 |INVALID_BATTERY_RANGE| +-------+---------+-----------+---------------------+
Common mistakeFailing to attach explicit error reason strings to quarantine records, making automated alerts unactionable.
How you will knowQuarantine output shows p3 with NULL_DEVICE_ID and p4 with INVALID_BATTERY_RANGE.
Compute certified silver telematics metrics per device
In the final step, we aggregate certified silver readings to generate gold-ready metrics. We group silver_df by device_id and calculate the total reading count, average temperature, and average battery percentage using F.round. Both dev_101 and dev_102 have 2 valid readings each: dev_101 averages 22.75 degrees Celsius with 84.5 percent battery, while dev_102 averages 24.3 degrees Celsius with 89.5 percent battery. The resulting metrics are mathematically certified against negative outliers or null keys, delivering clean operational telemetry for building management systems.
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder.master("local[1]").appName("medallion_etl").getOrCreate() raw_data = [ ("p1", "dev_101", 22.5, 85, "2024-03-01 10:00:00"), ("p2", "dev_102", 24.1, 90, "2024-03-01 10:00:05"), ("p3", None, 21.0, 75, "2024-03-01 10:00:10"), ("p4", "dev_103", 19.8, -15, "2024-03-01 10:00:15"), ("p5", "dev_101", 23.0, 84, "2024-03-01 10:05:00"), ("p6", "dev_102", 24.5, 89, "2024-03-01 10:05:05"), ] df_bronze = spark.createDataFrame(raw_data, ["ping_id", "device_id", "temp_c", "battery_pct", "timestamp"]) valid_rule = ( F.col("device_id").isNotNull() & (F.col("battery_pct") >= 0) & (F.col("battery_pct") <= 100) & (F.col("temp_c") >= -20) & (F.col("temp_c") <= 60) ) silver_df = df_bronze.filter(valid_rule) device_summary = ( silver_df .groupBy("device_id") .agg( F.count("*").alias("readings"), F.round(F.avg("temp_c"), 2).alias("avg_temp_c"), F.round(F.avg("battery_pct"), 1).alias("avg_battery_pct") ) .orderBy("device_id") ) device_summary.show(truncate=False)+---------+--------+----------+---------------+ |device_id|readings|avg_temp_c|avg_battery_pct| +---------+--------+----------+---------------+ |dev_101 |2 |22.75 |84.5 | |dev_102 |2 |24.3 |89.5 | +---------+--------+----------+---------------+
Common mistakeApplying rounding functions before aggregation rather than on the aggregated summary expression.
How you will knowOutput shows 2 devices (dev_101 and dev_102) with 2 readings each and clean positive battery averages.
Build it yourself
Write your solution below, run it in your browser, and pass every check.
What the result means
Interpretation
Dual-routing Bronze-to-Silver ETL prevents data corruption while preserving operational observability. By routing invalid pings into dead-letter quarantine tables with explicit failure causes rather than silently dropping rows, data engineering teams provide hardware engineering teams with instant visibility into faulty firmware while delivering 100 percent certified metrics to downstream analytical consumers.
The message you would send
IoT Bronze-to-Silver medallion validation active: we implemented multi-condition data quality filters and automated dead-letter quarantine routing across 12,000 smart building installations. Negative battery anomalies and missing device identifiers dropped from production silver tables to zero, and firmware triage teams identified and patched 2 faulty gateway microcodes within 24 hours.
Put it on your CV
Resume or LinkedIn bullet
Designed PySpark lakehouse medallion ingestion pipelines with dual-routing dead-letter quarantine auditing for 12,000 IoT devices.README for GitHub
# Lakehouse Medallion Bronze-to-Silver ETL in PySpark
## Problem
Raw IoT telemetry pings arrived with null device IDs and negative battery percentages (-15%), polluting executive energy dashboards.
## Solution Architecture
- Bronze: Append-only immutable landing zone.
- Validation: Vectorized PySpark expressions for device nullness, battery bounds (0..100), and temperature limits (-20..60C).
- Silver: Certified valid records.
- Quarantine: Dead-letter table capturing rejected pings with specific `rejection_reason` codes.
## Results
Reduced silver data defects to zero and provided hardware teams with real-time firmware diagnostic telemetry.Implement an automated quarantine threshold alert that triggers PagerDuty notifications when quarantine rates exceed 5 percent.