Field IDs, or How to Rename a Column Without Losing Your Data

Every column has a number, and the number is the column. This chapter renames, drops, re-adds and promotes columns on a live table, finds a table that lies about itself, and finds an off-by-one-word bug in PyIceberg's type-promotion rules.

Somebody wants a column renamed. title should be book_title, because there are three other tables with a title column and none of them mean the same thing.

Stable field IDs—not column names—bind logical fields to physical data, making rename, drop, reorder, and safe evolution metadata operations.

On a directory of Parquet files this is a request to rewrite the table. Not because renaming is expensive — because the name is the column. Every file has title written into its own schema, and every reader finds the column by looking for that string. The moment the table definition says book_title while the files say title, the two have stopped referring to the same thing. Chapter 1 showed how that ends: a query that succeeds, returns a plausible number, and is wrong.

On an Iceberg table it is one line, it rewrites nothing, and the files on disk go on saying title forever.

The mechanism is a single design decision made at the bottom of the format. Everything in this chapter follows from it, including the parts that surprise people. Everything here runs on PyIceberg 0.11.1 against the bookshop orders table from chapter 6 — a SQLite catalog, a local directory, no JVM.

Names are not identity

Start with the schema Iceberg keeps:

for f in t.schema().fields:
    print(f"  {f.field_id:>3}  {f.name:<14} {str(f.field_type):<12} required={f.required}")
    1  order_id       long         required=True
    2  customer_id    long         required=True
    3  title          string       required=False
    4  country        string       required=False
    5  quantity       int          required=False
    6  amount         double       required=False
    7  status         string       required=False
    8  ordered_at     timestamp    required=True

The column on the left is the whole idea. Every field has an integer field ID, assigned when the field is created, never reused, and never changed. The name is a label attached to the ID. The ID is the column.

That would be a bookkeeping detail if it stayed in the catalog. It does not. Look at what the writer put in the Parquet file:

for fld in pq.read_schema(data_file):
    print(" ", fld.name, dict(fld.metadata or {}))
  order_id {b'PARQUET:field_id': b'1'}
  customer_id {b'PARQUET:field_id': b'2'}
  title {b'PARQUET:field_id': b'3'}
  country {b'PARQUET:field_id': b'4'}
  quantity {b'PARQUET:field_id': b'5'}
  amount {b'PARQUET:field_id': b'6'}
  status {b'PARQUET:field_id': b'7'}
  ordered_at {b'PARQUET:field_id': b'8'}

Parquet has a per-column field_id slot in its own metadata, and Iceberg fills it in. So the file is self-describing in the way that matters: it says this column is field 3, not merely this column is called title.

A read now works differently from anything a directory can do. The reader takes the table’s current schema and finds that field 3 is called book_title and is a string. Then it goes to each data file and asks for field 3. What that column is called inside the file is irrelevant. It could be called title. It could be called x. It is field 3.

Adding a column changes no data

Adding channel allocates a new field ID. Existing files have no value for it, so the reader must supply null:

with t.update_schema() as u:
    u.add_column("channel", StringType(), doc="web / app / phone")
    9  channel        string       required=False
data files before: 3 after: 3 rewritten: False
rows: 300 channel distinct: {None}
schema_id now: 1 schemas retained: [0, 1]

Three data files before, the same three after — checked by comparing the sets of paths, not by counting. No file was touched. The three hundred existing rows read back with channel as None, because the reader asks each file for field 9, does not find it, and supplies null.

Two things to notice in that output beyond the obvious. The new field got ID 9, one past the highest ID ever used in this table. And the schema got a new schema_id, with the old one retained. Iceberg keeps every schema version the table has ever had, exactly as it keeps every snapshot.

The doc argument is worth using. It is a comment stored in the table metadata, it survives every engine, and it is the only documentation about the column that cannot get separated from it.

Renaming, and the file that never found out

A rename keeps field 3 and changes the name attached to it:

with t.update_schema() as u:
    u.rename_column("title", "book_title")
    3  book_title     string       required=False
rows: 300 sample: Dune

Three hundred rows, and book_title holds Dune. Nothing was rewritten — the three appends happened before the rename and their files have not been opened since.

Which means the files disagree with the table, in writing:

parquet columns on disk: ['order_id', 'customer_id', 'title', 'country', 'quantity', 'amount', 'status', 'ordered_at']
iceberg schema names   : ['order_id', 'customer_id', 'book_title', 'country', 'quantity', 'amount', 'status', 'ordered_at', 'channel']
scan works anyway      : 300

The word title is still on disk, and will be until something rewrites that file for an unrelated reason. It does not matter. The reader never looked at it.

This is the single clearest demonstration of what field IDs buy. In a name-resolved world the state above is a broken table. Here it is Tuesday.

The table schema lists field 3 as book_title while the Parquet file on disk still names its third column title, and the file has no field 9 at all. Because the reader resolves columns by field ID rather than by name, the scan still returns 300 rows and field 9 reads as null.

The stored data survives, but callers using the old name still need to change. A filter on title fails:

t.scan(row_filter=EqualTo("title", "Dune"))
ValueError: Could not find field with name title, case_sensitive=True

And appending an Arrow table that still has the old column produces an error whose wording will briefly confuse you:

ValueError: PyArrow table contains more columns: title. Update the schema first (hint, use union_by_name).

It says more columns because from PyIceberg’s point of view your dataframe carries a column the table does not have. It is not wrong, exactly. The actual problem is that your producer has not been updated. A rename is safe for the stored data, and a breaking change for everything that writes or queries by name. The format solved the hard half of the problem, not the whole of it.

Dropping, and the trap that IDs close

Drop a column:

with t.update_schema() as u:
    u.delete_column("country")
after drop: ['order_id', 'customer_id', 'book_title', 'quantity', 'amount', 'status', 'ordered_at', 'channel', 'region']
rows: 300
country still in the parquet file: True

Metadata-only again, and the data is still physically there. Field 4 sits in all three Parquet files, complete, and is simply never requested. It costs storage until something rewrites those files. Chapter 12 comes back to that point: a dropped column is not a space saving.

Now the interesting half. Add country back:

with t.update_schema() as u:
    u.add_column("country", StringType())
    1  order_id       long         required=True
    2  customer_id    long         required=False
    3  book_title     string       required=False
    5  quantity       long         required=False
    ...
   11  country        string       required=False
country values after re-add: {None}

Field 11, not field 4. The gap where 4 used to be is permanent. The three hundred rows read back with country as null, even though the old values are sitting in the files a few bytes away.

That is the correct answer, and it is worth dwelling on why. In a name-resolved table, dropping a column and adding one with the same name resurrects the old data. Silently, and with whatever semantics it had before — which may be nothing like what the new column means. Somebody drops region because it held ISO country codes, adds region back to hold sales territories, and half the table quietly comes back full of country codes. IDs make that impossible: a new field is a new field, and the old bytes belong to a field nobody is asking about any more.

Dropping country is metadata-only and leaves field 4 in every Parquet file. Adding a column of the same name back gives it field ID 11 rather than 4, so the three hundred existing rows read back as None and last-column-id becomes 11. The retired ID is never reused, which is what stops a re-added column inheriting a dead one’s data.

The cost is that you cannot use drop-and-re-add to change a column’s type or meaning in place. If you want the old values under the new column you must write them, which is the honest amount of work.

Reordering is cosmetic

with t.update_schema() as u:
    u.move_first("status")
    u.move_after("channel", "book_title")
['status', 'order_id', 'customer_id', 'book_title', 'channel', 'quantity', 'amount', 'ordered_at', 'region', 'country']
arrow column order: ['status', 'order_id', 'customer_id', 'book_title', 'channel', 'quantity', 'amount', 'ordered_at', 'region', 'country']
field ids: [7, 1, 2, 3, 9, 5, 6, 8, 10, 11]

The scan hands back columns in the schema’s order, and the IDs are undisturbed — 7 first, then 1, 2, 3. Column order in Iceberg is presentation, not physical layout, and moving a column costs one metadata write.

This matters for callers that read columns by position. Iceberg resolves them by ID, so reordering changes SELECT * order without changing its contents; positional readers still see a different presentation.

The four schema changes that touch no data file: adding a column gives it a new field ID and old rows read null, renaming keeps the same ID while the files keep the old name, dropping leaves the bytes in place and simply stops requesting that ID, and reordering changes SELECT star order while the physical files do not move. Data files rewritten: zero.

Type promotion, and exactly what is allowed

Two promotions on the bookshop table:

with t.update_schema() as u:
    u.update_column("quantity", field_type=LongType())
with t.update_schema() as u:
    u.update_column("amount", field_type=DoubleType())
OK   quantity -> long
OK   amount -> double

And the four obvious things people try, each refused:

FAIL quantity -> int    : ValidationError: Cannot change column type: quantity: long -> int
FAIL amount   -> float  : ValidationError: Cannot change column type: amount: double -> float
FAIL order_id -> string : ValidationError: Cannot change column type: order_id: long -> string
FAIL status   -> long   : ValidationError: Cannot change column type: status: string -> long

The rule is that a promotion must be widening and lossless. Old files keep their old physical type forever, and the reader has to be able to convert every value it finds. An int column promoted to long leaves int32 in the existing Parquet files and writes int64 in new ones; the reader upcasts on the way out. Reversing that would mean promising that no stored value overflows, which nobody can promise.

Watch it work across the seam:

after promotion: [{'id': 1, 'qty': 2147483647}, {'id': 2, 'qty': 3}]
arrow type now: int64
mixed int32/int64 files read as one table:
  [{'id': 3, 'qty': 9223372036854775807}, {'id': 1, 'qty': 2147483647}, {'id': 2, 'qty': 3}]
files: 2

Two files, one written int32 and one int64, both holding their type’s maximum value. They read back as a single int64 column with both values intact. No rewrite, no loss.

Here is PyIceberg 0.11.1’s full promotion set, read from its own dispatch table rather than from documentation: int → long, float → double, decimal(P,S) → decimal(P',S) widening precision, string ↔ binary in both directions, fixed[16] → uuid, and the v3 unknown → any primitive. That is a short list, and it is short on purpose.

PyIceberg allows six promotions: int to long, float to double, decimal precision widening at the same scale, string and binary in both directions, fixed of 16 bytes to uuid, and the v3 unknown to any primitive. Narrowing back to int, to float, to string or from string is refused with a ValidationError. Two files, one written int32 holding 2147483647 and one int64 holding 9223372036854775807, read back as a single int64 column with both values intact and nothing rewritten.

A bug in the decimal rule

Decimal widening is the one to test carefully, and testing it turned up something real:

OK   decimal(12, 2)     # from decimal(9, 2) — precision widened, scale unchanged
OK   decimal(12, 4)     # scale changed 2 -> 4
FAIL decimal(9, 2)      # ValidationError: Cannot change column type: price: decimal(12, 4) -> decimal(9, 2)

The middle line should not be OK. The Iceberg promotion rules allow decimal(P,S) → decimal(P2,S) where the precision grows and the scale is identical. Change the scale and you change what the stored unscaled integers mean. PyIceberg accepted it anyway, and here is why:

@promote.register(DecimalType)
def _(file_type: DecimalType, read_type: IcebergType) -> IcebergType:
    if isinstance(read_type, DecimalType):
        if file_type.precision <= read_type.precision and file_type.scale == file_type.scale:

file_type.scale == file_type.scale. The field is compared against itself, so the scale check is always true and every scale change passes. One word.

The behaviour that follows is not as bad as it could be. Reading the table back after the illegal promotion returns correct values:

before: [{'id': 1, 'price': Decimal('12.50')}, {'id': 2, 'price': Decimal('7.99')}, ...]
after:  [{'id': 1, 'price': Decimal('12.5000')}, {'id': 2, 'price': Decimal('7.9900')}, ...]

PyIceberg’s reader casts through PyArrow, which rescales properly, so twelve pounds fifty stays twelve pounds fifty. But you now have a table whose metadata records a promotion the specification does not permit. I have not tested what a different engine does with it — Spark does not arrive until chapter 10. This is exactly the class of divergence chapter 18 is about. Do not change a decimal’s scale. If PyIceberg lets you, that is not permission.

Nullability, and a table that lies

Making a required column optional is always safe:

required -> optional: OK

Going the other way is not, and is refused:

ValueError: Cannot change column nullability: country: optional -> required

There may already be nulls in the column. Iceberg will not let you assert something about stored data that it cannot check without reading all of it.

Unless you insist. update_schema takes allow_incompatible_changes, and it does what it says:

with t3.update_schema(allow_incompatible_changes=True) as u:
    u.update_column("note", required=True)
optional -> required with allow_incompatible_changes: OK
schema: [('id', True), ('note', True)]
read back (note has a NULL in it): [{'id': 1, 'note': 'x'}, {'id': 2, 'note': None}]

The column is now declared required and still contains a null. Every reader that trusts the schema is being lied to by the table’s own metadata: a code generator, a downstream loader that skips null checks on required fields, an engine that plans on non-nullability.

The same flag will take you somewhere worse:

with t.update_schema(allow_incompatible_changes=True) as u:
    u.update_column("id", field_type=StringType())
long -> string with allow_incompatible_changes: OK
schema: [('id', 'string'), ('note', 'string')]
READ FAILS: ResolveError: Cannot promote long to string

The commit succeeded. The table is now unreadable — every scan raises, because the reader reaches a file holding long and is asked for string. It is recoverable, since the schema is metadata and you can change it back; setting the type to long again brought the rows straight back. But between those two commits the table was a table nobody could query, and no warning was issued at any point.

Treat allow_incompatible_changes=True as what it is: a way to write metadata that the format’s own rules reject. There are legitimate uses during a migration you fully control. There is no legitimate use in a scheduled job.

Adding a required column, and a v3 feature on a v2 table

Adding a required column is refused for the obvious reason:

ValueError: Incompatible change: cannot add required column: region

Every existing row would have a null in a field declared non-null. Unless the column comes with a default. Here it is on a two-column throwaway table that already holds two rows, so the effect on existing data is easy to see:

with t2.update_schema() as u:
    u.add_column("region", StringType(), required=True, default_value="EU")
with default_value: OK
schema: [(1, 'id', True, None, None), (2, 'name', False, None, None), (3, 'region', True, 'EU', 'EU')]
read back: [{'id': 1, 'name': 'a', 'region': 'EU'}, {'id': 2, 'name': 'b', 'region': 'EU'}]

Rows written before the column existed read back as EU, with no file rewritten. The value is stored once in the schema and materialised at read time. That is exactly the right design, and considerably better than the migration this would be in a database.

Here is the part to be careful with. Look at what went into the metadata file:

{"id": 3, "name": "region", "type": "string", "required": true,
 "initial-default": "EU", "write-default": "EU"}
format version: 2
format-version in metadata: 2

initial-default and write-default are format v3 features, and this is a format v2 table. PyIceberg wrote them anyway. A reader that implements v2 strictly is entitled to ignore fields it does not know, which would give it a null in a column marked required.

I have not tested what another engine does with this table, because the only engine in this chapter’s environment is PyIceberg. What I can say is that a v2 reader is under no obligation to honour that metadata. The safe version of this operation is to upgrade the table to v3 first — chapter 19 — or to add the column as optional and fill it yourself. If you use default_value, know that you have used a v3 feature, whatever the table says its version is.

Time travel across a rename

The bookshop table has been through nine schema versions. Its first snapshot predates the rename, so reading it asks which schema belongs to that earlier state:

old = t.scan(snapshot_id=first_snapshot).to_arrow()
print("snapshot 1 columns:", old.column_names)
snapshot 1 columns: ['order_id', 'customer_id', 'title', 'country', 'quantity', 'amount', 'status', 'ordered_at']
snapshot 1 rows   : 100
schema ids retained: [0, 1, 2, 3, 4, 5, 6, 7, 8]
snapshot schema_id : [(8633273259493773719, 0), (4012455259446278702, 0), (8223081118807324600, 0)]

title, not book_title. No channel, no region. quantity back to int.

A snapshot pins a schema as well as a file list. Every snapshot records the schema_id that was current when it was committed, and a time-travelling read resolves through that schema rather than today’s. Which is correct. The point of time travel is to see the table as it was, and the table as it was had a column called title.

A query that works against the live table can therefore fail against a snapshot from before a rename, and vice versa. Reproducing an old result requires the old query’s column names as well as its snapshot id.

The metadata carries all of this:

current-schema-id: 8 | schemas kept: 9
last-column-id: 11
schema 0 field names: ['order_id', 'customer_id', 'title', 'country', 'quantity', 'amount', 'status', 'ordered_at']
schema 8 field names: ['status', 'order_id', 'customer_id', 'book_title', 'channel', 'quantity', 'amount', 'ordered_at', 'region', 'country']

Reading the table’s first snapshot returns the eight columns of schema 0, including title and an int quantity, while the live table shows the ten reordered columns of schema 8 with book_title, channel, region and country. Nine schemas are retained and last-column-id stands at 11, and snapshot 1 still returns 100 rows.

Nine schemas retained, and last-column-id: 11 — the counter that guarantees a new field never reuses a retired ID. That counter is why drop-and-re-add cannot resurrect anything. It is the one piece of state that must never go backwards.

The rest of the surface

Nested types evolve too, with paths instead of names:

with t3.update_schema() as u:
    u.add_column(("addr", "country"), StringType())
    u.rename_column(("addr", "postcode"), "postal_code")
2: addr: optional struct<3: city: optional string, 4: postal_code: optional string, 5: country: optional string>
[{'order_id': 1, 'addr': {'city': 'Bath', 'postal_code': 'BA1', 'country': None}},
 {'order_id': 2, 'addr': {'city': 'Ely',  'postal_code': 'CB7', 'country': None}}]

Struct fields get IDs from the same counter as top-level ones, and the same rules apply at every depth.

union_by_name takes a whole schema — an Iceberg schema or a PyArrow one — and adds whatever is missing:

with t3.update_schema() as u:
    u.union_by_name(new_arrow_schema)
['order_id', 'addr', 'gift_message', 'promo_code']

This is the right tool when an upstream producer has gained columns and you want the table to follow. It only ever adds. It will not drop a column that disappeared upstream and it will not rename anything, so it cannot destroy data. That is precisely why it is safe to run automatically, and why it should never be your only schema management.

Names must stay unique, and the error tells you which IDs collided:

ValueError: Invalid schema, multiple fields for name promo_code: 6 and 7

And Iceberg is case-sensitive by default, which is a live cross-engine hazard:

added 'title' next to 'Title': ['id', 'Title', 'title']

Two distinct fields, differing only in case, accepted without complaint. Spark SQL is case-insensitive by default, and cannot resolve either of them unambiguously. Do not do this. If a producer hands you Title and title, rename one before it reaches the table.

What schema evolution is not

It is not a data migration. Renaming amount to amount_gbp does not convert anything; promoting int to long does not change a stored value; adding a column does not populate it. Every one of these operations changes how the bytes are interpreted, and none of them changes the bytes. That is the source of both the speed and the entire risk profile.

It is not free of downstream cost. The stored data survives a rename; your dbt models, your BI tool’s saved queries, your Airflow DAG’s SELECT list and every notebook anybody wrote do not. The format guarantees you will not lose data. It guarantees nothing about your Monday morning.

And it is not a reason to be casual with the schema. Field IDs are permanent, the ID counter only goes up, and the metadata keeps every version. That is nine schemas in a table that has existed for the length of one chapter. Evolution is cheap enough that the temptation is to iterate in production. Resist it a little. Each change is a commit, each commit is a snapshot, and a table with two hundred schema versions is telling a story about the team that owns it.

Two safe habits, stated plainly. Add columns rather than repurposing them, because a new ID cannot inherit an old meaning. And when a producer changes shape, prefer union_by_name and an explicit rename over a drop and a re-add. The drop is the only operation here that throws information away.

Final thoughts

Field IDs are the least glamorous idea in the format and possibly the most valuable. One integer per column, written into the table metadata and into every data file, and the entire class of name-resolution disasters stops being possible. Renames are metadata, drops are metadata, reorders are metadata, and a re-added column cannot inherit a dead one’s data.

What the IDs cannot do is make the change safe for anything outside the table. That is the recurring shape of this arc: the format removes the data risk and leaves you the coordination problem. It is the right division of labour, but it is not the same as the problem going away.

The next chapter applies the same trick to the other thing a Hive table forced into its file paths and could never change afterwards: the partitioning.

Next: The Partition Column That Isn’t There

Comments