chaowen.← Back to archive

Databases / storage deep dive

Iceberg v3 deletion vectors: one bitmap, one data file, one snapshot rule

An Iceberg table keeps data files immutable, but real workloads still need updates and deletes. Version 3 answers with a deletion vector: a compressed bitmap of deleted row positions, stored as a Puffin blob and attached to exactly one data file through manifest metadata.

Databases / storage deep dive · immutable files, position deletes, Roaring bitmaps, Puffin, manifest planning, sequence numbers, and production debugging

2026-09-01

The concrete problem

Columnar data files such as Parquet are excellent for analytics because they are immutable, independently readable, and easy to place in object storage. They are awkward for a small update. If row 17 in orders-0001.parquet must disappear, rewriting a large file just to remove one row is wasteful.

Iceberg v2 solved this with position-delete files. A delete file stores pairs such as (orders-0001.parquet, 17). That preserves the original data file, but a busy table can accumulate many delete files. Every scan must discover the relevant files, compare sequence numbers, match partitions, and merge the positions while reading the data file. The logical answer is simple; the metadata and execution path are not.

v2: many sidecars for one immutable data file orders-0001.parquet ────────────────┐ row positions: 0 1 2 3 ... │ ├─ pos-delete-7.avro: 17, 44 ├─ pos-delete-8.avro: 81 └─ pos-delete-9.avro: 17, 105 scan = read data + find matching sidecars + merge position streams v3: one indexed sidecar per data file in a snapshot orders-0001.parquet ──► DV bitmap: ............................ bit 17 = 1, bit 44 = 1, bit 81 = 1, bit 105 = 1

On August 31, 2026, Amazon Redshift announced read/write support for Iceberg v3 and specifically described deletion vectors as compact compressed bitmaps for high-frequency update and delete workloads. The important engineering idea is not the product flag. It is the boundary: the bitmap is scoped to one immutable file, while the manifest and snapshot still decide when that bitmap applies.

Core invariant: a set bit at position P means “the row at ordinal position P in this one referenced data file is deleted.” A deletion vector is not a table-wide row ID set, and it is not an equality predicate such as customer_id = 42.

From v1 to v3

Iceberg versionMutation modelOperational consequence
v1Immutable data files, no row-level delete format.Updates generally require rewriting affected data files.
v2Equality deletes and position-delete files.Small mutations can be appended as sidecars; readers merge them during scans.
v3Everything above remains readable, but new position deletes are replaced by deletion vectors. DVs are binary Puffin blobs.One bitmap can answer many position-membership tests cheaply, with fewer delete-file objects to plan and open.

“Replaced” does not mean old v2 position deletes vanish instantly. A v2 table upgraded to v3 may contain existing position-delete files. When a writer creates a DV for a data file, it must merge the old position deletes for that file into the bitmap. Position deletes that cover several data files may remain until each target is converted.

The on-disk data flow

Iceberg separates the bytes from the table state. The bitmap bytes live in a Puffin file, which can contain multiple typed blobs. A delete manifest carries the locator and scope needed to find that blob. A reader first plans the data file and applicable delete metadata, then fetches the DV content and filters rows by position.

writer catalog / table metadata reader │ │ │ │ collect deletes for data-0001.parquet │ │ │ positions = {17, 44, 81, 105} │ │ │ │ │ ├─ serialize Roaring bitmap │ │ ├─ write Puffin blob: type=dv-v1 │ │ │ │ │ ├─ create DeleteFile metadata ─────────────────────►│ manifest entry │ │ referenced_data_file = data-0001.parquet │ │ │ content_offset, content_size_in_bytes │ │ │ record_count = 4 │ │ │ │ │ │ commit new snapshot atomically │◄──── load snapshot ───┤ │ │ │ │ │──── scan planning ───►│ │ │ │ match path + partition │ │ │ + data seq ≤ DV seq │ │ │ │ │──── DV locator ──────►│ read blob, test bit(pos) │ │ │ emit rows where bit=0

The commit is still optimistic and snapshot-based. A reader that already loaded snapshot S sees the old set of data and delete files. A reader that loads snapshot S+1 sees the new manifest state. The DV does not mutate a Parquet file in place and cannot make an uncommitted delete visible.

What the manifest records

The logical delete file is represented by the ordinary Iceberg DeleteFile abstraction, with fields that tell a reader how to locate and scope the vector:

FieldMeaningWhy it matters
file_pathLocation of the Puffin container.Several DVs may share one physical Puffin file.
content_offsetByte offset of this DV blob inside the container.Lets the reader range-read one blob rather than the whole file.
content_size_in_bytesExact byte length of the blob.Bounds the read and must agree with the Puffin footer.
referenced_data_fileExact data-file path that the bitmap indexes.Prevents a position from being applied to a different file with coincidentally similar row offsets.
record_countNumber of set bits, i.e. deleted positions.Provides statistics and an integrity cross-check during deserialization.
partition and sequence numberPartition tuple and the commit age of the delete.They constrain which data-file snapshot entries the DV may affect.

The Iceberg v3 specification requires at most one DV for a given data file in a snapshot. If a new delete arrives, the writer merges it with the existing DV (and any remaining position deletes for that file), writes a replacement DV, and removes the superseded file-scoped delete metadata from the committed snapshot.

Why a bitmap, and why Roaring?

A naïve bitmap for a billion-row file needs a billion bits, about 119 MiB, even if only a few rows are deleted. A sorted list of positions is compact for sparse deletes but requires a search or merge for each scan. Roaring bitmaps switch representation by density: sparse chunks can store arrays of integers, while dense chunks can use bitmaps or runs. The result is fast membership testing without paying for every possible row position.

Iceberg’s deletion-vector format accepts positive 64-bit positions but is optimized around 32-bit Roaring bitmaps. It divides a 64-bit position into a high 32-bit key and a low 32-bit sub-position. Each present high key selects a 32-bit Roaring bitmap; membership then becomes “find the chunk, test the low position.” This is a storage and CPU trade, not magic compression: random deletes can still produce larger vectors than clustered deletes.

position = 0x00000002_00000011
high_key = position >> 32       # 2
low_pos  = position & 0xffffffff # 17

chunks[high_key].contains(low_pos)  # true means the row is deleted

The current Apache Iceberg implementation adds an integrity envelope around the serialized bitmap: a big-endian length, a little-endian magic number plus portable Roaring payload, and a CRC-32 over the magic number and bitmap. The envelope is deliberately checked against manifest metadata before the vector is trusted.

Open-source implementation landmarks

Iceberg core handles format objects, writing, metadata indexing, and scan-task planning. The execution engine that reads Parquet and applies the positions may be Spark, Flink, Trino, or another integration. That separation is important: Iceberg tells the engine which delete files apply; the engine’s reader removes the affected rows from its scan output.

Module / functionRole in the pathUseful inspection question
DeletionVector and DeletionVectorStructModel the locator, cardinality, and optional encryption key metadata.Are location, offset, length, and cardinality all present and non-negative?
BaseDVFileWriter.delete()Collects positions per referenced data-file path.Are deletes grouped by exact file path before serialization?
BaseDVFileWriter.close()Loads previous deletes, merges them, writes deletion-vector-v1 blobs to Puffin, and creates replacement DeleteFile metadata.Were older file-scoped deletes merged and marked for replacement?
BitmapPositionDeleteIndexStores positions, serializes portable Roaring data, and verifies length, magic, CRC, and cardinality when reading.Does the on-disk cardinality equal the manifest’s record_count?
DeleteFileIndex.forDataFile()Indexes deletes by path, partition, and sequence number; returns the deletes applicable to one data file.Did the DV win over matching position deletes, and did partition/sequence validation pass?
BaseFileScanTaskAttaches the selected DeleteFile[] to a data-file scan task.Does the downstream engine actually consume the attached deletes?
PuffinWriter / PuffinReaderWrite and range-read typed blobs in the shared container.Does the manifest offset and length point to the exact blob footer entry?

A small but revealing detail in DeleteFileIndex is that it keeps DVs in a map keyed by referenced data-file path. When a DV exists for a file, matching position-delete files are not also returned for that file; the writer’s one-DV rule makes this safe. Equality deletes are different: they are value predicates and can still be returned when their sequence and partition rules match.

The scope rules that prevent false deletes

For a DV to apply to a data file, all of these conditions must hold:

  1. The data file’s file_path equals the DV’s referenced_data_file.
  2. The data file’s data sequence number is less than or equal to the DV’s sequence number. A delete cannot reach backward from an older snapshot and erase data added later.
  3. The partition spec and partition values match.

These checks are why a position alone is not a row identity. Position 17 in data-a.parquet says nothing about position 17 in data-b.parquet, and an offset can change meaning after a rewrite. The file path and snapshot metadata supply the missing context.

data file DV applies? path=data-a, partition=P, seq=10 ref=data-a, P, seq=12 yes path=data-a, partition=P, seq=14 ref=data-a, P, seq=12 no: data is newer path=data-b, partition=P, seq=10 ref=data-a, P, seq=12 no: path differs path=data-a, partition=Q, seq=10 ref=data-a, P, seq=12 no: partition differs

Tradeoffs and failure modes

Failure or costWhat breaksDetection or mitigation
DV not merged with an older file-scoped deleteRows deleted by the old sidecar reappear after the new DV supersedes it.Merge previous positions by data-file path; test mixed v2/v3 upgrade cases.
Wrong referenced pathDeletes are silently applied to the wrong file or not applied at all.Validate exact URI equality and inspect manifest entries, not just basenames.
Duplicate DVs for one fileReaders may double-read metadata or disagree about which vector wins.Enforce the one-DV-per-data-file-per-snapshot invariant at write and commit validation.
Corrupt or truncated Puffin blobMissing rows can leak into query results, or the scan fails.Check byte length, magic, CRC, and cardinality; fail closed for integrity errors.
Large or frequently rewritten DVsEvery mutation may read and rewrite a growing bitmap; write amplification moves to the delete path.Compact data files and DVs based on delete density and scan cost, not file count alone.
Orphaned Puffin blobsStorage cost grows after a failed commit or expired snapshot.Use reachable-file cleanup only after snapshot-retention safety checks; a blob may still belong to an older retained snapshot.

Deletion vectors optimize the common “many positions in one known file” case. Equality deletes remain useful when the writer cannot identify a stable file and position without reading existing data. DVs also do not make an immutable file mutable: they defer physical cleanup until a rewrite or compaction makes the data file smaller and the delete metadata unnecessary.

Security and correctness boundaries

A DV is data-removal metadata, so treating it as ordinary low-value cache state is dangerous. A reader that loses the vector can return rows that the table snapshot says are deleted. A reader that accepts a forged vector can hide arbitrary rows. The protections are layered:

How to debug a production table

  1. Start at the snapshot. Record the snapshot ID, operation, manifest-list path, and data-file path for the unexpected row.
  2. Inspect delete candidates. For that data file, list every matching delete manifest entry, its content type, referenced path, partition, sequence number, offset, length, and cardinality.
  3. Recompute applicability. Check path equality first, then partition equality, then data_sequence_number ≤ delete_sequence_number. This catches most “delete did not apply” reports.
  4. Check the blob, not just metadata. Range-read exactly content_offset..content_offset + content_size_in_bytes, validate the Puffin footer and DV CRC, and compare set-bit cardinality with record_count.
  5. Compare engines. Run the same snapshot through two readers or through an engine’s file-scan debug mode. If Iceberg plans the DV but the result still contains the row, the bug is downstream in delete application.
  6. Check conversion history. For upgraded v2 tables, look for old position deletes covering the same file and verify that a conversion did not remove them before their positions were merged.

Useful metrics are delete files per data file, DV bytes per deleted row, DV cardinality divided by data-file record count, range-read bytes, scan CPU spent applying deletes, and the age of unreachable Puffin files. A rising DV density is a compaction signal; a rising number of tiny DVs is usually a write-pattern or batching problem.

Compact reproducible lab

Goal and prerequisites

This lab models the exact semantic core without requiring a JVM, Spark, or an object store: positions are collected into a set, serialized as a simple bitmap, and applied only to the referenced file when the sequence and partition rules match. It is intentionally not a wire-compatible Puffin writer; the point is to make the scope invariant observable before adding format machinery.

mkdir -p /tmp/iceberg-dv-lab
cd /tmp/iceberg-dv-lab
python3 --version

Save this as dv_lab.py:

from dataclasses import dataclass

@dataclass(frozen=True)
class DataFile:
    path: str
    partition: str
    sequence: int
    rows: list[str]

@dataclass(frozen=True)
class DeletionVector:
    referenced_path: str
    partition: str
    sequence: int
    bits: frozenset[int]

def applies(dv: DeletionVector, data: DataFile) -> bool:
    return (dv.referenced_path == data.path
            and data.partition == dv.partition
            and data.sequence <= dv.sequence)

def read_visible(data: DataFile, dv: DeletionVector) -> list[str]:
    if not applies(dv, data):
        return data.rows
    return [row for pos, row in enumerate(data.rows) if pos not in dv.bits]

data = DataFile("data-a.parquet", "day=2026-09-01", 10,
                ["alice", "bob", "carol", "dave", "erin", "fran"])
dv = DeletionVector(data.path, data.partition, 12, frozenset({1, 4}))

print("visible:", read_visible(data, dv))
print("applies-to-wrong-file:", applies(dv, DataFile(
    "data-b.parquet", data.partition, 10, data.rows)))
print("applies-to-newer-data:", applies(dv, DataFile(
    data.path, data.partition, 13, data.rows)))

Run it:

python3 dv_lab.py

Expected result:

visible: ['alice', 'carol', 'dave', 'fran']
applies-to-wrong-file: False
applies-to-newer-data: False

The two deleted positions are 1 and 4, so bob and erin disappear. The same bits do not apply to a different data-file path, and a DV at sequence 12 cannot delete data written at sequence 13.

Variation 1 — the classic bug: change referenced_path to "data-b.parquet" and rerun. The first result becomes the original six rows. A bitmap without its file identity is not a correct delete representation.

Variation 2 — merge before replacement: change the DV bits to frozenset({1, 4, 5}). The result removes three positions. This simulates a writer merging a previous DV with a new delete before publishing the replacement metadata. In a real implementation, the set is a serialized Roaring bitmap inside a Puffin blob, and the manifest’s cardinality must change from 2 to 3.

To inspect the real format after the semantic lab, use the Apache Iceberg source links below. A full Puffin fixture requires Iceberg’s Java build and dependencies; this environment’s static site does not assume those are installed, so the fallback lab keeps the important correctness checks runnable with the Python standard library.

The durable mental model

Iceberg v3 deletion vectors are a carefully scoped indirection. The data file remains immutable. The bitmap says which ordinal positions are hidden. Puffin stores the bytes efficiently. Delete manifests locate and describe the blob. Snapshot sequence and partition metadata decide whether it applies. The reader performs the final membership test.

When a result is wrong, ask in order: which snapshot, which data-file path, which DV blob, which positions, and which sequence/partition scope? That sequence follows the actual architecture and avoids the tempting but incorrect assumption that a row position is globally meaningful.

Primary sources