What you will be able to do
- Explain why bronze data has to be cleaned before it reaches silver tables, and how to read bronze data with PySpark or SQL
- Drop rows containing nulls with dropna, using how, thresh and subset
- Fill or replace missing values with fillna, coalesce and a CASE WHEN placeholder rewrite
- Send invalid records to a quarantine table with WHERE filters, as separate writes
Key concept
Silver layer as the cleaning boundary — Bronze tables keep source data raw and barely validated. All the cleanup work (null handling, type casting, quality filtering) happens on the way into silver: you read from bronze and write the cleaned result to a separate silver table.
1.What bronze hands you, and how to read it
Cleaning starts with knowing the state of the input. Very little validation happens in bronze. To avoid losing data when a source changes shape, Databricks recommends storing most fields as string, VARIANT, or binary. That means a silver job usually gets text where it needs numbers and dates, plus nulls and placeholder values the source system never resolved. Bronze also isn't meant for analysts. It exists to feed the workloads that build silver.
| Layer | What happens here | Typical state of the data |
|---|---|---|
| Bronze | Raw data ingestion | Minimal validation; most fields stored as string, VARIANT, or binary |
| Silver | Data cleaning and validation | Schema enforcement, handling of null and missing values, type casting, data quality checks |
The pattern is always read, then transform, then write: read from one or more bronze (or silver) tables, and write to silver tables. Databricks advises against writing to silver straight from ingestion, because schema changes and corrupt source records then break the silver load. If the bronze sources are append-only, make most bronze reads streaming reads, and keep batch reads for small datasets such as small dimension tables. In PySpark, spark.read.table loads a table as a DataFrame. In SQL, a plain SELECT ... FROM bronze_table inside an INSERT INTO silver_table statement does the same job, as the SQL examples later in this lesson show.
spark.read.table("tblA").sort("age").show()Checkpoint 1 of 5· Check yourself
A large bronze table is append-only and feeds a silver table. How does Databricks recommend reading it?
When sources are append-only, most bronze reads should be streaming reads. Batch reads are for small datasets such as dimension tables.
“Assuming all sources are append-only, configure most reads from bronze as streaming reads.”Source: docs.databricks.com
Sources1
2.Dropping rows with dropna
The bluntest fix is to remove incomplete rows. The medallion example does exactly that for customer and transaction data in silver, dropping nulls and quarantining invalid records. In PySpark, DataFrame.dropna and df.na.drop are aliases. Both return a new DataFrame without rows that contain null or NaN values. Here is the sample DataFrame the examples below use:
from pyspark.sql import Row
df = spark.createDataFrame([
Row(age=10, height=80.0, name="Alice"),
Row(age=5, height=float("nan"), name="Bob"),
Row(age=None, height=None, name="Tom"),
Row(age=None, height=float("nan"), name=None),
])| Parameter | Default | Effect |
|---|---|---|
| how | 'any' | 'any' drops a row containing any null; 'all' drops a row only if every value is null |
| thresh | None | Drops rows with fewer than thresh non-null values; overrides how |
| subset | None | Limits the check to the listed column names |
df.na.drop(thresh=2).show()
# +---+------+-----+
# |age|height| name|
# +---+------+-----+
# | 10| 80.0|Alice|
# | 5| NaN| Bob|
# +---+------+-----+Pay attention to the default. A bare df.na.drop() uses how='any', which leaves only Alice, because Bob's height is NaN. That is usually far more than you meant to drop. Use subset when only certain columns, such as keys, must be populated.
Checkpoint 2 of 5· Fill the gap
This call drops only the row whose values are all null or NaN. Which value completes it?
df.na.drop(how=' ? ').show()
# +----+------+-----+
# | age|height| name|
# +----+------+-----+
# | 10| 80.0|Alice|
# | 5| NaN| Bob|
# |NULL| NULL| Tom|
# +----+------+-----+how='all' drops a row only when every value is null, so only the fourth row goes. 'any' is the default and would keep only Alice.
Source: docs.databricks.com3.Filling and replacing missing values
Dropping a row throws away its good fields as well. Often a default value is the better choice. DataFrame.fillna and df.na.fill are aliases. They accept either one scalar or a dict that maps column names to replacement values. With a scalar, only columns of a matching type are filled. In the docs example, df.na.fill(50) fills the numeric age and height columns and leaves the string name null. With a dict, you set a value per column, and any subset you pass is ignored.
df.na.fill({'age': 50, 'name': 'unknown'}).show()
# +---+------+-------+----+
# |age|height| name|bool|
# +---+------+-------+----+
# | 10| 80.5| Alice|NULL|
# | 5| NULL| Bob|NULL|
# | 50| NULL| Tom|NULL|
# | 50| NULL|unknown|true|
# +---+------+-------+----+Checkpoint 3 of 5· Check yourself
In the docs example the columns are age (int), height (float), name (string) and bool (boolean). After df.na.fill(50), which columns can still contain nulls?
A numeric scalar only fills columns of a matching type. age and height are filled with 50, while the string column name and the boolean column bool keep their nulls.
“Columns specified in subset that do not have matching data types are ignored.”Source: docs.databricks.com
In SQL, coalesce returns its first non-null argument, which makes coalesce(col, default) the SQL counterpart of a fill. The result type is the least common type of the arguments. The arguments are evaluated left to right, and evaluation stops at the first non-null value.
coalesce(2, 5 / 0) returns 2 without error, because the second argument is never evaluated. coalesce(NULL, 5 / 0) raises DIVISION_BY_ZERO, because the first argument is null and evaluation moves on to the second.
The opposite problem also comes up: a value that should be null but isn't. If an upstream system can't encode NULL, it may write a placeholder such as -1. Instead of making every downstream query filter out -1, convert it back to NULL once, during the bronze-to-silver write:
INSERT INTO silver_table
SELECT
* EXCEPT weight,
CASE
WHEN weight = -1 THEN NULL
ELSE weight
END AS weight
FROM bronze_table;Checkpoint 4 of 5· Exam question
A bronze `orders` table has nulls in `email` (expected for guest checkouts) and nulls in `order_total` (caused only by an upstream parsing bug). Which approach correctly cleans this bronze table into a silver table while keeping valid guest rows?
Correct answer: A — Call `df.na.drop(subset=["order_total"])` so only rows missing the parsed total are dropped, leaving guest email nulls untouched
- A. Scoping `subset` to `order_total` drops exactly the rows broken by the parsing bug while leaving every other column's nulls, including valid guest emails, intact. This targeted drop matches the requirement that only genuinely bad rows are removed.
- B. The default `how='any'` behavior evaluates every column, so it would also discard legitimate guest-checkout rows just because `email` is null. That erases valid data the silver table is supposed to keep.
- C. Filling the parsing-bug rows with a placeholder total masks corrupted data instead of removing it, which lets a fabricated zero-dollar order total flow into downstream aggregates as if it were real.
- D. Deduplicating on `order_total` addresses repeated rows, not missing values, and it also risks collapsing distinct valid orders that happen to share a null total into a single row.
4.Quarantining invalid records instead of losing them
Some rows are invalid rather than incomplete, such as a negative quantity or a timestamp in the future. Use filters and WHERE clauses to keep them out of silver. Write the complementary predicate to a quarantine table, so the rows can still be inspected later. Each destination gets its own INSERT:
DECLARE current_time = now()
INSERT INTO silver_table
SELECT * FROM bronze_table
WHERE event_timestamp <= current_time AND quantity >= 0;
INSERT INTO quarantine_table
SELECT * FROM bronze_table
WHERE event_timestamp > current_time OR quantity < 0;The two writes are deliberately separate. Databricks recommends processing filtered data as a separate write operation, especially with Structured Streaming, because using .foreachBatch to write to several tables can produce inconsistent results. In PySpark the same split is a pair of filter calls, using column predicates such as isNotNull().
df.filter(df.name.isNotNull()).show()Checkpoint 5 of 5· Check yourself
A streaming bronze-to-silver job must send valid rows to silver and invalid rows to quarantine. What does Databricks recommend?
Each filtered output should be its own write. Writing to several tables from .foreachBatch can give inconsistent results.
“Databricks recommends always processing filtered data as a separate write operation, especially when using Structured Streaming.”Source: docs.databricks.com
Sources4
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.df.na.drop() with no arguments removes only rows that are entirely null.Why is that wrong?
The default is how='any', so a single null or NaN anywhere in a row drops it. Only how='all' limits the drop to fully null rows.
Covered in Dropping rows with dropna
2.Passing subset together with a dict to fillna restricts the fill to the subset columns.Why is that wrong?
When value is a dict, subset is ignored. The dict keys alone decide which columns are filled.
Covered in Filling and replacing missing values
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.
“Databricks recommends storing most fields as string, VARIANT, or binary to protect against unexpected schema changes.”
↩︎ What bronze hands you, and how to read it“To build the silver layer, read data from one or more bronze or silver tables, and write data to silver tables.”
↩︎ What bronze hands you, and how to read it“Is intended for consumption by workloads that enrich data for silver tables, not for access by analysts and data scientists.”
↩︎ What bronze hands you, and how to read it“Batch reads should be reserved for small datasets (for example, small dimensional tables).”
↩︎ What bronze hands you, and how to read it“Data about customers and transactions is cleaned by dropping nulls and quarantining invalid records.”
↩︎ Dropping rows with dropna“Data cleanup and validation are performed in silver layer.”
↩︎ Key concept“Assuming all sources are append-only, configure most reads from bronze as streaming reads.”
↩︎ Checkpoint - 2.
“Returns a new DataFrame omitting rows with null or NaN values.”
↩︎ Dropping rows with dropna“optional list of column names to consider.”
↩︎ Filling and replacing missing values“If 'any', drop a row if it contains any nulls. If 'all', drop a row only if all its values are null.”
↩︎ Exam trap 1“If specified, drop rows that have less than thresh non-null values. This overwrites the how parameter.”
↩︎ Prediction - 3.
“coalesce evaluates arguments left to right until a non-null value is found. If all arguments are NULL, the result is NULL.”
↩︎ Filling and replacing missing values - 4.
“the placeholder value -1 is used to represent missing data”
↩︎ Filling and replacing missing values“CASE WHEN ... OTHERWISE clauses allow you to define conditional logic to gracefully apply business logic”
↩︎ Filling and replacing missing values“You can use filters and WHERE clauses to define custom logic that quarantines bad records and prevents them from propagating to downstream tables.”
↩︎ Quarantining invalid records instead of losing them“Using .foreachBatch to write to multiple tables can lead to inconsistent results.”
↩︎ Quarantining invalid records instead of losing them“Databricks recommends always processing filtered data as a separate write operation, especially when using Structured Streaming.”
↩︎ Checkpoint - 5.
“Columns specified in subset that do not have matching data types are ignored.”
↩︎ Filling and replacing missing values“If the value is a dict, then subset is ignored and value must be a mapping from column name (string) to replacement value.”
↩︎ Exam trap 2