Cleaning, Joining and Reshaping Data with PySpark and SQL (Databricks Data Engineer Associate)
Cleaning, joining and reshaping are the silver-layer transformations a Databricks data engineer applies with PySpark or SQL to turn raw bronze tables into clean, typed, well-structured silver tables. This lesson covers reading bronze tables and writing silver tables safely, handling nulls and standardizing data types, combining data with inner, left, broadcast, multi-key and cross joins and with unions, and reshaping columns and rows by adding, dropping, renaming and splitting columns, filtering rows and exploding arrays.
On this page10 sections
- Reading bronze tables and writing silver tables
- Cleaning nulls with dropna and fillna
- Standardizing data types
- Inner, left and other join types
- Joining on multiple keys and null-safe equality
- Broadcast joins and cross joins
- UNION, UNION ALL, union and unionByName
- Adding, dropping, renaming and splitting columns
- Filtering rows and null semantics
- Exploding arrays and flattening structs
- Read a bronze table by its Unity Catalog name and write a rerunnable silver table with saveAsTable or CREATE OR REPLACE TABLE AS SELECT, including schema changes
- Clean nulls with scoped dropna and fillna calls, and standardize types with cast, to_date patterns, try_cast and DECIMAL
- Choose between inner, left, semi, anti and cross joins, and predict row multiplication and the effect of filters in ON versus WHERE
- Join on multiple keys with a list or an expression, and use null-safe equality where null is a key value
- Use a broadcast hint for a large-to-small join and know when broadcasting is wrong
- Stack DataFrames with UNION, UNION ALL, union and unionByName without misaligning columns
- Add, drop, rename and split columns, filter rows with correct null semantics, and explode arrays and arrays of structs
Reading bronze tables and writing silver tables
A bronze-to-silver step reads the raw table by its Unity Catalog name, cleans it in PySpark or SQL, and writes the result to a new silver Delta table with a write that is safe to rerun. Read a governed table with spark.read.table("catalog.schema.table") (or SELECT ... FROM catalog.schema.table): it returns a batch DataFrame of the table's latest version, and you never need the managed storage path. Path-based readers such as spark.read.load(path) or spark.read.parquet(path) expect a file location, not a table name, and reading a Delta table's Parquet files directly bypasses its transaction log, so the read can include files that were removed by later commits or never committed, and does not reliably match the table's current version. spark.readStream.table(...) turns the read into an incremental streaming query, which suits incremental pipelines rather than a full nightly rebuild.
raw = spark.read.table("main.bronze.shipments")
clean = (raw
.dropna(subset=["shipment_id"])
.withColumn("freight_cost", col("freight_cost").cast("decimal(18,2)")))
clean.write.mode("overwrite").saveAsTable("main.silver.shipments")
The save mode decides what a rerun does, which matters because jobs are retried:
| Write | When the table exists | Effect of running it again |
|---|---|---|
mode("overwrite") | Replaces all rows in one Delta commit | Idempotent: the table holds one copy of the output |
mode("append") | Adds rows | Each run adds another copy |
mode("ignore") | Does nothing | Content frozen at the first run |
mode("errorifexists") (default) | Fails | Only the first run succeeds |
In SQL, CREATE OR REPLACE TABLE main.silver.products AS SELECT ... (CTAS) materializes the cleaned query into a Delta table and replaces its contents on each run. CREATE TABLE IF NOT EXISTS ... AS SELECT only creates it once, INSERT INTO appends, and CREATE OR REPLACE VIEW stores no data at all (views belong to the gold-layer lesson). Both mode("overwrite") and CREATE OR REPLACE TABLE write a new version of the same table, so its history stays available for DESCRIBE HISTORY and time travel; dropping and recreating the table loses that history.
When the cleaned schema changes
By default an overwrite replaces the data but keeps the table's schema, and Delta schema enforcement checks the new DataFrame against it. A column whose type changed (for example STRING to DECIMAL) or a column the table does not have makes the write fail. A column the cleaned DataFrame no longer outputs does not fail the write: it stays in the table and is filled with nulls. To change a column's type or actually remove a column, add .option("overwriteSchema", "true"), which replaces schema and data in one commit and keeps the history. mergeSchema is a different tool: it allows compatible evolution such as adding new columns during a write, not replacing a column's type.
Cleaning nulls with dropna and fillna
Clean nulls column by column: dropna removes rows that are missing values you cannot do without, and fillna replaces nulls with a default where a default is correct. Both act on every column unless you scope them, so the subset argument (or a dict) is what keeps a rule from touching columns it should not.
| Call | Effect |
|---|---|
df.dropna() / df.na.drop() | Drops a row if any column is null (how="any", the default) |
df.dropna(how="all") | Drops a row only if every considered column is null |
df.dropna(subset=["device_id"]) | Considers only the listed columns |
df.dropna(thresh=2) | Keeps rows with at least 2 non-null values among the considered columns (overrides how) |
df.fillna(0) | Fills nulls in every column whose type matches the value |
df.fillna(0, subset=["units"]) | Fills only the listed columns |
df.fillna({"units": 0, "channel": "unknown"}) | A different default per column; other columns untouched |
Two behaviours catch people out. First, fillna only touches columns whose type matches the value: a numeric value fills numeric columns and a string value fills string columns, and a non-matching column is silently skipped. A digits-only quantity stored as a string keeps its nulls after fillna(0); cast it to INT first, then fill. Second, order matters: filling every string column with a placeholder before dropna(subset=["device_id"]) hides the null identifiers, so the rows you meant to remove survive. Drop first, then fill only what should be filled.
df.na.replace(...) replaces existing values (for example the string '--'), not nulls. In SQL the same cleaning uses WHERE device_id IS NOT NULL and coalesce(channel, 'unknown') (or nvl).
Standardizing data types
Standardizing types means giving each silver column the type its meaning needs: dates as DATE, timestamps as TIMESTAMP, counts as INT or BIGINT, and money as an exact DECIMAL(precision, scale). Bronze data loaded from CSV or JSON often holds all of these as strings, so the silver step converts them explicitly with cast (PySpark col("x").cast("int"), SQL CAST(x AS INT)) and the parsing functions.
- Dates and timestamps in a non-ISO format need a pattern:
to_date(col("ship_date"), "dd.MM.yyyy")for '15.03.2026',to_timestamp(col, pattern)for timestamps. In patterns,MMis the month andmmis minutes;ddis the day. A plain cast to DATE only understands ISO-style strings such as '2026-03-15'.date_formatgoes the other way: it turns a date into a formatted string. - Money belongs in DECIMAL. DOUBLE and FLOAT are approximate binary types, so cent values drift when summed. Choose a precision large enough for the biggest value:
DECIMAL(5,2)tops out at 999.99, whileDECIMAL(18,2)holds large totals exactly. - Text is standardized with
trim,lower/upperandregexp_replacebefore comparisons or joins depend on it.
Malformed values: CAST versus try_cast
What happens to a value that cannot be converted, such as 'unknown' cast to DECIMAL, depends on ANSI mode. With ANSI mode on (the default on Databricks SQL warehouses; on clusters it follows the spark.sql.ansi.enabled setting, whose default depends on the Databricks Runtime version), CAST raises an error and the whole statement fails. With it off, CAST returns NULL. try_cast(unit_price AS DECIMAL(10,2)) returns NULL for malformed values in either mode, so the statement succeeds, valid values convert, and the bad rows stay in the table with a null you can review. Wrapping a failing CAST in coalesce does not help, because the error is raised before coalesce sees a value.
Inner, left and other join types
A join pairs each row on one side with every matching row on the other, and the join type decides what happens to rows that have no match. An inner join keeps only matched pairs; a left (left outer) join keeps every row of the left side and fills the right side's columns with nulls where nothing matches.
Type (PySpark how) | Rows kept | Columns returned |
|---|---|---|
"inner" (default) | Matched pairs only | Both sides |
"left" / "left_outer" | Every left row; unmatched get null right columns | Both sides |
"right" | Every right row | Both sides |
"full" / "full_outer" | Every row of both sides | Both sides |
"left_semi" | Left rows that have a match (each once) | Left side only |
"left_anti" | Left rows that have no match | Left side only |
The table you must not lose rows from is the preserved side: invoices.join(suppliers, "supplier_id", "left") keeps every invoice even when its supplier record has not arrived yet.
Row multiplication
Joins never deduplicate. If the right side has several rows for a key (a price history, or one row per sales channel), every left row with that key appears once per match, so a one-row-per-invoice table grows after the join. Reduce the right side to one row per key before joining, for example prices.filter("is_latest"). Deduplicating on the left key after the join is not equivalent: dropDuplicates keeps an arbitrary one of the matched rows, so the surviving right-side value is not necessarily the current one.
Filters on the right side: ON versus WHERE
Where you put a condition on the right side's columns changes an outer join's result. In the ON clause it limits which right rows can match, and the left join still keeps every left row. In WHERE it runs after the join: unmatched rows have null right columns, the comparison evaluates to null, and the row is dropped, so the left join silently behaves like an inner join. The same applies in PySpark: invoices.join(prices.filter("is_latest"), "product_id", "left") keeps every invoice, while filtering on is_latest after the left join removes invoices with no price row.
SELECT s.shipment_id, c.carrier_name
FROM silver.shipments s
LEFT JOIN silver.carriers c
ON s.carrier_id = c.carrier_id AND c.active = true -- every shipment keptJoining on multiple keys and null-safe equality
A join on a composite key needs every key part to match, combined with AND. How you write it depends on whether the key columns have the same names on both sides.
- Same names: pass a list,
inventory.join(products, ["product_id", "site"], "inner")(SQL:USING (product_id, site)). The keys are combined with AND and the result keeps one copy of each key column, so selectingproduct_idafterwards is not ambiguous. - Different names: write an expression, one equality per key pair, joined with
&:(shipments.wh_code == warehouses.warehouse_code) & (shipments.country == warehouses.country). Both sides' columns stay in the result, so drop or alias the duplicates you do not need, or later references to a shared name such ascountryare ambiguous.
In PySpark, combine Column conditions with &, | and ~, and wrap each comparison in parentheses (& binds tighter than ==). Python's and/or keywords do not work on Columns: they raise a "Cannot convert column into bool" error, and "product_id" and "site" on plain strings simply evaluates to one string. Using | instead of & matches rows that share either key, and leaving out a key part lets IDs that repeat across sites match the wrong row.
Null key parts
In SQL logic, NULL = NULL is null, not true, so rows whose key contains a null never match in an equi-join. When null is a meaningful key value (for example a top-level region with no parent region, present in both tables), use null-safe equality on the column that can be null: r.parent_id.eqNullSafe(t.parent_id) in PySpark, <=> in SQL. It returns true for two nulls and false for a null versus a value. Adding OR right_key IS NULL instead is wrong: it matches the null row to every row on the other side.
Broadcast joins and cross joins
A broadcast join copies a small table to every executor so that the large table can be joined where it sits, without shuffling it across the cluster. Without it, a join of two large inputs is usually a sort-merge join, which shuffles both sides by the key; for a multi-terabyte fact table joined to a small dimension, that shuffle is most of the cost.
- PySpark: wrap the small side,
clicks.join(broadcast(campaigns), "campaign_id"), withbroadcastimported frompyspark.sql.functions. - SQL: add a hint naming the small relation or its alias:
SELECT /*+ BROADCAST(d) */ ... FROM events e JOIN devices d ON e.device_id = d.device_id.
A hint is part of the code or query text, so it needs no session or cluster setting change. Spark also broadcasts automatically when a side is smaller than spark.sql.autoBroadcastJoinThreshold; tuning that setting is covered in the lesson on deduplication, aggregation and basic Spark tuning. Two rules decide whether a broadcast is right:
| Situation | Broadcast? |
|---|---|
| One side small enough to copy to every executor | Yes, the small side |
| The side that is large | No: copying it everywhere risks out-of-memory errors |
| Neither side small | No: the copy must fit in memory on every executor, and Spark has a hard limit on broadcast size |
| The preserved side of an outer join | No: in a left outer join only the right side can be broadcast |
Other join hints exist, such as MERGE (sort-merge) and SHUFFLE_HASH, but both shuffle the data by the key, so they do not remove the shuffle of the large table. Repartitioning the large side by the key also shuffles it.
Cross joins
A cross join (df1.crossJoin(df2), SQL CROSS JOIN) returns the cartesian product: every row of one side paired with every row of the other, so the output has rows(left) x rows(right) rows. It is the right tool in two common cases:
- A scaffold of all combinations, such as every product for every week. Left joining the facts to that scaffold on the full key (
["product_id", "week_start"]), with the scaffold as the preserved side, thenfillna(0, subset=["units_sold"]), produces rows for weeks with no sales. - Attaching a one-row result (an overall average or a run timestamp) to every row. Crossing with a single-row DataFrame multiplies the row count by one. You cannot do this with
withColumn("x", other_df["x"]), because a Column can only be used inside its own DataFrame.
A cross join followed by an equality filter is not a performance trick: the optimizer plans it as the same equi-join. Unions are not a substitute either; they stack rows rather than pairing them.
UNION, UNION ALL, union and unionByName
A union stacks the rows of two inputs with the same columns. In SQL, UNION removes duplicate rows (it means UNION DISTINCT) and UNION ALL keeps every row, which is also cheaper because no deduplication step runs. When identical rows are legitimate separate records, or the inputs cannot overlap, use UNION ALL. INTERSECT and EXCEPT compare sets; they do not append.
The PySpark methods differ from SQL in two ways:
| Method | Duplicates | Columns matched by |
|---|---|---|
df1.union(df2) / unionAll (alias) | Kept, like SQL UNION ALL | Position |
df1.unionByName(df2) | Kept | Name; both sides need the same column set |
df1.unionByName(df2, allowMissingColumns=True) | Kept | Name; a column missing on one side is filled with null |
Any of the above + .distinct() | Removed, like SQL UNION | As above |
Position-based matching is the trap: if two DataFrames hold the same columns in a different order, union puts one side's values under the other side's column names, and when the types are compatible nothing fails. Appending a missing column with withColumn places it last, which does not line it up with a column that sits in the middle of the other DataFrame. unionByName resolves columns by name, and allowMissingColumns=True handles a feed that gained or lost a column. Adding distinct() never fixes alignment; it only removes duplicates.
Adding, dropping, renaming and splitting columns
Column changes in a DataFrame use four methods: withColumn(name, expr) adds a column or replaces one with the same name, drop(name) removes it, withColumnRenamed(existing, new) renames it, and select (with alias) chooses and renames columns in one step. In SQL the same thing happens in the SELECT list (prod_desc AS product_description).
silver = (df
.withColumnRenamed("prod_desc", "product_description") # existing name first
.drop("src_file")
.withColumn("load_date", current_date())) # DATE; current_timestamp() is a TIMESTAMP
Missing names are silent no-ops. withColumnRenamed returns the DataFrame unchanged when the existing name is not in the schema (including when the two arguments are reversed), and drop ignores a name that does not exist. After an upstream rename, the cleaning job still succeeds and the problem surfaces only downstream. Selecting the expected columns explicitly, or checking df.columns, makes such drift fail early. Copying a column with withColumn("new", col("old")) is not a rename: the old column stays.
Renaming or dropping a column in a Delta table
A DataFrame rename changes only that DataFrame, not the stored table. To change the table itself use ALTER TABLE ... RENAME COLUMN or ALTER TABLE ... DROP COLUMN (add columns with ALTER TABLE ... ADD COLUMNS). Delta Lake allows RENAME and DROP COLUMN only when column mapping is enabled. On an existing table, enable it with ALTER TABLE t SET TBLPROPERTIES ('delta.columnMapping.mode' = 'name'); after that, rename and drop change metadata only, with no rewrite of the data files. The other mode, 'id', must be set when the table is created. Enabling column mapping upgrades the table's Delta protocol, so very old readers can no longer read it. Rebuilding the table with CTAS also renames a column, but it rewrites all of the data.
Splitting a column
split(col, pattern, limit) turns a string into an array. Read elements with getItem(i) (or arr[i]), which is zero-based, or with element_at(arr, n), which is one-based (index 0 is an error; negative indexes count from the end). Reading past the end returns null with ANSI mode off and raises an error with it on; try_element_at returns null in either mode.
- The pattern is a regular expression. Characters such as
|,.and$have special meaning: an unescaped|is an empty alternation that matches between every character, so the result is single characters. Use a character class ("[|]") or escape it with a backslash. A forward slash is not an escape. - The limit caps the pieces. With a positive limit, the pattern is applied at most limit - 1 times and the last element holds the rest of the string, so
split(col("file_name"), "_", 3)cuts at the first two underscores and leaves any further underscores inside the third element. A limit of zero or less (the default is -1) splits as many times as possible; a limit of 1 does not split. - In SQL,
split_part(str, delimiter, n)takes a literal delimiter and a one-based part number.
To turn array elements into separate columns, use getItem; to turn them into separate rows, use explode.
Filtering rows and null semantics
Filter rows with df.filter(condition) or its alias df.where(condition), using a Column expression or a SQL string; a row is kept only when the condition is true. Because comparisons with null evaluate to null (SQL three-valued logic), a row whose column is null is dropped by both tier != 'trial' and tier = 'trial', and by their negations.
| Condition (tier is null) | Result | Row kept? |
|---|---|---|
col("tier") != "trial" | null | No |
~(col("tier") == "trial") / NOT (tier = 'trial') | null | No |
~col("tier").eqNullSafe("trial") / NOT (tier <=> 'trial') | true | Yes |
(col("tier") != "trial") | col("tier").isNull() | true | Yes |
Other filtering rules worth knowing:
between(lower, upper)is inclusive at both ends, so the first and last day of a month bound a whole calendar month of DATE values. Strict>and<drop the boundary days.- Combine conditions with
&and|, each comparison in parentheses; Python'sandraises an error on Columns. - NOT IN with a null in the list returns no rows.
x NOT IN (v1, v2, NULL)expands tox <> v1 AND x <> v2 AND x <> NULL; the last comparison is unknown, so the predicate is never true. Deduplicating the list does not remove the null.NOT EXISTS (SELECT 1 ... WHERE b.key = o.key)or aleft_antijoin asks only whether a match exists and gives the expected result.
Exploding arrays and flattening structs
explode turns an array column into rows: one output row per element, with the other selected columns repeated. Four generator functions cover the combinations of "do I need the element's position?" and "must rows with a null or empty array survive?":
| Function | Output columns | Null or empty array |
|---|---|---|
explode | col | Row is dropped |
explode_outer | col | One row with a null element |
posexplode | pos, col (zero-based position) | Row is dropped |
posexplode_outer | pos, col | One row with null position and element |
The non-outer functions emit nothing for an empty array as well as for a null one, so replacing nulls with an empty array does not keep those rows; only the _outer variants do. On a map column, explode returns key and value columns.
Arrays of structs
An array of structs (for example lines with fields product_code, units and unit_price) is flattened in two steps: explode the array into one struct per row, then expand the struct's fields with .*, keeping the parent key so each line stays linked to its invoice.
(invoices
.select("invoice_id", explode("lines").alias("ln"))
.select("invoice_id", "ln.*")) # invoice_id, product_code, units, unit_price
Star expansion works on a struct, not on an array, so select("lines.*") fails. In SQL, inline(lines) explodes an array of structs straight into columns.
Several arrays at once
Spark allows only one generator per select, and exploding two arrays one after the other produces every combination (a cartesian product per row). When elements at the same position belong together, combine the arrays first: explode(arrays_zip("labels", "scores")) yields one struct per position, whose fields are named after the input columns. concat joins arrays end to end, which loses the pairing.
Tip. This part of Section 3 is tested with short scenarios that describe a bronze or silver dataset, a requirement and a constraint, then offer four PySpark or SQL snippets that differ in one detail: a dropna or fillna that is or is not scoped, a date pattern, CAST versus try_cast, the join type or the preserved side, a filter in ON versus WHERE, a list versus an expression for join keys, which side gets the broadcast hint, union versus unionByName, a split pattern or limit, or which explode variant to use. Some items show a symptom instead, such as extra rows after a join, values under the wrong column, an empty result or a job that succeeds while a downstream query fails, and ask for the cause or the fix. Harder items combine two behaviours, for example row multiplication with an outer-join filter, or positions with null arrays.
- Read governed tables with spark.read.table("catalog.schema.table"); write full rebuilds with mode("overwrite") or CREATE OR REPLACE TABLE AS SELECT, which are rerun-safe and keep Delta history.
- An overwrite keeps the table's schema: a type change fails, a column you stop writing stays and fills with nulls, and overwriteSchema replaces the schema; mergeSchema only adds compatible changes.
- Scope dropna and fillna with subset or a dict; fillna skips columns whose type does not match the value, so cast before filling.
- Use to_date with an explicit pattern for non-ISO dates, DECIMAL for money, and try_cast when malformed values must become null instead of failing under ANSI mode.
- Joins multiply rows when a key repeats on the other side, and a right-side condition in WHERE turns a left join into an inner join; put it in ON or filter the right side first.
- Same-named keys go in a list (one copy of each key in the result); differently named keys need an & expression; eqNullSafe or <=> matches null to null.
- Broadcast the small side with broadcast() or /*+ BROADCAST(alias) */; never the large side, never two large tables.
- SQL UNION deduplicates and UNION ALL keeps every row; PySpark union keeps duplicates and matches by position, unionByName matches by name.
- withColumnRenamed and drop silently ignore missing names; renaming or dropping a Delta table column needs column mapping ('name' mode can be enabled on an existing table).
- split takes a regex and an optional limit; getItem is zero-based, element_at one-based; posexplode_outer keeps positions and rows with null or empty arrays.
Frequently asked questions
What is the difference between UNION and UNION ALL in Databricks SQL?
UNION combines two query results and removes duplicate rows, because it means UNION DISTINCT. UNION ALL combines them and keeps every row, which is faster because no deduplication runs. Use UNION ALL when identical rows are separate records or the inputs cannot overlap. In PySpark, DataFrame.union behaves like UNION ALL; add distinct() to get UNION behaviour.
Why does a left join return fewer rows than the left table?
A left join itself never drops left rows, so the loss comes from a filter applied after the join. A WHERE condition on a right-side column evaluates to null for unmatched rows, and filter keeps only rows where the condition is true, so those rows disappear and the join behaves like an inner join. Put the condition in the ON clause, or filter the right-side DataFrame before joining.
When should I use a broadcast join in PySpark?
Use a broadcast join when a large table is joined to a small one, such as a dimension that comfortably fits in each executor's memory (as a rough guide, megabytes rather than many gigabytes, not a fixed Databricks threshold), and the shuffle of the large table is the cost to remove. Wrap the small side in broadcast() from pyspark.sql.functions, or use the SQL hint /*+ BROADCAST(alias) */. Do not broadcast the large side or a table too big to fit in each executor's memory.
Why does fillna(0) not replace nulls in some columns?
fillna only fills columns whose data type matches the replacement value: a number fills numeric columns and a string fills string columns. A column holding numbers as strings is skipped silently when you pass 0. Cast the column to a numeric type first, then call fillna, scoped with subset so other columns keep their nulls.
How do I rename a column in a Delta table without rewriting the data?
Enable column mapping on the table with ALTER TABLE ... SET TBLPROPERTIES ('delta.columnMapping.mode' = 'name'), then run ALTER TABLE ... RENAME COLUMN old TO new. With column mapping on, renaming and dropping columns only change table metadata. Renaming the column in a DataFrame with withColumnRenamed does not change the stored table.
What is the difference between explode and explode_outer?
explode returns one row per array element and drops rows whose array is null or empty. explode_outer returns the same rows but keeps a row with a null element when the array is null or empty. posexplode and posexplode_outer behave the same way and also return each element's zero-based position.
Why does NOT IN return no rows when the subquery contains a null?
x NOT IN (list) is evaluated as x <> each value in the list, all combined with AND. A comparison with null is unknown, so the combined predicate can never be true and every row is filtered out. NOT EXISTS with an equality condition, or a left anti join, asks only whether a matching row exists and is not affected by the null.
Should I use CAST or try_cast when cleaning bronze data?
Use try_cast when some values may be malformed and the statement must not fail. With ANSI mode on, which is the default on Databricks SQL warehouses, CAST raises an error on a value such as 'unknown' and the whole query fails. try_cast returns null for that value and converts the rest, so the rows remain available for review.
Source
This lesson covers the "Data Transformation and Modeling" domain of the official Databricks DEA exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.