Post

Getting Started with Databricks Delta Tables for Analytics Pipelines

If you work with data pipelines long enough, you will eventually hit a point where your Parquet files stop being enough. You need upserts, you need to roll back a bad run, you need to know who changed what and when. That is exactly the space where Delta tables fit in.

In this article, let us walk through setting up a Delta table in Databricks, building an analytics pipeline around it, and the things I learned the hard way along the way. We will cover creating the table, doing incremental loads, using time travel for debugging, and a few production considerations that do not always show up in the docs.

What Delta Tables Actually Give You

Before writing code, it helps to understand what Delta tables bring over plain Parquet or CSV files sitting in a data lake.

FeaturePlain ParquetDelta Table
ACID transactionsNoYes
Upserts and deletesNot nativelyMERGE INTO, DELETE
Time travelNot built-inQuery any snapshot
Schema enforcementManualAutomatic on write
File compactionManualOPTIMIZE command
Change data feedNoBuilt-in CDF

In practice, the ACID transactions and upsert support are the two things that change how you design pipelines. Instead of full-refresh-after-failed-run, you can just fix the bad data and move on. Instead of complex dedup logic in PySpark, you use MERGE.

Setting Up Your First Delta Table

Let us assume you already have a Databricks workspace. If not, the community edition is free and enough to follow along.

We will work with a practical example — an e-commerce orders dataset that gets new and updated rows every hour from the source system. The downstream analytics team needs the latest state for their dashboards.

First, let us create a Delta table from some initial data.

1
2
3
4
5
6
7
8
9
10
11
12
13
# Create an initial DataFrame
orders_data = spark.createDataFrame([
    (1, "CUST001", "2026-05-01", "Delivered", 150.00),
    (2, "CUST002", "2026-05-02", "Shipped", 200.50),
    (3, "CUST003", "2026-05-03", "Processing", 75.25)
], ["order_id", "customer_id", "order_date", "status", "amount"])

# Write as a Delta table
orders_data.write \
    .format("delta") \
    .mode("overwrite") \
    .option("path", "/mnt/datalake/analytics/orders") \
    .saveAsTable("analytics.orders")

That is it. You now have a Delta table registered in the Hive metastore and physically stored at /mnt/datalake/analytics/orders. If you check the directory, you will see Parquet files plus a _delta_log folder — that folder is where Delta stores the transaction log, and it is the secret behind all the features.

Incremental Loads with MERGE

Now the real work begins. Your source system keeps sending CDC (change data capture) records. Some are new orders, some are updates to existing orders. A classic scenario for MERGE.

Let us assume you receive a batch of changes into a staging DataFrame.

1
2
3
4
5
6
7
8
# Simulating incoming CDC data
cdc_data = spark.createDataFrame([
    (3, "CUST003", "2026-05-03", "Shipped", 75.25),   # status update
    (4, "CUST004", "2026-05-04", "Processing", 320.00) # new order
], ["order_id", "customer_id", "order_date", "status", "amount"])

# Create a temp view so we can reference it in SQL
cdc_data.createOrReplaceTempView("orders_cdc")

Now the MERGE statement. This is where Delta earns its keep.

1
2
3
4
5
6
7
8
9
10
11
MERGE INTO analytics.orders AS target
USING orders_cdc AS source
ON target.order_id = source.order_id
WHEN MATCHED THEN
  UPDATE SET
    target.status = source.status,
    target.amount = source.amount,
    target.order_date = source.order_date
WHEN NOT MATCHED THEN
  INSERT (order_id, customer_id, order_date, status, amount)
  VALUES (source.order_id, source.customer_id, source.order_date, source.status, source.amount)

After running this, order 3 moves from Processing to Shipped, and order 4 is inserted fresh. The Delta table now has four rows.

One thing I learned: always validate your CDC data before merging. A duplicate order_id in the source with conflicting values will error out with an ambiguous match. Add a dedup step on the source side first, picking the latest row by timestamp.

1
2
3
4
5
6
7
8
9
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number, col

window_spec = Window.partitionBy("order_id").orderBy(col("updated_at").desc())

deduped_cdc = cdc_data \
    .withColumn("rn", row_number().over(window_spec)) \
    .filter(col("rn") == 1) \
    .drop("rn")

Time Travel: The Feature You Hope You Never Need (But Will)

Say someone pushed a bad update to the orders table, or you accidentally ran your MERGE against the wrong CDC file. Instead of restoring from a backup, you can query the table as it was before the mistake.

1
2
3
-- See what the table looked like at a specific timestamp
SELECT * FROM analytics.orders 
TIMESTAMP AS OF '2026-05-04T08:00:00'

You can also use a version number if you know which version you need.

1
2
3
4
5
-- Check version history
DESCRIBE HISTORY analytics.orders;

-- Query an older version
SELECT * FROM analytics.orders VERSION AS OF 2

To actually restore the table to a previous state, you have two approaches. The safer one is to create a new table from the old snapshot, validate it, then swap.

1
2
CREATE OR REPLACE TABLE analytics.orders_restored AS
SELECT * FROM analytics.orders VERSION AS OF 2;

The nuclear option is RESTORE, but be careful — this changes the current table irreversibly.

1
RESTORE TABLE analytics.orders TO VERSION AS OF 2;

In production, I prefer the create-and-swap approach. It gives you a chance to verify the restored data before pointing downstream consumers at it.

Optimizing Delta Tables Over Time

As you run more MERGE operations, small files pile up. Delta has an OPTIMIZE command that compacts them.

1
OPTIMIZE analytics.orders

This rewrites small files into larger ones — by default targeting 1 GB per file. If your table is large, you can add a ZORDER BY clause on columns you frequently filter on, which co-locates related data and speeds up queries.

1
OPTIMIZE analytics.orders ZORDER BY (customer_id)

For production pipelines, set up a scheduled job to run OPTIMIZE during off-peak hours. How often depends on your write volume — daily works for most use cases, but high-frequency pipelines might need it every few hours.

Things to Watch Out For

Concurrent writes. Delta uses optimistic concurrency control. If two jobs try to write to the same table at the exact same time, one will fail with a concurrent modification error. If your pipeline runs multiple parallel MERGEs against the same table, serialize them or use partition-level isolation.

Data retention and storage costs. Delta keeps old versions of data around for time travel. By default it keeps 30 days of history. Check your retention settings if you are working with large tables — keeping a month of history on a 10 TB table adds up.

1
2
3
4
5
6
-- Adjust retention
ALTER TABLE analytics.orders 
SET TBLPROPERTIES ('delta.logRetentionDuration' = '7 days');

-- Clean up old files
VACUUM analytics.orders RETAIN 168 HOURS

Schema evolution. Delta enforces the schema on write by default, which is good — it stops you from accidentally writing a column as string when the target table expects integer. If you do need to add columns, Delta handles it gracefully with mergeSchema or autoMerge.

1
2
3
4
5
df.write \
    .format("delta") \
    .mode("append") \
    .option("mergeSchema", "true") \
    .save("/mnt/datalake/analytics/orders")

Just do not use this blindly. I have seen pipelines where a source system renamed a column and the merged schema added a brand new column while the old one started filling with nulls. That is a mess to clean up.

No primary key enforcement. Delta does not enforce uniqueness on any column. If your MERGE logic has a bug that lets duplicates through, Delta will happily store them. You need to handle uniqueness either at the merge level or with a downstream dedup job. Some teams use Delta’s generated columns or check constraints for lightweight validation, but these are not replacements for proper pipeline logic.

What a Production Pipeline Looks Like

Here is roughly how the flow would be structured in a real setup, beyond the notebook demo above.

  1. Landing zone ingestion. Raw CDC files land in a cloud storage bucket (S3, ADLS, GCS) from the source system.
  2. Bronze layer. A scheduled Databricks job reads the raw files and writes them to a Bronze Delta table — append-only, minimal transformations, keeps the raw history.
  3. Silver layer. Another job reads from Bronze, applies dedup, type casting, and basic cleaning, then MERGEs into the Silver orders table we built above.
  4. Gold layer. An aggregation job creates summary tables for dashboards — daily sales, order volume by status, that kind of thing.

For orchestration, Databricks Workflows handles this natively. You define a DAG of notebooks or Python scripts, set the schedule, and configure retry behavior. Each task can run on its own cluster size, so your Bronze job can use a small cluster while the Gold aggregation scales up.

If you are already in the Databricks ecosystem, sticking with Workflows is simpler than introducing Airflow just for Delta table pipelines — fewer moving parts.

Wrapping Up

Delta tables are not magic, but they solve a lot of real problems that come up in data pipeline work. ACID transactions mean you can do upserts without worrying about corrupting downstream reads. Time travel means you can recover from mistakes without restoring full backups. And the tooling around OPTIMIZE and ZORDER helps keep query performance reasonable as tables grow.

The biggest thing to internalize is that Delta does not replace good pipeline hygiene. You still need to validate your source data, handle duplicates, and think about retention. It just makes the infrastructure side of those concerns much simpler than managing raw Parquet files ever was.

If you are starting fresh with a new pipeline, I would default to Delta from day one. The overhead is near zero — it is just .format("delta") instead of .format("parquet") — and you will be glad you had it when the first bad run hits.

This post is licensed under CC BY 4.0 by the author.