DatabricksZero to Advanced

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 orders table 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
Python
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}")
7.01Concept

Batch vs Streaming Ingestion

By the end: choose between batch and incremental (streaming) ingestion for a source.

Batchread everything each runsimplewasteful as data growsIncrementalread only what's newcheckpoint remembers progressscalesContinuousalways runningseconds of latencynot on serverless
  • 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).

7.02Hands-on on Free Edition

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.

Source systemexports filesLanding zonevolume, append onlyBronze tableraw copy
The landing zone is the contract with the source: files arrive, nobody edits them.
Python
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.

7.03Hands-on on Free Edition

COPY INTO

By the end: load files into a Delta table idempotently with COPY INTO.

SQL
%sql
CREATE TABLE IF NOT EXISTS orders_copy
Python
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_copy starts as an empty placeholder table. mergeSchema lets COPY INTO infer 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
Python
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
7.04Hands-on on Free Edition

Auto Loader

By the end: ingest files incrementally with Auto Loader into a bronze table.

Landing filescloudFilesAuto LoaderCheckpointremembers filesorders_bronze
Auto Loader finds new files, loads each once, and records it in the checkpoint.
Python
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 format
  • cloudFiles.schemaLocation: where the inferred schema is stored
  • checkpointLocation: where progress is stored. One checkpoint per target
  • trigger(availableNow=True): process everything new, then stop
  • cloudFiles.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).

7.05Hands-on on Free Edition

Schema Evolution and Rescue Data

By the end: handle new and bad columns with Auto Loader schema evolution and the rescued data column.

addNewColumnsdefault without a schemastream stops oncenew columns addedrestart continuesrescueschema never changesnew data goes to _rescued_datafailOnNewColumnsstream failsyou update the schemanonedefault with a schemanew columns ignored

See evolution happen. Land a batch with a new column:

Python
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:
Python
run_autoloader()
spark.table("orders_bronze").printSchema()     # has "coupon"
  • Old rows get NULL for coupon
  • 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 path
  • cloudFiles.schemaHints forces 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.

7.06Hands-on on Free Edition

Incremental File Processing

By the end: explain how a checkpoint makes ingestion exactly-once and how to reprocess on purpose.

Run 1batches 1 and 2checkpoint savedRun 2only new filescheckpoint savedRun 3nothing new, nothing to do
The checkpoint is the memory. Without it, every run starts from scratch.
  • 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:

Python
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.

7.07Hands-on with caveats

Database Ingestion via JDBC

By the end: read a relational database with JDBC and know why federation or Lakeflow Connect is preferred on serverless.

Python
# 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, numPartitions the read is one task: slow
  • Bounds only split the range, they don't filter rows
  • Push work to the database: dbtable can 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
JDBC readyou manage driversyou manage partitioningclassic computeFederationUC connectiongovernedquery in placeLakeflow Connectmanaged ingestionincremental

On Free Edition

Not runnable: outbound internet is limited to trusted domains, and serverless requires a Unity Catalog connection for external databases.

7.08Hands-on on Free Edition

API-Based Data Ingestion

By the end: ingest a paginated API into the landing zone with retries and ingestion metadata.

APIpages of JSONFetch with retriesmetadata addedLanding zoneraw JSONAuto Loader
Treat an API like any other source: land the raw response first.

The lab simulates an API so it runs offline. Swap fetch_page for requests.get(...) against a real one.

Python
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_at and _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.

7.09Concept

Lakeflow Connect

By the end: explain what Lakeflow Connect does and when it beats writing your own ingestion.

SourceSaaS app or databaseLakeflow Connectmanaged connectorDelta tablesin Unity Catalog
Managed, incremental, governed ingestion without code.
  • 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.

7.10Hands-on on Free Edition

Change Data Capture (CDC)

By the end: apply inserts, updates and deletes from a change feed to a table with MERGE.

Source DBChange feedop: I, U, D + orderMERGETarget tablecurrent state
CDC sends what changed, not the whole table.
Python
# 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
%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.

7.11Hands-on on Free Edition

Initial Loads vs Incremental Loads

By the end: build an initial full load followed by incremental loads using a high-water mark.

Initial loadeverything up to nowSave high-water markmax id or timestampIncrementalonly newer
Each run reads rows beyond the last mark, then moves the mark.
Python
# 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.

7.12Hands-on on Free Edition

Idempotent Ingestion

By the end: make an ingestion step safe to run twice.

Built inCOPY INTOAuto Loader checkpointYou buildMERGE on a keyreplaceWhere per partitionINSERT OVERWRITEUnsafeplain append of a full extract
Python
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 append of a full extract duplicates data on every retry
Python
(batch.write.mode("overwrite")
      .option("replaceWhere", "order_id BETWEEN 1 AND 50")
      .saveAsTable("orders_incr"))
  • replaceWhere replaces 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.

7.13Hands-on on Free Edition

Ingestion Error Handling

By the end: separate bad records from good ones and keep them for review instead of dropping them.

Python
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")
Raw recordsValidateschema and rulesGood rowsto bronze
Bad rows go to a quarantine table, with the reason and the time.
  • PERMISSIVE (default) keeps the row and puts the raw text in _corrupt_record. DROPMALFORMED silently drops. FAILFAST stops
  • 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.

Recap

What you can do now

  • Landing zones and append-only raw files
  • COPY INTO and 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