Delta Lake Deep Dive — ACID Transactions on Data Lakes

Category: Data

What is Delta Lake? Think of it like adding “Track Changes” and “Version History” to your data files. Imagine if every time you edited a Word document, it automatically saved every version, prevented two people from conflicting changes, and let you undo any mistake — even from months ago. That’s Delta Lake for big data!

Why Delta Lake Exists

Data lakes have a fundamental problem:

The Problem:

Day 1: Upload data.parquet → ✅ Success
Day 2: Overwrite data.parquet → ⚠️ Readers see corrupted half-written file!
Day 3: Delete old data → ❌ No undo, data lost forever

Think of it this way: Traditional data lakes are like having important documents scattered across multiple USB drives with no backup. Delta Lake is like upgrading to Google Drive with version history, auto-save, and collaboration features.

What’s Missing:

  • ❌ No transactions (ACID)
  • ❌ No time travel (can’t undo mistakes)
  • ❌ No efficient updates/deletes
  • ❌ No data quality enforcement

Delta Lake’s Solution: Add a transaction log on top of Parquet files:

flowchart LR subgraph traditional["Traditional Data Lake"] PARQUET["Parquet Files"] PROBLEMS["Problems:
❌ No ACID
❌ No history
❌ Hard to update"] end subgraph delta["Delta Lake"] LOG["Transaction Log
(_delta_log/)"] DATA["Parquet Files"] FEATURES["Features:
✅ ACID
✅ Time travel
✅ Easy updates
✅ Schema enforcement"] end traditional --> |"Add transaction log"| delta LOG --> |"Tracks all changes"| DATA style traditional fill:#fecaca,stroke:#dc2626 style delta fill:#d1fae5,stroke:#059669 style LOG fill:#fef3c7,stroke:#d97706

Architecture: Transaction Log + Parquet Files

Here’s the key insight: Parquet files are like Word documents saved on your computer. Delta Lake adds a “change log” (like Google Docs’ version history) that tracks every edit, who made it, and when. The documents themselves (Parquet files) are unchanged — Delta just adds smart tracking on top!

Delta Lake is NOT a file format — it’s a storage layer on top of Parquet:

flowchart TD DELTA["Delta Table"] --> LOG["_delta_log/
(transaction log)"] DELTA --> DATA["Parquet Files
(actual data)"] LOG --> V0["00000000000000000000.json
(version 0)"] LOG --> V1["00000000000000000001.json
(version 1)"] LOG --> V2["00000000000000000002.json
(version 2)"] LOG --> CHECKPOINT["00000000000000000010.checkpoint.parquet"] V0 --> |"Add file1.parquet"| ACTION1["Actions"] V1 --> |"Add file2.parquet
Remove file1.parquet"| ACTION2["Actions"] DATA --> FILE1["part-00000.parquet"] DATA --> FILE2["part-00001.parquet"] DATA --> FILE3["part-00002.parquet"] style DELTA fill:#dbeafe,stroke:#2563eb style LOG fill:#fef3c7,stroke:#d97706 style DATA fill:#d1fae5,stroke:#059669 style CHECKPOINT fill:#fce7f3,stroke:#db2777

File Structure

my_table/
│
├── _delta_log/                              # Transaction log (metadata)
│   ├── 00000000000000000000.json            # Version 0 (initial data)
│   ├── 00000000000000000001.json            # Version 1 (added data)
│   ├── 00000000000000000002.json            # Version 2 (updated data)
│   ├── 00000000000000000010.checkpoint.parquet  # Checkpoint (optimization)
│   └── _last_checkpoint                     # Points to latest checkpoint
│
└── [data files]/                            # Parquet files (actual data)
    ├── part-00000-abc123.snappy.parquet
    ├── part-00001-def456.snappy.parquet
    └── part-00002-ghi789.snappy.parquet

Transaction Log Entry Example

// 00000000000000000001.json
{
  "commitInfo": {
    "timestamp": 1702742400000,
    "operation": "WRITE",
    "operationMetrics": {
      "numFiles": "1",
      "numOutputRows": "1000"
    }
  }
}
{
  "protocol": {
    "minReaderVersion": 1,
    "minWriterVersion": 2
  }
}
{
  "metaData": {
    "id": "abc-123-def",
    "format": {"provider": "parquet"},
    "schemaString": "{\"type\":\"struct\",\"fields\":[...]}",
    "partitionColumns": ["date"],
    "configuration": {}
  }
}
{
  "add": {
    "path": "part-00000-abc123.snappy.parquet",
    "partitionValues": {"date": "2024-12-16"},
    "size": 10485760,
    "modificationTime": 1702742400000,
    "dataChange": true,
    "stats": "{\"numRecords\":1000,\"minValues\":{...},\"maxValues\":{...}}"
  }
}
{
  "remove": {
    "path": "part-00000-old123.snappy.parquet",
    "deletionTimestamp": 1702742400000,
    "dataChange": true
  }
}

Key Actions:

  • add: New file added
  • remove: File removed (logical deletion)
  • metaData: Schema, partitioning
  • commitInfo: Who, when, what operation

ACID Guarantees

What is ACID? It means “your data stays safe and consistent.” Here’s what it guarantees:

  • Atomic: Changes either fully happen or don’t happen at all (no half-finished saves)
  • Consistent: Data follows your rules (like “age must be a number”)
  • Isolated: Two people editing at once don’t mess each other up
  • Durable: Once saved, it stays saved even if the computer crashes

Bottom line: Your data won’t get corrupted or lost!

Delta Lake provides full ACID transactions:

Atomicity

# Either ALL rows are written, or NONE
try:
    df.write.format("delta").save("my_table")
    # ✅ All-or-nothing
except:
    # ❌ Failure → No partial writes visible

How It Works:

  1. Write new Parquet files (not visible yet)
  2. Create transaction log entry
  3. Atomic commit: Write log entry as a single file
  4. If log write fails → Files ignored (invisible)

Consistency

# Schema enforcement
df_v1 = spark.createDataFrame([(1, "Alice")], ["id", "name"])
df_v1.write.format("delta").save("users")

# ❌ This fails (wrong schema)
df_v2 = spark.createDataFrame([(1, "Bob", 30)], ["id", "name", "age"])
df_v2.write.format("delta").mode("append").save("users")
# SchemaEvolutionException!

# ✅ Must enable schema evolution explicitly
df_v2.write.format("delta") \
  .mode("append") \
  .option("mergeSchema", "true") \
  .save("users")

Isolation

Optimistic Concurrency Control:

sequenceDiagram participant W1 as Writer 1 participant Log as Delta Log participant W2 as Writer 2 W1->>Log: Read version 5 W2->>Log: Read version 5 W1->>Log: Write version 6 ✅ W2->>Log: Try to write version 6 ❌ Log->>W2: Conflict! Version 6 exists W2->>Log: Read version 6, retry W2->>Log: Write version 7 ✅

Two writers can’t write the same version — automatic retry!

Durability

# Once committed, data is durable
df.write.format("delta").save("users")
# ✅ Crash after this? Data is safe on disk

Time Travel: Query Any Version

Think of it like this: Remember when you accidentally deleted important photos, but could recover them because your phone keeps backups? Time Travel is exactly that for your data. You can see what your data looked like yesterday, last week, or last year — and even restore old versions if you made a mistake!

Delta Lake tracks every version of your data:

flowchart LR V0["Version 0
1000 rows"] --> V1["Version 1
+500 rows"] V1 --> V2["Version 2
DELETE 100"] V2 --> V3["Version 3
UPDATE status"] QUERY["Query"] --> |"@v0"| V0 QUERY --> |"@v1"| V1 QUERY --> |"@v2"| V2 QUERY --> |"@v3"| V3 style V0 fill:#fef3c7,stroke:#d97706 style V1 fill:#d1fae5,stroke:#059669 style V2 fill:#dbeafe,stroke:#2563eb style V3 fill:#fce7f3,stroke:#db2777
# Read current version
df = spark.read.format("delta").load("users")

# Read specific version
df_v0 = spark.read.format("delta").option("versionAsOf", 0).load("users")
df_v5 = spark.read.format("delta").option("versionAsOf", 5).load("users")

# Read as of timestamp
df_yesterday = spark.read.format("delta") \
  .option("timestampAsOf", "2024-12-15") \
  .load("users")

# SQL syntax
spark.sql("SELECT * FROM users VERSION AS OF 5")
spark.sql("SELECT * FROM users TIMESTAMP AS OF '2024-12-15'")

Use Cases:

  • 🔍 Audit: “What did the data look like yesterday?”
  • 🐛 Debug: “What changed between versions 5 and 6?”
  • ♻️ Rollback: “Undo that bad DELETE!”

Updates & Deletes: How They Work

Traditional Parquet can’t update/delete. Delta Lake can!

DELETE

from delta.tables import DeltaTable

delta_table = DeltaTable.forPath(spark, "users")

# Delete rows
delta_table.delete("age < 18")

Under the Hood:

flowchart TD BEFORE["file1.parquet:
1000 rows
(100 match DELETE)"] DELETE["DELETE WHERE age < 18"] BEFORE --> DELETE DELETE --> LOG["Transaction Log:
REMOVE file1.parquet
ADD file1_new.parquet
ADD file2_new.parquet"] DELETE --> NEW1["file1_new.parquet:
600 rows (no matches)"] DELETE --> NEW2["file2_new.parquet:
300 rows (no matches)"] style BEFORE fill:#fecaca,stroke:#dc2626 style NEW1 fill:#d1fae5,stroke:#059669 style NEW2 fill:#d1fae5,stroke:#059669 style LOG fill:#fef3c7,stroke:#d97706

Process:

  1. Read files that might contain matching rows
  2. Filter out deleted rows
  3. Write new files (without deleted rows)
  4. Update transaction log: REMOVE old, ADD new

Old files still exist! (Enables time travel)

UPDATE

# Update rows
delta_table.update(
  condition = "status = 'pending'",
  set = {"status": "'active'"}
)

Same process as DELETE:

  1. Read files with matching rows
  2. Apply updates
  3. Write new files
  4. Log: REMOVE old, ADD new

MERGE (Upsert)

# Upsert (UPDATE if exists, INSERT if not)
delta_table.alias("target").merge(
  updates_df.alias("source"),
  "target.user_id = source.user_id"
).whenMatchedUpdate(set = {
  "name": "source.name",
  "email": "source.email"
}).whenNotMatchedInsert(values = {
  "user_id": "source.user_id",
  "name": "source.name",
  "email": "source.email"
}).execute()

Use Case: CDC (Change Data Capture) from databases


Schema Evolution

Delta Lake enforces schemas but allows safe evolution:

Adding Columns

# Initial schema: id, name
df1 = spark.createDataFrame([(1, "Alice")], ["id", "name"])
df1.write.format("delta").save("users")

# Add column: email
df2 = spark.createDataFrame([(2, "Bob", "bob@example.com")], ["id", "name", "email"])

# ❌ Fails without option
df2.write.format("delta").mode("append").save("users")

# ✅ Enable schema evolution
df2.write.format("delta") \
  .mode("append") \
  .option("mergeSchema", "true") \
  .save("users")

# Old rows get NULL for new column
spark.read.format("delta").load("users").show()
# +---+-----+---------------+
# | id| name|          email|
# +---+-----+---------------+
# |  1|Alice|           null|  ← NULL for missing column
# |  2|  Bob|bob@example.com|
# +---+-----+---------------+

Changing Column Types

# ❌ Unsafe: Changing type (rejected)
ALTER TABLE users ALTER COLUMN age TYPE STRING;  # Fails!

# ✅ Safe: Add new column, migrate data, drop old
ALTER TABLE users ADD COLUMN age_str STRING;
UPDATE users SET age_str = CAST(age AS STRING);
ALTER TABLE users DROP COLUMN age;
ALTER TABLE users RENAME COLUMN age_str TO age;

Schema Enforcement

# Set column constraints
spark.sql("""
  ALTER TABLE users ADD CONSTRAINT valid_age CHECK (age >= 0 AND age <= 120);
  ALTER TABLE users ALTER COLUMN email SET NOT NULL;
""")

# ❌ These fail
INSERT INTO users VALUES (1, "Alice", null, 25);  # email NOT NULL
INSERT INTO users VALUES (2, "Bob", "bob@ex.com", 150);  # age > 120

Optimization: OPTIMIZE & Z-ORDER

What’s happening here: Over time, you end up with lots of tiny files (like having 1000 Word documents when you could have 10). OPTIMIZE combines them into bigger files, making everything faster. Z-ORDER is like organizing your files so related stuff is stored together — makes searches way faster!

Delta Lake files accumulate over time. Optimize them!

OPTIMIZE (Compaction)

# Before: 1000 small files (slow reads)
# After: 10 large files (fast reads)

spark.sql("OPTIMIZE users")

# Or with Python API
delta_table.optimize().executeCompaction()
flowchart LR subgraph before["Before OPTIMIZE"] F1["file1.parquet
1 MB"] F2["file2.parquet
1 MB"] F3["file3.parquet
1 MB"] F4["... (1000 files)"] end subgraph after["After OPTIMIZE"] F5["file_new1.parquet
100 MB"] F6["file_new2.parquet
100 MB"] end before --> |"Compact"| after style before fill:#fecaca,stroke:#dc2626 style after fill:#d1fae5,stroke:#059669

Z-ORDER (Co-locate Data)

# Co-locate data by frequently filtered columns
spark.sql("OPTIMIZE users ZORDER BY (country, city)")

# Queries on these columns become 10x faster!
spark.sql("SELECT * FROM users WHERE country = 'US' AND city = 'NYC'")

How Z-ORDER Works:

flowchart TD subgraph without["Without Z-ORDER"] R1["Row 1: US, NYC"] R2["Row 2: UK, London"] R3["Row 3: US, NYC"] R4["Row 4: UK, London"] end subgraph with["With Z-ORDER BY country, city"] R5["Row 1: US, NYC
Row 3: US, NYC"] R6["Row 2: UK, London
Row 4: UK, London"] end without --> |"Z-ORDER"| with QUERY["WHERE country='US'"] --> with with --> |"Read only 1 file"| FAST["🚀 Fast"] style without fill:#fecaca,stroke:#dc2626 style with fill:#d1fae5,stroke:#059669

Result: Data with similar filter values is physically co-located!


Vacuum: Clean Up Old Files

Here’s the trade-off: Remember how Time Travel keeps all old versions of your data? That’s great, but eventually you’re storing 100 versions and running out of space! VACUUM is like emptying your trash can — it permanently deletes old versions you don’t need anymore. Warning: Once you vacuum, you can’t time-travel to those deleted versions!

Time travel keeps old files forever. Eventually, clean up:

# WARNING: Deletes files older than 7 days (default retention)
spark.sql("VACUUM users")

# Or specify retention period
spark.sql("VACUUM users RETAIN 168 HOURS")  # 7 days

# See what would be deleted (dry run)
spark.sql("VACUUM users DRY RUN")

What VACUUM Does:

flowchart LR OLD["Old files
(not in current version)"] RETENTION["Retention
Check"] DELETE["Delete files
older than retention"] OLD --> RETENTION RETENTION --> |"Older than 7 days"| DELETE RETENTION --> |"Within 7 days"| KEEP["Keep files
(time travel)"] style OLD fill:#fef3c7,stroke:#d97706 style DELETE fill:#fecaca,stroke:#dc2626 style KEEP fill:#d1fae5,stroke:#059669

⚠️ Warning: After VACUUM, you can’t time-travel to deleted versions!


Performance Tips

1. Partition Your Data

# Partition by date (common pattern)
df.write.format("delta") \
  .partitionBy("year", "month", "day") \
  .save("events")

# Directory structure:
# events/
#   year=2024/
#     month=12/
#       day=15/part-00000.parquet
#       day=16/part-00000.parquet

# Query: Only reads relevant partitions
spark.sql("SELECT * FROM events WHERE year=2024 AND month=12 AND day=16")

Partition Pruning:

Total data: 1 TB (365 days)
Query: WHERE day=16 → Reads 3 GB (1 day) → 300x faster!

2. Enable Auto-Optimize

# Enable auto-compaction (Databricks)
spark.sql("""
  ALTER TABLE users SET TBLPROPERTIES (
    'delta.autoOptimize.optimizeWrite' = 'true',
    'delta.autoOptimize.autoCompact' = 'true'
  )
""")

# Automatically optimizes during writes

3. Use Data Skipping

Delta Lake tracks min/max statistics per file:

# Query: WHERE age > 50
# Delta skips files where max_age < 50 (don't contain matches)

# Enable data skipping statistics
spark.sql("""
  ALTER TABLE users SET TBLPROPERTIES (
    'delta.dataSkippingNumIndexedCols' = '10'
  )
""")

4. Liquid Clustering (New!)

# Alternative to Z-ORDER and partitioning (Delta Lake 3.0+)
spark.sql("""
  CREATE TABLE users (id LONG, country STRING, city STRING)
  USING DELTA
  CLUSTER BY (country, city)
""")

# Automatically maintains clustering over time

Delta Lake vs Traditional Parquet

Feature Parquet Delta Lake
File Format Parquet Parquet + Transaction Log
ACID ❌ No ✅ Yes
Time Travel ❌ No ✅ Yes
Schema Enforcement ❌ No ✅ Yes
Updates/Deletes ❌ No (must rewrite) ✅ Yes (efficient)
Concurrent Writes ⚠️ Unsafe ✅ Safe
Compatibility Universal Spark, Databricks, Pandas

Real-World Example

# E-commerce orders table
from pyspark.sql.functions import col, current_timestamp

# Create Delta table
orders_df = spark.createDataFrame([
  (1, "pending", "2024-12-16", 100.0),
  (2, "shipped", "2024-12-16", 200.0),
], ["order_id", "status", "order_date", "amount"])

orders_df.write.format("delta") \
  .partitionBy("order_date") \
  .save("/data/orders")

# Later: Update order status
from delta.tables import DeltaTable

delta_table = DeltaTable.forPath(spark, "/data/orders")

delta_table.update(
  condition = "order_id = 1",
  set = {"status": "'shipped'", "updated_at": "current_timestamp()"}
)

# Merge new orders (upsert)
new_orders = spark.createDataFrame([
  (1, "delivered", "2024-12-16", 100.0),  # Update existing
  (3, "pending", "2024-12-17", 300.0),    # Insert new
], ["order_id", "status", "order_date", "amount"])

delta_table.alias("target").merge(
  new_orders.alias("source"),
  "target.order_id = source.order_id"
).whenMatchedUpdate(set = {
  "status": "source.status"
}).whenNotMatchedInsert(values = {
  "order_id": "source.order_id",
  "status": "source.status",
  "order_date": "source.order_date",
  "amount": "source.amount"
}).execute()

# Time travel: What was order 1's status yesterday?
historical_df = spark.read.format("delta") \
  .option("timestampAsOf", "2024-12-15") \
  .load("/data/orders") \
  .filter("order_id = 1")

# Optimize for better read performance
spark.sql("OPTIMIZE delta.`/data/orders` ZORDER BY (status)")

# Clean up old files (keep 30 days)
spark.sql("VACUUM delta.`/data/orders` RETAIN 720 HOURS")

Best Practices

1. Always Use Partitioning

# ✅ Partition by time (most common)
.partitionBy("year", "month", "day")

# ❌ Avoid too many partitions
.partitionBy("user_id")  # Millions of partitions → slow!

# ✅ Good cardinality
.partitionBy("country", "date")  # Hundreds of partitions

Rule of Thumb: Aim for 10-100 files per partition

2. Optimize Regularly

# Set up optimization schedule
# Daily: OPTIMIZE for read performance
spark.sql("OPTIMIZE my_table ZORDER BY (frequently_filtered_col)")

# Weekly: VACUUM to clean up
spark.sql("VACUUM my_table RETAIN 168 HOURS")

3. Use MERGE for Upserts

# ✅ Efficient upsert
delta_table.merge(updates, "id").whenMatched...

# ❌ Slow: DELETE + INSERT
spark.sql("DELETE FROM table WHERE id IN (...)")
spark.sql("INSERT INTO table VALUES (...)")

4. Enable Schema Evolution Explicitly

# ✅ Explicit opt-in
.option("mergeSchema", "true")

# ❌ Automatic evolution can cause surprises

5. Monitor Transaction Log Size

# Check number of commits
spark.sql("DESCRIBE HISTORY my_table")

# Create checkpoint manually if needed (every 10 commits auto)
spark.sql("CHECKPOINT my_table")

Common Pitfalls

1. Too Many Small Files

# ❌ Problem: 10,000 small files
# Solution: OPTIMIZE
spark.sql("OPTIMIZE my_table")

2. Not Using Time Travel Before DELETE

# ❌ Oops! Deleted wrong data
spark.sql("DELETE FROM users WHERE status = 'active'")

# ✅ Rollback using time travel
old_version = spark.sql("DESCRIBE HISTORY users").first().version - 1
spark.sql(f"RESTORE TABLE users TO VERSION AS OF {old_version}")

3. Forgetting to VACUUM

# Problem: Disk usage grows forever
# Solution: Regular VACUUM
spark.sql("VACUUM my_table RETAIN 168 HOURS")

4. Wrong Partition Column

# ❌ High cardinality
.partitionBy("user_id")  # Millions of partitions

# ✅ Low-medium cardinality
.partitionBy("country", "date")  # Hundreds of partitions

5. Not Handling Conflicts

# ❌ Assumes write will succeed
df.write.format("delta").mode("append").save("table")

# ✅ Handle concurrent write conflicts
from delta.exceptions import ConcurrentAppendException

try:
    df.write.format("delta").mode("append").save("table")
except ConcurrentAppendException:
    # Retry logic
    pass

Should You Use Delta Lake?

Quick Decision Guide:

  • Use Delta Lake: Your data changes frequently (updates/deletes), you need to track history, or multiple people are working with the same data
  • Use Plain Parquet: You just save data once and never change it (like archiving old records)
  • Use Delta Lake: You’re building anything business-critical where mistakes could be costly
  • Use Plain Parquet: You need to share files with systems that don’t support Delta Lake

Example: A bank should use Delta Lake (need perfect records, auditing). A weather station archiving old readings could use plain Parquet (never changes).

When to Use Delta Lake

flowchart TD START["Choose Delta Lake when:"] START --> ACID["Need ACID
transactions"] START --> UPDATE["Need to UPDATE/
DELETE data"] START --> TIME["Need time travel/
audit history"] START --> QUALITY["Need schema
enforcement"] START2["Use Plain Parquet when:"] START2 --> SIMPLE["Write-once,
read-many"] START2 --> COMPAT["Need universal
compatibility"] START2 --> STREAM["Simple streaming"] style START fill:#d1fae5,stroke:#059669 style START2 fill:#fef3c7,stroke:#d97706

✅ Use Delta Lake For:

  1. Data Warehouses / Lakehouses
    • ACID transactions
    • Schema enforcement
    • Updates/deletes
  2. CDC (Change Data Capture)
    • MERGE for upserts
    • Track changes over time
  3. Auditing / Compliance
    • Time travel
    • Full history
  4. Collaborative Environments
    • Multiple writers
    • Concurrent updates

❌ Use Plain Parquet For:

  1. Simple ETL
    • Write-once, read-many
    • No updates needed
  2. Universal Compatibility
    • Share with non-Spark tools
    • Athena, Redshift Spectrum
  3. Extreme Performance
    • No transaction log overhead

Summary

Quick Takeaways:

  • What is Delta Lake? Parquet files + a smart “change log” that tracks every edit
  • Real-world comparison: Like adding Google Docs features (version history, undo, collaboration) to regular files
  • Why use it? You can update/delete data, undo mistakes, see history, and multiple people can edit safely
  • When to use it? Business-critical data, anything that changes frequently, data that needs auditing
  • When NOT to use it? Data you write once and never change (like archiving old records)
  • Bottom line: If your data is important and changes over time → use Delta Lake. If you just dump data and never touch it again → plain Parquet is fine.

Delta Lake adds ACID transactions to data lakes:

Key Features:

  1. ACID transactions — Your data stays safe and consistent (no corruption or half-finished saves)
  2. Time travel — See what data looked like yesterday, last week, or last year
  3. Schema enforcement — Prevent bad data from sneaking in
  4. Efficient updates/deletes — Change or remove data without rewriting everything
  5. MERGE (upsert) support — Update existing rows or insert new ones in one operation
  6. Concurrent writes — Multiple people can edit safely without conflicts

Key Components:

  • Transaction log (_delta_log/) — Like a diary tracking every change
  • Parquet files — The actual data (unchanged format)
  • Checkpoints — Speedup for reading the log
  • Statistics — Smart indexes to skip irrelevant data

Decision Matrix:

Your Need Solution
ACID transactions 🏆 Delta Lake
Time travel / audit 🏆 Delta Lake
Updates/deletes 🏆 Delta Lake
Write-once, read-many Plain Parquet
Universal compatibility Plain Parquet
Streaming with exactly-once 🏆 Delta Lake

The Bottom Line:

  • Need database features on a data lake? → Delta Lake
  • Simple append-only workload? → Plain Parquet is fine
  • In doubt? → Start with Delta Lake. It’s worth the small overhead for the safety and features.

Further Reading