9.2 Data Cleansing: Deduplication, Null Handling, & Type Casting in PySpark/SQL
Key Takeaways
- Deduplication in PySpark/SQL is implemented via distinct() for full-row equality or dropDuplicates(['col1', 'col2']) / ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) for subset key-based deduplication with deterministic ranking.
- Null replacement and coalescing leverage coalesce(), nvl(), ifnull(), nanvl(), and DataFrame fillna() / dropna() to substitute defaults, eliminate missing values, or drop incomplete records.
- Safe type casting with try_cast() prevents runtime query aborts on malformed strings by returning NULL instead of throwing an exception, enabling graceful exception logging and filtering.
- Corrupt record handling during file ingestion utilizes Spark parser modes (PERMISSIVE, DROPMALFORMED, FAILFAST) and the _corrupt_record column or Auto Loader's _rescued_data column to quarantine invalid data.
- String manipulation and regex extraction via regexp_extract(), regexp_replace(), and split() enable standardization of unstructured identifiers, phone numbers, postal codes, and email addresses.
9.2 Data Cleansing: Deduplication, Null Handling, & Type Casting in PySpark/SQL
DP-750 Exam Focus: Master enterprise data cleansing patterns in PySpark and Spark SQL. Understand the operational differences between
distinct()anddropDuplicates(subset), window-based deduplication withROW_NUMBER(), safe type conversions usingtry_cast(), null evaluation withcoalesce()andnvl(), corrupt record handling across parser modes (PERMISSIVE,DROPMALFORMED,FAILFAST), and regular expression cleansing.
1. Deduplication Strategies: Row-Level vs. Key-Level
Data ingestion from distributed systems, message queues (Event Hubs, Kafka), and REST endpoints frequently produces duplicate records due to at-least-once delivery semantics or upstream retries. Data engineers must apply the appropriate deduplication strategy based on business requirements.
1. Full-Row Deduplication (distinct)
distinct() evaluates every single column across a row. If two rows have identical values in all columns, only one is retained.
# PySpark: Full-row deduplication
df_distinct = df_raw.distinct()
-- SQL: Full-row deduplication
SELECT DISTINCT * FROM bronze.orders_raw;
2. Subset Key Deduplication (dropDuplicates)
In real-world data pipelines, rows may have the same business key (e.g., order_id) but slightly different technical metadata (such as different ingest_timestamp or file_offset values). dropDuplicates([subset]) drops duplicates based exclusively on the specified subset of columns, retaining an non-deterministic instance of the duplicated rows.
# PySpark: Deduplicate on business keys
df_dedup = df_raw.dropDuplicates(["customer_id", "transaction_id"])
3. Deterministic Deduplication with Window Functions
Because dropDuplicates() picks an arbitrary row among duplicate keys, enterprise pipelines requiring deterministic retention (e.g., "keep only the most recent record by timestamp") must use the ROW_NUMBER() window function pattern.
-- SQL: Deterministic deduplication retaining the latest record
WITH ranked_records AS (
SELECT
order_id,
customer_id,
order_status,
order_amount,
updated_at,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY updated_at DESC, ingest_timestamp DESC
) AS row_num
FROM bronze.sales.orders_raw
)
SELECT
order_id,
customer_id,
order_status,
order_amount,
updated_at
FROM ranked_records
WHERE row_num = 1;
# PySpark: Deterministic deduplication with Window
from pyspark.sql.window import Window
from pyspark.sql.functions import col, row_number
spec = Window.partitionBy("order_id").orderBy(col("updated_at").desc(), col("ingest_timestamp").desc())
df_latest = (df_raw
.withColumn("row_num", row_number().over(spec))
.filter(col("row_num") == 1)
.drop("row_num"))
Exam Trap:
dropDuplicates()causes a full distributed shuffle across worker nodes based on the key hashing. If deduplicating billions of rows, ensure the cluster is sized adequately and consider partitioning by date before running deduplication.
2. Comprehensive Null Handling & Imputation
Missing or null values can distort statistical calculations, break join conditions, and cause unexpected null-propagation in scalar expressions.
SQL Null Evaluation & Coalescing Functions
| Function | Syntax | Operational Description |
|---|---|---|
COALESCE | COALESCE(val1, val2, ..., valN) | Returns the first non-null expression in the argument list. Returns NULL if all arguments are null. |
NVL / IFNULL | NVL(expr1, expr2) | Evaluates expr1; if null, returns expr2 (standard 2-argument null replacement). |
NVL2 | NVL2(expr1, expr2, expr3) | If expr1 is NOT null, returns expr2; if expr1 is null, returns expr3. |
NANVL | NANVL(expr1, expr2) | Floating-point null/NaN check: returns expr1 if not NaN; returns expr2 if expr1 is NaN. |
NULLIF | NULLIF(expr1, expr2) | Returns NULL if expr1 = expr2; otherwise returns expr1. Ideal for converting empty strings '' to NULL. |
-- SQL Null Sanitization Pipeline
SELECT
customer_id,
COALESCE(work_phone, mobile_phone, home_phone, 'UNAVAILABLE') AS primary_phone,
NVL(discount_percentage, 0.0) AS applied_discount,
NVL2(shipping_address, 'SHIPPABLE', 'DIGITAL_ONLY') AS fulfillment_mode,
NULLIF(TRIM(middle_name), '') AS sanitized_middle_name
FROM silver.crm.customer_profiles;
PySpark DataFrame Null Utilities (DataFrame.na)
PySpark provides a dedicated .na sub-module for high-level DataFrame null operations:
# 1. Fill null values across specific columns
df_clean = df.na.fill({
"discount_percentage": 0.0,
"preferred_language": "en-US",
"is_active": True
})
# 2. Drop rows containing nulls
# how='any': drops row if ANY column in subset is null
# how='all': drops row only if ALL columns in subset are null
df_filtered = df.na.drop(how="any", subset=["order_id", "customer_id", "order_amount"])
# 3. Replace sentinel values (e.g., 'N/A', 'UNKNOWN') with NULL
df_standardized = df.na.replace({"N/A": None, "UNKNOWN": None, "NULL": None}, subset=["postal_code"])
3. Safe Type Casting with TRY_CAST
In standard Spark SQL and PySpark, invoking CAST(col AS type) on invalid data causes fatal runtime exceptions in ANSI SQL mode (which is enabled by default in modern Databricks runtimes):
-- Throws SparkException / NumberFormatException in ANSI mode:
SELECT CAST('INVALID_NUMBER' AS INT);
The TRY_CAST / try_cast() Paradigm
To prevent malformed source strings from crashing production batch and streaming jobs, data engineers must use try_cast() (or TRY_CAST). When try_cast() encounters an unparseable value or invalid format, it returns NULL instead of raising a runtime exception.
-- Safe evaluation: Returns NULL without failing the query
SELECT
raw_id,
TRY_CAST(raw_id AS BIGINT) AS parsed_id,
TRY_CAST(event_date_str AS DATE) AS parsed_event_date,
TRY_CAST(amount_str AS DECIMAL(12,2)) AS parsed_amount
FROM bronze.raw_events;
# PySpark: Safe casting using expr / try_cast
from pyspark.sql.functions import expr
df_safe = df.select(
expr("try_cast(transaction_id as BIGINT) as transaction_id"),
expr("try_cast(order_date as DATE) as order_date"),
expr("try_cast(revenue as DECIMAL(10,2)) as revenue")
)
Error Quarantine Pattern with TRY_CAST
By combining try_cast() with conditional filtering, you can quarantine invalid records for offline triage while allowing valid records to proceed downstream:
-- Identify and quarantine records that failed type casting
CREATE OR REPLACE TABLE silver.quarantine.invalid_cast_records AS
SELECT
raw_record_id,
raw_payload,
'AMOUNT_PARSE_FAILURE' AS quarantine_reason,
current_timestamp() AS quarantine_timestamp
FROM bronze.raw_events
WHERE amount_str IS NOT NULL
AND TRY_CAST(amount_str AS DECIMAL(12,2)) IS NULL;
4. Corrupt Record Handling & File Parser Modes
When reading unstructured or semi-structured files (JSON, CSV) via Spark DataFrame readers, corrupted rows (syntax errors, mismatched quotes, truncated records) can disrupt parsing.
Spark Parser Modes
SPARK FILE PARSER MODES
+-------------------------------------------------------------------------+
| 1. PERMISSIVE (Default) |
| - Sets corrupted fields to NULL. |
| - Captures raw bad record in `_corrupt_record` column. |
+-------------------------------------------------------------------------+
| 2. DROPMALFORMED |
| - Silently drops corrupted records entirely from output DataFrame. |
| - No error thrown; bad rows vanish without audit trail. |
+-------------------------------------------------------------------------+
| 3. FAILFAST |
| - Immediately aborts execution upon encountering the first bad row. |
| - Throws RuntimeException. |
+-------------------------------------------------------------------------+
Capturing Corrupt Records with _corrupt_record
To use the PERMISSIVE mode effectively with quarantine tracking, declare a dedicated _corrupt_record column in the explicit reading schema:
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType
schema = StructType([
StructField("order_id", IntegerType(), True),
StructField("customer_id", IntegerType(), True),
StructField("amount", DoubleType(), True),
StructField("_corrupt_record", StringType(), True) # Special quarantine column
])
df_json = (spark.read
.option("mode", "PERMISSIVE")
.option("columnNameOfCorruptRecord", "_corrupt_record")
.schema(schema)
.json("abfss://raw@adlsgen2.dfs.core.windows.net/incoming_orders/"))
# Split into clean and corrupted DataFrames
df_clean = df_json.filter(col("_corrupt_record").isNull()).drop("_corrupt_record")
df_corrupt = df_json.filter(col("_corrupt_record").isNotNull())
5. String Manipulation, Regex Parsing, & Standardization
Cleansing raw attributes often requires regular expression pattern extraction and text normalization.
-- SQL Regular Expression and String Cleansing
SELECT
-- Extract 10-digit phone number digits from messy strings
REGEXP_REPLACE(raw_phone, '[^0-9]', '') AS clean_phone_digits,
-- Extract domain from email
REGEXP_EXTRACT(email_address, '@([a-zA-Z0-9.-]+)', 1) AS email_domain,
-- Standardize state codes
UPPER(TRIM(state_province)) AS normalized_state,
-- Mask credit card numbers leaving last 4 digits
CONCAT('XXXX-XXXX-XXXX-', SUBSTRING(REGEXP_REPLACE(cc_num, '[^0-9]', ''), -4)) AS masked_cc
FROM bronze.customer_feed;
# PySpark: Regex and string extraction
from pyspark.sql.functions import col, regexp_replace, regexp_extract, upper, trim
df_curated = (df
.withColumn("clean_phone", regexp_replace(col("raw_phone"), r"[^0-9]", ""))
.withColumn("email_domain", regexp_extract(col("email"), r"@([a-zA-Z0-9.-]+)", 1))
.withColumn("state", upper(trim(col("state"))))
)
A production PySpark pipeline ingests dirty customer data where the 'account_balance' string column contains non-numeric characters (such as '$1,240.50' or 'PENDING'). When using standard CAST(account_balance AS DECIMAL(10,2)), the Spark job terminates with a NumberFormatException under ANSI mode. How should the data engineer rewrite this query to ensure the job processes without failing and safely converts invalid values to NULL?
A data engineer is configuring a JSON ingestion reader to process partner feeds. The pipeline must not crash when encountering malformed JSON lines, and it must isolate the exact malformed input strings into a dead-letter quarantine table for auditing. Which configuration pattern meets these requirements?
An ingestion stream receives duplicate order status updates with the same 'order_id'. Business rules dictate that the pipeline must retain strictly the single record with the highest 'event_timestamp' for each 'order_id'. Why is using df.dropDuplicates(['order_id']) insufficient for this requirement, and what is the correct solution?