Schema evolution и column IDs
В уроке 1 мы видели, что metadata file хранит массив schemas[] с полной историей схем и поле current-schema-id. В уроке 3 — что partition evolution не требует перезаписи данных. Schema evolution в Iceberg работает по тому же принципу: мгновенное изменение схемы без перезаписи файлов, но с гарантией корректности через уникальные field ID.
Это один из самых важных архитектурных выборов Iceberg — и главное отличие от Delta Lake и чистого Parquet.
Примеры кода — на Python с pyiceberg 0.11.1 (март 2026). Версия спецификации — V2 (с пометками о V3 default values).
Проблема: как связать колонки в старых и новых файлах?
Когда таблица эволюционирует (добавляются колонки, удаляются, переименовываются), возникает фундаментальный вопрос: как при чтении связать колонки в Parquet-файлах, записанных со старой схемой, с колонками в новой схеме?
Три подхода:
| Подход | Rename | Reorder | Add в середину | Drop + Add | Вложенные структуры |
|---|---|---|---|---|---|
| По позиции | Ломает | Ломает | Ломает | Плоский только | |
| По имени | Ломает | ! Зависит | ! Нужен dotted path | ||
| По field ID | Nested field IDs |
Уникальные field IDs — основа корректности
Каждое поле в схеме Iceberg-таблицы получает уникальный целочисленный ID при создании. Этот ID:
- Никогда не переиспользуется — удалённое поле оставляет “дыру” в нумерации
- Записывается в Parquet — как
field_idв Thrift-схеме каждого SchemaElement - Одинаков для всех файлов — старые и новые Parquet-файлы используют одни и те же field ID
- Работает для nested-структур — struct, list, map — каждый вложенный элемент имеет свой ID
{
"schema-id": 0,
"type": "struct",
"fields": [
{ "id": 1, "name": "order_id", "type": "long", "required": true },
{ "id": 2, "name": "customer_id", "type": "long", "required": true },
{ "id": 3, "name": "amount", "type": "decimal(10,2)", "required": true },
{ "id": 4, "name": "order_ts", "type": "timestamptz", "required": true }
]
}
Metadata file отслеживает last-column-id — следующий свободный ID. При добавлении нового поля last-column-id инкрементируется.
CREATE TABLE: id=1,2,3,4 (last-column-id=4)
CREATE TABLE создаёт начальную схему (schema-id=0). Каждому полю присваивается уникальный ID: order_id=1, customer_id=2, amount=3, order_ts=4. last-column-id устанавливается в 4.ADD COLUMN: status получает id=5 (last-column-id=5)
ALTER TABLE ADD COLUMN status STRING. Новое поле получает id=5. Создаётся новая схема (schema-id=1), включающая все прежние поля + status(id=5). last-column-id=5.Zombie data prevention
Что произошло бы, если ID=2 переиспользовали для region?
- Старые Parquet-файлы содержат колонку с
field_id=2— этоcustomer_id(числовые значения: 42, 73, 15…) - Новая схема говорит: поле с
id=2— этоregion(строки: “EU”, “US”, “APAC”…) - Чтение вернёт числа
42, 73, 15как значенияregion— молчаливое повреждение данных
Это называется zombie data — данные “воскресают” под чужим именем. Iceberg предотвращает это фундаментально: ID никогда не переиспользуется.
Parquet без Iceberg не защищает от zombie data. Если вы управляете Parquet-файлами вручную (без Iceberg/Delta Lake), при изменении схемы рискуете молча получить неверные данные. Iceberg решает эту проблему на уровне спецификации.
Поддерживаемые операции schema evolution
Iceberg поддерживает следующие операции изменения схемы — все без перезаписи данных:
Add Column
from pyiceberg.catalog import load_catalog
catalog = load_catalog("my_catalog")
table = catalog.load_table("db.orders")
# Добавить top-level колонку
with table.update_schema() as update:
update.add_column("region", StringType(), doc="Регион заказа")
# Добавить вложенную колонку в struct
with table.update_schema() as update:
update.add_column("address.zip_code", StringType())
Новый field получает следующий last-column-id + 1. Старые файлы будут возвращать null (или default value в V3) для нового поля.
Drop Column
with table.update_schema() as update:
update.delete_column("customer_id")
Поле удаляется из текущей схемы, но его field_id навсегда зарезервирован. Данные в старых файлах не удаляются физически — они просто не проецируются при чтении.
Rename Column
with table.update_schema() as update:
update.rename_column("amount", "total_amount")
Только имя меняется — field_id остаётся тем же. Все Parquet-файлы (старые и новые) читаются корректно, потому что маппинг идёт по ID, а не по имени.
Rename в Iceberg — безопасная операция. В Delta Lake rename по умолчанию ломает чтение старых файлов (нужен column mapping mode). В чистом Parquet rename невозможен без перезаписи.
Reorder Columns
with table.update_schema() as update:
update.move_first("region") # Переместить в начало
update.move_after("status", "order_id") # Переместить после order_id
Порядок колонок в схеме меняется, но field_id каждого поля остаётся прежним. Parquet-файлы хранят данные в своём порядке — projection на чтении переставляет колонки.
Type Promotion
Iceberg разрешает безопасное расширение типов:
| Исходный тип | Допустимый promoted тип |
|---|---|
int | long |
float | double |
decimal(P, S) | decimal(P', S) где P’ > P |
with table.update_schema() as update:
update.update_column("amount", LongType()) # int → long
Сужение типов запрещено: long → int, double → float, string → int — вызовут ошибку. Это гарантирует, что старые данные всегда читаемы с новой схемой.
Make Optional / Make Required
with table.update_schema() as update:
update.make_column_optional("order_id") # required → optional
required → optional — безопасно (старые данные всегда имеют значение). optional → required — запрещено (старые файлы могут содержать null).
Schemas[] и current-schema-id в metadata file
Каждая операция schema evolution создаёт новую схему в массиве schemas[] metadata file:
{
"schemas": [
{
"schema-id": 0,
"fields": [
{ "id": 1, "name": "order_id", "type": "long" },
{ "id": 2, "name": "customer_id", "type": "long" },
{ "id": 3, "name": "amount", "type": "decimal(10,2)" }
]
},
{
"schema-id": 1,
"fields": [
{ "id": 1, "name": "order_id", "type": "long" },
{ "id": 2, "name": "customer_id", "type": "long" },
{ "id": 3, "name": "total_amount", "type": "decimal(10,2)" },
{ "id": 5, "name": "status", "type": "string" }
]
}
],
"current-schema-id": 1,
"last-column-id": 5
}
Каждый snapshot ссылается на schema-id, который был текущим на момент записи:
{
"snapshot-id": 3497810934857103984,
"schema-id": 0,
"manifest-list": "s3://..."
}
Это позволяет точно знать, с какой схемой были записаны данные в каждом snapshot.
Projection on read — как читаются старые файлы
Когда запрос читает Parquet-файл, записанный со старой схемой (schema-id=0), а текущая схема — schema-id=1:
Parquet файл (schema-id=0)
Parquet-файл, записанный со schema-id=0. Содержит три колонки с field_id=1,2,3. Имена в файле: order_id, customer_id, amount. Имена могут не совпадать с текущей схемой — неважно.Текущая схема (schema-id=1)
Текущая схема (schema-id=1). Другой набор полей: customer_id удалён, amount переименован в total_amount, добавлен status(id=5). Маппинг по field_id: id=1→order_id, id=3→total_amount, id=5→status.order_id=100, total_amount=99.50, status=null
Результат projection: id=1 (order_id) — читается из файла, id=3 (total_amount, переименован из amount) — читается из файла по field_id=3, id=2 (customer_id) — пропускается (нет в текущей схеме), id=5 (status) — null (нет в файле).Правила projection:
- Поле есть в файле и в схеме (одинаковый field_id) → читать из файла, применить type promotion если нужно
- Поле есть в файле, но нет в схеме → пропустить (данные от удалённых колонок)
- Поле есть в схеме, но нет в файле → вернуть
null(илиdefault valueв V3) - Тип в файле уже, чем в схеме → автоматический type promotion (int → long)
Projection on read — это то, что делает schema evolution в Iceberg zero-copy. Ни один файл не перезаписывается. Движок просто по-разному проецирует данные из существующих файлов.
Nested schema evolution
Iceberg поддерживает эволюцию вложенных типов — struct, list, map:
# Исходная схема
# address: struct<
# street: string (id=10),
# city: string (id=11)
# > (id=9)
with table.update_schema() as update:
# Добавить поле в struct
update.add_column("address.zip_code", StringType()) # получит id=12
# Переименовать вложенное поле
update.rename_column("address.street", "street_line1")
# Удалить вложенное поле
update.delete_column("address.city")
Для map и list — аналогично:
# tags: map<string (key-id=20), string (value-id=21)> (id=19)
# items: list<struct<name: string (id=31), qty: int (id=32)> (element-id=30)> (id=29)
with table.update_schema() as update:
# Добавить поле в element struct списка
update.add_column("items.element.price", DecimalType(10, 2)) # получит id=33
Sort order evolution
Помимо схемы и partition spec, Iceberg хранит sort orders — порядок сортировки внутри data files. Sort order тоже может эволюционировать:
{
"sort-orders": [
{
"order-id": 0,
"fields": []
},
{
"order-id": 1,
"fields": [
{
"source-id": 4,
"transform": "identity",
"direction": "asc",
"null-order": "nulls-first"
},
{
"source-id": 1,
"transform": "identity",
"direction": "asc",
"null-order": "nulls-first"
}
]
}
],
"default-sort-order-id": 1
}
source-id— ссылка наfield_idв текущей схеме- При изменении sort order создаётся новый
order-id, старый сохраняется - Файлы, написанные с разным sort order, читаются одинаково — sort order влияет только на оптимизацию (range pruning, merge sort при compaction)
Sort order — это hint для writers, не constraint для readers. Читатель не обязан полагаться на сортировку. Но правильный sort order ускоряет compaction (merge sort вместо полной пересортировки) и улучшает data skipping (min/max статистика плотнее).
V3: Default values
Iceberg V3 добавляет default values — значения по умолчанию для новых колонок:
ALTER TABLE orders ADD COLUMN version INT DEFAULT 1;
Без default value (V2): старые файлы возвращают null для нового поля.
С default value (V3): старые файлы возвращают 1 — без перезаписи файлов, значение хранится в metadata.
Это две категории default:
| Тип | Когда применяется | Пример |
|---|---|---|
| initial-default | При чтении файлов, записанных ДО добавления колонки | null → 1 для старых файлов |
| write-default | При записи новых данных, если значение не указано | INSERT INTO ... (order_id) → version=1 |
Старый файл (нет version)
Файлы, записанные до ADD COLUMN version. Не содержат колонку version вообще. При чтении: projection видит, что field_id отсутствует → возвращает initial-default (1).Сравнение schema evolution
| Аспект | Parquet (чистый) | Delta Lake | Apache Iceberg |
|---|---|---|---|
| Маппинг колонок | По позиции | По имени (default) / по ID (column mapping) | По field ID (всегда) |
| Rename | ! Безопасно (по позиции) | Ломает (без mapping) / Да (с mapping) | Безопасно (по ID) |
| Reorder | Ломает | Безопасно | Безопасно |
| Add column | ! Только в конец | В любую позицию | В любую позицию |
| Drop + Add | Zombie data | ! Зависит от mode | Zombie prevention |
| Type promotion | int→long, decimal widening | int→long, float→double, decimal | |
| Nested evolution | Ограничено | Полная (struct/list/map) | |
| Default values | V3 (initial + write) | ||
| Перезапись данных | Всегда | Никогда | Никогда |
| История схем | _delta_log/ JSON | schemas[] в metadata |
Практика: schema evolution с pyiceberg
from pyiceberg.catalog import load_catalog
from pyiceberg.types import StringType, LongType, DecimalType
catalog = load_catalog("my_catalog")
table = catalog.load_table("db.orders")
# Несколько операций в одной транзакции
with table.update_schema() as update:
# Добавить колонку
update.add_column("shipping_method", StringType(), doc="Способ доставки")
# Переименовать
update.rename_column("amount", "order_total")
# Расширить тип
update.update_column("order_id", LongType()) # int → long
# Сделать опциональным
update.make_column_optional("customer_id")
# Все операции применяются атомарно — один новый metadata file
print(f"Текущая схема: {table.schema()}")
print(f"Schema ID: {table.metadata.current_schema_id}")
print(f"Всего схем в истории: {len(table.metadata.schemas)}")
update_schema() — транзакционный. Все операции применяются атомарно: или все, или ни одна. Если type promotion невозможен (например, long → int), весь batch отклоняется.
Итоги
- Field ID — каждое поле получает уникальный целочисленный ID, который записывается в Parquet и никогда не переиспользуется
- Zombie data prevention — запрет переиспользования ID предотвращает молчаливое повреждение данных
- Projection on read — zero-copy schema evolution: ни один файл не перезаписывается, проекция при чтении
- Полная nested evolution — struct, list, map — каждый вложенный элемент имеет свой ID
- Sort order evolution — изменение порядка сортировки без перезаписи, влияет на оптимизацию
- V3 default values — initial-default (для старых файлов) и write-default (для новых INSERT), без backfill
- Сравнение: Iceberg (по ID) > Delta Lake (по имени) > Parquet (по позиции) по надёжности schema evolution