from pyspark.sql import functions as F
catalog = spark.sql("SELECT current_catalog()").first()[0]
schema = "dbx_course"
spark.sql(f"USE SCHEMA {catalog}.{schema}")
base = f"/Volumes/{catalog}/{schema}/raw"
landing = f"{base}/landing/orders"
checkpoints = f"{base}/_checkpoints"
def land_orders(batch, lo, hi, extra=None):
"""Write a batch of orders as JSON files into the landing zone."""
df = spark.table("orders").filter(F.col("order_id").between(lo, hi))
if extra:
df = df.withColumn(*extra)
df.coalesce(1).write.mode("overwrite").json(f"{landing}/batch_{batch}")
Module 7: Data Ingestion
Bring data in with COPY INTO, Auto Loader, JDBC, APIs and change data capture.
Topics in this module
- Batch vs Streaming Ingestion
- Files and Cloud Object Storage
- COPY INTO
- Auto Loader
- Schema Evolution and Rescue Data
- Incremental File Processing
- Database Ingestion via JDBC
- API-Based Data Ingestion
- Lakeflow Connect
- Change Data Capture (CDC)
- Initial Loads vs Incremental Loads
- Idempotent Ingestion
- Ingestion Error Handling
Getting data in, incrementally and safely. You build a landing zone in a volume, then load it with COPY INTO and Auto Loader.
- Needs the
orderstable from Module 4 (2,000 rows) - Files land in
/Volumes/<catalog>/dbx_course/raw/landing/orders/batch_N/ - Streaming on serverless: only
trigger(availableNow=True)is supported. That's what ingestion wants anyway - Run this setup cell once per session
Batch vs Streaming Ingestion
By the end: choose between batch and incremental (streaming) ingestion for a source.
- Ingestion is almost always incremental: new files or rows since the last run
- Run it as a scheduled job, finishing each run with
availableNow(process all new data, then stop) - Continuous streams cost a cluster around the clock: use them only for real latency needs (Module 11)
- Tools:
COPY INTO(SQL, simple), Auto Loader (files at scale), JDBC / federation (databases), Lakeflow Connect (SaaS and databases, managed)
Remember
Ingest raw and unchanged first. Clean and reshape in the next layer (Module 8).
Files and Cloud Object Storage
By the end: build a landing zone in a volume and write files into it the way a source system would.
land_orders(1, 1, 1000)
display(dbutils.fs.ls(f"{landing}/batch_1"))
- One batch = a folder of JSON files. A real source drops files on a schedule
- Landing zone rules:
- Append only: never edit or overwrite a landed file
- Put the date or batch in the path (
.../2026/10/19/) - Keep file formats consistent per source
- Files over many small ones: thousands of tiny files slow everything down
- Cloud object storage works the same way through external locations (Module 6)
Keep the raw files
The landing zone is your replay source. If a bronze table is wrong, reload it from the files.
COPY INTO
By the end: load files into a Delta table idempotently with COPY INTO.
%sql
CREATE TABLE IF NOT EXISTS orders_copy
def copy_into_orders():
return spark.sql(f"""
COPY INTO orders_copy
FROM '{landing}'
FILEFORMAT = JSON
PATTERN = 'batch_*/*.json'
COPY_OPTIONS ('mergeSchema' = 'true')
""")
copy_into_orders().show() # 1000 rows loaded
orders_copystarts as an empty placeholder table.mergeSchemaletsCOPY INTOinfer its columns- Run
copy_into_orders()again: 0 rows. Files already loaded are skipped - Add a batch and run again: only the new files load
land_orders(2, 1001, 2000)
copy_into_orders().show() # 1000 new rows
print(spark.table("orders_copy").count()) # 2000
COPY INTO |
|
|---|---|
| Good for | Thousands of files, simple SQL pipelines |
| Tracks | Which files were loaded, in the table |
| Options | FILEFORMAT, FORMAT_OPTIONS, COPY_OPTIONS, FILES / PATTERN |
| Docs now recommend | Streaming tables (SQL) or Auto Loader for scale |
Auto Loader
By the end: ingest files incrementally with Auto Loader into a bronze table.
def run_autoloader():
stream = (
spark.readStream.format("cloudFiles")
.option("cloudFiles.format", "json")
.option("cloudFiles.inferColumnTypes", "true")
.option("cloudFiles.schemaLocation", f"{checkpoints}/orders_bronze_schema")
.option("pathGlobFilter", "*.json")
.load(landing)
)
query = (
stream.withColumn("_source_file", F.col("_metadata.file_path"))
.writeStream
.option("checkpointLocation", f"{checkpoints}/orders_bronze")
.trigger(availableNow=True)
.toTable("orders_bronze")
)
query.awaitTermination()
run_autoloader()
print(spark.table("orders_bronze").count()) # 2000: both batches
cloudFiles.format: the source file formatcloudFiles.schemaLocation: where the inferred schema is storedcheckpointLocation: where progress is stored. One checkpoint per targettrigger(availableNow=True): process everything new, then stopcloudFiles.inferColumnTypes: without it, JSON and CSV columns are strings_metadata.file_path: which file a row came from. Great for debugging
In pipelines
Databricks recommends Auto Loader inside Lakeflow pipelines: schema and checkpoint are managed for you (Module 9).
Schema Evolution and Rescue Data
By the end: handle new and bad columns with Auto Loader schema evolution and the rescued data column.
See evolution happen. Land a batch with a new column:
land_orders(3, 1, 10, extra=("coupon", F.lit("SPRING")))
try:
run_autoloader()
except Exception as e:
print("stopped:", str(e).splitlines()[0]) # new column found
- The stream stops on purpose after adding the column to the schema
- Run it again and it loads the new files:
run_autoloader()
spark.table("orders_bronze").printSchema() # has "coupon"
- Old rows get
NULLforcoupon - Run streams under a job with retries so the restart happens by itself
_rescued_data: a JSON column holding values that didn't fit the schema (new columns, type mismatches, case mismatches) plus the source file pathcloudFiles.schemaHintsforces types:"order_id INT, quantity INT"
Types don't change
Existing column types stay. A value that no longer fits goes to the rescued column. Check it regularly.
Incremental File Processing
By the end: explain how a checkpoint makes ingestion exactly-once and how to reprocess on purpose.
- Run
run_autoloader()again: no new files, no new rows - The checkpoint stores file metadata (RocksDB) so each file is processed once
- Discovery modes: directory listing (default, fine for most) or file notification / file events (cheaper at huge scale)
- Limit batch size:
cloudFiles.maxFilesPerTrigger,cloudFiles.maxBytesPerTrigger - Include files already present at first start:
cloudFiles.includeExistingFiles(default true)
Reprocess from scratch on purpose:
spark.sql("DROP TABLE IF EXISTS orders_bronze")
dbutils.fs.rm(f"{checkpoints}/orders_bronze", True)
dbutils.fs.rm(f"{checkpoints}/orders_bronze_schema", True)
run_autoloader()
print(spark.table("orders_bronze").count()) # 2010 if you ran topic 5: every file again
Table and checkpoint go together
Delete a table without its checkpoint and the stream thinks the data is loaded. Delete the checkpoint without the table and you load duplicates.
Database Ingestion via JDBC
By the end: read a relational database with JDBC and know why federation or Lakeflow Connect is preferred on serverless.
# classic compute pattern: do NOT hard-code passwords
df = (
spark.read.format("jdbc")
.option("url", "jdbc:postgresql://db.example.com:5432/shop")
.option("dbtable", "public.orders")
.option("user", dbutils.secrets.get("shop", "user"))
.option("password", dbutils.secrets.get("shop", "password"))
.option("partitionColumn", "order_id") # numeric, date or timestamp
.option("lowerBound", 1)
.option("upperBound", 2_000_000)
.option("numPartitions", 8) # 8 parallel queries
.load()
)
- Without
partitionColumn,lowerBound,upperBound,numPartitionsthe read is one task: slow - Bounds only split the range, they don't filter rows
- Push work to the database:
dbtablecan be(SELECT ... ) AS q - Credentials from secret scopes, never in the notebook
- Land the result in a Delta table. Don't query the database live from dashboards
On Free Edition
Not runnable: outbound internet is limited to trusted domains, and serverless requires a Unity Catalog connection for external databases.
API-Based Data Ingestion
By the end: ingest a paginated API into the landing zone with retries and ingestion metadata.
The lab simulates an API so it runs offline. Swap fetch_page for requests.get(...) against a real one.
import json
import time
from datetime import datetime, timezone
def fetch_page(page, page_size=5):
"""Stand-in for requests.get(url, params={'page': page}).json()"""
start = (page - 1) * page_size
items = [{"id": i, "name": f"item-{i}"} for i in range(start + 1, start + page_size + 1) if i <= 12]
return {"items": items, "next_page": page + 1 if items and start + page_size < 12 else None}
def fetch_with_retry(page, attempts=3):
for n in range(1, attempts + 1):
try:
return fetch_page(page)
except Exception:
if n == attempts:
raise
time.sleep(2 ** n) # backoff: 2s, 4s
run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
api_dir = f"{base}/landing/api_items/run_{run_id}"
dbutils.fs.mkdirs(api_dir)
page, written = 1, 0
while page:
body = fetch_with_retry(page)
for item in body["items"]:
item["_ingested_at"] = datetime.now(timezone.utc).isoformat()
item["_source"] = "demo-api"
with open(f"{api_dir}/page_{page:04d}.json", "w") as f:
f.write("\n".join(json.dumps(i) for i in body["items"]))
written += len(body["items"])
page = body["next_page"]
print(written) # 12 items over 3 pages
- Land raw responses plus
_ingested_atand_source. Parse later - Retry only transient errors (timeouts, 429, 5xx), with backoff
- Respect rate limits and paginate until the API says it's done
- Secrets from secret scopes. The next step is Auto Loader on
landing/api_items
On Free Edition
Real calls must target a trusted domain: outbound access is restricted. That's why the lab simulates the API.
Lakeflow Connect
By the end: explain what Lakeflow Connect does and when it beats writing your own ingestion.
- Managed connectors for popular SaaS apps and databases (for example Salesforce, Workday, SQL Server)
- Handles auth, incremental reads, schema changes and retries for you
- Runs as a managed pipeline with serverless compute. Results are streaming tables
- Standard connectors (Auto Loader, Kafka and others) cover files and streams you manage
- Build your own only when no connector exists, or you need unusual logic
On Free Edition
I couldn't confirm which managed connectors your workspace offers. Treat as explanation only.
Change Data Capture (CDC)
By the end: apply inserts, updates and deletes from a change feed to a table with MERGE.
# target: current customers
spark.sql("CREATE OR REPLACE TABLE customers_cdc AS SELECT * FROM customers")
# change feed: insert 201, update 1 and 2, delete 3; two updates for customer 1 (keep the latest)
changes = spark.createDataFrame(
[
(1, "Customer 1", "FR", "2024-02-01", "U", 1),
(1, "Customer 1", "DE", "2024-02-01", "U", 2),
(2, "Customer 2", "ES", "2024-03-01", "U", 1),
(3, None, None, None, "D", 1),
(201, "Customer 201", "NL", "2025-01-01", "I", 1),
],
"customer_id INT, name STRING, country STRING, signup_date STRING, op STRING, seq INT",
).withColumn("signup_date", F.to_date("signup_date"))
# keep only the latest change per key
from pyspark.sql.window import Window
w = Window.partitionBy("customer_id").orderBy(F.desc("seq"))
latest = changes.withColumn("rn", F.row_number().over(w)).filter("rn = 1").drop("rn")
latest.createOrReplaceTempView("changes_latest")
%sql
MERGE INTO customers_cdc AS t
USING changes_latest AS s
ON t.customer_id = s.customer_id
WHEN MATCHED AND s.op = 'D' THEN DELETE
WHEN MATCHED AND s.op = 'U' THEN UPDATE SET
name = s.name, country = s.country, signup_date = s.signup_date
WHEN NOT MATCHED AND s.op <> 'D' THEN INSERT
(customer_id, name, country, signup_date) VALUES (s.customer_id, s.name, s.country, s.signup_date)
- Customer 1 ends in
DE(the later change), customer 3 is deleted, 201 is added: count stays 200 - Always dedupe the feed by key and order before MERGE: two source rows for one target row can fail it
- Include the operation and a sequence (log position or timestamp) in the feed
- Lakeflow pipelines do this declaratively with
AUTO CDC(Module 9.9)
Order matters
Order by the source's sequence, not by arrival time. Late and out-of-order events are normal.
Initial Loads vs Incremental Loads
By the end: build an initial full load followed by incremental loads using a high-water mark.
# initial load: orders up to id 1000
spark.sql("DROP TABLE IF EXISTS orders_incr")
spark.table("orders").filter("order_id <= 1000").write.saveAsTable("orders_incr")
def incremental_load():
mark = spark.table("orders_incr").agg(F.max("order_id")).first()[0] or 0
new = spark.table("orders").filter(F.col("order_id") > mark)
new.write.mode("append").saveAsTable("orders_incr")
return new.count()
print(incremental_load()) # 1000
print(incremental_load()) # 0: nothing new
- The mark can be an id, a timestamp (
updated_at) or a file/batch id - Store it with the data (
max()of the target) or in a small control table - Weak spots: deletes aren't seen, late rows older than the mark are missed. Use CDC or a safety lookback window
- Re-run the initial load any time to rebuild
Mark updates and appends together
If the mark is stored apart from the data, a crash between the two steps loses or duplicates rows. Computing it from the target avoids that.
Idempotent Ingestion
By the end: make an ingestion step safe to run twice.
batch = spark.table("orders").filter("order_id BETWEEN 1 AND 50")
batch.createOrReplaceTempView("batch_v")
# running this twice gives the same result
spark.sql("""
MERGE INTO orders_incr t USING batch_v s
ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
""")
print(spark.table("orders_incr").count()) # 2000 both times
- Idempotent = same input, same result, any number of runs
- Patterns:
- MERGE on a business key: reruns update, never duplicate
replaceWhere: overwrite exactly one slice, such as a day- Dedupe on read with a window, if you can't merge
- Plain
appendof a full extract duplicates data on every retry
(batch.write.mode("overwrite")
.option("replaceWhere", "order_id BETWEEN 1 AND 50")
.saveAsTable("orders_incr"))
replaceWherereplaces only rows matching the predicate. Source rows outside it fail the write
Design for retries
Jobs fail and rerun. Make every step safe to repeat, then retries and repair runs cost nothing.
Ingestion Error Handling
By the end: separate bad records from good ones and keep them for review instead of dropping them.
bad_dir = f"{base}/landing/messy"
dbutils.fs.mkdirs(bad_dir)
with open(f"{bad_dir}/messy.json", "w") as f:
f.write('{"id": 1, "amount": 10.5}\n')
f.write('{"id": 2, "amount": 7.0}\n')
f.write('this line is not json\n')
f.write('{"id": 4, "amount": 3.25}\n')
schema = "id INT, amount DOUBLE, _corrupt_record STRING"
(spark.read.schema(schema).option("mode", "PERMISSIVE").json(bad_dir)
.write.mode("overwrite").saveAsTable("messy_parsed"))
parsed = spark.table("messy_parsed")
good = parsed.filter(F.col("_corrupt_record").isNull()).drop("_corrupt_record")
bad = (parsed.filter(F.col("_corrupt_record").isNotNull())
.withColumn("_quarantined_at", F.current_timestamp()))
print(good.count(), bad.count()) # 3 good, 1 bad
bad.write.mode("overwrite").saveAsTable("quarantine_messy")
PERMISSIVE(default) keeps the row and puts the raw text in_corrupt_record.DROPMALFORMEDsilently drops.FAILFASTstops- Auto Loader has the same idea:
_rescued_data - Quarantine table: bad rows, source file, reason, timestamp. Review and replay
- Alert when the quarantine count jumps. Silent data loss is the worst failure
Parse into a table first
Spark refuses queries on raw JSON or CSV that touch only the corrupt-record column. Saving the parsed result as a table first avoids it. On classic compute you could cache() instead, but caching isn't available on serverless.
Never drop silently
Fail loudly, or quarantine and count. Dropping rows with no trace hides data problems until a report is wrong.
What you can do now
- Landing zones and append-only raw files
COPY INTOand Auto Loader, checkpoints and exactly-once- Schema evolution modes and
_rescued_data - JDBC, APIs, Lakeflow Connect and CDC with MERGE
- Initial vs incremental loads, idempotent reruns and quarantine tables