Skip to content

Split View: Iceberg v3 로우 리니지 — 행 ID는 파일에 저장되지 않는다

|

Iceberg v3 로우 리니지 — 행 ID는 파일에 저장되지 않는다

들어가며 — v3에서 로우 리니지는 끌 수 없다

Apache Iceberg 스펙 원문의 "Format Versioning" 절은 이렇게 시작합니다 — 버전 1, 2, 3은 완료되었고 커뮤니티가 채택했으며, 버전 4는 활발히 개발 중이고 아직 공식 채택되지 않았다. v3가 더 이상 미래형이 아니라는 뜻입니다. Java 라이브러리는 이미 v4까지 읽고 쓸 수 있게 상수가 잡혀 있습니다(SUPPORTED_TABLE_FORMAT_VERSION = 4).

v3가 더한 것은 목록으로 보면 이렇습니다 — 나노초 타임스탬프, unknown·variant·geometry·geography 타입, 컬럼 기본값, 다중 인자 파티션 변환, 로우 리니지 추적, 바이너리 삭제 벡터, 테이블 암호화 키. 이 중 삭제 벡터는 이야기가 많이 됐습니다. 이 글은 상대적으로 덜 다뤄진, 그리고 훨씬 더 조용히 당신의 파이프라인을 물어뜯을 수 있는 로우 리니지를 봅니다.

먼저 알아야 할 사실 하나. 로우 리니지는 켜고 끄는 테이블 속성이 아닙니다. Iceberg 코어의 판정 로직은 이게 전부입니다.

public static boolean supportsRowLineage(Table table) {
  Preconditions.checkArgument(null != table, "Invalid table: null");
  if (table instanceof BaseMetadataTable) {
    return false;
  }

  return formatVersion(table) >= TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE;
}

MIN_FORMAT_VERSION_ROW_LINEAGE는 3입니다. 즉 포맷 버전이 3 이상이면 로우 리니지는 켜져 있습니다. 초기 설계에는 이걸 켜는 메타데이터 필드가 있었지만 제거됐고("Core: Enable row lineage for all v3 tables", PR #12593), 스펙은 단정적으로 씁니다 — v3 이상에서 Iceberg 테이블은 새로 생성되는 모든 행에 대해 로우 리니지 필드를 반드시 추적해야 한다. v3로 올리는 순간, 그 테이블에 쓰는 모든 엔진이 리니지 유지 의무를 집니다. 당신이 그 기능을 원했든 아니든.

반대로 안심할 사실도 하나. 같은 파일에서 DEFAULT_TABLE_FORMAT_VERSION은 여전히 2입니다. 2026년 7월 현재 main 브랜치에서도 그렇습니다. 새 테이블을 만든다고 v3가 되지는 않습니다 — v3는 명시적으로 선택해야 들어가는 문입니다.

로우 리니지가 주는 것

v3는 두 개의 예약된 메타데이터 컬럼을 정의합니다.

필드 ID이름타입의미
2147483540_row_idlong테이블 안에서 행마다 유일한 식별자
2147483539_last_updated_sequence_numberlong그 행을 마지막으로 수정한 커밋의 시퀀스 번호

핵심은 _row_id행의 정체성이라는 점입니다. 행이 UPDATE되어도, 컴팩션으로 다른 파일에 옮겨 써져도, 파티션이 바뀌어도 같은 _row_id를 유지해야 합니다. 물리적 위치와 논리적 정체성을 분리하는 것 — 이게 없으면 "이 행이 언제 처음 들어왔고 그동안 몇 번 바뀌었나"를 테이블 바깥의 별도 장부 없이는 답할 수 없습니다.

여기에 _last_updated_sequence_number가 붙으면 "시퀀스 번호 N 이후에 바뀐 행만 달라"가 테이블 메타데이터만으로 표현됩니다. 증분 처리와 CDC를 외부 도구 없이 세우는 토대가 이겁니다.

핵심 설계 — 값을 쓰지 않고 상속한다

여기가 이 기능의 전부입니다. _row_id는 데이터 파일에 저장되지 않습니다.

새 행을 쓸 때 writer는 _row_id_last_updated_sequence_numbernull로 둡니다. 아예 컬럼을 생략해도 됩니다 — 스펙은 컬럼이 없으면 reader가 "모든 행에서 null인 컬럼이 존재하는 것처럼" 취급하라고 합니다. 실제 값은 읽는 시점에 계산됩니다.

왜 이렇게 하냐면, 스펙이 직접 답을 씁니다 — 커밋 시퀀스 번호와 시작 행 ID는 스냅샷이 성공적으로 커밋되기 전까지 정해지지 않기 때문입니다. Iceberg의 커밋은 낙관적 동시성 제어입니다. 두 writer가 동시에 커밋을 시도하면 하나는 지고, 진 쪽은 최신 스냅샷 위에서 재시도합니다. 만약 행 ID를 데이터 파일 안에 박아 넣었다면, 커밋이 재시도될 때마다 이미 다 써 놓은 데이터 파일과 매니페스트 파일을 전부 다시 써야 합니다. 쓰기가 클수록 충돌 한 번의 대가가 그대로 커지는 구조입니다. 스펙의 표현대로, 상속은 값이 정해지기 전에 파일을 쓸 수 있게 해서 낙관적 커밋이 재시도될 때 파일을 다시 쓰지 않아도 되게 하려고 있습니다.

대가는 명확합니다. 값이 없으니, 값을 만들어 내는 체인이 정확해야 합니다. 체인은 이렇게 내려옵니다.

테이블 메타데이터   next-row-id           (다음에 나눠 줄 ID의 시작점)
   └─ 스냅샷        first-row-id           = 커밋 시점의 테이블 next-row-id
        └─ 매니페스트  first_row_id        = 앞선 매니페스트들의 행 수만큼 밀린 값
             └─ 데이터 파일  first_row_id  = 앞선 파일들의 record_count 만큼 밀린 값
                  └─ 행     _row_id        = 데이터 파일의 first_row_id + _pos

맨 아래 한 줄이 결론입니다 — 행의 ID는 그 행이 담긴 데이터 파일의 first_row_id에 파일 내 행 위치(_pos)를 더한 값입니다. ID를 저장하는 대신, ID 공간을 구간으로 예약해 나눠 주고 위치로 오프셋을 잡는 구조입니다.

스펙의 예제를 따라가 보기

스펙에 실린 예제가 이 체인을 잘 보여줍니다. next-row-id가 1000인 테이블에서 시작합니다. append 스냅샷은 first-row-id로 테이블의 현재 next-row-id인 1000을 받습니다.

매니페스트 리스트를 쓸 때 각 매니페스트에 first_row_id가 할당됩니다.

manifest_pathadded_rows_countexisting_rows_countfirst_row_id
existing750925
added1100251000
added201001125
added3125251225

added1은 스냅샷과 같은 1000을 받고, 그다음부터는 앞선 매니페스트의 행 수(추가된 행 + 기존 행)만큼 밀립니다. 1000 + 125 = 1125, 1125 + 100 = 1225.

여기서 스펙이 짚는 함정 하나 — added2는 추가된 데이터 파일이 하나도 없는데도 다음 매니페스트의 first_row_id를 100만큼 밀어 놓습니다. first_row_id가 없는 데이터 파일이라면 EXISTING 상태여도 ID를 할당받을 수 있기 때문에 그만큼 공간을 비워 두는 겁니다.

첫 매니페스트 added1 안의 데이터 파일들은 이렇게 됩니다.

statusfile_pathrecord_countfirst_row_id
EXISTINGdata125800
ADDEDdata250null → 1000
ADDEDdata350null → 1050

data1은 이미 예전에 800을 할당받았으므로 그 값이 그대로 복사됩니다. data2data3null로 쓰여 있고, 읽을 때 매니페스트의 first_row_id(1000)에서 시작해 앞선 파일들의 record_count만큼 밀려 각각 1000과 1050을 받습니다. 그래서 data3의 세 번째 행의 _row_id는 1050 + 2 = 1052입니다.

커밋이 끝나면 테이블의 next-row-id가 갱신됩니다. 스펙의 의무 조항은 "그 스냅샷에서 새로 할당된 행 ID 수 이상"만큼 밀라는 것이고, 권장 계산은 first_row_id를 할당받은 매니페스트들의 added_rows_countexisting_rows_count를 모두 더하라는 것입니다. 예제에서 그 합이 375(added1 125 + added2 100 + added3 150)이므로 next-row-id는 1000 + 375 = 1375가 됩니다.

여기서 EXISTING 행들도 ID 공간을 소비한다는 점에 주목하세요. 이미 자기 ID를 갖고 있어 새 ID를 받지 않는 행들인데도 범위는 예약됩니다. 의무는 하한만 정하고 권장 계산은 그보다 넉넉히 잡으니, ID 공간에는 구멍이 생기는 게 정상입니다. _row_id는 유일할 뿐 조밀하지 않습니다 — 행 개수를 세거나 연속성을 가정하는 용도로 쓰면 안 됩니다.

읽을 때의 규칙

reader 쪽 규칙은 스펙 부록 E에 못 박혀 있습니다.

  • 데이터 파일의 first_row_id가 non-null이면, reader는 null이거나 없는 _row_idfirst_row_id + _pos로 채운다.
  • 마찬가지로 null이거나 없는 _last_updated_sequence_number를 그 데이터 파일의 data_sequence_number로 채운다.
  • 이미 non-null인 값은 손대지 않고 그대로 읽는다.
  • 데이터 파일의 first_row_id가 null이면, reader는 _row_id_last_updated_sequence_number를 null로 낸다.

세 번째 규칙이 중요합니다. 값이 이미 있으면 그건 "옮겨져 온 행"이라는 뜻이고, 그 정체성을 덮어쓰면 안 됩니다.

여기서부터가 진짜 — 리니지가 끊기는 자리들

컴팩션이 리니지를 죽인다

행이 다른 데이터 파일로 옮겨질 때(컴팩션, 리라이트, 파티션 변경 등 이유 불문) writer가 지켜야 할 규칙은 이렇습니다.

  1. 행의 기존 non-null _row_id는 새 데이터 파일로 복사되어야 한다.
  2. 그 쓰기가 행을 수정했다면 _last_updated_sequence_numbernull로 둔다(수정 시점의 시퀀스 번호가 상속되도록).
  3. 수정하지 않았다면 기존 non-null _last_updated_sequence_number를 그대로 복사한다.

말은 간단한데, 구현 입장에서는 고약합니다. 컴팩션은 원래 "데이터를 읽어서 큰 파일로 다시 쓰는" 단순한 작업입니다. 그런데 v3에서는 컴팩션 job이 숨은 메타데이터 컬럼 두 개를 함께 읽어서 함께 써야 합니다. 그냥 다시 쓰면 _row_id가 전부 새로 발급되고, 테이블의 모든 행이 "방금 처음 생긴 행"이 되어 버립니다. 리니지 위에 세운 CDC 파이프라인은 전체 재처리를 하게 됩니다. 조용히, 에러 하나 없이.

이게 이론적 우려가 아니라는 증거는 커밋 로그에 다 있습니다. 뒤에서 봅니다.

equality delete는 리니지를 추적하지 않는다

스펙이 명시적으로 예외를 둡니다 — equality delete로 수정된 행에 대해서는 로우 리니지를 추적하지 않습니다. 이유도 스펙에 있습니다. equality delete를 쓰는 엔진은 애초에 기존 데이터를 읽지 않고 변경을 쓰기 때문에, 새 행에 원래의 row ID를 넣어 줄 수가 없습니다. 그래서 이런 갱신은 기존 행이 완전히 제거되고 유일한 새 행이 추가된 것처럼 취급됩니다.

이건 큽니다. Flink의 upsert 스트리밍처럼 equality delete에 의존하는 워크로드라면, v3로 올려도 행 정체성은 UPDATE를 건너지 못합니다. 로우 리니지를 CDC 기반으로 쓰려는 계획이 여기서 정확히 절반쯤 무너질 수 있습니다.

업그레이드한 테이블의 과거는 영원히 null

v2 테이블을 v3로 올리면 next-row-id는 0으로 초기화되고, 기존 스냅샷은 수정되지 않습니다. first-row-id가 없는 그 스냅샷들에서는 데이터 파일과 매니페스트의 first_row_id가 null이고, 따라서 모든 행의 _row_id가 null로 읽힙니다. 스펙의 표현 그대로 — 업그레이드 전에 만들어진 스냅샷에는 행 ID가 없습니다.

업그레이드 후 첫 커밋에서 기존 파일들에도 ID가 할당되긴 합니다. 하지만 그건 "그 파일들이 지금 막 ID를 받았다"는 뜻이지, 과거 이력이 복원된다는 뜻이 아닙니다. 로우 리니지는 소급되지 않습니다.

브랜치마다 ID 범위가 갈라진다

스펙이 조용히 남긴 각주 하나. 업그레이드 후, 서로 다른 브랜치의 새 스냅샷들은 커밋 시점의 next-row-id에 따라 기존 데이터 파일들에 서로 겹치지 않는 ID 범위를 할당합니다. 여러 브랜치에 있는 데이터 파일에 대해 writer는 다른 브랜치의 first_row_id를 쓸 수도 있고, 큰 메타데이터 재작성을 피하려고 새 first_row_id를 할당할 수도 있습니다.

즉 브랜치를 쓰는 테이블에서 _row_id는 브랜치 간에 같은 행을 가리킨다고 보장되지 않습니다. _row_id를 조인 키로 쓰려던 계획이 있다면 여기서 멈춰야 합니다.

엔진 현실 점검 — 스펙의 "must"와 구현의 거리

여기가 이 글을 쓴 이유입니다. 벤더 블로그들은 "v3 지원"을 체크박스로 그립니다. 실제 코드는 다른 이야기를 합니다. 아래는 2026년 7월 16일 기준으로 각 저장소의 main/master 브랜치와 병합된 PR을 직접 확인한 내용입니다.

Spark 4.0이 사실상의 레퍼런스입니다. 로우 리니지 지원이 PR #13310으로 2025년 7월 14일에 들어갔습니다. PR 설명에 따르면 Spark 4.0에서 새로 생긴 조건부 nullification 메커니즘을 쓰는데, 이게 Spark 3.5에서는 없어서 Iceberg가 자체 rewrite 규칙을 따로 구현해야 했습니다(PR #12736, 2025년 4월 28일). 그 PR이 정리한 규칙은 앞에서 본 스펙 그대로입니다 — 기존 row ID는 연산 종류와 무관하게 항상 이어받고, INSERT에서는 null이며, 수정되지 않은 행의 시퀀스 번호는 보존하고 수정된 행은 null로 만든다.

컴팩션 보존은 별도의 작업이었습니다. 앞서 "컴팩션이 리니지를 죽인다"고 한 게 정확히 이것 때문입니다. Spark 4.0의 컴팩션 리니지 보존은 PR #13555로 2025년 7월 22일에야 들어갔고, 3.5와 3.4로는 그다음 날 백포트됐습니다(PR #13637, #13641). 구현 방식이 흥미로운데, PR 작성자 본인의 표현으로는 rewrite인 경우 SparkTable의 스키마를 "가로채서(hijacking)" 메타데이터 컬럼을 추가하는 방식입니다. 우아하지 않다는 걸 저자도 알고 있습니다.

Trino는 생각보다 앞서 있습니다. 이 부분은 검색 결과가 저를 속일 뻔했습니다 — 여러 2차 자료가 "Trino는 2026년 6월 기준 v3 준비가 안 됐다"거나 "로우 리니지는 아직"이라고 적고 있는데, 저장소를 직접 보면 사실이 아닙니다. 삭제 벡터는 PR #27788로 2026년 1월 16일에, 로우 리니지는 PR #27836으로 2026년 3월 19일에 master로 병합됐습니다. 후자는 읽기뿐 아니라 UPDATE/MERGE에서 원래 row ID를 보존하고, v3 테이블에서 OPTIMIZE를 다시 허용하면서 리니지를 지키는 것까지 포함합니다.

Trino가 그 전에 한 일도 볼 만합니다. PR #27786(2026년 1월 10일)은 v3 테이블 생성을 열되, 아직 구현하지 않은 v3 기능은 전부 NOT_SUPPORTED로 명시적으로 거부하도록 만들었습니다. v3 테이블에 대한 DELETE/UPDATE/MERGE, OPTIMIZE, add_files, 삭제 벡터, 컬럼 기본값, 암호화가 모두 여기에 걸렸습니다. PR 설명의 표현대로 "스펙 위반을 피하기 위해" 조용히 틀린 답을 내는 대신 빠르고 예측 가능하게 실패하는 쪽을 택한 겁니다. 부분 구현 상태에서 이건 옳은 선택이고, 다른 엔진들이 배울 만합니다.

컬럼 이름은 엔진마다 다릅니다. Iceberg 코어와 Spark는 _row_id / _last_updated_sequence_number를 쓰고, Trino는 자기 메타데이터 컬럼 관례에 따라 $row_id / $last_updated_sequence_number로 노출합니다. 필드 ID는 같으니 데이터는 호환되지만, 쿼리는 그대로 옮겨지지 않습니다.

Flink는 두 갈래입니다. 이게 가장 헷갈리는 부분입니다. Iceberg main 브랜치의 Flink 커넥터에는 컴팩션 경로가 둘 있고, 로우 리니지에 대해 정반대로 동작합니다.

구형 actions API(org.apache.iceberg.flink.actions.RewriteDataFilesAction)는 지금도 v3 테이블을 그냥 거부합니다. main 브랜치의 v1.20, v2.0, v2.1 모두에 이 코드가 그대로 있습니다.

Preconditions.checkArgument(
    !TableUtil.supportsRowLineage(table),
    "Flink does not support compaction on row lineage enabled tables (V3+)");

반면 신형 maintenance API(org.apache.iceberg.flink.maintenance.operator.DataFileRewriteRunner)는 리니지를 보존합니다. PR #14149로 2025년 11월 6일에 들어갔고, 코드는 이렇게 갈라집니다.

boolean preserveRowId = TableUtil.supportsRowLineage(value.table());

try (TaskWriter<RowData> writer = writerFor(value, preserveRowId)) {
  try (DataIterator<RowData> iterator = readerFor(value, preserveRowId)) {

정리하면 — Flink로 v3 테이블을 컴팩션하려면 maintenance API를 써야 하고, 구형 actions API를 쓰고 있다면 v3로 올리는 순간 컴팩션이 예외를 던지며 멈춥니다. 나쁜 소식 같지만 실은 좋은 설계입니다. 조용히 리니지를 날려 먹는 것보다 시끄럽게 멈추는 게 낫습니다. 참고로 저 거부 로직 자체가 PR #13646("Flink: fail file rewrite for V3 tables as row lineage not supported", 2025년 7월 23일)으로 의도적으로 추가된 것입니다.

PyIceberg는 아직 v3 테이블을 쓸 수 없습니다. main 브랜치의 pyiceberg/table/metadata.py에는 아직 이렇게 적혀 있습니다.

SUPPORTED_TABLE_FORMAT_VERSION = 2

v3 매니페스트/매니페스트 리스트 쓰기와 로우 리니지 스냅샷 커밋을 넣으려던 PR #3070은 병합되지 않고 닫혔고, 후속인 PR #3551("Writing v3 Table Metadata")은 2026년 7월 8일에 마지막으로 갱신된 채 아직 열려 있습니다. v3 업그레이드 지원 PR #3623은 #3551이 병합되기를 기다리는 draft 상태입니다. Python으로 Iceberg에 쓰는 파이프라인이 있다면 v3 계획에서 일단 빼 두는 게 정확합니다.

그리고 상속 로직은 아직도 고쳐지고 있습니다. 이 글을 쓰기 일주일 전인 2026년 7월 9일, PR #17039("Core: Fix row lineage last updated sequence inheritance")가 병합됐습니다. 버그는 이렇습니다 — Java가 _last_updated_sequence_number 메타데이터 컬럼을 채울 때 data_sequence_number 대신 fileSequenceNumber()를 쓰고 있었습니다.

둘의 차이가 평소엔 안 보입니다. 그런데 v2 시절 컴팩션이 더 오래된 data sequence number를 가진 대체 파일을 만들어 놓았고, 그 테이블을 나중에 v3로 올리면 갈라집니다. data_sequence_number는 파일의 내용에 결부된 값이라 리라이트를 건너 보존되지만, file_sequence_number는 그 물리적 파일이 추가된 시점입니다. 그래서 버그가 있는 버전은 "이 행이 마지막으로 수정된 시점"을 물었을 때 원래의 갱신 시점 대신 컴팩션이 돌아간 시점을 답했습니다.

이게 상속 설계의 대가를 정확히 보여줍니다. 값이 파일에 없으니, 상속 규칙 하나가 틀리면 데이터는 멀쩡한데 답이 틀립니다. 그리고 그 틀림은 예외를 던지지 않습니다. 스펙이 2024년 10월(PR #11130)에 로우 리니지를 처음 넣은 뒤 21개월이 지난 지금도 이런 버그가 나온다는 사실은, 이 기능의 난이도가 스펙 문서 분량에 비해 훨씬 높다는 뜻입니다. Avro reader 쪽에서도 2026년 2월과 3월에 비슷한 성격의 수정이 있었습니다(PR #15187, #15508).

언제 쓰지 말아야 하나

정직하게 정리하면 이렇습니다.

아직 v3로 올리지 마세요, 이런 경우엔

  • 파이프라인에 PyIceberg writer가 하나라도 있다. 지금은 v3 테이블을 쓸 수 없습니다.
  • Flink 구형 actions API로 컴팩션을 돌리고 있고, maintenance API로 옮길 계획이 없다.
  • 워크로드가 equality delete 중심이다(전형적으로 Flink upsert 스트리밍). 리니지를 얻겠다고 올렸는데 정작 UPDATE에서 행 정체성이 안 이어집니다.
  • 여러 엔진이 같은 테이블에 쓰는데, 그중 하나라도 리니지 보존이 검증되지 않았다. 리니지를 안 지키는 writer 하나가 테이블 전체의 리니지를 망가뜨립니다.

올려도 되는 경우

  • writer가 Spark 4.0(또는 백포트된 3.5/3.4)이나 최근 Trino로 한정된다.
  • 컴팩션 경로가 리니지를 보존하는지 실제로 확인했다 — 문서가 아니라 컴팩션 전후로 _row_id를 직접 비교해서.
  • 삭제 벡터의 이득만으로도 이미 값이 나온다. 로우 리니지는 v3의 덤으로 따라오는 것이고, 사실 대부분의 조직이 v3에 가는 실제 이유는 삭제 벡터입니다.

그리고 로우 리니지를 CDC로 쓰기 전에 반드시 확인할 것 — _row_id는 유일하지만 조밀하지 않고, 브랜치 간에 안정적이지 않으며, 업그레이드 이전 이력에는 존재하지 않고, equality delete를 건너지 못합니다. 이 네 가지가 당신의 설계와 충돌하지 않는지 먼저 보세요.

마치며

로우 리니지의 설계는 영리합니다. 행 ID를 저장하는 대신 ID 공간을 예약해 나눠 주고 위치로 오프셋을 잡음으로써, 낙관적 커밋이 재시도돼도 데이터 파일을 다시 쓰지 않아도 됩니다. Iceberg의 커밋 모델을 아는 사람이라면 이게 왜 이렇게 생겼는지 바로 이해할 겁니다.

대가는 책임의 이동입니다. 값이 파일에 없으니, 그 값을 만들어 내고 지켜 내는 일이 전적으로 writer와 reader의 정확성에 달립니다. 스펙은 "must"라고 쓰지만 스펙은 코드를 실행하지 않습니다. 그래서 2026년 7월 현재의 그림은 이렇습니다 — Spark 4.0은 되고, Trino는 3월부터 되고, Flink는 어느 API를 쓰느냐에 따라 갈리고, PyIceberg는 아직이고, 코어의 상속 로직은 지난주에도 고쳐졌습니다.

이건 v3를 쓰지 말라는 이야기가 아닙니다. 삭제 벡터만으로도 v3는 갈 만한 곳입니다. 다만 v3로 올리는 순간 로우 리니지는 끌 수 없이 켜지고, 그 테이블을 만지는 모든 도구가 암묵적 계약에 서명하게 된다는 걸 알고 가야 합니다. 그 계약을 못 지키는 도구가 하나라도 섞여 있으면, 당신이 얻는 건 리니지가 아니라 리니지가 있다는 착각입니다. 그게 훨씬 나쁩니다.

Iceberg의 전반적인 구조와 운영은 Apache Iceberg 데이터 레이크하우스 가이드 편에서 따로 다룹니다.

참고 자료

Iceberg v3 Row Lineage: Row IDs Aren't Stored in the File

Introduction — In v3, Row Lineage Can't Be Turned Off

The "Format Versioning" section of the Apache Iceberg spec itself opens like this — versions 1, 2, and 3 are complete and adopted by the community, while version 4 is under active development and not yet officially adopted. Which means v3 is no longer future tense. The Java library already has its constants set to read and write up through v4 (SUPPORTED_TABLE_FORMAT_VERSION = 4).

As a list, what v3 adds looks like this — nanosecond timestamps, the unknown/variant/geometry/geography types, column defaults, multi-argument partition transforms, row lineage tracking, binary deletion vectors, and table encryption keys. Deletion vectors have gotten a lot of coverage. This post looks at row lineage, which has been covered relatively less, and which can bite your pipeline far more quietly.

One fact to know up front: row lineage is not a table property you switch on or off. Here is the entirety of Iceberg core's determination logic.

public static boolean supportsRowLineage(Table table) {
  Preconditions.checkArgument(null != table, "Invalid table: null");
  if (table instanceof BaseMetadataTable) {
    return false;
  }

  return formatVersion(table) >= TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE;
}

MIN_FORMAT_VERSION_ROW_LINEAGE is 3. In other words, if the format version is 3 or higher, row lineage is on. The early design had a metadata field to toggle it, but that field was removed ("Core: Enable row lineage for all v3 tables", PR #12593), and the spec is categorical about it — at v3 and above, an Iceberg table must track the row lineage fields for every newly created row. The moment you upgrade to v3, every engine writing to that table takes on the obligation to maintain lineage. Whether you wanted that feature or not.

One reassuring fact in the other direction: in that same file, DEFAULT_TABLE_FORMAT_VERSION is still 2 — true on the main branch as of July 2026 as well. Creating a new table does not make it v3 — v3 is a door you have to walk through explicitly.

What Row Lineage Gives You

v3 defines two reserved metadata columns.

Field IDNameTypeMeaning
2147483540_row_idlongA unique identifier for each row within the table
2147483539_last_updated_sequence_numberlongThe sequence number of the commit that last modified that row

The key point is that _row_id is the row's identity. It has to stay the same even when the row is UPDATEd, even when compaction rewrites it into a different file, even when the partition changes. This separates physical location from logical identity — without it, you cannot answer "when did this row first appear, and how many times has it changed since" without a separate ledger outside the table.

Add _last_updated_sequence_number to that, and "give me only the rows that changed after sequence number N" becomes expressible purely with table metadata. This is the foundation for building incremental processing and CDC without external tooling.

The Core Design — Inherited, Not Written

This is the whole of the feature. _row_id is not stored in the data file.

When writing a new row, the writer leaves _row_id and _last_updated_sequence_number as null. It's even fine to omit the columns entirely — the spec says that if the column is absent, the reader should treat it "as if a column that is null for all rows" existed. The actual values are computed at read time.

As for why, the spec writes the answer itself — the commit sequence number and the starting row ID aren't determined until the snapshot commits successfully. Iceberg's commits use optimistic concurrency control. When two writers try to commit at the same time, one loses, and the loser retries on top of the latest snapshot. If row IDs were baked into the data file, every retried commit would have to rewrite every data file and manifest file already written. The bigger the write, the bigger the cost of a single conflict. In the spec's own words, inheritance exists to let files be written before the values are determined, so that optimistic commits don't have to rewrite files when they retry.

The tradeoff is clear. Because there's no stored value, the chain that produces the value has to be exact. The chain flows down like this.

table metadata     next-row-id           (starting point for the next IDs to hand out)
   └─ snapshot      first-row-id         = table's next-row-id at commit time
        └─ manifest   first_row_id       = shifted by the row count of preceding manifests
             └─ data file  first_row_id  = shifted by the record_count of preceding files
                  └─ row      _row_id    = data file's first_row_id + _pos

The bottom line is the conclusion — a row's ID is the first_row_id of the data file it lives in, plus its position within the file (_pos). Instead of storing the ID, the design reserves and hands out ranges of ID space, then offsets by position.

Walking Through the Spec's Example

The example in the spec shows this chain well. Start with a table whose next-row-id is 1000. An append snapshot receives the table's current next-row-id, 1000, as its first-row-id.

When the manifest list is written, each manifest is assigned a first_row_id.

manifest_pathadded_rows_countexisting_rows_countfirst_row_id
existing750925
added1100251000
added201001125
added3125251225

added1 receives the same 1000 as the snapshot, and from there each subsequent manifest is shifted by the preceding manifest's row count (added rows + existing rows): 1000 + 125 = 1125, 1125 + 100 = 1225.

There's a trap the spec flags here — added2 has zero added data files, yet it still shifts the next manifest's first_row_id by 100. A data file without a first_row_id can still be assigned an ID even in the EXISTING state, so that much space is reserved for it.

The data files inside the first manifest, added1, look like this.

statusfile_pathrecord_countfirst_row_id
EXISTINGdata125800
ADDEDdata250null → 1000
ADDEDdata350null → 1050

data1 was already assigned 800 previously, so that value is simply copied over. data2 and data3 are written as null, and at read time they get 1000 and 1050 respectively, starting from the manifest's first_row_id (1000) and shifting by the preceding files' record_count. So the third row in data3 has a _row_id of 1050 + 2 = 1052.

Once the commit finishes, the table's next-row-id is updated. The spec's mandatory clause says to shift by at least the number of row IDs newly assigned in that snapshot, and the recommended calculation is to sum the added_rows_count and existing_rows_count of every manifest that was assigned a first_row_id. In the example that sum is 375 (added1's 125 + added2's 100 + added3's 150), so next-row-id becomes 1000 + 375 = 1375.

Notice that EXISTING rows consume ID space here too. Even though they already have their own IDs and don't get new ones, a range is still reserved for them. Since the mandatory rule only sets a floor and the recommended calculation runs generously above it, gaps in the ID space are normal. _row_id is unique but not dense — don't use it to count rows or assume continuity.

The Rules at Read Time

The rules on the reader side are nailed down in Appendix E of the spec.

  • If a data file's first_row_id is non-null, the reader fills any null or missing _row_id with first_row_id + _pos.
  • Likewise, it fills any null or missing _last_updated_sequence_number with that data file's data_sequence_number.
  • A value that's already non-null is read as-is, untouched.
  • If a data file's first_row_id is null, the reader outputs _row_id and _last_updated_sequence_number as null.

The third rule matters. If a value already exists, that means it's a "row that was carried over," and its identity must not be overwritten.

Where This Gets Real — The Places Lineage Breaks

Compaction Kills Lineage

When a row is moved to a different data file (compaction, rewrite, a partition change — whatever the reason), the rules a writer has to follow are these.

  1. A row's existing non-null _row_id must be copied to the new data file.
  2. If the write modified the row, _last_updated_sequence_number is left null (so the sequence number at modification time is inherited).
  3. If it wasn't modified, the existing non-null _last_updated_sequence_number is copied over as-is.

That sounds simple, but it's nasty from an implementation standpoint. Compaction used to be a simple job — "read the data and rewrite it into bigger files." In v3, though, the compaction job has to read and write two hidden metadata columns alongside the data. Just rewrite naively, and every _row_id gets freshly reissued, turning every row in the table into a "row that just appeared for the first time." A CDC pipeline built on top of lineage would end up doing a full reprocess. Silently, without a single error.

The proof that this isn't a theoretical worry is all in the commit logs. We'll get to it below.

Equality Deletes Don't Track Lineage

The spec carves out an explicit exception — row lineage is not tracked for rows modified via equality delete. The reason is in the spec too. An engine using equality delete writes the change without reading the existing data in the first place, so it has no way to put the original row ID on the new row. Such an update is therefore treated as if the existing row were entirely removed and a brand-new row were added.

This is a big deal. For a workload that relies on equality delete — like Flink's upsert streaming — row identity does not survive an UPDATE even after upgrading to v3. A plan to build CDC on top of row lineage can fall apart roughly halfway right here.

An Upgraded Table's Past Is Permanently Null

When a v2 table is upgraded to v3, next-row-id is initialized to 0, and existing snapshots are not modified. In those snapshots that have no first-row-id, the first_row_id on the data files and manifests is null, so every row's _row_id reads as null. In the spec's own words — snapshots created before the upgrade have no row IDs.

The first commit after the upgrade does assign IDs to existing files too. But that means "those files just received IDs right now," not that their past history has been restored. Row lineage is not retroactive.

ID Ranges Diverge Across Branches

There's one footnote the spec leaves quietly. After an upgrade, new snapshots on different branches assign non-overlapping ID ranges to existing data files, based on the next-row-id at commit time. For a data file that lives on multiple branches, a writer may reuse another branch's first_row_id, or it may assign a new first_row_id to avoid a large metadata rewrite.

In other words, in a table that uses branches, _row_id is not guaranteed to point at the same row across branches. If you were planning to use _row_id as a join key, this is where that plan should stop.

Engine Reality Check — The Gap Between the Spec's "Must" and the Implementation

This is the reason this post exists. Vendor blogs draw "v3 support" as a checkbox. The actual code tells a different story. What follows is what I directly verified against each repository's main/master branch and merged PRs, as of July 16, 2026.

Spark 4.0 is the de facto reference. Row lineage support landed via PR #13310 on July 14, 2025. According to the PR description, it uses a conditional nullification mechanism that's new in Spark 4.0; that mechanism doesn't exist in Spark 3.5, so Iceberg had to implement its own separate rewrite rules there (PR #12736, April 28, 2025). The rules that PR lays out are exactly the spec we saw earlier — the existing row ID is always carried forward regardless of the operation type, it's null on INSERT, and the sequence number is preserved for unmodified rows and set to null for modified ones.

Compaction preservation was a separate piece of work. This is exactly why I said earlier that "compaction kills lineage." Compaction lineage preservation for Spark 4.0 only landed via PR #13555 on July 22, 2025, and was backported to 3.5 and 3.4 the very next day (PR #13637, #13641). The implementation approach is interesting — in the PR author's own words, for a rewrite it works by "hijacking" SparkTable's schema to add the metadata columns. The author is well aware this isn't elegant.

Trino is further along than I expected. This one nearly fooled me via search results — several secondary sources state that "Trino wasn't v3-ready as of June 2026" or that "row lineage is still pending," and that isn't true once you look at the repository directly. Deletion vectors merged to master via PR #27788 on January 16, 2026, and row lineage via PR #27836 on March 19, 2026. The latter covers not just reads but preserving the original row ID on UPDATE/MERGE, and re-enabling OPTIMIZE on v3 tables while keeping lineage intact.

What Trino did before that is also worth a look. PR #27786 (January 10, 2026) opened up v3 table creation, while making sure that every v3 feature not yet implemented is explicitly rejected with NOT_SUPPORTED. DELETE/UPDATE/MERGE, OPTIMIZE, add_files, deletion vectors, column defaults, and encryption on v3 tables were all caught by this. As the PR description puts it, "to avoid violating the spec," they chose to fail fast and predictably instead of quietly returning a wrong answer. That's the right call for a partial implementation, and other engines could learn from it.

Column names differ per engine. Iceberg core and Spark use _row_id / _last_updated_sequence_number, while Trino, following its own metadata-column convention, exposes them as $row_id / $last_updated_sequence_number. The field IDs are the same, so the data is compatible, but queries don't port over as-is.

Flink is forked into two paths. This is the most confusing part. The Flink connector on the Iceberg main branch has two compaction paths, and they behave in opposite ways regarding row lineage.

The old actions API (org.apache.iceberg.flink.actions.RewriteDataFilesAction) still simply rejects v3 tables. This code is present as-is across v1.20, v2.0, and v2.1 on the main branch.

Preconditions.checkArgument(
    !TableUtil.supportsRowLineage(table),
    "Flink does not support compaction on row lineage enabled tables (V3+)");

The new maintenance API (org.apache.iceberg.flink.maintenance.operator.DataFileRewriteRunner), on the other hand, preserves lineage. It landed via PR #14149 on November 6, 2025, and the code branches like this.

boolean preserveRowId = TableUtil.supportsRowLineage(value.table());

try (TaskWriter<RowData> writer = writerFor(value, preserveRowId)) {
  try (DataIterator<RowData> iterator = readerFor(value, preserveRowId)) {

To sum up — compacting a v3 table with Flink requires the maintenance API, and if you're on the old actions API, the moment you upgrade to v3, compaction stops by throwing an exception. That sounds like bad news, but it's actually good design. Failing loudly beats silently destroying lineage. For what it's worth, that rejection logic itself was added deliberately, via PR #13646 ("Flink: fail file rewrite for V3 tables as row lineage not supported", July 23, 2025).

PyIceberg still can't write v3 tables. pyiceberg/table/metadata.py on the main branch still reads like this.

SUPPORTED_TABLE_FORMAT_VERSION = 2

PR #3070, which set out to add v3 manifest/manifest-list writing and row lineage snapshot commits, was closed without merging, and its successor, PR #3551 ("Writing v3 Table Metadata"), is still open, last updated on July 8, 2026. The v3 upgrade support PR #3623 is a draft waiting on #3551 to merge. If you have a pipeline that writes to Iceberg with Python, the accurate move is to leave it out of your v3 plans for now.

And the inheritance logic is still being fixed. A week before writing this post, on July 9, 2026, PR #17039 ("Core: Fix row lineage last updated sequence inheritance") was merged. Here's the bug — Java was using fileSequenceNumber() instead of data_sequence_number when filling in the _last_updated_sequence_number metadata column.

The difference between the two is usually invisible. But if compaction back in v2 days created a replacement file carrying an older data sequence number, and that table is later upgraded to v3, the two diverge. data_sequence_number is tied to the file's content, so it survives a rewrite, while file_sequence_number is when that physical file was added. So the buggy version, when asked "when was this row last modified," answered with when compaction happened to run instead of the original modification time.

This shows exactly what inheritance-based design costs. Since the value isn't in the file, get one inheritance rule wrong and the data is fine but the answer is wrong — and that wrongness never throws an exception. The fact that a bug like this still surfaces 21 months after the spec first introduced row lineage in October 2024 (PR #11130) tells you this feature's difficulty runs far ahead of how much space the spec document gives it. There were similar-natured fixes on the Avro reader side too, in February and March 2026 (PR #15187, #15508).

When Not to Use It

Laid out honestly, here's where things stand.

Don't upgrade to v3 yet if

  • You have even one PyIceberg writer in your pipeline. It can't write v3 tables right now.
  • You run compaction with Flink's old actions API and have no plan to migrate to the maintenance API.
  • Your workload is centered on equality delete (typically Flink upsert streaming). You'd upgrade for lineage, and row identity still wouldn't survive an UPDATE.
  • Multiple engines write to the same table, and even one of them hasn't had its lineage preservation verified. A single writer that doesn't honor lineage breaks lineage for the entire table.

It's fine to upgrade if

  • Your writers are limited to Spark 4.0 (or the backported 3.5/3.4) or a recent Trino.
  • You've actually verified that your compaction path preserves lineage — by comparing _row_id before and after compaction yourself, not by reading documentation.
  • The benefit of deletion vectors alone already pays for the upgrade. Row lineage comes along as a bonus with v3; the actual reason most organizations move to v3 is deletion vectors.

And before using row lineage for CDC, be sure to confirm this — _row_id is unique but not dense, it isn't stable across branches, it doesn't exist for pre-upgrade history, and it doesn't survive equality delete. Check first whether these four facts conflict with your design.

Closing

Row lineage's design is clever. Instead of storing the row ID, it reserves and hands out ranges of ID space and offsets by position, so that data files don't need to be rewritten even when an optimistic commit retries. Anyone who knows Iceberg's commit model will immediately understand why it's shaped this way.

The cost is a shift in responsibility. Because the value isn't in the file, producing and preserving it correctly depends entirely on the writer and reader getting it right. The spec writes "must," but the spec doesn't execute code. So the picture as of July 2026 looks like this — Spark 4.0 works, Trino has worked since March, Flink splits depending on which API you use, PyIceberg still doesn't, and core's inheritance logic was still being fixed as recently as last week.

This isn't an argument against v3. Deletion vectors alone already make v3 worth going to. But you need to go in knowing that the moment you upgrade to v3, row lineage switches on with no way to turn it off, and every tool that touches that table signs onto an implicit contract. If even one tool that can't honor that contract is mixed in, what you get isn't lineage — it's the illusion that you have lineage. That's much worse.

Iceberg's overall structure and operations are covered separately in Apache Iceberg Data Lakehouse Table Format Guide.

References