Databricks DE ยท Topic 2.2

Writing to Delta: MERGE, OVERWRITE & CTAS

Four ways to write a Delta table โ€” pick by how much you're changing: row-level (MERGE), full/partition reload (INSERT OVERWRITE), rebuild (CREATE OR REPLACE), or derive (CTAS). This deep dive walks each with the mechanism and the code.

MERGE = upsertCDC / SCD2replaceWhereAtomic writes

The one-liner

MERGE changes only matching rows (upsert); INSERT OVERWRITE swaps all/partition data; CREATE OR REPLACE rebuilds the table while keeping its name & history; CTAS makes a new table from a query. Use the smallest tool that fits the change.
๐Ÿ“‡ Analogy (a contacts app): MERGE = sync (update changed, add new, remove gone); INSERT OVERWRITE = wipe & paste a fresh export; CREATE OR REPLACE = reset the whole app; CTAS = save a filtered copy as a new list.

Which write should I use?

โ–ธ click a statement

MERGE INTO โ€” row-level upsert by a match key. Use for CDC, SCD, dedup, daily deltas. Cheapest when only some rows change.
INSERT OVERWRITE โ€” atomically replace all rows or a partition. Use for full or partition reloads / backfills.
CREATE OR REPLACE TABLE โ€” rebuild definition + data, resetting schema cleanly while keeping the table name and history. Prefer over DROP+CREATE.
CTAS (CREATE TABLE x AS SELECT โ€ฆ) โ€” create a brand-new table from a query. Great for derived silver/gold tables.

Why it matters

๐ŸŒ™ Real-world use case: a nightly feed of changed customers โ†’ one MERGE updates existing, inserts new, and optionally expires departed customers โ€” atomically, without rewriting the whole table.

1 ยท MERGE โ€” the one that matters

Match a source (incoming changes) against the target (Delta table) on a key, then apply up to three clause types โ€” all in one atomic commit.

MERGE INTO customers t                       -- target (the Delta table)
USING customer_updates s                     -- source (incoming changes)
ON t.id = s.id                               -- the match key
WHEN MATCHED        THEN UPDATE SET *         -- in both โ†’ update existing
WHEN NOT MATCHED    THEN INSERT *             -- source only โ†’ add new
WHEN NOT MATCHED BY SOURCE THEN DELETE;       -- target only โ†’ remove gone rows

โ–ธ click each clause

WHEN MATCHEDโ–ธ
Row exists in both source and target โ†’ UPDATE or DELETE (with optional conditions; multiple matched clauses allowed, first match wins). The "update existing" path.
WHEN NOT MATCHEDโ–ธ
Row is in the source only โ†’ INSERT. Unspecified columns get NULL. The "add new" path.
WHEN NOT MATCHED BY SOURCEโ–ธ
Row is in the target only โ†’ UPDATE/DELETE. Cannot reference source columns (no source row exists). Requires DBR 12.2 LTS+. The "expire/remove gone rows" path.

Conditional clauses โ€” one MERGE, several actions

MERGE INTO customers t USING customer_updates s ON t.id = s.id
WHEN MATCHED AND s.op = 'DELETE'      THEN DELETE
WHEN MATCHED AND s.op = 'UPDATE'      THEN UPDATE SET name = s.name, tier = s.tier
WHEN NOT MATCHED AND s.op <> 'DELETE' THEN INSERT (id, name, tier) VALUES (s.id, s.name, s.tier);

โš  Gotcha that fails the merge: multiple source matches

If more than one source row matches the same target row, the merge errors out. Dedup the source to one row per key first โ€” keep the latest by event time:

from pyspark.sql import functions as F, Window
w = Window.partitionBy("id").orderBy(F.col("event_ts").desc())
deduped = (raw_updates
    .withColumn("rn", F.row_number().over(w))
    .filter("rn = 1").drop("rn"))      # one row per key โ†’ safe to MERGE

Performance: always prune the target

MERGE rewrites the files holding matched rows. A predicate on the target lets Delta file-skip so it touches a fraction of the table.

MERGE INTO orders t USING daily_changes s
ON t.id = s.id
   AND t.order_date >= current_date() - INTERVAL 7 DAYS   -- prunes old files
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;

Deletion vectors (merge-on-read, delta.enableDeletionVectors) cut cost further by marking rows deleted instead of rewriting whole files.

Schema evolution inside MERGE + the PySpark API

MERGE enforces schema by default; opt in to let new source columns flow in. The DeltaTable builder is the DataFrame-API equivalent.

SET spark.databricks.delta.schema.autoMerge.enabled = true;   -- session-wide
from delta.tables import DeltaTable
tgt = DeltaTable.forName(spark, "main.sales.customers")
(tgt.alias("t")
   .merge(deduped.alias("s"), "t.id = s.id")
   .whenMatchedUpdateAll()
   .whenNotMatchedInsertAll()
   .whenNotMatchedBySourceDelete()
   .execute())

Worked example โ€” SCD Type 2 (keep history)

SCD2 closes the old row and inserts a new current row, so you keep full history. Step through what one MERGE does when customer C1 moves Silver โ†’ Gold:

โ–ธ step through the SCD2 merge

STEP 1 / 4

The SCD2 merge (two-pass source)

MERGE INTO dim_customer t
USING (
  -- pass 1: changed row keyed normally โ†’ closes the old version
  SELECT id AS merge_key, * FROM customer_updates
  UNION ALL
  -- pass 2: same change, NULL key โ†’ can't match โ†’ forces an INSERT
  SELECT NULL AS merge_key, u.* FROM customer_updates u
  JOIN dim_customer d ON u.id = d.id AND d.is_current = true
  WHERE u.tier <> d.tier
) s
ON t.id = s.merge_key AND t.is_current = true
WHEN MATCHED AND t.tier <> s.tier THEN
  UPDATE SET is_current = false, end_date = current_date()   -- close old
WHEN NOT MATCHED THEN
  INSERT (id, tier, is_current, start_date, end_date)
  VALUES (s.id, s.tier, true, current_date(), NULL);          -- open new

2 ยท INSERT OVERWRITE โ€” full & partition reloads

Atomically swap a table's (or a partition's) contents in one commit โ€” readers never see an empty/partial table.

-- Full table reload (replace everything atomically)
INSERT OVERWRITE main.sales.orders SELECT * FROM staging.orders_full;

Targeted overwrite with replaceWhere (only one day)

(corrected_day.write.format("delta").mode("overwrite")
   .option("replaceWhere", "order_date = '2024-01-07'")   # only this day is replaced
   .saveAsTable("main.sales.orders"))
# Fails if any row's order_date != 2024-01-07 โ€” the constraint check protects you.

Dynamic partition overwrite (only the partitions present in new data)

spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
(new_partitions.write.format("delta").mode("overwrite")
   .saveAsTable("main.sales.orders"))   # untouched partitions stay as-is

You can't combine replaceWhere and partitionOverwriteMode=dynamic in one write โ€” pick one targeting strategy.

3 ยท CREATE OR REPLACE TABLE โ€” atomic rebuild

Redefine + repopulate in one atomic commit, keeping the table name and history lineage. Prefer it over DROP + CREATE, which loses history and leaves a window where the table doesn't exist.

CREATE OR REPLACE TABLE main.sales.dim_date AS
SELECT d AS date_key, year(d) AS yr, month(d) AS mo
FROM (SELECT explode(sequence(DATE'2020-01-01', DATE'2030-12-31', INTERVAL 1 DAY)) AS d);

4 ยท CTAS โ€” derive a new table from a query

CREATE TABLE AS SELECT builds a new table, inferring schema from the query โ€” the default for silver/gold derived tables.

CREATE TABLE main.sales.gold_daily_revenue AS
SELECT order_date, sum(amount) AS revenue
FROM main.sales.orders GROUP BY order_date;

-- Want the structure but not the data? Copy the schema only:
CREATE TABLE main.sales.orders_empty LIKE main.sales.orders;

Pick the smallest tool that fits

Rows touchedAtomicKeeps historyTypical use
MERGEonly matched filesโœ…โœ…CDC, SCD, upserts, dedup
INSERT OVERWRITEall / a partitionโœ…โœ…full / partition reload
CREATE OR REPLACEwhole tableโœ…โœ… (vs DROP+CREATE)schema reset / rebuild
CTASnew tableโœ…n/a (new)derived silver/gold table
Decision rule: some rows changed โ†’ MERGE ยท whole partition/table stale โ†’ INSERT OVERWRITE ยท schema/definition must change โ†’ CREATE OR REPLACE ยท building a new derived table โ†’ CTAS.

Uses, edge cases & limitations

Common gotchas