Learning Platform
Глоссарий Troubleshooting
Урок 13.04 · 35 мин
Продвинутый
Apache IcebergSchema EvolutionColumn IDField IDType PromotionProjectionSort OrderSchema HistoryZombie Data

Schema evolution и column IDs

В уроке 1 мы видели, что metadata file хранит массив schemas[] с полной историей схем и поле current-schema-id. В уроке 3 — что partition evolution не требует перезаписи данных. Schema evolution в Iceberg работает по тому же принципу: мгновенное изменение схемы без перезаписи файлов, но с гарантией корректности через уникальные field ID.

Это один из самых важных архитектурных выборов Iceberg — и главное отличие от Delta Lake и чистого Parquet.

NOTE

Примеры кода — на Python с pyiceberg 0.11.1 (март 2026). Версия спецификации — V2 (с пометками о V3 default values).

Проблема: как связать колонки в старых и новых файлах?

Когда таблица эволюционирует (добавляются колонки, удаляются, переименовываются), возникает фундаментальный вопрос: как при чтении связать колонки в Parquet-файлах, записанных со старой схемой, с колонками в новой схеме?

Три подхода:

Три подхода к маппингу колонок
По позиции (Parquet)Чистый Parquet без Iceberg сопоставляет колонки по порядковому номеру. Колонка 0 в файле = колонка 0 в схеме. Если колонка удалена или добавлена в середину — всё ломается. Переименование безопасно, но reorder или drop ведёт к ошибочным данным.
По имени (Delta Lake)Delta Lake сопоставляет по имени колонки. Переименование колонки (ALTER TABLE RENAME COLUMN) ломает связь со старыми Parquet-файлами — данные теряются или читаются неверно. Column mapping mode решает это, но не по умолчанию.
По field ID (Iceberg)Iceberg присваивает каждому полю уникальный целочисленный ID при создании. Этот ID записывается в Parquet-файл как field_id в SchemaElement. Rename, reorder, add, drop — ID остаётся тот же. Безусловная корректность.
ПодходRenameReorderAdd в серединуDrop + AddВложенные структуры
По позиции Ломает Ломает Ломает Плоский только
По имени Ломает! Зависит! Нужен dotted path
По field ID Nested field IDs

Уникальные field IDs — основа корректности

Каждое поле в схеме Iceberg-таблицы получает уникальный целочисленный ID при создании. Этот ID:

  1. Никогда не переиспользуется — удалённое поле оставляет “дыру” в нумерации
  2. Записывается в Parquet — как field_id в Thrift-схеме каждого SchemaElement
  3. Одинаков для всех файлов — старые и новые Parquet-файлы используют одни и те же field ID
  4. Работает для 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 инкрементируется.

Field 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

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.
DROP COLUMN customer_id
DROP COLUMN: id=2 удалён, ID=2 ≠ reuse (last-column-id=5)ALTER TABLE DROP COLUMN customer_id. Поле id=2 удаляется из новой схемы (schema-id=2), но его ID=2 НИКОГДА не будет переиспользован. last-column-id остаётся 5. Дыра в нумерации — это нормально.
ADD COLUMN region
ADD COLUMN: region получает id=6, НЕ id=2 (last-column-id=6)ALTER TABLE ADD COLUMN region STRING. Новое поле получает id=6, а не id=2. Переиспользование запрещено — иначе старые файлы с customer_id(id=2) вернули бы данные в region(id=2). last-column-id=6.
Итоговая схемаФинальная схема (schema-id=3): order_id(1), amount(3), order_ts(4), status(5), region(6). Нет id=2 — он навсегда зарезервирован за удалённым customer_id. Старые Parquet-файлы с колонкой id=2 просто пропускаются при чтении.

Zombie data prevention

Что произошло бы, если ID=2 переиспользовали для region?

  1. Старые Parquet-файлы содержат колонку с field_id=2 — это customer_id (числовые значения: 42, 73, 15…)
  2. Новая схема говорит: поле с id=2 — это region (строки: “EU”, “US”, “APAC”…)
  3. Чтение вернёт числа 42, 73, 15 как значения regionмолчаливое повреждение данных

Это называется zombie data — данные “воскресают” под чужим именем. Iceberg предотвращает это фундаментально: ID никогда не переиспользуется.

WARNING

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, а не по имени.

TIP

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 тип
intlong
floatdouble
decimal(P, S)decimal(P', S) где P’ > P
with table.update_schema() as update:
 update.update_column("amount", LongType()) # int → long
DANGER

Сужение типов запрещено: longint, doublefloat, stringint — вызовут ошибку. Это гарантирует, что старые данные всегда читаемы с новой схемой.

Make Optional / Make Required

with table.update_schema() as update:
 update.make_column_optional("order_id") # required → optional

required → optional — безопасно (старые данные всегда имеют значение). optional → required — запрещено (старые файлы могут содержать null).

Безопасные и запрещённые операции
Да БезопасныеОперации, которые не ломают обратную совместимость: добавление колонки, удаление колонки, переименование, reorder, расширение типа (int→long), required→optional. Все без перезаписи данных.
Нет ЗапрещённыеОперации, которые могут привести к потере данных или ошибкам чтения: сужение типа (long→int), optional→required, изменение field ID, переиспользование удалённого ID. Iceberg отвергает их на уровне API.
! V3: Default valuesV3 добавляет default values: при ADD COLUMN можно указать значение по умолчанию. Старые файлы вернут это значение вместо null. Мгновенная операция — default хранится в metadata, не в файлах.

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:

Projection on read: маппинг field ID

Parquet файл (schema-id=0)

Parquet-файл, записанный со schema-id=0. Содержит три колонки с field_id=1,2,3. Имена в файле: order_id, customer_id, amount. Имена могут не совпадать с текущей схемой — неважно.
Колонки в файлеФизические колонки в Parquet-файле. Каждая имеет field_id из Thrift SchemaElement. Именно field_id используется для маппинга, а не имя колонки.

Текущая схема (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.
Поля в схемеЛогические поля в текущей схеме. Каждое поле имеет field_id. При чтении Parquet-файла: совпадение по ID → читать; нет в файле → null (или default в V3); нет в схеме → пропустить.
Projection rules

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:

  1. Поле есть в файле и в схеме (одинаковый field_id) → читать из файла, применить type promotion если нужно
  2. Поле есть в файле, но нет в схеме → пропустить (данные от удалённых колонок)
  3. Поле есть в схеме, но нет в файле → вернуть null (или default value в V3)
  4. Тип в файле уже, чем в схеме → автоматический type promotion (int → long)
TIP

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
Nested field IDs — struct, list, map
StructКаждое вложенное поле struct имеет собственный field_id. Сам struct тоже имеет ID. При добавлении поля в struct — новый ID. При удалении — ID зарезервирован.
ListList имеет ID для самого списка и element-id для типа элемента. Если элемент — struct, его поля тоже имеют IDs. Вся иерархия полностью ID-based.
MapMap имеет ID для самого map, key-id для типа ключа, value-id для типа значения. Key и Value могут быть struct — и каждое вложенное поле получает свой ID.

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)
NOTE

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При чтении файлов, записанных ДО добавления колонкиnull1 для старых файлов
write-defaultПри записи новых данных, если значение не указаноINSERT INTO ... (order_id) → version=1
V3 Default Values: initial vs write

Старый файл (нет version)

Файлы, записанные до ADD COLUMN version. Не содержат колонку version вообще. При чтении: projection видит, что field_id отсутствует → возвращает initial-default (1).
initial-default=1
Читатель видитЧитатель получает version=1 для каждой строки из старого файла. Значение берётся из metadata, а не из Parquet. Zero-cost операция — нет I/O.
Новый INSERT (version не указан)Новые INSERT-ы после ADD COLUMN. Если writer не указывает значение version — используется write-default (1). Если указывает — используется указанное значение. write-default хранится в metadata.
write-default=1
Записано в файлWriter записывает version=1 в Parquet-файл. В отличие от initial-default, значение физически присутствует в файле.

Сравнение schema evolution

АспектParquet (чистый)Delta LakeApache Iceberg
Маппинг колонокПо позицииПо имени (default) / по ID (column mapping)По field ID (всегда)
Rename! Безопасно (по позиции) Ломает (без mapping) / Да (с mapping) Безопасно (по ID)
Reorder Ломает Безопасно Безопасно
Add column! Только в конец В любую позицию В любую позицию
Drop + Add Zombie data! Зависит от mode Zombie prevention
Type promotionint→long, decimal wideningint→long, float→double, decimal
Nested evolutionОграниченоПолная (struct/list/map)
Default values V3 (initial + write)
Перезапись данныхВсегдаНикогдаНикогда
История схем_delta_log/ JSONschemas[] в 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)}")
TIP

update_schema() — транзакционный. Все операции применяются атомарно: или все, или ни одна. Если type promotion невозможен (например, long → int), весь batch отклоняется.

Итоги

  1. Field ID — каждое поле получает уникальный целочисленный ID, который записывается в Parquet и никогда не переиспользуется
  2. Zombie data prevention — запрет переиспользования ID предотвращает молчаливое повреждение данных
  3. Projection on read — zero-copy schema evolution: ни один файл не перезаписывается, проекция при чтении
  4. Полная nested evolution — struct, list, map — каждый вложенный элемент имеет свой ID
  5. Sort order evolution — изменение порядка сортировки без перезаписи, влияет на оптимизацию
  6. V3 default values — initial-default (для старых файлов) и write-default (для новых INSERT), без backfill
  7. Сравнение: Iceberg (по ID) > Delta Lake (по имени) > Parquet (по позиции) по надёжности schema evolution

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Iceberg-таблица: колонка `city` (field ID=7) удалена (DROP COLUMN), затем добавлена новая колонка `city` (field ID=12). Старые Parquet-файлы содержат field ID=7. Что вернёт SELECT city FROM t при чтении старых файлов?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6