Manage a Delta Lake table through its life
A table changes as data arrives, a new column appears, a month gets rebuilt, labels are fixed, rows are removed, and late corrections come in. This example takes a Delta Lake table through each of those changes and reads all eight versions it creates. The table holds daily trips per pickup borough, built from real NYC green taxi records for January to March 2024.
Run it
Run all commands below from the repository root (sail-examples/):
uv run pipelines/delta-table-lifecycle/main.py
The first run downloads three months of trip records and the zone lookup (4 MB) into pipelines/delta-table-lifecycle/.data/. Sail reads and writes Delta Lake without extra packages.
The table ends at version 7. Reading version 3 shows March 31 before the corrections: Manhattan with 740 trips, and the borough labels N/A and Unknown that later versions relabeled or removed.
Output
The newest capture appears first. Older output stays here when a result changes.
Current output · Sail 0.7.3
Captured on Sail 0.7.3. Latest capture: 2026-11-05, Python 3.12.14.
versions
+-------+----+
|version|rows|
+-------+----+
|0 |0 |
|1 |182 |
|2 |353 |
|3 |531 |
|4 |531 |
|5 |531 |
|6 |440 |
|7 |441 |
+-------+----+
by_month
+-----+-----+--------------+
|month|trips|rows_with_tips|
+-----+-----+--------------+
|1 |56437|151 |
|2 |53448|0 |
|3 |57575|148 |
+-----+-----+--------------+
march_31
+---------+-------------+-----+--------+
|version |borough |trips|fares |
+---------+-------------+-----+--------+
|current |Bronx |17 |347.64 |
|current |Brooklyn |267 |6939.9 |
|current |Manhattan |1000 |15000.0 |
|current |Outside NYC |1 |69.0 |
|current |Queens |446 |8526.5 |
|current |Staten Island|1 |20.0 |
|version 3|Bronx |17 |347.64 |
|version 3|Brooklyn |267 |6939.9 |
|version 3|Manhattan |740 |10492.56|
|version 3|N/A |1 |69.0 |
|version 3|Queens |446 |8526.5 |
|version 3|Unknown |4 |129.8 |
+---------+-------------+-----+--------+
boroughs
+-------------+
|borough |
+-------------+
|Bronx |
|Brooklyn |
|EWR |
|Manhattan |
|Outside NYC |
|Queens |
|Staten Island|
+-------------+
Python code
main.py165 lines
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "pysail==0.7.3",
# "pyspark-client==4.1.3",
# "pyarrow==26.0.0",
# "pandas==3.0.6",
# ]
# ///
"""The life of a Delta Lake table: append, evolve the schema, update, delete, merge, rebuild, time travel.
The table holds daily taxi trips per pickup borough, built from real NYC green taxi records for
January to March 2024. The example reads each of the eight table versions it creates.
"""
import atexit
import hashlib
import os
import shutil
import tempfile
import urllib.request
from pathlib import Path
from pyspark.sql import DataFrame, SparkSession
DATA = Path(__file__).resolve().parent / ".data"
SOURCE = "https://d37ci6vzurychx.cloudfront.net"
FILES = {
# name: (url, SHA-256 of the file this example was written against)
"green_tripdata_2024-01.parquet": "1512a953bed564ac68dcb622827424dfb4f68ad93c2a4572e50ef6ab0b09c1ad",
"green_tripdata_2024-02.parquet": "cb7eb405ee7e795c9fd6b8ad1b291f46f4e0a1cd60a57067b0aaca2420e8bc5d",
"green_tripdata_2024-03.parquet": "43dedd66dd556acfc38da679a174b6adc32e01dd6dccad14bdfdf1521a14abed",
"taxi_zone_lookup.csv": "1a99e105092230f8620f301edcca7f80d3080642ff404d28ed957d3fa222c8ed",
}
def temp_dir(prefix: str) -> str:
"""A temporary directory that is removed when the process exits."""
path = tempfile.mkdtemp(prefix=prefix)
atexit.register(shutil.rmtree, path, ignore_errors=True)
return path
def download(name: str) -> Path:
"""Reuse or download a file only when its SHA-256 matches the pinned input."""
expected = FILES[name]
folder = "misc" if name.endswith(".csv") else "trip-data"
url = f"{SOURCE}/{folder}/{name}"
path = DATA / name
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with (
urllib.request.urlopen(url, timeout=120) as response,
tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", delete=False) as out,
):
temporary = Path(out.name)
while chunk := response.read(1024 * 1024):
out.write(chunk)
with temporary.open("rb") as downloaded:
if hashlib.file_digest(downloaded, "sha256").hexdigest() != expected:
raise ValueError(f"SHA-256 mismatch: {name}")
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
with path.open("rb") as cached:
if hashlib.file_digest(cached, "sha256").hexdigest() != expected:
raise ValueError(f"SHA-256 mismatch: {name}")
return path
def daily_by_borough(spark: SparkSession, month: int, with_tips: bool) -> DataFrame:
"""One month of trips summarized per day and pickup borough."""
trips = spark.read.parquet(str(download(f"green_tripdata_2024-{month:02d}.parquet")))
trips.createOrReplaceTempView("month_trips")
tips = ", round(sum(t.tip_amount), 2) AS tips" if with_tips else ""
return spark.sql(f"""
SELECT {month} AS month, date(t.lpep_pickup_datetime) AS day, z.Borough AS borough,
count(*) AS trips, round(sum(t.fare_amount), 2) AS fares{tips}
FROM month_trips t JOIN zones z ON t.PULocationID = z.LocationID
WHERE month(t.lpep_pickup_datetime) = {month} AND year(t.lpep_pickup_datetime) = 2024
GROUP BY 2, 3
""")
def run(spark: SparkSession) -> dict[str, DataFrame]:
spark.read.schema("LocationID int, Borough string, Zone string, service_zone string").csv(
str(download("taxi_zone_lookup.csv")), header=True
).createOrReplaceTempView("zones")
path = temp_dir("delta-") + "/daily"
# Version 0: declare the table: its columns, their types, and the partition column.
spark.sql(f"""
CREATE TABLE daily_trips (month INT, day DATE, borough STRING, trips BIGINT, fares DOUBLE)
USING delta PARTITIONED BY (month) LOCATION '{path}'
""")
# Versions 1 and 2: append January, then February.
for month in (1, 2):
daily_by_borough(spark, month, with_tips=False).write.format("delta").mode("append").save(path)
# Version 3: March arrives with a new column. mergeSchema adds it; older rows read it as NULL.
march = daily_by_borough(spark, 3, with_tips=True)
march.write.format("delta").mode("append").option("mergeSchema", "true").save(path)
# Version 4: rebuild January with the tips column, replacing only that month's rows.
january = daily_by_borough(spark, 1, with_tips=True)
january.write.format("delta").mode("overwrite").option("replaceWhere", "month = 1").save(path)
# Version 5: relabel a value in place.
spark.sql("UPDATE daily_trips SET borough = 'Outside NYC' WHERE borough = 'N/A'")
# Version 6: remove rows that should not be reported.
spark.sql("DELETE FROM daily_trips WHERE borough = 'Unknown'")
# Version 7: late corrections for one day: update the matching row, insert the missing one.
corrections = spark.createDataFrame(
[
(3, "2024-03-31", "Manhattan", 1000, 15000.0, 1500.0),
(3, "2024-03-31", "Staten Island", 1, 20.0, 0.0),
],
"month int, day string, borough string, trips long, fares double, tips double",
).selectExpr("month", "CAST(day AS DATE) AS day", "borough", "trips", "fares", "tips")
corrections.createOrReplaceTempView("corrections")
spark.sql("""
MERGE INTO daily_trips t USING corrections c
ON t.day = c.day AND t.borough = c.borough
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
""")
# Time travel: every version is still readable.
versions = spark.createDataFrame(
[(v, spark.read.format("delta").option("versionAsOf", v).load(path).count()) for v in range(8)],
"version int, rows long",
)
current = spark.read.format("delta").load(path)
summary = (
current.groupBy("month")
.agg({"trips": "sum", "tips": "count"})
.withColumnsRenamed({"sum(trips)": "trips", "count(tips)": "rows_with_tips"})
)
march_31 = spark.sql(f"""
SELECT 'version 3' AS version, borough, trips, fares FROM delta.`{path}` VERSION AS OF 3
WHERE day = DATE'2024-03-31'
UNION ALL
SELECT 'current', borough, trips, fares FROM daily_trips WHERE day = DATE'2024-03-31'
""")
return {
"versions": versions.orderBy("version"),
"by_month": summary.orderBy("month"),
"march_31": march_31.orderBy("version", "borough"),
"boroughs": current.select("borough").distinct().orderBy("borough"),
}
if __name__ == "__main__":
from pysail.spark import SparkConnectServer
server = SparkConnectServer()
server.start()
_, port = server.listening_address
spark = SparkSession.builder.remote(f"sc://localhost:{port}").getOrCreate()
results = run(spark)
for name, frame in results.items():
print(name)
frame.show(truncate=False)
spark.stop()
server.stop()
How it works
| Version | Change | How |
|---|---|---|
| 0 | Declare the table | CREATE TABLE ... (columns) USING delta PARTITIONED BY (month) LOCATION ... |
| 1, 2 | Append January, then February | df.write.format("delta").mode("append") |
| 3 | Append March with a new tips column |
.option("mergeSchema", "true"): older rows read tips as NULL |
| 4 | Rebuild January, now with tips | .mode("overwrite").option("replaceWhere", "month = 1") replaces only January |
| 5 | Relabel N/A as Outside NYC |
UPDATE daily_trips SET ... WHERE ... |
| 6 | Remove the Unknown borough |
DELETE FROM daily_trips WHERE ... |
| 7 | Late corrections for March 31 | MERGE INTO ... WHEN MATCHED THEN UPDATE SET * WHEN NOT MATCHED THEN INSERT * |
- Time travel.
option("versionAsOf", v)reads versionv, and SQL can do the same withVERSION AS OF. Theversionsoutput counts the rows in each version, andmarch_31puts one day from version 3 next to the current table. - Declare first. Creating the table with its columns, types and partitioning before writing makes the schema a deliberate decision. Columns declared this way are nullable.
On Apache Spark
The same code runs on Apache Spark with the delta-spark package, its session extension (io.delta.sql.DeltaSparkSessionExtension) and its catalog (org.apache.spark.sql.delta.catalog.DeltaCatalog as spark_catalog). The checks run it on Spark 4.2 with delta-spark 4.4.1 and compare every result with Sail's.
On Sail
- On Sail, reads of some older versions after a Delta checkpoint can fail. This example checks versions 0 through 7 before the first checkpoint. Verify reads of the specific versions you need in longer table histories.
- To add a column with an append and
mergeSchema, do it the way this example does: a table created withCREATE TABLE ... (columns) USING delta, then an append whose new column is computed (heresum(tip_amount)).ALTER TABLE ... ADD COLUMNSalso works. - Sail does not support
RESTORE. Read old versions withversionAsOf.DESCRIBE HISTORY,OPTIMIZEandVACUUMwork.
Data
NYC TLC Trip Record Data, green taxi trips for January to March 2024, and the taxi zone lookup table.