<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged bigdata</title>
<link>https://coderlegion.com/tag/bigdata</link>
<description>Powered by Question2Answer</description>
<item>
<title>Understanding Parquet File Format</title>
<link>https://coderlegion.com/30266/understanding-parquet-file-format</link>
<description>&lt;h3 id=&quot;what-is-a-file-format-&quot;&gt;What is a File Format?&lt;/h3&gt;
&lt;p&gt;A file format defines how data is structured and stored so that software can read and write it consistently.&lt;/p&gt;
&lt;p&gt;Think of it as the container for your data. Just like music comes in MP3, WAV, or FLAC, data files also come in different formats depending on the use case.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common data file formats:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;CSV – Simple text-based, rows separated by commas (easy to use, but&lt;br&gt;
large in size and no compression).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;JSON – Flexible, hierarchical (good for APIs but verbose).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Avro – Row-based binary format, compact, supports schema evolution &lt;br&gt;
(common in streaming).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ORC – Columnar format optimized for Hadoop and Hive.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Parquet – Columnar format designed for analytics and compression &lt;br&gt;
efficiency.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;introduction-to-parquet&quot;&gt;Introduction to Parquet&lt;/h3&gt;
&lt;p&gt;If you work with data, you have probably heard the term Parquet come up in conversations about data lakes, analytics, or cloud storage. But what exactly is it, and why does everyone seem to love it?&lt;/p&gt;
&lt;h3 id=&quot;the-everyday-analogy-organizing-your-groceries&quot;&gt;The Everyday Analogy: Organizing Your Groceries&lt;/h3&gt;
&lt;p&gt;Imagine you run a grocery delivery service, and you have to store all the items customers ordered.&lt;/p&gt;
&lt;p&gt;You could store them in two ways:&lt;/p&gt;
&lt;p&gt; &lt;strong&gt;&lt;em&gt;Row-based storage (like CSV):&lt;/em&gt;&lt;/strong&gt; &lt;/p&gt;
&lt;p&gt;You put one of each item from an order into a bag: milk, bread, apples, and pasta all together. To find all the bread you have sold, you have to open every bag and dig around to see whether it is inside.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;Column-based storage (like Parquet):&lt;/em&gt;&lt;/strong&gt;&lt;br&gt;
Instead of mixing, you group similar items together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One box for all the milk&lt;/li&gt;
&lt;li&gt;One box for all the bread&lt;/li&gt;
&lt;li&gt;One box for all the apples&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you need all the bread you have sold, you simply open the bread box, no need to touch anything else.&lt;/p&gt;
&lt;p&gt;That is Parquet in action, organized storage that makes retrieval faster and more efficient.&lt;/p&gt;
&lt;h3 id=&quot;the-technical-view&quot;&gt;The Technical View&lt;/h3&gt;
&lt;p&gt;Parquet is an open-source, columnar storage format developed by Twitter and Cloudera, now an Apache project. It is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Self-describing – Metadata in the file footer stores schema, number&lt;br&gt;
of rows, and statistics.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Highly compressed – Supports Snappy, Gzip, ZSTD, and more.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Structured internally into:&lt;br&gt;
&lt;strong&gt;Row Groups&lt;/strong&gt; → subsets of rows (horizontal partitions).&lt;br&gt;
&lt;strong&gt;Column Chunks&lt;/strong&gt; → data for each column inside a row group (vertical partitions).&lt;br&gt;
&lt;strong&gt;Pages&lt;/strong&gt; → the smallest unit of storage, making reads efficient.&lt;br&gt;
&lt;strong&gt;Magic Number (PAR1)&lt;/strong&gt; – Ensures the file is recognized as Parquet.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This hybrid layout balances efficient columnar reads with manageable write performance.&lt;/p&gt;
&lt;h3 id=&quot;why-choose-parquet-over-other-formats-&quot;&gt;Why Choose Parquet Over Other Formats?&lt;/h3&gt;
&lt;p&gt;Now that we understand Parquet, let us see why it stands out compared to other formats.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CSV – Human-readable but inefficient; no compression or schema&lt;br&gt;
support.&lt;/li&gt;
&lt;li&gt;JSON – Flexible for nested data, but verbose and large in size.&lt;/li&gt;
&lt;li&gt;Avro – Great for row-based streaming but not optimized for analytics.&lt;/li&gt;
&lt;li&gt;ORC – Similar to Parquet, optimized for Hadoop and Hive workloads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Parquet vs the rest:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Much smaller file sizes due to efficient compression&lt;/li&gt;
&lt;li&gt;Faster query performance with column pruning and predicate pushdown&lt;/li&gt;
&lt;li&gt;Widely supported across Spark, Hive, Presto, Trino, BigQuery,&lt;br&gt;
Snowflake, AWS Athena, and more&lt;/li&gt;
&lt;li&gt;Ideal for data lakes and analytical workloads&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;how-data-is-written-in-parquet&quot;&gt;How Data is Written in Parquet&lt;/h3&gt;
&lt;p&gt;When writing data into a Parquet file, the process follows a layered structure:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6044621059311078360&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt; &lt;strong&gt;Rows are collected into Row Groups&lt;/strong&gt;            &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Instead of writing each record one by one (like CSV), Parquet groups&lt;br&gt;
multiple rows together into a Row Group (horizontal partition).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Within each Row Group, data is split into Column Chunks&lt;/strong&gt; &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Each column’s values are stored together → Column A values go into &lt;br&gt;
Column Chunk A, Column B values into Column Chunk B, and so on.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;This preserves the columnar storage advantage.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Column Chunks are broken into Pages&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Pages are the smallest unit of storage in Parquet.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Data Pages → contain the actual column data&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Dictionary Pages → optionally store dictionaries used for dictionary encoding&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Optional Page Indexes can provide additional information for&lt;br&gt;
efficiently locating and skipping pages:&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Column Index → stores statistics such as min/max values for pages&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Offset Index → stores the locations and sizes of pages&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Metadata is written into the File Footer&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Contains schema, row count, statistics (min/max per column), and row&lt;br&gt;
group metadata.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Makes Parquet a self-describing format.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Magic Number (PAR1) is added&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;At the start and end of the file → ensures the file can be validated&lt;br&gt;
as a Parquet file.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;how-data-is-read-from-a-parquet-file&quot;&gt;How Data is Read from a Parquet File&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=17966435297597599916&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;File Footer Read First&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Parquet does not scan the entire file right away. &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It jumps directly to the footer, where all the metadata is stored.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Metadata tells the engine:&lt;br&gt;
Schema (what columns exist)&lt;br&gt;
Number of rows&lt;br&gt;
Statistics for each column in each row group (min/max)&lt;br&gt;
Row group and column chunk offsets (where the data lives in the file)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;➡️ &lt;em&gt;This helps the engine decide which parts of the file to actually read.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Predicate Pushdown &amp;amp; Column Pruning&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;If the query only needs certain columns (SELECT name, age), only&lt;br&gt;
those column chunks are accessed.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If the query has filters (WHERE age &amp;gt; 30), Parquet uses min/max stats&lt;br&gt;
in metadata to skip entire row groups that don’t match.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;➡️ &lt;em&gt;This reduces I/O drastically compared to row-based formats.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reading Row Groups&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The engine opens only the relevant row groups.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Inside each row group, it locates the column chunks needed for the&lt;br&gt;
query.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Decompression &amp;amp; Decoding Pages&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Column chunks are split into pages.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Each page is decompressed (Snappy, Gzip, ZSTD, etc.) and decoded&lt;br&gt;
(Dictionary, RLE, Delta encoding).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pages are read into memory one at a time, which is efficient.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Reconstructing Results&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Once the engine reads, decompresses, and decodes the relevant column&lt;br&gt;
data, it reconstructs the requested rows and columns.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The engine materializes the final output as a result set for the&lt;br&gt;
query.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;parquet-file-size-small-vs-large&quot;&gt;Parquet File Size: Small vs Large&lt;/h3&gt;
&lt;p&gt;The size of each Parquet file matters a lot for performance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Too Small Files (&amp;lt; 50 MB each)&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Too much metadata and file-open operations.&lt;/li&gt;
&lt;li&gt;Increases overhead for query engines (Spark, Presto, Hive).&lt;/li&gt;
&lt;li&gt;Known as the small files problem.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Too Large Files (&amp;gt; 1 GB each)&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Slows down reads and reduces parallelism.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;💡 &lt;strong&gt;Ideal File Size:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;256 MB to 1 GB for most big data frameworks.&lt;/li&gt;
&lt;li&gt;Large enough to compress well, small enough for parallelism.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;how-data-types-affect-compression-and-file-size-&quot;&gt;How Data Types Affect Compression (and File Size)&lt;/h3&gt;
&lt;p&gt;Parquet’s efficiency is heavily influenced by data type selection:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strings vs Integers:&lt;/strong&gt; Integers compress far better than storing&lt;br&gt;
numbers as strings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decimals vs Floats:&lt;/strong&gt; Decimals (fixed precision) often compress better.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Categorical Data:&lt;/strong&gt; Repeated values (like “USA”) compress extremely&lt;br&gt;
well with dictionary encoding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timestamps&lt;/strong&gt;: Storing as INT64 (epoch values) is more efficient than strings.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Tip: Choose the most precise and smallest data type possible to save space and improve query speed.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;encoding-strategies-used-in-parquet&quot;&gt;Encoding Strategies used in Parquet&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=3891040807583055239&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Run Length Encoding (RLE)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; Stores consecutive repeated values as a single value + a count.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Columns with long runs of the same value (e.g., flags, booleans, country/state codes).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Benefit:&lt;/strong&gt; Extremely efficient for repetitive data, reduces size drastically.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Example:

Raw → A A A A B B C C C
RLE → (A,4), (B,2), (C,3)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Dictionary Encoding&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; Builds a dictionary of unique values in a column and replaces each value with a small index reference.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Low-cardinality columns (few unique values, but repeated often).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Benefit:&lt;/strong&gt; Saves space when values repeat often, lookups are fast.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Example:

Raw → USA, USA, CAN, USA, CAN, MEX
Dictionary → {0: USA, 1: CAN, 2: MEX}
Encoded → 0, 0, 1, 0, 1, 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Delta Encoding&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt; Stores the difference (delta) between consecutive values instead of storing full values.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Sequential/numeric data like timestamps, IDs, counters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Benefit:&lt;/strong&gt; Great for compressing increasing sequences, works well with integers and dates.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Example:

Raw → 100, 105, 110, 115
Delta → 100, +5, +5, +5
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;parquet-optimization-amp-best-practices&quot;&gt;Parquet Optimization &amp;amp; Best Practices&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Predicate Pushdown&lt;/strong&gt; – Enable in your query engine to filter row groups&lt;br&gt;
at scan time.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Compression Codecs&lt;/strong&gt; &lt;br&gt;
Snappy: Fast, decent ratio&lt;br&gt;
Gzip: Higher compression, slower&lt;br&gt;
ZSTD: Good balance&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Avoid Small Files&lt;/strong&gt; – Merge into larger files to reduce metadata&lt;br&gt;
overhead.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Row Group Size&lt;/strong&gt; – Balance between compression (bigger row groups) and&lt;br&gt;
parallelism (smaller ones).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Encoding Strategies &lt;/strong&gt;&lt;br&gt;
Dictionary Encoding for low-cardinality strings&lt;br&gt;
Run-Length Encoding for repeated values&lt;br&gt;
Delta Encoding for incremental numbers like timestamps&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sort Data&lt;/strong&gt; – Sorting on commonly filtered columns improves compression&lt;br&gt;
and predicate pushdown.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;ACID Transactions&lt;/strong&gt; – Parquet itself does not provide ACID, use table formats like Delta Lake, Iceberg, or Hudi for transactional&lt;br&gt;
guarantees.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Parquet has become a default choice in the modern data stack because it offers the best of both worlds like compact storage and fast analytics.&lt;/p&gt;
&lt;p&gt;By understanding how it works internally (row groups, column chunks, pages) and following best practices (ideal file sizes, data types, compression, and encodings), you can save costs, improve query speeds, and build scalable pipelines.&lt;/p&gt;
&lt;h3 id=&quot;further-reading&quot;&gt;Further Reading&lt;/h3&gt;
&lt;p&gt;For a deeper dive into Parquet’s internals, file layout, and optimizations, check out “&lt;a rel=&quot;nofollow&quot; href=&quot;https://vutr.substack.com/p/the-overview-of-parquet-file-format&quot;&gt;The Overview of Parquet File Format&lt;/a&gt;” by Vu Trinh. It’s a clear and insightful read for data engineers looking to go beyond the basics.&lt;/p&gt;
&lt;p&gt;In short, Parquet is the “&lt;strong&gt;organized pantry&lt;/strong&gt;” of the data world: efficient, structured, and always ready when you need it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30266/understanding-parquet-file-format</guid>
<pubDate>Thu, 08 Oct 2026 11:42:51 +0000</pubDate>
</item>
<item>
<title>The Story Behind Apache Iceberg's Format-Version: v1 to v4</title>
<link>https://coderlegion.com/21445/the-story-behind-apache-icebergs-format-version-v1-to-v4</link>
<description>&lt;p&gt;Most engineers meet Apache Iceberg as a one-line answer: &quot;it's the thing that&lt;br&gt;
gives you ACID transactions and time travel on object storage.&quot; That's true, and&lt;br&gt;
it's also where most people stop. But Iceberg has a version dial baked into every&lt;br&gt;
table - a single integer called &lt;code&gt;format-version&lt;/code&gt;, currently 1 through 4 - and&lt;br&gt;
each turn of that dial is a chapter in a single, surprisingly coherent story.&lt;/p&gt;
&lt;p&gt;It's a story about pushing transactional behavior down through layers. v1 made a&lt;br&gt;
table &lt;em&gt;atomic&lt;/em&gt; on top of immutable files, but it could only append and overwrite&lt;br&gt;
whole files. v2 pushed correctness down to the &lt;em&gt;row&lt;/em&gt;. v3 pushed identity down to&lt;br&gt;
the row, so a row keeps the same id across compactions. v4 turned around and&lt;br&gt;
refactored the &lt;em&gt;metadata itself&lt;/em&gt; so it can scale and move without rewrites.&lt;/p&gt;
&lt;p&gt;To follow that arc you need the one mental model the rest of this post hangs&lt;br&gt;
on. So we start there, then walk the four versions in order, keeping the parts&lt;br&gt;
that actually matter for understanding what's on disk.&lt;/p&gt;
&lt;h3 id=&quot;the-primer-an-iceberg-table-is-a-tree-of-files-not-a-folder&quot;&gt;The primer: an Iceberg table is a tree of files, not a folder&lt;/h3&gt;
&lt;p&gt;The instinct everyone brings from Hive is that a table &lt;em&gt;is&lt;/em&gt; a directory: point at&lt;br&gt;
a path, list the files under it, that's your table. Iceberg breaks that instinct&lt;br&gt;
on purpose. An Iceberg table is &lt;strong&gt;not&lt;/strong&gt; a directory of data files. It is a&lt;br&gt;
&lt;strong&gt;tree of immutable metadata files&lt;/strong&gt; rooted at a single pointer held in a&lt;br&gt;
catalog.&lt;/p&gt;
&lt;p&gt;Every commit &lt;em&gt;appends&lt;/em&gt; new files to that tree and atomically moves the pointer.&lt;br&gt;
Nothing is ever mutated in place. That single design choice - immutable files,&lt;br&gt;
one movable pointer - is what makes everything else possible.&lt;/p&gt;
&lt;p&gt;The tree has five layers:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kjeddcvjzmn9xdip9wn6.png&quot; alt=&quot;Iceberg's five metadata layers as a top-down flow.    Layer 0, the Catalog (REST, Hive, Glue, or JDBC),    holds the current pointer for a table and points at    one metadata.json. Layer 1, the table metadata JSON,    holds schemas, partition specs, sort orders,    snapshots, refs, and properties; its snapshot's    manifest-list points to Layer 2. Layer 2, the    manifest list (one per snapshot), lists manifests    with partition summaries; each manifest_path points    to Layer 3. Layer 3, a manifest in Avro, indexes data   and delete files with per-column stats; each    data_file.file_path points to Layer 4. Layer 4 holds    the data, delete, and DV files in Parquet, ORC, Avro,   or Puffin    format.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Read it top to bottom and you have the whole format:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Layer 0 - the catalog.&lt;/strong&gt; It holds one fact per table: &quot;the current pointer&lt;br&gt;
for &lt;code&gt;prod.db.sales&lt;/code&gt; is &lt;em&gt;this&lt;/em&gt; metadata file.&quot; The catalog is the only mutable&lt;br&gt;
thing in the entire system, and it lives &lt;em&gt;outside&lt;/em&gt; the format spec (REST&lt;br&gt;
catalog, Hive Metastore, Glue, JDBC, and so on).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layer 1 - the table metadata JSON.&lt;/strong&gt; A new one is written on every commit.&lt;br&gt;
It holds the schemas, partition specs, sort orders, table properties, the list&lt;br&gt;
of snapshots, and named references. This is the file the catalog points at.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layer 2 - the manifest list.&lt;/strong&gt; One per snapshot. An Avro file listing the&lt;br&gt;
manifests that make up that snapshot, each row carrying a partition summary so a&lt;br&gt;
scan can skip whole manifests it doesn't need.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layer 3 - the manifests.&lt;/strong&gt; Avro files, each an index over a set of data or&lt;br&gt;
delete files, with per-column statistics for each file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layer 4 - the data.&lt;/strong&gt; The actual Parquet/ORC/Avro files, plus delete files&lt;br&gt;
and deletion-vector blobs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why build a table this way? Two reasons, and they're the whole pitch:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Atomic commits without coordination.&lt;/strong&gt; Because everything below the catalog&lt;br&gt;
is content-addressed by path and never mutated, readers and writers can build&lt;br&gt;
files independently with no locking. The &lt;em&gt;only&lt;/em&gt; contended operation in the&lt;br&gt;
entire system is the catalog pointer swap. One compare-and-set, and the commit&lt;br&gt;
is either visible or it isn't.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cheap reads.&lt;/strong&gt; A scan loads the manifest list (roughly 100 KB even for a&lt;br&gt;
huge table), prunes manifests by partition, and only then opens any data file.&lt;br&gt;
You never list a directory; you walk an index.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;On disk, a table looks roughly like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;s3://warehouse/db/sales/                 &amp;lt;- table location
  metadata/
    00001-....metadata.json              &amp;lt;- Layer 1: root JSON, one per commit
    00002-....metadata.json
    00042-....metadata.json              &amp;lt;- currently pointed at by the catalog
    snap-....avro                        &amp;lt;- Layer 2: a manifest list per snapshot
    8ef6-....avro                        &amp;lt;- Layer 3: manifests
    9b21-....avro
    stats-....puffin                     &amp;lt;- optional: table stats (NDV sketches)
  data/
    year=2025/month=06/00000-0-....parquet   &amp;lt;- Layer 4: data
    year=2025/month=06/00001-0-....parquet
    dv-....puffin                            &amp;lt;- Layer 4: deletion vectors
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hold that picture. Every version below is a change to one or more of these five&lt;br&gt;
layers, and the format-version integer in Layer 1 is what tells a reader which&lt;br&gt;
rules apply. A reader that supports up to version &lt;em&gt;N&lt;/em&gt; will refuse to open a table&lt;br&gt;
whose &lt;code&gt;format-version&lt;/code&gt; is higher than &lt;em&gt;N&lt;/em&gt; rather than silently misread it.&lt;/p&gt;
&lt;p&gt;Now the four chapters.&lt;/p&gt;
&lt;h3 id=&quot;v1-a-table-that-s-atomic-but-only-at-the-file&quot;&gt;v1: a table that's atomic, but only at the file&lt;/h3&gt;
&lt;p&gt;v1 is the foundation, and it nails the hard part: snapshot isolation and atomic&lt;br&gt;
commits on immutable files. A v1 table already has schemas, partition specs,&lt;br&gt;
snapshots, and time travel. If all you ever do is &lt;em&gt;append&lt;/em&gt; data and occasionally&lt;br&gt;
&lt;em&gt;overwrite whole files&lt;/em&gt;, v1 is a complete, correct table format.&lt;/p&gt;
&lt;p&gt;Its limits are exactly the things the later versions go after.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It can't delete or update a single row.&lt;/strong&gt; The smallest unit v1 can change is a&lt;br&gt;
file. Want to delete one row? Rewrite the entire file without it, then swap the&lt;br&gt;
file in a new snapshot. This is copy-on-write, and on a wide table it means&lt;br&gt;
rewriting gigabytes to remove a handful of rows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Its metadata carries a few quirks that v2 had to clean up:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The schema and partition spec were stored as &lt;strong&gt;singular&lt;/strong&gt; fields - one schema,&lt;br&gt;
one spec - rather than a list of historical ones. A v1 metadata file has a&lt;br&gt;
&lt;code&gt;schema&lt;/code&gt; and a &lt;code&gt;partition-spec&lt;/code&gt;; both are deprecated from v2 onward in favor of&lt;br&gt;
&lt;code&gt;schemas[]&lt;/code&gt; and &lt;code&gt;partition-specs[]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Partition field IDs were &lt;strong&gt;not tracked explicitly&lt;/strong&gt;. Implementations just&lt;br&gt;
assigned them sequentially starting at 1000. That caused real ambiguity when&lt;br&gt;
the same logical field got a different transform across specs - there was no&lt;br&gt;
stable identity to tie them together.&lt;/li&gt;
&lt;li&gt;A snapshot could &lt;strong&gt;embed its manifests inline&lt;/strong&gt; as a &lt;code&gt;manifests: [path, ...]&lt;/code&gt;&lt;br&gt;
list instead of pointing at a separate manifest-list file. Convenient, but it&lt;br&gt;
meant the per-snapshot bookkeeping that later inheritance rules depend on had&lt;br&gt;
nowhere to live.&lt;/li&gt;
&lt;li&gt;A few &lt;code&gt;data_file&lt;/code&gt; fields existed that nobody needed: &lt;code&gt;block_size_in_bytes&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;file_ordinal&lt;/code&gt;, &lt;code&gt;sort_columns&lt;/code&gt;. All removed in v2.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this makes v1 wrong. It makes v1 &lt;em&gt;append-shaped&lt;/em&gt;. The entire push of v2&lt;br&gt;
is to make the table &lt;em&gt;mutable at the row&lt;/em&gt;, and to add the bookkeeping that makes&lt;br&gt;
that safe.&lt;/p&gt;
&lt;h3 id=&quot;v2-correctness-pushed-down-to-the-row&quot;&gt;v2: correctness pushed down to the row&lt;/h3&gt;
&lt;p&gt;v2 is the version most production tables ran on for years, because it's the one&lt;br&gt;
that turns Iceberg from an append log into a real transactional table. Three big&lt;br&gt;
ideas arrive together: &lt;strong&gt;row-level deletes&lt;/strong&gt;, &lt;strong&gt;sequence numbers&lt;/strong&gt;, and &lt;strong&gt;named&lt;br&gt;
references&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;row-level-deletes-merge-on-read&quot;&gt;Row-level deletes: merge-on-read&lt;/h4&gt;
&lt;p&gt;Instead of rewriting a file to remove rows, v2 lets you write a small &lt;strong&gt;delete&lt;br&gt;
file&lt;/strong&gt; that says &quot;these rows in that data file are gone.&quot; The reader applies&lt;br&gt;
deletes on the fly at scan time. This is &lt;strong&gt;merge-on-read&lt;/strong&gt;, and it comes in two&lt;br&gt;
flavors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Position deletes&lt;/strong&gt; - a list of &lt;code&gt;(file_path, position)&lt;/code&gt; tuples: &quot;row 42 and&lt;br&gt;
row 1007 of &lt;em&gt;this&lt;/em&gt; file are deleted.&quot; Precise, cheap to write, used by &lt;code&gt;DELETE&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;MERGE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Equality deletes&lt;/strong&gt; - a predicate on column values: &quot;any row where&lt;br&gt;
&lt;code&gt;id = 12345&lt;/code&gt; is deleted.&quot; These don't need to know &lt;em&gt;where&lt;/em&gt; the row lives, which&lt;br&gt;
makes them ideal for streaming upserts where you delete-then-insert without&lt;br&gt;
reading the old file first.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To make this work, the format needed a way to say whether a file holds data or&lt;br&gt;
deletes, and a way to order deletes against data. Both arrived in v2.&lt;/p&gt;
&lt;h4 id=&quot;the-content-discriminator-and-sequence-numbers&quot;&gt;The &lt;code&gt;content&lt;/code&gt; discriminator and sequence numbers&lt;/h4&gt;
&lt;p&gt;A v2 manifest, and each file it lists, now carries a &lt;code&gt;content&lt;/code&gt; field. On the&lt;br&gt;
manifest list it's &lt;code&gt;0 = data&lt;/code&gt; or &lt;code&gt;1 = deletes&lt;/code&gt;; a single manifest holds &lt;em&gt;either&lt;/em&gt;&lt;br&gt;
data files &lt;em&gt;or&lt;/em&gt; delete files, never both, so scan planning can load all the&lt;br&gt;
delete manifests first. On the data file itself, &lt;code&gt;content&lt;/code&gt; is &lt;code&gt;0 = DATA&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;1 = POSITION_DELETES&lt;/code&gt;, &lt;code&gt;2 = EQUALITY_DELETES&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The ordering problem is subtler. If a delete file says &quot;delete &lt;code&gt;id = 12345&lt;/code&gt;,&quot;&lt;br&gt;
&lt;em&gt;which&lt;/em&gt; inserts of that id does it kill - the ones before it, or also the ones&lt;br&gt;
after? v2 answers this with a monotonic &lt;strong&gt;sequence number&lt;/strong&gt; assigned at commit&lt;br&gt;
time and threaded through every layer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The table metadata tracks &lt;code&gt;last-sequence-number&lt;/code&gt;, bumped on each commit.&lt;/li&gt;
&lt;li&gt;Each snapshot records its &lt;code&gt;sequence-number&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The manifest list records each manifest's &lt;code&gt;sequence_number&lt;/code&gt; and a&lt;br&gt;
&lt;code&gt;min_sequence_number&lt;/code&gt; (the smallest data sequence number among live files in&lt;br&gt;
it).&lt;/li&gt;
&lt;li&gt;Each manifest entry carries the file's &lt;code&gt;sequence_number&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The rule that falls out: an &lt;strong&gt;equality delete applies to a data file only when&lt;br&gt;
the delete's sequence number is greater than the file's&lt;/strong&gt; (and they share a&lt;br&gt;
partition). A position delete applies at equal-or-greater sequence. That's how&lt;br&gt;
&quot;delete then insert&quot; does the right thing - the new insert has a higher sequence&lt;br&gt;
number than the delete, so it survives.&lt;/p&gt;
&lt;h4 id=&quot;inheritance-why-this-is-cheap&quot;&gt;Inheritance: why this is cheap&lt;/h4&gt;
&lt;p&gt;Here's the piece that surprises people. A manifest entry can leave its&lt;br&gt;
&lt;code&gt;snapshot_id&lt;/code&gt;, &lt;code&gt;sequence_number&lt;/code&gt;, and &lt;code&gt;file_sequence_number&lt;/code&gt; &lt;strong&gt;null in the&lt;br&gt;
file&lt;/strong&gt;, and the reader fills them in from the manifest list. Why bother? Because&lt;br&gt;
it lets the &lt;em&gt;same manifest file&lt;/em&gt; be reused across optimistic-retry attempts. When&lt;br&gt;
a commit loses the compare-and-set race and has to retry with a new sequence&lt;br&gt;
number, only the small manifest list needs rewriting - the manifests and data&lt;br&gt;
files it points at are untouched.&lt;/p&gt;
&lt;h4 id=&quot;named-references-branches-and-tags&quot;&gt;Named references: branches and tags&lt;/h4&gt;
&lt;p&gt;v2 adds a &lt;code&gt;refs&lt;/code&gt; map to the table metadata - named branches and tags pointing at&lt;br&gt;
snapshots:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;refs&quot;: {
  &quot;main&quot;:        { &quot;snapshot-id&quot;: 8392648, &quot;type&quot;: &quot;branch&quot; },
  &quot;audit&quot;:       { &quot;snapshot-id&quot;: 8000000, &quot;type&quot;: &quot;branch&quot;,
                   &quot;min-snapshots-to-keep&quot;: 10, &quot;max-snapshot-age-ms&quot;: 604800000 },
  &quot;prod_release&quot;:{ &quot;snapshot-id&quot;: 7281001, &quot;type&quot;: &quot;tag&quot;,
                   &quot;max-ref-age-ms&quot;: 2592000000 }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;main&lt;/code&gt; always exists; if &lt;code&gt;refs&lt;/code&gt; is empty it implicitly points at&lt;br&gt;
&lt;code&gt;current-snapshot-id&lt;/code&gt;. Branches let you stage and validate writes off to the side&lt;br&gt;
(write-audit-publish); tags pin a snapshot so expiration won't garbage-collect&lt;br&gt;
it. Branches carry their own retention floor (&lt;code&gt;min-snapshots-to-keep&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;max-snapshot-age-ms&lt;/code&gt;); tags and non-main branches carry &lt;code&gt;max-ref-age-ms&lt;/code&gt;. &lt;code&gt;main&lt;/code&gt;&lt;br&gt;
never expires.&lt;/p&gt;
&lt;h4 id=&quot;the-v2-metadata-cleanup&quot;&gt;The v2 metadata cleanup&lt;/h4&gt;
&lt;p&gt;v2 also formalized a lot of Layer 1. These fields became &lt;strong&gt;required&lt;/strong&gt;:&lt;br&gt;
&lt;code&gt;last-sequence-number&lt;/code&gt;, &lt;code&gt;current-schema-id&lt;/code&gt;, &lt;code&gt;schemas&lt;/code&gt;, &lt;code&gt;default-spec-id&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;partition-specs&lt;/code&gt;, &lt;code&gt;last-partition-id&lt;/code&gt;, &lt;code&gt;default-sort-order-id&lt;/code&gt;, &lt;code&gt;sort-orders&lt;/code&gt;,&lt;br&gt;
and &lt;code&gt;table-uuid&lt;/code&gt; (a stable identity generated at create time, used as a&lt;br&gt;
refresh-time integrity check). The singular &lt;code&gt;schema&lt;/code&gt; and &lt;code&gt;partition-spec&lt;/code&gt; are&lt;br&gt;
deprecated, the inline snapshot &lt;code&gt;manifests&lt;/code&gt; list is gone, and partition field IDs&lt;br&gt;
are now &lt;strong&gt;explicit and unique across all specs&lt;/strong&gt; - fixing the v1 ambiguity.&lt;/p&gt;
&lt;p&gt;One nice compatibility property: &lt;strong&gt;a v1 file reads cleanly as v2.&lt;/strong&gt; A missing&lt;br&gt;
&lt;code&gt;sequence_number&lt;/code&gt; is read as 0, and a missing &lt;code&gt;content&lt;/code&gt; is read as 0 (data). So&lt;br&gt;
upgrading is a metadata-only operation; nothing has to be rewritten on day one.&lt;/p&gt;
&lt;p&gt;By the end of v2, Iceberg is a full transactional table: insert, delete, update,&lt;br&gt;
upsert, branch, tag, time-travel. So what's left for v3?&lt;/p&gt;
&lt;h3 id=&quot;v3-identity-efficient-deletes-and-richer-data&quot;&gt;v3: identity, efficient deletes, and richer data&lt;/h3&gt;
&lt;p&gt;If v2 made the &lt;em&gt;table&lt;/em&gt; transactional, v3 makes the &lt;em&gt;row&lt;/em&gt; a first-class citizen.&lt;br&gt;
It adds three things that don't fit neatly into v2's model: a stable identity for&lt;br&gt;
every row, a far more efficient delete mechanism, and a richer type and security&lt;br&gt;
surface.&lt;/p&gt;
&lt;h4 id=&quot;row-lineage-every-row-gets-a-stable-id&quot;&gt;Row lineage: every row gets a stable id&lt;/h4&gt;
&lt;p&gt;This is the headline feature, and it's genuinely clever because it touches three&lt;br&gt;
layers at once without storing an id per row anywhere. v3 mandates that every row&lt;br&gt;
has a stable &lt;code&gt;_row_id&lt;/code&gt; that survives compaction - so you can track a row across&lt;br&gt;
rewrites, build change feeds, and reason about lineage. It works by seeding,&lt;br&gt;
not storing:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Layer &lt;/th&gt;&lt;th&gt;Field &lt;/th&gt;&lt;th&gt;Set when &lt;/th&gt;&lt;th&gt;Used for&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 (table metadata) &lt;/td&gt;&lt;td&gt;&lt;code&gt;next-row-id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;bumped per commit &lt;/td&gt;&lt;td&gt;seeds the next snapshot's first row id&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 (snapshot) &lt;/td&gt;&lt;td&gt;&lt;code&gt;first-row-id&lt;/code&gt;, &lt;code&gt;added-rows&lt;/code&gt; &lt;/td&gt;&lt;td&gt;at commit &lt;/td&gt;&lt;td&gt;starting &lt;code&gt;_row_id&lt;/code&gt; for the manifest list&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 (manifest list) &lt;/td&gt;&lt;td&gt;&lt;code&gt;first_row_id&lt;/code&gt; per manifest &lt;/td&gt;&lt;td&gt;at commit &lt;/td&gt;&lt;td&gt;starting &lt;code&gt;_row_id&lt;/code&gt; for files in that manifest&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3 (manifest entry) &lt;/td&gt;&lt;td&gt;&lt;code&gt;data_file.first_row_id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;at commit &lt;/td&gt;&lt;td&gt;starting &lt;code&gt;_row_id&lt;/code&gt; for rows in that file&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4 (data file) &lt;/td&gt;&lt;td&gt;reserved fields &lt;code&gt;_row_id&lt;/code&gt;, &lt;code&gt;_last_updated_sequence_number&lt;/code&gt; &lt;/td&gt;&lt;td&gt;inherited at read &lt;/td&gt;&lt;td&gt;stable identity across compactions&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The reader computes a row's id with one formula:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;_row_id = data_file.first_row_id + row_position_in_file
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No per-row storage; the id is derived from where the row sits. If &lt;code&gt;first_row_id&lt;/code&gt;&lt;br&gt;
is null - say a v2-era file in a table that was upgraded to v3 - then &lt;code&gt;_row_id&lt;/code&gt;&lt;br&gt;
reads as null for those rows, which is exactly the honest answer. Equality&lt;br&gt;
deletes deliberately break lineage: an equality-delete update never reads the old&lt;br&gt;
row, so the replacement gets a fresh &lt;code&gt;_row_id&lt;/code&gt; rather than inheriting one it&lt;br&gt;
can't prove.&lt;/p&gt;
&lt;h4 id=&quot;deletion-vectors-position-deletes-done-right&quot;&gt;Deletion vectors: position deletes, done right&lt;/h4&gt;
&lt;p&gt;Position delete &lt;em&gt;files&lt;/em&gt; worked, but they had a scaling problem: lots of tiny&lt;br&gt;
delete files, each needing to be opened and merged. v3 replaces them with&lt;br&gt;
&lt;strong&gt;deletion vectors (DVs)&lt;/strong&gt; - a single compressed bitmap per data file, stored as&lt;br&gt;
a blob inside a Puffin file. One bitmap, one referenced data file, looked up by&lt;br&gt;
byte offset.&lt;/p&gt;
&lt;p&gt;The manifest entry for a DV reuses the position-delete &lt;code&gt;content&lt;/code&gt; code but adds&lt;br&gt;
three fields:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;manifest_entry {
  status = 1
  data_file {
    content              = 1                              // shares position-delete code
    file_path            = &quot;s3://warehouse/db/sales/data/dv-....puffin&quot;
    file_format          = &quot;puffin&quot;
    partition            = { country: &quot;IN&quot;, ts_day: 2025-06-15 }  // same partition as target
    record_count         = 3                              // number of deleted positions
    referenced_data_file = &quot;s3://.../00000-abc.parquet&quot;   // v3 NEW: which data file this DV covers
    content_offset       = 4                              // v3 NEW: byte offset of the blob in the Puffin file
    content_size_in_bytes= 23                             // v3 NEW: blob length
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;referenced_data_file&lt;/code&gt;, &lt;code&gt;content_offset&lt;/code&gt;, and &lt;code&gt;content_size_in_bytes&lt;/code&gt; are the new&lt;br&gt;
fields that let the reader jump straight to one bitmap. Position delete files are&lt;br&gt;
&lt;strong&gt;deprecated&lt;/strong&gt; in v3: writers can't create new ones, and existing ones get merged&lt;br&gt;
into DVs over time. The result is one delete artifact per data file instead of a&lt;br&gt;
pile of small files.&lt;/p&gt;
&lt;h4 id=&quot;new-types-and-column-defaults&quot;&gt;New types and column defaults&lt;/h4&gt;
&lt;p&gt;v3 broadens what a column can hold. New primitive types: &lt;code&gt;variant&lt;/code&gt; (semi-&lt;br&gt;
structured), &lt;code&gt;geometry&lt;/code&gt; and &lt;code&gt;geography&lt;/code&gt; (spatial), &lt;code&gt;unknown&lt;/code&gt; (a column whose type&lt;br&gt;
isn't known yet), and nanosecond timestamps &lt;code&gt;timestamp_ns&lt;/code&gt; / &lt;code&gt;timestamptz_ns&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;It also adds &lt;strong&gt;column defaults&lt;/strong&gt;, which finally make adding a non-null column&lt;br&gt;
sane:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;id&quot;: 5, &quot;name&quot;: &quot;country&quot;, &quot;type&quot;: &quot;string&quot;, &quot;required&quot;: false,
  &quot;initial-default&quot;: &quot;IN&quot;,   // value for rows in files written before this column existed
  &quot;write-default&quot;: &quot;IN&quot;      // value to fill when a writer omits the column
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;initial-default&lt;/code&gt; is the value that &lt;em&gt;existing&lt;/em&gt; rows get for a freshly added&lt;br&gt;
column, with no file rewrite - the reader synthesizes it. &lt;code&gt;write-default&lt;/code&gt; is what&lt;br&gt;
new writes use when the column is omitted. Together they make schema evolution a&lt;br&gt;
metadata change instead of a backfill.&lt;/p&gt;
&lt;p&gt;The type-promotion rules that keep schema evolution safe also expand in v3:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;From &lt;/th&gt;&lt;th&gt;v1 / v2 promotion &lt;/th&gt;&lt;th&gt;v3+ adds&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;unknown&lt;/code&gt; &lt;/td&gt;&lt;td&gt;- &lt;/td&gt;&lt;td&gt;promotable to &lt;em&gt;any&lt;/em&gt; type&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;int&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;long&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;long&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;date&lt;/code&gt; &lt;/td&gt;&lt;td&gt;- &lt;/td&gt;&lt;td&gt;&lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;timestamp_ns&lt;/code&gt; (not the tz variants)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;float&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;double&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;double&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;decimal(P, S)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;decimal(P', S)&lt;/code&gt; with &lt;code&gt;P' &amp;gt; P&lt;/code&gt; &lt;/td&gt;&lt;td&gt;same&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;partition-transforms-get-multi-argument&quot;&gt;Partition transforms get multi-argument&lt;/h4&gt;
&lt;p&gt;v3 adds &lt;code&gt;source-ids&lt;/code&gt; (plural) on partition fields, so a transform can take more&lt;br&gt;
than one source column. Single-argument transforms still write the old&lt;br&gt;
&lt;code&gt;source-id&lt;/code&gt;. The full set of allowed transforms is &lt;code&gt;identity&lt;/code&gt;, &lt;code&gt;bucket[N]&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;truncate[W]&lt;/code&gt;, &lt;code&gt;year&lt;/code&gt;, &lt;code&gt;month&lt;/code&gt;, &lt;code&gt;day&lt;/code&gt;, &lt;code&gt;hour&lt;/code&gt;, and &lt;code&gt;void&lt;/code&gt;. And a forward-&lt;br&gt;
compatibility rule lands: &lt;strong&gt;v3 readers must tolerate an unknown transform&lt;/strong&gt; and&lt;br&gt;
simply skip filter pushdown on it, rather than refusing to read. (v1/v2 only&lt;br&gt;
&lt;em&gt;should&lt;/em&gt;.) Writers, of course, still can't commit a transform they don't&lt;br&gt;
understand.&lt;/p&gt;
&lt;h4 id=&quot;encryption-arrives&quot;&gt;Encryption arrives&lt;/h4&gt;
&lt;p&gt;v3 adds table-level encryption with a three-place key model:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;encryption-keys&quot;: [
  { &quot;key-id&quot;: &quot;k1&quot;,
    &quot;encrypted-key-metadata&quot;: &quot;BASE64...&quot;,   // KMS-wrapped data encryption key
    &quot;encrypted-by-id&quot;: &quot;kms-master-2025&quot; }   // the logical key-encryption-key id
]
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;The table JSON holds &lt;code&gt;encryption-keys[]&lt;/code&gt; - data encryption keys (DEKs) each&lt;br&gt;
wrapped by a KMS-resident key-encryption-key (KEK).&lt;/li&gt;
&lt;li&gt;Each snapshot carries a &lt;code&gt;key-id&lt;/code&gt; naming which DEK protects that snapshot's&lt;br&gt;
manifest-list key metadata.&lt;/li&gt;
&lt;li&gt;Each file can carry per-file &lt;code&gt;key_metadata&lt;/code&gt; (this field already existed in&lt;br&gt;
v1/v2 on data files, but without a central registry).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The DEK-to-KEK chain is opaque to the format; implementations plug into AWS KMS,&lt;br&gt;
GCP KMS, Vault, and so on via the wrapped bytes.&lt;/p&gt;
&lt;p&gt;By the end of v3, a row has an identity, deletes are a single bitmap, columns can&lt;br&gt;
default and hold variant or spatial data, and the table can be encrypted. The&lt;br&gt;
&lt;em&gt;user-visible&lt;/em&gt; feature set is essentially complete. Which is why v4 looks&lt;br&gt;
different from everything before it.&lt;/p&gt;
&lt;h3 id=&quot;v4-the-refactor-for-scale-and-portability&quot;&gt;v4: the refactor for scale and portability&lt;/h3&gt;
&lt;p&gt;v4 introduces &lt;strong&gt;no new user-visible types and no new delete mechanisms.&lt;/strong&gt; It is a&lt;br&gt;
metadata refactor aimed at three things: performance, portability, and richer&lt;br&gt;
per-file statistics. The changes are quieter, but two of them matter a lot in&lt;br&gt;
production.&lt;/p&gt;
&lt;h4 id=&quot;relative-paths-move-a-table-without-rewriting-it&quot;&gt;Relative paths: move a table without rewriting it&lt;/h4&gt;
&lt;p&gt;This is the biggest &lt;em&gt;invisible&lt;/em&gt; change in the whole format. In v1 through v3,&lt;br&gt;
every path stored inside metadata - &lt;code&gt;file_path&lt;/code&gt;, &lt;code&gt;manifest_path&lt;/code&gt;, the manifest&lt;br&gt;
list, &lt;code&gt;metadata-file&lt;/code&gt;, statistics paths - had to be &lt;strong&gt;absolute&lt;/strong&gt;, complete with a&lt;br&gt;
URI scheme like &lt;code&gt;s3://&lt;/code&gt; or &lt;code&gt;hdfs://&lt;/code&gt;. That meant the moment you wanted to move a&lt;br&gt;
table to a different bucket, every one of those absolute paths was wrong, and you&lt;br&gt;
had to rewrite the entire metadata tree to fix them.&lt;/p&gt;
&lt;p&gt;v4 allows paths to be &lt;strong&gt;relative to the table location&lt;/strong&gt;. The resolution rule is&lt;br&gt;
simple:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Format &lt;/th&gt;&lt;th&gt;Table location &lt;/th&gt;&lt;th&gt;Stored path &lt;/th&gt;&lt;th&gt;Resolves to&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;v4 &lt;/td&gt;&lt;td&gt;&lt;code&gt;s3://bucket/db/table&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;data/00000.parquet&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;s3://bucket/db/table/data/00000.parquet&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;v4 &lt;/td&gt;&lt;td&gt;&lt;code&gt;s3://bucket/db/table&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;hdfs://wh/...&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;hdfs://wh/...&lt;/code&gt; (absolute, used as-is)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;v3 and earlier &lt;/td&gt;&lt;td&gt;&lt;code&gt;s3://bucket/db/table&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;s3://bucket/db/table/data/00000.parquet&lt;/code&gt; &lt;/td&gt;&lt;td&gt;unchanged&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If a stored path has a URI scheme, it's absolute and used as-is. If it doesn't,&lt;br&gt;
the reader resolves it as &lt;code&gt;table_location + &quot;/&quot; + path&lt;/code&gt;. The writer rule:&lt;br&gt;
default to relative for files under the table location, use absolute for files&lt;br&gt;
outside it (say, a backfill from another bucket). Because location is now what&lt;br&gt;
ties relative paths together, &lt;code&gt;location&lt;/code&gt; in the table metadata JSON becomes&lt;br&gt;
&lt;strong&gt;optional&lt;/strong&gt; - the catalog can supply it.&lt;/p&gt;
&lt;p&gt;The operational payoff is the headline: moving a table from&lt;br&gt;
&lt;code&gt;s3://bucket-a/db/sales/&lt;/code&gt; to &lt;code&gt;s3://bucket-b/db/sales/&lt;/code&gt; needs only a catalog&lt;br&gt;
pointer update and, optionally, a new metadata.json with the new &lt;code&gt;location&lt;/code&gt;. &lt;strong&gt;No&lt;br&gt;
manifest list, no manifest, no data file gets rewritten.&lt;/strong&gt; Pre-v4 the same move&lt;br&gt;
required a full metadata rewrite.&lt;/p&gt;
&lt;h4 id=&quot;typed-content-stats-five-maps-become-one-struct&quot;&gt;Typed &lt;code&gt;content_stats&lt;/code&gt;: five maps become one struct&lt;/h4&gt;
&lt;p&gt;In v3 and earlier, per-column statistics on a data file were &lt;strong&gt;five parallel&lt;br&gt;
maps&lt;/strong&gt; keyed by field id: &lt;code&gt;value_counts&lt;/code&gt;, &lt;code&gt;null_value_counts&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;nan_value_counts&lt;/code&gt;, &lt;code&gt;lower_bounds&lt;/code&gt;, &lt;code&gt;upper_bounds&lt;/code&gt; (plus the on-disk&lt;br&gt;
&lt;code&gt;column_sizes&lt;/code&gt;). Five maps to keep in sync, all loosely typed (bounds were raw&lt;br&gt;
binary-encoded bytes).&lt;/p&gt;
&lt;p&gt;v4 replaces them with &lt;strong&gt;one typed struct&lt;/strong&gt;, &lt;code&gt;content_stats&lt;/code&gt;, whose layout is&lt;br&gt;
generated from the table schema itself. Each column reserves a block of ids and&lt;br&gt;
gets a typed sub-struct:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;146: optional struct content_stats {
  10_400: optional struct id (default null) {        // stats for table field 2 (int)
    10_401: optional int     lower_bound;
    10_402: optional int     upper_bound;
    10_403: optional boolean tight_bounds;
    10_404: optional long    value_count;
  }
  10_600: optional struct data (default null) {       // stats for table field 3 (string)
    10_601: optional string  lower_bound;
    10_602: optional string  upper_bound;
    10_603: optional boolean tight_bounds;            // v4 NEW: exact vs truncated min/max
    10_604: optional long    value_count;
    10_605: optional long    null_value_count;
    10_607: optional int     avg_value_size_in_bytes; // v4 NEW: variable-length sizing
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The id assignment is mechanical - each column reserves 200 ids - and two genuinely&lt;br&gt;
new pieces of information appear: &lt;code&gt;tight_bounds&lt;/code&gt;, a flag saying whether the&lt;br&gt;
min/max are exact or truncated (truncated bounds still prune, but you have to scan&lt;br&gt;
to confirm a match), and &lt;code&gt;avg_value_size_in_bytes&lt;/code&gt; for variable-length columns,&lt;br&gt;
which helps the planner estimate read cost. Spatial columns use typed&lt;br&gt;
&lt;code&gt;geo_lower&lt;/code&gt; / &lt;code&gt;geo_upper&lt;/code&gt; structs instead of opaque WKB bytes.&lt;/p&gt;
&lt;p&gt;The reassuring part: &lt;strong&gt;v3 and v4 statistics are equivalent.&lt;/strong&gt; A missing map key in&lt;br&gt;
v3 is the same as a missing-or-null sub-struct in v4. Nothing is lost in the&lt;br&gt;
translation; it's the same information, typed and consolidated.&lt;/p&gt;
&lt;h4 id=&quot;the-file-system-catalog-is-gone&quot;&gt;The file-system catalog is gone&lt;/h4&gt;
&lt;p&gt;v1 through v3 allowed a &quot;file-system table&quot;: sequential metadata filenames&lt;br&gt;
(&lt;code&gt;v1.metadata.json&lt;/code&gt;, &lt;code&gt;v2.metadata.json&lt;/code&gt;, ...) where a commit was an atomic file&lt;br&gt;
&lt;em&gt;rename&lt;/em&gt;. That only ever worked safely on HDFS, because object stores like S3&lt;br&gt;
don't offer atomic rename. v4 &lt;strong&gt;removes it entirely.&lt;/strong&gt; Every v4 table uses a real&lt;br&gt;
catalog (the metastore model), where a commit is a compare-and-set on the catalog&lt;br&gt;
pointer. This closes a long-standing source of silent corruption on object&lt;br&gt;
storage.&lt;/p&gt;
&lt;p&gt;That's the whole of v4: relative paths, optional &lt;code&gt;location&lt;/code&gt;, typed&lt;br&gt;
&lt;code&gt;content_stats&lt;/code&gt; with &lt;code&gt;tight_bounds&lt;/code&gt; and average value size, and the death of the&lt;br&gt;
file-system catalog. A refactor, not a feature release - and exactly the kind of&lt;br&gt;
change a format makes once its feature surface has settled.&lt;/p&gt;
&lt;h3 id=&quot;how-the-layers-earn-their-keep-scan-planning&quot;&gt;How the layers earn their keep: scan planning&lt;/h3&gt;
&lt;p&gt;The reason all this structure exists is to make a scan cheap, so it's worth&lt;br&gt;
watching a query actually use it. Given the current snapshot, a scan does&lt;br&gt;
&lt;strong&gt;three-level pruning&lt;/strong&gt; without ever needing a separate planner index:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Open the manifest list.&lt;/strong&gt; One read, roughly 100 KB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layer-2 pruning.&lt;/strong&gt; Drop any manifest whose partition summary can't match the&lt;br&gt;
query predicate. Whole manifests skipped without opening them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open the surviving delete manifests first&lt;/strong&gt;, then the data manifests. Delete&lt;br&gt;
manifests come first so the reader knows which deletes are in play before it&lt;br&gt;
decides what to emit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layer-3 pruning.&lt;/strong&gt; For each data file, check its &lt;code&gt;lower_bounds&lt;/code&gt; /&lt;br&gt;
&lt;code&gt;upper_bounds&lt;/code&gt;; if they rule the file out, skip it. Otherwise emit it as a scan&lt;br&gt;
task.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pair each scan task with the deletes that apply&lt;/strong&gt;, using the sequence-number&lt;br&gt;
rules from v2:&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;deletion vector&lt;/strong&gt; applies when its &lt;code&gt;referenced_data_file&lt;/code&gt; matches and its&lt;br&gt;
sequence is greater-or-equal, in the same partition;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;position delete&lt;/strong&gt; applies by the same rule, but only when no DV is present;&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;equality delete&lt;/strong&gt; applies when its sequence is strictly greater than the&lt;br&gt;
data file's, in the same partition (or globally if unpartitioned).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Manifest list, then manifest, then file. Three reads narrow a petabyte table to&lt;br&gt;
the handful of files a query actually needs. That funnel is the entire reason the&lt;br&gt;
tree-of-files design beats a directory listing.&lt;/p&gt;
&lt;h3 id=&quot;reading-across-versions&quot;&gt;Reading across versions&lt;/h3&gt;
&lt;p&gt;A practical note that saves real debugging time: the format is designed so older&lt;br&gt;
files read correctly under newer rules.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;v1 read as v2:&lt;/strong&gt; a missing &lt;code&gt;sequence_number&lt;/code&gt; is 0; a missing &lt;code&gt;content&lt;/code&gt; is 0&lt;br&gt;
(data). Upgrading v1 to v2 is metadata-only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v2 read as v3:&lt;/strong&gt; files without &lt;code&gt;first_row_id&lt;/code&gt; simply report &lt;code&gt;_row_id&lt;/code&gt; as null;&lt;br&gt;
position delete files keep working but can't be created anew.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v3 read as v4:&lt;/strong&gt; a missing stats map key equals a null typed sub-struct; absolute&lt;br&gt;
paths keep working unchanged alongside new relative ones.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the one hard rule in the other direction: a reader refuses to open a table&lt;br&gt;
whose &lt;code&gt;format-version&lt;/code&gt; is higher than the reader supports. The version integer is&lt;br&gt;
a contract, not a hint.&lt;/p&gt;
&lt;h3 id=&quot;the-whole-format-on-one-page&quot;&gt;The whole format on one page&lt;/h3&gt;
&lt;p&gt;Here is the entire metadata surface, top to bottom, with the version each piece&lt;br&gt;
arrived in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;catalog -&amp;gt; metadata.json
  |-- table level: format-version, schemas[], partition-specs[], sort-orders[],
  |               properties{}, last-*-id, last-sequence-number, encryption-keys[] (v3+)
  |-- refs{} -&amp;gt; main / branches / tags -&amp;gt; snapshot-id                            (v2+)
  |-- snapshots[] (whole history; expired entries pruned)
        |-- one snapshot:
              snapshot-id, parent-snapshot-id, sequence-number, summary{op,...},
              first-row-id + added-rows (v3+), key-id (v3+)
                |-- manifest list (Avro): [ manifest_file x N ]
                      manifest_file: path, len, spec-id, content (data|deletes),  (v2+)
                                     seq#, min_seq#, counts, partitions[],
                                     first_row_id (v3+)
                        |-- manifest (Avro): header{schema, spec, content} + [ entry x N ]
                              entry: status, snapshot_id, seq#, file_seq#,
                                     data_file {
                                       content, file_path, format, partition,
                                       record_count, size, sort_order_id,
                                       metrics maps (v1-v3)  -- OR --  content_stats struct (v4),
                                       equality_ids (v2+),
                                       referenced_data_file / content_offset / size (v3+),
                                       first_row_id (v3+), key_metadata
                                     }
                                |-- data file / delete file / Puffin DV blob
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the version-by-version cheat sheet:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;v1 &lt;/th&gt;&lt;th&gt;v2 &lt;/th&gt;&lt;th&gt;v3 &lt;/th&gt;&lt;th&gt;v4&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Core model&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;atomic snapshots on immutable files &lt;/td&gt;&lt;td&gt;+ row-level deletes, sequence numbers &lt;/td&gt;&lt;td&gt;+ stable row identity &lt;/td&gt;&lt;td&gt;metadata refactor only&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Deletes&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;rewrite whole file (copy-on-write) &lt;/td&gt;&lt;td&gt;position + equality delete files &lt;/td&gt;&lt;td&gt;deletion vectors (position deletes deprecated) &lt;/td&gt;&lt;td&gt;unchanged&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Schema/spec&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;singular &lt;code&gt;schema&lt;/code&gt; / &lt;code&gt;partition-spec&lt;/code&gt; &lt;/td&gt;&lt;td&gt;lists, explicit partition field ids &lt;/td&gt;&lt;td&gt;column defaults, new types, &lt;code&gt;source-ids&lt;/code&gt; &lt;/td&gt;&lt;td&gt;unchanged&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;References&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;- &lt;/td&gt;&lt;td&gt;&lt;code&gt;refs&lt;/code&gt;: branches + tags &lt;/td&gt;&lt;td&gt;unchanged &lt;/td&gt;&lt;td&gt;unchanged&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Row lineage&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;- &lt;/td&gt;&lt;td&gt;- &lt;/td&gt;&lt;td&gt;&lt;code&gt;next-row-id&lt;/code&gt; / &lt;code&gt;first-row-id&lt;/code&gt; / &lt;code&gt;_row_id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;unchanged&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Types&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;base &lt;/td&gt;&lt;td&gt;+ sort orders &lt;/td&gt;&lt;td&gt;variant, geometry, geography, unknown, ns timestamps &lt;/td&gt;&lt;td&gt;unchanged&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Encryption&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;per-file key metadata only &lt;/td&gt;&lt;td&gt;per-file key metadata only &lt;/td&gt;&lt;td&gt;&lt;code&gt;encryption-keys&lt;/code&gt;, snapshot &lt;code&gt;key-id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;unchanged&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Statistics&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;metrics maps &lt;/td&gt;&lt;td&gt;metrics maps &lt;/td&gt;&lt;td&gt;metrics maps &lt;/td&gt;&lt;td&gt;typed &lt;code&gt;content_stats&lt;/code&gt; + &lt;code&gt;tight_bounds&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Paths&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;absolute only &lt;/td&gt;&lt;td&gt;absolute only &lt;/td&gt;&lt;td&gt;absolute only &lt;/td&gt;&lt;td&gt;relative or absolute; &lt;code&gt;location&lt;/code&gt; optional&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Catalog&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;file-system or metastore &lt;/td&gt;&lt;td&gt;file-system or metastore &lt;/td&gt;&lt;td&gt;file-system or metastore &lt;/td&gt;&lt;td&gt;metastore only&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every commit appends a new metadata.json plus new manifest-list, manifest, and&lt;br&gt;
data files. The old tree stays reachable through the metadata log for rollback&lt;br&gt;
and time travel, until snapshot expiration garbage-collects it. Nothing is ever&lt;br&gt;
mutated; the whole format is an append-only tree with one movable pointer at the&lt;br&gt;
root.&lt;/p&gt;
&lt;h3 id=&quot;the-throughline&quot;&gt;The throughline&lt;/h3&gt;
&lt;p&gt;Read the four versions back to back and the arc is clean. v1 made a table&lt;br&gt;
&lt;em&gt;atomic&lt;/em&gt; on immutable files. v2 pushed correctness down to the &lt;em&gt;row&lt;/em&gt; with deletes&lt;br&gt;
and sequence numbers, and added branches. v3 pushed &lt;em&gt;identity&lt;/em&gt; down to the row,&lt;br&gt;
made deletes a single bitmap, and broadened types and security. v4 turned inward&lt;br&gt;
and refactored the metadata so it can move and scale without rewrites.&lt;/p&gt;
&lt;p&gt;It's the same instinct applied at finer and finer grain: make the unit of change smaller, and make the metadata that tracks it cheaper. That's why a format that started as an append-only snapshot log can now back a streaming upsert table with row-level lineage across petabytes - without ever giving up the one property it started with, the single atomic pointer swap.&lt;/p&gt;
&lt;p&gt;If you want to build this understanding from the ground up - why data lakes broke,how snapshots and manifests really work, and the hands-on mechanics of deletes,branches, and compaction - that's the&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://petascalelabs.com/curriculum/open-table-formats/iceberg-foundations&quot;&gt;Iceberg Foundations&lt;/a&gt; track,&lt;br&gt;
part of the broader &lt;a rel=&quot;nofollow&quot; href=&quot;https://petascalelabs.com/curriculum/open-table-formats&quot;&gt;open table formats&lt;/a&gt;&lt;br&gt;
curriculum. The format rewards reading it as a story, because that's how it was written.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21445/the-story-behind-apache-icebergs-format-version-v1-to-v4</guid>
<pubDate>Sat, 27 Jun 2026 09:08:20 +0000</pubDate>
</item>
<item>
<title>ETL vs ELT — Why Notebook Placement Matters  Body / Content: Yes — the placement of your Databricks</title>
<link>https://coderlegion.com/11185/etl-vs-elt-why-notebook-placement-matters-body-content-yes-the-placement-your-databricks</link>
<description>&lt;h2 id=&quot;etl-vs-elt-why-notebook-placement-matters&quot;&gt;ETL vs ELT — Why Notebook Placement Matters&lt;/h2&gt;
&lt;h3 id=&quot;body-content-&quot;&gt;Body / Content:&lt;/h3&gt;
&lt;p&gt;The placement of your Databricks notebooks determines whether your pipeline behaves like ETL or ELT.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;If Databricks runs after loading data into the lake, it’s ELT.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If Databricks runs&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; before loading transformed data, it’s ETL.&lt;/p&gt;
&lt;p&gt;Business Analogy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Clean vegetables before putting them in the fridge → ETL&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;Dump everything in the fridge and clean later → ELT&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Architecture Mapping:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;RAW → BRONZE in ADF = Extract + Load (EL)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SILVER → GOLD in Data-bricks = Transform (T)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;✅ This makes your pipeline ELT, the modern standard:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cheap storage Scalable compute Delta Lake supports late&lt;br&gt;
transformations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Notebooks preserve raw data and give flexibility&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11185/etl-vs-elt-why-notebook-placement-matters-body-content-yes-the-placement-your-databricks</guid>
<pubDate>Fri, 06 Feb 2026 14:09:12 +0000</pubDate>
</item>
<item>
<title>Hydrolix buys Quesma tech to keep Kibana dashboards running on cost-efficient data lakes.</title>
<link>https://coderlegion.com/5517/hydrolix-buys-quesma-tech-to-keep-kibana-dashboards-running-on-cost-efficient-data-lakes</link>
<description>&lt;h2 id=&quot;hydrolix-secures-kibana-support-by-acquiring-quesma-gateway-technology&quot;&gt;Hydrolix Secures Kibana Support by Acquiring Quesma Gateway Technology&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Acquisition ensures seamless dashboard integration as data lake platform expands ecosystem reach.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Hydrolix has made a strategic move to protect its customers' existing workflows by acquiring intellectual property from Quesma, specifically the gateway proxy technology that enables Kibana integration. This acquisition comes after Quesma announced it would discontinue support for the product, potentially leaving Hydrolix customers without their familiar dashboard interface.&lt;/p&gt;
&lt;h3 id=&quot;why-this-matters-for-developers&quot;&gt;Why This Matters for Developers&lt;/h3&gt;
&lt;p&gt;The acquisition addresses a common challenge in the data analytics space: maintaining familiar tools while upgrading underlying infrastructure. Many organizations have invested heavily in Kibana dashboards, queries, and workflows. When they migrate to more cost-effective platforms like Hydrolix, they want to preserve these investments rather than rebuilding everything from scratch.&lt;/p&gt;
&lt;p&gt;&quot;Many of our customers rely on Kibana as a familiar and powerful interface for monitoring and alerting,&quot; said Marty Kagan, CEO of Hydrolix. &quot;By bringing Quesma's gateway proxy IP in-house, we can guarantee that customers will continue to enjoy seamless interoperability between Kibana dashboards and Hydrolix data.&quot;&lt;/p&gt;
&lt;h3 id=&quot;the-technical-solution&quot;&gt;The Technical Solution&lt;/h3&gt;
&lt;p&gt;Quesma's gateway proxy acts as an intelligent intermediary between Kibana and Hydrolix. When users run queries through Kibana, the gateway translates these requests into the appropriate format for Hydrolix's streaming data lake platform. This translation happens transparently, so users can continue working with their existing dashboards while benefiting from Hydrolix's cost advantages and performance improvements.&lt;/p&gt;
&lt;p&gt;The technology has been integrated into Hydrolix since 2024, giving the company nearly a year of operational experience with the solution. This integration allows organizations to redirect queries from expensive Elasticsearch clusters to Hydrolix's more efficient storage and query engine without changing their front-end tools.&lt;/p&gt;
&lt;h3 id=&quot;beyond-kibana-integration&quot;&gt;Beyond Kibana Integration&lt;/h3&gt;
&lt;p&gt;This acquisition reflects Hydrolix's broader ecosystem strategy. The company doesn't just support Kibana—it also integrates with Splunk, Databricks, Microsoft Fabric, and various machine learning exploration tools. This approach recognizes that different teams within organizations often prefer different tools, and forcing standardization can create adoption barriers.&lt;/p&gt;
&lt;p&gt;The move also demonstrates how Hydrolix is positioning itself in the competitive data lake market. Rather than requiring customers to abandon their existing toolchains, the company is building bridges that make migration easier and less risky.&lt;/p&gt;
&lt;h3 id=&quot;market-context-and-timing&quot;&gt;Market Context and Timing&lt;/h3&gt;
&lt;p&gt;The acquisition comes at a time when organizations are increasingly focused on data infrastructure costs. Traditional log analytics platforms like Elasticsearch can become expensive at scale, particularly when organizations need to retain data for extended periods or handle high-volume ingestion.&lt;/p&gt;
&lt;p&gt;Hydrolix has built its platform specifically to address these cost concerns. The company's streaming data lake can handle up to 20 million events per second while keeping data &quot;hot&quot; and queryable for 15 months or more—something that would be prohibitively expensive with traditional solutions.&lt;/p&gt;
&lt;h3 id=&quot;customer-impact&quot;&gt;Customer Impact&lt;/h3&gt;
&lt;p&gt;For existing Hydrolix customers using the Kibana integration, this acquisition means business as usual. They won't need to worry about losing access to their dashboards or having to rewrite queries. The transition should be seamless, with Hydrolix taking over support and development responsibilities.&lt;/p&gt;
&lt;p&gt;New customers evaluating Hydrolix can also feel confident that their Kibana investments are protected. This removes a significant barrier to adoption, especially for organizations with large teams already trained on Kibana.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next-for-quesma&quot;&gt;What's Next for Quesma&lt;/h3&gt;
&lt;p&gt;While Quesma is exiting the gateway proxy business, the company isn't disappearing entirely. According to Jacek Migdał, founder and CEO of Quesma, the transaction allows his company to focus on its next chapter of product development. The partnership between the companies will continue, though in a different capacity.&lt;/p&gt;
&lt;h3 id=&quot;industry-implications&quot;&gt;Industry Implications&lt;/h3&gt;
&lt;p&gt;This acquisition highlights a broader trend in the data infrastructure market: the importance of ecosystem integration. Companies that build walls around their platforms often struggle to gain adoption, while those that embrace interoperability can capture market share more effectively.&lt;/p&gt;
&lt;p&gt;It also shows how smaller, specialized companies like Quesma can have outsized impact by solving specific integration challenges. Even though Quesma is stepping back from this particular product, their technology will continue to benefit users through Hydrolix.&lt;/p&gt;
&lt;h3 id=&quot;the-bottom-line&quot;&gt;The Bottom Line&lt;/h3&gt;
&lt;p&gt;For developers and data engineers working with log analytics, this acquisition removes uncertainty about Kibana support on the Hydrolix platform. It also demonstrates Hydrolix's commitment to protecting customer investments in existing tools and workflows.&lt;/p&gt;
&lt;p&gt;As organizations continue to grapple with rising data infrastructure costs, solutions that offer both economic advantages and tool compatibility become increasingly valuable. By securing the Quesma technology, Hydrolix has strengthened its position in this competitive market while ensuring its customers can continue using the tools they know and trust.&lt;/p&gt;
</description>
<category>Data Science</category>
<guid isPermaLink="true">https://coderlegion.com/5517/hydrolix-buys-quesma-tech-to-keep-kibana-dashboards-running-on-cost-efficient-data-lakes</guid>
<pubDate>Tue, 23 Sep 2025 13:11:07 +0000</pubDate>
</item>
</channel>
</rss>