All posts

Hidden partitioning and partition evolution: changing layout without rewriting history

Iceberg derives partition values from a transform on a real column, so queries never name the partition. That indirection is also what lets the layout change later: after switching months to days, two of three data files still carried the old spec and nothing was rewritten.

19 min read Iceberg

TL;DR

  • Partition values are derived from a column by a transform, so a query filters on event_ts and Iceberg prunes event_ts_day for it. Measured: a plain timestamp predicate matched 3,000 rows and read 1 of 3 data files.
  • Changing the layout does not rewrite data. After REPLACE PARTITION FIELD months(ts) WITH days(ts), files carried spec_id values of [0, 0, 1] — the old ones untouched.
  • Both specs stay in metadata (spec-id 0 and 1) and both reference the same source-id, so one predicate prunes across both layouts.
  • Directory names like ts_month=2026-01 and ts_day=2026-03-09 coexist in the same table, and all 900 rows stayed queryable.
  • Hidden partitioning removes the Hive failure where forgetting the partition filter scans everything silently.

The Hive problem it fixes

On a Hive table, a partition is a physical column in the path:

/trips/dt=2026-01-06/part-0000.parquet

To get pruning, a query must filter on dt. Filtering on the actual event timestamp does not prune, because dt is a separate column the user is responsible for keeping consistent.

Two failures follow, both routine. Someone writes WHERE event_ts >= '2026-01-06' and scans the whole table, because the query is correct and simply did not mention dt. And someone writes a row whose dt disagrees with its event_ts, producing data that is invisible to correct queries.

How Iceberg derives partitions

You declare a transform on a real column:

CREATE TABLE ice.blog_partitioning.events (
  id BIGINT, event_ts TIMESTAMP, region STRING, amount DOUBLE)
USING iceberg PARTITIONED BY (days(event_ts));

There is no dt column. Iceberg computes the day from event_ts at write time and records it in the manifests. The spec, read from the metadata file:

partition-specs: [{"spec-id": 0, "fields": [{"name": "event_ts_day", "transform": "day",
                   "source-id": 2, "field-id": 1000}]}]
field id of event_ts: 2

source-id: 2 is the field id of event_ts. The partition is defined as a function of that column, which is the indirection everything else depends on.

At read time the query names the real column:

SELECT count(*) FROM ice.blog_partitioning.events
WHERE event_ts >= timestamp'2026-01-06 00:00:00'
  AND event_ts <  timestamp'2026-01-07 00:00:00'
partition directories on disk: ['event_ts_day=2026-01-05', 'event_ts_day=2026-01-06', 'event_ts_day=2026-01-07']
data files in table: 3
rows matched: 3000 | data files read: 1 | skipped by pruning: 2

Iceberg applies day() to the predicate and matches it against partition values. The scan read one file and skipped the other two; the counts come from the Iceberg scan node’s own resultDataFiles and skippedDataFiles metrics. Nobody has to know the layout, and nobody can forget to filter on it.

The transforms

Transform Use
years, months, days, hours time columns, at the granularity queries filter on
bucket(N, col) high-cardinality keys; spreads evenly into N buckets
truncate(N, col) prefixes of strings, ranges of numbers
identity(col) the value itself — the Hive-style behaviour

bucket is the one people under-use. Partitioning by identity(customer_id) on a million customers produces a million partitions and a small-file disaster; bucket(64, customer_id) gives 64 balanced partitions that still prune equality filters. Measured below: 20,000 customers landed in exactly 64 partitions of 264 to 368 rows each.

Partition evolution, measured

Monthly partitioning stops working when data grows. On Hive that means rewriting the table. On Iceberg:

ALTER TABLE ice.blog_partitioning.transactions REPLACE PARTITION FIELD months(ts) WITH days(ts);

The table held 300 transactions each in January and February. Then one more append of 300 on March 9. The result:

dirs after months(): ['ts_month=2026-01', 'ts_month=2026-02']
dirs after days()  : ['ts_day=2026-03-09', 'ts_month=2026-01', 'ts_month=2026-02']
  spec 0 [{"name": "ts_month", "transform": "month", "source-id": 2, "field-id": 1000}]
  spec 1 [{"name": "ts_day", "transform": "day", "source-id": 2, "field-id": 1001}]
default-spec-id: 1
spec_id per data file: [0, 0, 1]
rows total: 900

After replacing a monthly partition transform with a daily one, the table records two partition specs and each data file is tagged with the spec that wrote it: two files keep spec_id 0 with month-based paths, the new file uses spec_id 1 with a day-based path, and nothing was rewritten

Two of the three data files still carry spec_id = 0. They were not rewritten, moved or touched. The new file uses spec 1. Both directory layouts sit in the same table, and all 900 rows are queryable through one name.

This works because a manifest records which spec each file was written under. A reader consults the right spec per file rather than assuming one layout for the table.

Note both specs reference source-id: 2. Because both derive from the same column, a single predicate on ts prunes files under either spec — the reader applies month() to old files and day() to new ones. Measured, one day’s predicate under each spec:

  January 12 (month spec): rows 300 | data files read 1 | skipped 1
  March 9 (day spec)     : rows 300 | data files read 1 | skipped 0

Both read one of the three files, and each skipped one of the table’s two manifests first. The January and February files share a spec-0 manifest whose partition summary spans 2026-01 to 2026-02; the March file has a spec-1 manifest of its own. The January query drops the March manifest and then skips the February file inside the other one. The March query drops the whole spec-0 manifest, so it reports no skipped data files at all.

The new field also got a new field-id (1001, not 1000), which keeps old partition values unambiguous.

Choosing the transform, with arithmetic

The transform decides how many partitions exist, and partition count is the number that goes wrong.

-- how many partitions does the current layout actually produce?
SELECT count(*) AS partitions,
       avg(file_count) AS avg_files,
       avg(record_count) AS avg_rows
FROM ice.blog_partitioning.events.partitions;
+----------+---------+--------+
|partitions|avg_files|avg_rows|
+----------+---------+--------+
|         3|      1.0|  3000.0|
+----------+---------+--------+

Aim for partitions holding hundreds of megabytes to a few gigabytes. Work backwards from volume: a table taking 50 GB a month partitioned by days gives ~1.6 GB per partition, which is comfortable. The same table partitioned by hours gives ~70 MB per partition and 720 partitions a month, which is the beginning of a small-file problem.

bucket is the transform for high-cardinality keys, and the bucket count is a capacity decision:

CREATE TABLE ice.blog_partitioning.orders (order_id BIGINT, customer_id BIGINT, amount DOUBLE)
USING iceberg
PARTITIONED BY (bucket(64, customer_id));
distinct customers: 20000
+----------+--------+--------+
|partitions|min_rows|max_rows|
+----------+--------+--------+
|        64|     264|     368|
+----------+--------+--------+
data files in table: 64
equality filter: rows 1 | data files read: 1 | skipped: 63
range filter   : rows 101 | data files read: 64 | skipped: 0

Sixty-four balanced partitions, regardless of how many customers exist. An equality filter on customer_id read one file of 64; a range filter over 101 consecutive ids read all 64, because hashing destroys ordering. Use bucket for keys you look up and truncate or a time transform for keys you scan ranges of.

Multiple fields multiply:

PARTITIONED BY (days(event_ts), bucket(16, customer_id))    -- 31 x 16 = 496 in January
+----------+--------+
|partitions|avg_rows|
+----------+--------+
|       496|    30.0|
+----------+--------+

A month of 14,880 events, one every three minutes, filled all 496 partitions with 30 rows each. Writing that month in a single append also ran a 1 GB driver out of heap (java.lang.OutOfMemoryError: Java heap space inside Iceberg’s FanoutDataWriter), because the task kept a Parquet writer open for every partition it touched; the script loads it one day at a time. That is usually one field too many. Each additional field divides the data further, and the small-file cost arrives faster than the pruning benefit.

Evolving in practice

-- add a field: new data gains the finer layout, old data keeps its own
-- (events_daily is PARTITIONED BY days(event_ts))
ALTER TABLE ice.blog_partitioning.events_daily ADD PARTITION FIELD hours(event_ts);

-- replace one: the usual month-to-day move
-- (events_evolving is PARTITIONED BY months(event_ts), bucket(16, customer_id))
ALTER TABLE ice.blog_partitioning.events_evolving
  REPLACE PARTITION FIELD months(event_ts) WITH days(event_ts);

-- drop one entirely
ALTER TABLE ice.blog_partitioning.events_evolving DROP PARTITION FIELD bucket(16, customer_id);
  OK    ADD PARTITION FIELD hours(event_ts) on a days(event_ts) table
  OK    REPLACE PARTITION FIELD months(event_ts) WITH days(event_ts)
  OK    DROP PARTITION FIELD bucket(16, customer_id)

All three are metadata-only, and all three leave existing files alone. Adding hours next to an existing days on the same column is accepted; Iceberg only refuses a field that duplicates one already in the spec. The replace and the drop each wrote a spec, so events_evolving now has three:

  spec 0 ['month(2)', 'bucket[16](3)']
  spec 1 ['bucket[16](3)', 'day(2)']
  spec 2 ['day(2)']

To check what you have afterwards, after one more append of 200 rows:

SELECT spec_id, count(*) AS files, sum(record_count) AS records
FROM ice.blog_partitioning.events_evolving.files GROUP BY spec_id ORDER BY spec_id;
+-------+-----+-------+
|spec_id|files|records|
+-------+-----+-------+
|      0|   32|   1000|
|      2|    9|    200|
+-------+-----+-------+

Spec 1 owns no files: it existed only between the two ALTERs.

A table with files spread across three or four specs is not broken, but it is harder to reason about, and planning must consult each spec. When old data is queried often enough to matter, rewrite it into the current layout:

CALL ice.system.rewrite_data_files(
  table => 'blog_partitioning.events_evolving',
  where => "event_ts < TIMESTAMP '2026-03-01 00:00:00'");

The where argument is a string holding a SQL predicate, so a literal inside it needs the other quote character. Nesting single quotes, as in where => 'event_ts < date'2026-03-01'', does not parse:

AnalysisException: extraneous input '2026' expecting {',', ')'}

The corrected call rewrote the 32 month-and-bucket files into 42 day files, one per day from January 1 to February 11, and left the 9 March files alone:

+--------------------------+----------------------+
|rewritten_data_files_count|added_data_files_count|
+--------------------------+----------------------+
|                        32|                    42|
+--------------------------+----------------------+
+-------+-----+-------+
|spec_id|files|records|
+-------+-----+-------+
|      2|   51|   1200|
+-------+-----+-------+

That is a data-moving job, unlike the ALTER, and it is the step that actually gives historical data the new pruning. Every file is now on spec 2.

Where partitioning stops and sorting starts

Partitioning eliminates whole directories of files. Sorting narrows the column statistics inside them so individual files can be skipped. They solve adjacent problems and people reach for the wrong one regularly.

Filter shape What helps
event_ts in a narrow range a time partition transform
customer_id = ? on a huge key space bucket partitioning
region = ? with few regions sort order, not partitioning
amount > ? sort order
several unrelated columns sort by the most selective, accept the rest

Partitioning on a low-cardinality column like region is usually a mistake: four regions means four partitions, which does nothing for parallelism and produces skew when one region dominates. A sort order handles it without the file-count cost:

ALTER TABLE ice.blog_partitioning.events WRITE ORDERED BY region, event_ts;
sort-order after WRITE ORDERED BY: [   identity(3) ASC NULLS FIRST   identity(2) ASC NULLS FIRST ]

Field 3 is region and field 2 is event_ts. The table’s partitioning is unchanged; the sort order applies to files written from now on.

What evolution does not do

It does not reorganise history. Old data keeps the old granularity. If queries need day-level pruning over historical data, that is a rewrite — rewrite_data_files with the new spec — and it is a data-movement job, distinct from the metadata-only ALTER.

It does not fix bad partitioning retroactively. Over-partitioned history stays over-partitioned until rewritten.

It does not reduce the need to choose well. Evolution is an escape hatch, not a reason to skip the decision.

Choosing a layout

Partition on what you filter on, at the granularity you filter at. Hourly partitions for queries that always span weeks produce thousands of tiny files and prune nothing useful.

Target partitions that hold real data. Hundreds of megabytes to a few gigabytes is a reasonable band. A partition per minute holding ten rows is overhead with a directory name.

Use bucket for high-cardinality keys rather than identity.

Prefer fewer partition fields. Each one multiplies the partition count, and two fields with a hundred values each is ten thousand partitions.

Remember that pruning has a second stage: column bounds inside manifests skip files within a partition, but only if data is sorted. Partitioning and sort order work together, and the read path post covers where each applies.

Run it yourself

Every number above came from the two scripts below, run on the local stack described in Run every demo on this blog locally with Spark 3.5.9 and Iceberg 1.11.0. Start it with:

git clone https://github.com/rangareddy/datalake-playground.git
cd datalake-playground
SPARK_VERSION=3.5.9 sh run_datalake.sh start
sh run_datalake.sh status

Both scripts use the namespace ice.blog_partitioning and drop their tables before recreating them, so they are safe to re-run. Snapshot ids and file names will differ from run to run; the counts will not.

Script 1: hidden partitioning, bucket and sort order

Save as hidden_partitioning.py:

"""Hidden partitioning on Iceberg: derived partition values, pruning, and bucket.

Run inside the datalake-playground stack:
  docker cp hidden_partitioning.py spark-master:/tmp/
  docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
    --driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
    /tmp/hidden_partitioning.py'
"""
import json
from datetime import datetime, timedelta

from pyspark.sql import SparkSession
from pyspark.sql.types import (DoubleType, LongType, StringType, StructField,
                               StructType, TimestampType)

spark = (
    SparkSession.builder.appName("iceberg-hidden-partitioning")
    .config("spark.sql.extensions",
            "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
    .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
    .config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
    .config("spark.sql.catalog.ice.type", "hive")
    .config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
    .config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
    .config("spark.sql.session.timeZone", "UTC")
    .config("spark.eventLog.enabled", "false")
    .config("spark.ui.enabled", "false")
    .getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
NS = "ice.blog_partitioning"
EVENTS = f"{NS}.events"
ORDERS = f"{NS}.orders"
jvm = spark._jvm
conf = spark._jsc.hadoopConfiguration()


def location(table):
    return sql(f"DESCRIBE TABLE EXTENDED {table}").where("col_name = 'Location'").collect()[0][1]


def partition_dirs(table):
    """Directories that hold at least one data file (S3A can keep empty markers)."""
    path = jvm.org.apache.hadoop.fs.Path(location(table) + "/data")
    files = path.getFileSystem(conf).listFiles(path, True)
    dirs = set()
    while files.hasNext():
        dirs.add(files.next().getPath().getParent().getName())
    return sorted(dirs)


def partition_specs(table):
    """The partition-specs array, read from the table's current metadata.json."""
    meta = sql(f"SELECT file FROM {table}.metadata_log_entries "
               f"ORDER BY timestamp DESC LIMIT 1").collect()[0][0]
    path = jvm.org.apache.hadoop.fs.Path(meta)
    stream = path.getFileSystem(conf).open(path)
    text = jvm.org.apache.commons.io.IOUtils.toString(stream, "UTF-8")
    stream.close()
    doc = json.loads(text)
    schema = next(x for x in doc["schemas"] if x["schema-id"] == doc["current-schema-id"])
    field_ids = {f["name"]: f["id"] for f in schema["fields"]}
    return doc["partition-specs"], doc["default-spec-id"], field_ids


def scan(df):
    """Collect df, then read the Iceberg scan node's metrics from the plan that ran."""
    qe = df._jdf.queryExecution()
    rows = df._jdf.collectAsList().size()
    plan = qe.executedPlan()
    if plan.getClass().getSimpleName() == "AdaptiveSparkPlanExec":
        plan = plan.executedPlan()
    metrics = plan.collectLeaves().apply(0).metrics()
    get = lambda k: metrics.apply(k).value()
    return rows, get("resultDataFiles"), get("skippedDataFiles")


sql(f"CREATE NAMESPACE IF NOT EXISTS {NS}")
sql(f"DROP TABLE IF EXISTS {EVENTS} PURGE")
sql(f"DROP TABLE IF EXISTS {ORDERS} PURGE")

# ------------------------------------------------ 1. a derived day partition
sql(f"""
    CREATE TABLE {EVENTS} (
        id BIGINT, event_ts TIMESTAMP, region STRING, amount DOUBLE)
    USING iceberg PARTITIONED BY (days(event_ts))
""")
schema = StructType([
    StructField("id", LongType(), False),
    StructField("event_ts", TimestampType(), False),
    StructField("region", StringType(), False),
    StructField("amount", DoubleType(), False),
])
regions = ["us-east", "us-west", "eu-central", "ap-south"]
start = datetime(2026, 1, 5)
rows = [(i, start + timedelta(days=i // 3000, seconds=(i % 3000) * 20),
         regions[i % 4], round(10 + (i % 500) * 0.37, 2)) for i in range(9000)]
spark.createDataFrame(rows, schema).writeTo(EVENTS).append()

specs, default_spec, field_ids = partition_specs(EVENTS)
print("== 1. hidden partitioning ==")
print("partition-specs:", json.dumps(specs))
print("field id of event_ts:", field_ids["event_ts"])
print("partition directories on disk:", partition_dirs(EVENTS))
print("data files in table:", sql(f"SELECT count(*) FROM {EVENTS}.files").collect()[0][0])

one_day = spark.table(EVENTS).where(
    "event_ts >= timestamp'2026-01-06 00:00:00' AND event_ts < timestamp'2026-01-07 00:00:00'")
rows_, read, skipped = scan(one_day)
print(f"rows matched: {rows_} | data files read: {read} | skipped by pruning: {skipped}")

# The partitions metadata table, the numbers the layout decision rests on
print("\n== 2. partitions per layout ==")
sql(f"""
    SELECT count(*) AS partitions,
           avg(file_count) AS avg_files,
           avg(record_count) AS avg_rows
    FROM {EVENTS}.partitions
""").show()

# ------------------------------------------------------------ 3. bucket(64)
sql(f"""
    CREATE TABLE {ORDERS} (order_id BIGINT, customer_id BIGINT, amount DOUBLE)
    USING iceberg
    PARTITIONED BY (bucket(64, customer_id))
""")
orders = [(100000 + i, 5000 + (i * 7919) % 20000, round(5 + (i % 300) * 1.1, 2))
          for i in range(20000)]
spark.createDataFrame(orders, "order_id BIGINT, customer_id BIGINT, amount DOUBLE") \
    .writeTo(ORDERS).append()
print("== 3. bucket(64, customer_id) ==")
print("distinct customers:", spark.table(ORDERS).select("customer_id").distinct().count())
sql(f"""
    SELECT count(*) AS partitions, min(record_count) AS min_rows,
           max(record_count) AS max_rows
    FROM {ORDERS}.partitions
""").show()
eq = spark.table(ORDERS).where("customer_id = 12345")
rng = spark.table(ORDERS).where("customer_id BETWEEN 12000 AND 12100")
print("data files in table:", sql(f"SELECT count(*) FROM {ORDERS}.files").collect()[0][0])
rows_, read, skipped = scan(eq)
print(f"equality filter: rows {rows_} | data files read: {read} | skipped: {skipped}")
rows_, read, skipped = scan(rng)
print(f"range filter   : rows {rows_} | data files read: {read} | skipped: {skipped}")

# ------------------------------------------ 4. two fields multiply the count
sql(f"DROP TABLE IF EXISTS {NS}.events_two_fields PURGE")
sql(f"""
    CREATE TABLE {NS}.events_two_fields (
        id BIGINT, event_ts TIMESTAMP, customer_id BIGINT)
    USING iceberg PARTITIONED BY (days(event_ts), bucket(16, customer_id))
""")
# One append per day. Writing the whole month at once opens a Parquet writer for
# every one of its 496 partitions in one task, which ran a 1 GB driver out of heap.
for day in range(31):
    rows_day = [(day * 480 + i, datetime(2026, 1, 1 + day) + timedelta(minutes=i * 3),
                 5000 + ((day * 480 + i) * 7919) % 20000) for i in range(480)]
    spark.createDataFrame(rows_day, "id BIGINT, event_ts TIMESTAMP, customer_id BIGINT") \
        .writeTo(f"{NS}.events_two_fields").append()
print("\n== 4. days(event_ts) x bucket(16, customer_id), one month ==")
sql(f"SELECT count(*) AS partitions, avg(record_count) AS avg_rows "
    f"FROM {NS}.events_two_fields.partitions").show()

# ------------------------------------------------ 5. sort order, not region
sql(f"ALTER TABLE {EVENTS} WRITE ORDERED BY region, event_ts")
print("== 5. write order ==")
print("sort-order after WRITE ORDERED BY:",
      spark._jvm.org.apache.iceberg.spark.Spark3Util.loadIcebergTable(
          spark._jsparkSession, EVENTS).sortOrder().toString().replace("\n", " "))

spark.stop()

Script 2: partition evolution and rewrite

Save as partition_evolution.py:

"""Partition evolution on Iceberg: months to days without rewriting, then a rewrite.

Run inside the datalake-playground stack:
  docker cp partition_evolution.py spark-master:/tmp/
  docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
    --driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
    /tmp/partition_evolution.py'
"""
import json
from datetime import datetime, timedelta

from pyspark.sql import SparkSession

spark = (
    SparkSession.builder.appName("iceberg-partition-evolution")
    .config("spark.sql.extensions",
            "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
    .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
    .config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
    .config("spark.sql.catalog.ice.type", "hive")
    .config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
    .config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
    .config("spark.sql.session.timeZone", "UTC")
    .config("spark.eventLog.enabled", "false")
    .config("spark.ui.enabled", "false")
    .getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
NS = "ice.blog_partitioning"
TX = f"{NS}.transactions"
EV = f"{NS}.events_evolving"
DAILY = f"{NS}.events_daily"
jvm = spark._jvm
conf = spark._jsc.hadoopConfiguration()


def location(table):
    return sql(f"DESCRIBE TABLE EXTENDED {table}").where("col_name = 'Location'").collect()[0][1]


def partition_dirs(table):
    """Directories that hold at least one data file (S3A can keep empty markers)."""
    path = jvm.org.apache.hadoop.fs.Path(location(table) + "/data")
    files = path.getFileSystem(conf).listFiles(path, True)
    dirs = set()
    while files.hasNext():
        dirs.add(files.next().getPath().getParent().getName())
    return sorted(dirs)


def metadata(table):
    meta = sql(f"SELECT file FROM {table}.metadata_log_entries "
               f"ORDER BY timestamp DESC LIMIT 1").collect()[0][0]
    path = jvm.org.apache.hadoop.fs.Path(meta)
    stream = path.getFileSystem(conf).open(path)
    text = jvm.org.apache.commons.io.IOUtils.toString(stream, "UTF-8")
    stream.close()
    return json.loads(text)


def scan(df):
    """Collect df, then read the Iceberg scan node's metrics from the plan that ran."""
    qe = df._jdf.queryExecution()
    rows = df._jdf.collectAsList().size()
    plan = qe.executedPlan()
    if plan.getClass().getSimpleName() == "AdaptiveSparkPlanExec":
        plan = plan.executedPlan()
    metrics = plan.collectLeaves().apply(0).metrics()
    return rows, metrics.apply("resultDataFiles").value(), metrics.apply("skippedDataFiles").value()


def attempt(label, statement):
    try:
        sql(statement)
        print(f"  OK    {label}")
    except Exception as e:
        jvm_e = getattr(e, "java_exception", None)
        cls = jvm_e.getClass().getName() if jvm_e is not None else type(e).__name__
        msg = " ".join((jvm_e.getMessage() if jvm_e is not None else str(e)).split())[:220]
        print(f"  ERROR {label}: {cls}: {msg}")


def load(table, first_day, days, per_day, start_id):
    rows = []
    for d in range(days):
        for i in range(per_day):
            rows.append((start_id + d * per_day + i,
                         first_day + timedelta(days=d, minutes=i * 4),
                         round(20 + (i % 90) * 1.5, 2)))
    spark.createDataFrame(rows, "txn_id BIGINT, ts TIMESTAMP, amount DOUBLE") \
        .writeTo(table).append()


sql(f"CREATE NAMESPACE IF NOT EXISTS {NS}")
sql(f"DROP TABLE IF EXISTS {TX} PURGE")
sql(f"DROP TABLE IF EXISTS {EV} PURGE")
sql(f"DROP TABLE IF EXISTS {DAILY} PURGE")

# ------------------------------------------- 1. months, then replace with days
sql(f"""CREATE TABLE {TX} (txn_id BIGINT, ts TIMESTAMP, amount DOUBLE)
        USING iceberg PARTITIONED BY (months(ts))""")
load(TX, datetime(2026, 1, 12), days=1, per_day=300, start_id=1)
load(TX, datetime(2026, 2, 16), days=1, per_day=300, start_id=301)
print("== 1. partition evolution ==")
print("dirs after months():", partition_dirs(TX))

sql(f"ALTER TABLE {TX} REPLACE PARTITION FIELD months(ts) WITH days(ts)")
load(TX, datetime(2026, 3, 9), days=1, per_day=300, start_id=601)
print("dirs after days()  :", partition_dirs(TX))

doc = metadata(TX)
for spec in doc["partition-specs"]:
    print("  spec", spec["spec-id"], json.dumps(spec["fields"]))
print("default-spec-id:", doc["default-spec-id"])
print("spec_id per data file:",
      [r[0] for r in sql(f"SELECT spec_id FROM {TX}.files ORDER BY spec_id").collect()])
print("rows total:", sql(f"SELECT count(*) FROM {TX}").collect()[0][0])

# One predicate on ts, pruned under whichever spec wrote each file
for label, where in [
        ("January 12 (month spec)", "ts >= timestamp'2026-01-12 00:00:00' AND ts < timestamp'2026-01-13 00:00:00'"),
        ("March 9 (day spec)     ", "ts >= timestamp'2026-03-09 00:00:00' AND ts < timestamp'2026-03-10 00:00:00'")]:
    rows, read, skipped = scan(spark.table(TX).where(where))
    print(f"  {label}: rows {rows} | data files read {read} | skipped {skipped}")

# ------------------------------------------------ 2. add, replace, drop fields
EVENTS_SCHEMA = "id BIGINT, event_ts TIMESTAMP, customer_id BIGINT"
print("\n== 2. evolving in practice ==")

# add a finer time field to a table already partitioned by days(event_ts)
sql(f"CREATE TABLE {DAILY} ({EVENTS_SCHEMA}) USING iceberg PARTITIONED BY (days(event_ts))")
attempt("ADD PARTITION FIELD hours(event_ts) on a days(event_ts) table",
        f"ALTER TABLE {DAILY} ADD PARTITION FIELD hours(event_ts)")

# replace months with days, and drop a bucket field, on a months + bucket table
sql(f"""CREATE TABLE {EV} ({EVENTS_SCHEMA})
        USING iceberg PARTITIONED BY (months(event_ts), bucket(16, customer_id))""")
spark.createDataFrame(
    [(i, datetime(2026, 1, 1) + timedelta(hours=i), 5000 + (i * 7919) % 20000) for i in range(1000)],
    EVENTS_SCHEMA).writeTo(EV).append()
attempt("REPLACE PARTITION FIELD months(event_ts) WITH days(event_ts)",
        f"ALTER TABLE {EV} REPLACE PARTITION FIELD months(event_ts) WITH days(event_ts)")
attempt("DROP PARTITION FIELD bucket(16, customer_id)",
        f"ALTER TABLE {EV} DROP PARTITION FIELD bucket(16, customer_id)")
spark.createDataFrame(
    [(1000 + i, datetime(2026, 3, 1) + timedelta(hours=i), 5000 + i) for i in range(200)],
    EVENTS_SCHEMA).writeTo(EV).append()
for spec in metadata(EV)["partition-specs"]:
    print("  spec", spec["spec-id"], [f"{f['transform']}({f['source-id']})" for f in spec["fields"]])
sql(f"""SELECT spec_id, count(*) AS files, sum(record_count) AS records
        FROM {EV}.files GROUP BY spec_id ORDER BY spec_id""").show()

# ---------------------------------------- 3. rewrite old data into the new spec
print("== 3. rewrite_data_files ==")
attempt("where => 'event_ts < date'2026-03-01'' (nested single quotes)",
        f"CALL ice.system.rewrite_data_files(table => 'blog_partitioning.events_evolving', "
        f"where => 'event_ts < date'2026-03-01'')")
sql("""CALL ice.system.rewrite_data_files(
         table => 'blog_partitioning.events_evolving',
         where => "event_ts < TIMESTAMP '2026-03-01 00:00:00'")""") \
    .select("rewritten_data_files_count", "added_data_files_count").show()
sql(f"""SELECT spec_id, count(*) AS files, sum(record_count) AS records
        FROM {EV}.files GROUP BY spec_id ORDER BY spec_id""").show()

spark.stop()

To remove everything the scripts created:

DROP TABLE ice.blog_partitioning.events PURGE;
DROP TABLE ice.blog_partitioning.orders PURGE;
DROP TABLE ice.blog_partitioning.events_two_fields PURGE;
DROP TABLE ice.blog_partitioning.transactions PURGE;
DROP TABLE ice.blog_partitioning.events_daily PURGE;
DROP TABLE ice.blog_partitioning.events_evolving PURGE;
DROP NAMESPACE ice.blog_partitioning;

Common misconceptions

“Hidden partitioning means no partitions.” The partitions are real and visible on disk. What is hidden is the need to name them in queries.

“Partition evolution rewrites the table.” Measured: two of three files kept spec_id = 0 and nothing moved.

“I must query the partition column.” There is no partition column to query. Filter the real column.

“Old data becomes unqueryable after evolution.” All 900 rows stayed readable across both specs.

“More partitions prune better.” More partitions mean more metadata and smaller files. Pruning comes from matching the transform to the filter.

A model worth keeping

A partition is a function of a column, recorded per file. Queries filter the column; Iceberg applies the function. Change the function and old files keep their old one, because each file remembers which spec produced it.

That is hidden partitioning and partition evolution — one indirection, used twice.

References

Trademarks

Apache Iceberg, Apache Spark, Apache Hive, Apache Parquet, Apache and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries.

Found this useful?

These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.

Buy me a coffee