Read loose, cast strict
Hint volatile semi-structured columns as STRING, then cast downstream. A mismatched hint doesn't convert the value — it quarantines the field.
Run the diagnostic →A bronze Auto Loader stream lands rows with response_time NULL — but the value is sitting right there in the JSON. The cause: cloudFiles.schemaHints tells the reader what type to expect, not what to convert. When a file disagrees, Auto Loader quarantines the field in _rescued_data and leaves the target column NULL. The tactic: read loose, cast strict — and audit the rescued column.
Built for engineers who need an answer they can test in the next notebook, job run, or incident review.
Hint volatile semi-structured columns as STRING, then cast downstream. A mismatched hint doesn't convert the value — it quarantines the field.
Run the diagnostic →A rescue-mode Auto Loader stream plus the _rescued_data audit query: schema drift becomes a daily checklist instead of a silent NULL.
See the pattern →Three reasons a field lands in _rescued_data — missing from schema, type mismatch, case mismatch — and which evolution mode fits your bronze layer.
Get the answer →Last verified against Databricks docs: September 27, 2026. Corrections: none yet — this is a new issue.
The NULL is not a data-quality bug — it is Auto Loader doing exactly what you told it to do. A schema hint is a read expectation: "I expect this column to be a LONG, read it as one." When the file hands over the JSON string "450" instead of the number 450, there is nothing to coerce — a string is not a number — so Auto Loader refuses to guess. Data fidelity wins: the field is quarantined in _rescued_data and the target column lands NULL. Your downstream cast never even gets a chance, because the rescue happens at read time.
Reproducible example — illustrative. Two files land in the raw zone:
from pyspark.sql.functions import col
# Illustrative files in /Volumes/catalog/schema/vol/raw/orders/:
# file-a.json: {"order_id": 101, "response_time": "450", "region": "eu-west"}
# file-b.json: {"order_id": 102, "response_time": 512, "region": "eu-west"}
trap = (
spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.schemaLocation",
"/Volumes/catalog/schema/vol/checkpoints/orders/_schema")
.option("cloudFiles.schemaHints",
"response_time LONG, order_id LONG, region STRING")
.option("cloudFiles.schemaEvolutionMode", "rescue")
.option("rescuedDataColumn", "_rescued_data")
.load("/Volumes/catalog/schema/vol/raw/orders/")
)
# Illustrative result:
# order 101 → response_time NULL, "450" quarantined in _rescued_data
# order 102 → response_time 512
# The hint did not convert "450". The hint is why the column is NULL.
The fix inverts the assumption: accept the loosest type at read time, and convert under your own control afterwards.
from pyspark.sql.functions import col
# Read loose: hint the volatile field as STRING so mixed types
# ("450" and 450) both parse without rescue.
bronze = (
spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.schemaLocation",
"/Volumes/catalog/schema/vol/checkpoints/orders/_schema")
.option("cloudFiles.schemaHints",
"response_time STRING, order_id LONG, region STRING")
.option("cloudFiles.schemaEvolutionMode", "rescue")
.option("rescuedDataColumn", "_rescued_data")
.load("/Volumes/catalog/schema/vol/raw/orders/")
)
# Cast strict: convert downstream, where a failed cast is YOUR
# error to catch — not a silent quarantine.
clean = bronze.withColumn("response_time_ms", col("response_time").cast("long"))
SELECT count(*) FROM catalog.bronze.orders WHERE _rescued_data IS NOT NULL. The rescued volume for the volatile field should drop to your baseline. If it doesn't, the mismatch is a different field — inspect the blob, which names the source file for every rescued row.One related knob, used carefully: cloudFiles.schemaEvolutionMode = "addNewColumnsWithTypeWidening" (Public Preview in Databricks Runtime 16.4+) widens supported type changes automatically — int to long, for example — without a rewrite. Unsupported changes, like int to string, are still rescued. It handles drift in width, not drift in kind; the mixed-type trap still needs the loose-read pattern.
Recommended learning: Databricks docs: Configure schema inference and evolution in Auto Loader
Illustrative scenario: a team streams partner order JSON into a Delta bronze table — append mode, Auto Loader, checkpoint on a Unity Catalog volume. Overnight, the partner ships an SDK update that starts quoting numeric fields as strings. Nothing fails. The stream stays green, the job is healthy — but response_time is NULL on every new row, an illustrative ~40% of the table by morning. The dashboards go blank. The on-call opens the source files: the values are right there. Nobody suspects the ingest layer, because the ingest layer never complained. It didn't complain — it rescued.
Auto Loader's four schema-evolution modes are four different answers to "what should happen when the data changes shape." Picking one deliberately is the whole pattern:
# The decision table (verified against Databricks docs):
#
# addNewColumns (default when you DON'T pin a schema)
# New column → stream STOPS with UnknownFieldException.
# Schema location is updated; restart resumes with the
# new schema. Existing column types NEVER evolve.
# This is a signal, not a bug — run on Lakeflow Jobs
# so the restart is automatic.
#
# rescue Stream never fails on schema changes; schema never
# evolves. Every new column lands in _rescued_data.
# Best for a bronze layer you want always-on.
#
# failOnNewColumns
# Stream fails and stays down until you update the
# provided schema or remove the offending file.
# Use when a new column must be a human decision.
#
# none Silent. New columns are ignored — not even rescued
# unless rescuedDataColumn is set. The most dangerous
# option, and the DEFAULT when you pin a schema.
bronze = (
spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.schemaLocation",
"/Volumes/catalog/schema/vol/checkpoints/orders/_schema")
.option("cloudFiles.schemaEvolutionMode", "rescue")
.option("rescuedDataColumn", "_rescued_data")
.load("/Volumes/catalog/schema/vol/raw/orders/")
)
Two defaults to memorize: addNewColumns is the default when you let Auto Loader infer the schema; none is the default when you pin one. The second default is the one that burns teams — pinning a schema for safety and silently dropping every new column.
rescue mode only works as a strategy if someone reads the rescue column. The audit query is the saveable takeaway of this issue — run it after every load, or schedule it as a data-quality check on the bronze table:
-- SAVE THIS: the daily rescued-data audit.
-- The blob carries the rescued columns AND the source file path,
-- so each row names its culprit file.
SELECT _rescued_data
FROM catalog.bronze.orders
WHERE _rescued_data IS NOT NULL;
Every hit is actionable: the blob tells you which field was rescued and which file carried it. A new field appearing here weekly is schema drift you now see; a spike after a partner deploy is a regression you can name in the incident review.
Two more ways a value ends up rescued without an error. First, addNewColumnsWithTypeWidening (Public Preview, DBR 16.4+) widens supported changes — int to long — but still rescues unsupported ones like int to string. Second, case: when the rescued column is enabled, a field named Response_Time against a response_time schema is rescued as a case mismatch. Schema hints can enforce the casing you want, or set readerCaseSensitive to false to read case-insensitively.
Use this when: ingesting JSON or CSV from upstream teams, vendors, or partners whose schemas you don't control; fields with mixed or drifting types; a bronze layer that must stay up while drift is triaged.
Avoid this when: reading Parquet or Avro with encoded schemas — inference merges typed schemas and widens automatically, so the loose-read pattern buys little; or when you pin a full explicit schema and want strict failure on drift — then use failOnNewColumns, not rescue.
cloudFiles.schemaLocation — without it there is no inference tracking and no evolution.none by accident when you pin a schema.UnknownFieldException under addNewColumns as a deploy signal: the schema evolved, the restart picks it up.Recommended learning: Data engineering workflows — pipelines, orchestration, and streaming practice
The value exists in the file. The column is NULL anyway. Here's the checklist — start with the rescued column.
Check _rescued_data before anything else. A field lands there for exactly three reasons: it isn't in the schema, its type mismatches, or its case mismatches. The blob holds the rescued columns and the source file path — the culprit file is named for you. If the value is in the blob, the data was never missing; it was quarantined at read time.
A schema hint is a read expectation, not a cast. Hint says LONG, file says "450" → the field is rescued and the column is NULL. This is the mixed-type trap from the tip above: read loose (hint the field as STRING so both shapes parse), cast strict (convert downstream where a failed cast is your error to catch).
Inference locked on the first sample. Auto Loader samples the first 50 GB or 1,000 files and stores the schema in _schemas at your schema location. A rare field mistyped in the first sample — a struct inferred as a string, a double inferred as a string — stays mistyped; later files carrying the real shape get rescued. If the locked schema is wrong, repointing cloudFiles.schemaLocation resets it — a deliberate reset of the stream's memory, not a tweak.
Recommended learning: Databricks docs: Configure schema inference and evolution in Auto Loader
Applies to: Databricks Runtime 15.x–17.x LTS; Auto Loader (cloudFiles) over JSON/CSV sources on cloud storage; Delta Lake bronze sinks in append output mode. The scenario and all figures in it are illustrative. Last verified September 27, 2026 against official Databricks documentation. Corrections: none yet — this is a new issue.
Issues, newest first.
Contact: sbogale@sb9tech.com
Lakehouse Digest is a short, practical newsletter for Databricks engineers, published by the Lakehouse Digest team. Each issue delivers one tactic to try, one production pattern to understand, and one hard developer question answered with code and primary-source links. It is an independent publication and is not affiliated with Databricks, Inc.
Topic-led learning paths for readers who want to turn this issue’s tactics into repeatable Databricks practice.
Strengthen the platform basics behind notebooks, jobs, data workflows, and collaborative development.
Browse DataCamp Databricks courses →The official guide behind this issue: schema hints, the four evolution modes, and the rescued-data column.
Read the Databricks docs →Pipelines, orchestration, and streaming practice that turn this issue's tactics into repeatable habits.
Browse DataCamp data engineering →Partner links: we may earn a commission if you enroll through these recommendations, at no extra cost to you.
A technical audience of Databricks engineers, reading implementation notes they can test the same day.
Launch inventory is one clearly labeled, problem-first placement per issue — built for products that help data teams build, govern, observe, or ship more reliably.
Sponsor Lakehouse Digest →