The Arrow Row Format: How DataFusion Packs a Multi-Column Sort Key Into One Comparable Byte String
DataFusion converts columnar sort keys into Arrow's order-preserving row format so a multi-column comparison becomes one memcmp
TL;DR: Arrow’s
RowConverterturns N Arrow columns into one flat byte buffer per row, using an order-preserving encoding: signed ints get their sign bit flipped, floats become their IEEE-754totalOrderinteger image, strings are written in self-delimiting blocks, and each field starts with a null sentinel. Because the encoding is order-preserving,memcmpon two concatenated rows is the lexicographic comparison of the original columns. DataFusion uses this in the sort merge path, in range partitioning, in the TopK heap, and in the generic GROUP BY fallback — everywhere a multi-column key has to be compared against itself millions of times.
The problem: comparing a tuple of columns, over and over
Sorting is the canonical case. ORDER BY country, city, population produces a
composite key. The naive implementation keeps the three columns as three
Arrow arrays and compares them pairwise:
compare(a, b):
if a.country != b.country: return a.country < b.country
if a.city != b.city: return a.city < b.city
return a.population < b.populationThat is fine for one comparison. A sort does O(N log N) of them, and a merge of K sorted runs does O(N log K) more. With 100 million rows and a 3-column key, you are looking at billions of tuple comparisons, each of which has to branch on “which column are we in”, re-dispatch on the runtime type of that column, and handle the possibility that the column is null.
The classic fix in the database world is the “tuple” or “serialized key”
representation: pack the whole composite key into one opaque byte string so
that comparing keys is a single memcmp. The trouble is doing it without
losing information. If you just concatenate the raw bytes, ("a", "bc") and
("ab", "c") become the same bytes. The encoding has to be injective and
order-preserving at the same time.
What Arrow’s row format actually looks like
Arrow’s arrow-row crate (vendored here as arrow-row-60.0.0) defines
exactly such an encoding. The format is documented at length in
arrow-row-60.0.0/src/lib.rs:199-350, and the interesting parts are:
Signed integers get their sign bit flipped and are written big-endian, so the byte sequence is monotonically increasing in the numeric value:
// arrow-row-60.0.0/src/lib.rs:225-241
/// Signed integers have their most significant sign bit flipped, and are then encoded in the
/// same manner as an unsigned integer.
///
/// ```text
/// ┌──┬──┬──┬──┐ ┌──┬──┬──┬──┐ ┌──┬──┬──┬──┬──┐
/// 5 │05│00│00│00│ │05│00│00│80│ │01│80│00│00│05│
/// -5 │FB│FF│FF│FF│ │FB│FF│FF│7F│ │01│7F│FF│FF│FB│Nulls are encoded as a leading sentinel byte, 0x00 for nulls-first and
0xFF for nulls-last, with a valid value starting with 0x01:
// arrow-row-60.0.0/src/fixed.rs:224-241
let null_sentinel = null_sentinel(opts);
for (value_idx, is_valid) in nulls.iter().enumerate() {
...
if is_valid {
to_write[0] = 1;
...
} else {
data[*offset] = null_sentinel;
}Variable-length strings use a COBS-inspired block scheme so the decoder never has to scan for a terminator:
// arrow-row-60.0.0/src/variable.rs:82-102
/// - single `0_u8` if null
/// - single `1_u8` if empty array
/// - `2_u8` if not empty, followed by one or more blocks
/// where a block is encoded as
/// - [`BLOCK_SIZE`] bytes of string data, padded with 0s
/// - `0xFF_u8` if this is not the last block for this string
/// - otherwise the length of the block as a `u8`The first four blocks are only 8 bytes wide, to avoid space amplification for
short strings (variable.rs:30-38).
Descending order is obtained by negating the encoded bytes of the non-null payload — not by reversing the comparator:
// arrow-row-60.0.0/src/lib.rs:561-563
/// ## Reverse Column Ordering
///
/// The order of a given column can be reversed by negating the encoded bytes of non-null valuesThis is the crucial design decision. Because the same byte-comparison code handles ASC and DESC, the merge loop needs no special cases, and the sort direction becomes a property of the encoding, not of the comparison.
Floats are converted to their IEEE-754 totalOrder integer image, so
-0.0 sorts before +0.0 and NaNs have a defined position. This is
not the same as SQL’s = semantics — DataFusion has to normalize -0.0 to
+0.0 explicitly wherever it uses the row format for equality, which it
does in the GROUP BY fallback:
// datafusion/physical-plan/src/aggregates/group_values/row.rs:124-130
fn intern(&mut self, cols: &[ArrayRef], groups: &mut Vec<usize>) -> Result<()> {
// Normalize -0.0 → +0.0 so RowConverter (IEEE 754 totalOrder) and
// primitive hashing both group ±0 together. No-op for non-float
// columns.
let normalized_cols: Vec<ArrayRef> =
cols.iter().map(normalize_float_zero).collect();The payoff: one comparison, no dispatch
The Rows type is a flat Vec<u8> plus a Vec<usize> of row offsets:
// arrow-row-60.0.0/src/lib.rs:1331-1341
pub struct Rows {
/// Underlying row bytes
buffer: Vec<u8>,
/// Row `i` has data `&buffer[offsets[i]..offsets[i+1]]`
offsets: Vec<usize>,
/// The config for these rows
config: RowConfig,
}and comparing two rows is literally slice::cmp:
// arrow-row-60.0.0/src/lib.rs:1593-1599
impl Ord for Row<'_> {
#[inline]
fn cmp(&self, other: &Self) -> Ordering {
self.data.cmp(other.data)
}
}memcmp on two byte strings. No type dispatch, no null branch, no
“which column are we in” branch. That is the entire trick.
Where DataFusion uses it
1. The sort merge path
RowCursorStream is the multi-column path of the sort-preserving merge.
It builds one RowConverter for the whole ordering and appends each batch’s
key columns into a reusable Rows buffer:
// datafusion/physical-plan/src/sorts/stream.rs:191-201
let sort_fields = expressions
.iter()
.map(|expr| {
let data_type = expr.expr.data_type(schema)?;
Ok(SortField::new_with_options(data_type, expr.options))
})
.collect::<Result<Vec<_>>>()?;
let streams: Vec<_> = streams.into_iter().map(|s| s.fuse()).collect();
let stream_count = streams.len();
let converter = RowConverter::new(sort_fields)?;// datafusion/physical-plan/src/sorts/stream.rs:216-240
fn convert_batch(
&mut self,
batch: &RecordBatch,
stream_idx: usize,
) -> Result<RowValues> {
let cols = evaluate_expressions_to_arrays(&self.column_expressions, batch)?;
// At this point, ownership should of this Rows should be unique
let mut rows = self.rows.take_next(stream_idx, &self.converter)?;
rows.clear();
self.converter.append(&mut rows, &cols)?;
self.reservation.try_resize(self.converter.size())?;
...Note that the sort itself does not use the row format — sort_batch uses
Arrow’s lexsort_to_indices (datafusion/physical-plan/src/sorts/sort.rs:1097-1116).
The row format is for the merge, where the alternative is comparing two
streams of differently-shaped columns against each other, repeatedly.
The RowValues cursor goes one step further and caches the raw pointer and
length of the current row, so the hot comparison is a pointer dereference
plus memcmp rather than two offset lookups:
// datafusion/physical-plan/src/sorts/cursor.rs:293-317
fn compare(l: &Self, l_idx: usize, r: &Self, r_idx: usize) -> Ordering {
...
let _ = (l_idx, r_idx);
l.current_slice().cmp(r.current_slice())
}Interestingly, the comment above it says the compiler’s own inlining choice wins here:
// datafusion/physical-plan/src/sorts/cursor.rs:279-281
// No inline hint on purpose: for the heavyweight `Rows` byte comparison the
// compiler's own choice wins — both `#[inline]` and `#[inline(never)]`
// measurably regress the multi-column merge path.
2. Range partitioning
The range-partition router needs to answer “which of the N split points does this row fall after?” for an arbitrary composite key. The trick is to encode the split points into the row format once, at construction time, and then binary-search the encoded rows:
// datafusion/physical-plan/src/repartition/range.rs:543-555
let split_point_rows = if split_points.is_empty() {
None
} else {
let split_point_arrays = (0..num_cols)
.map(|col_idx| {
let col_scalars =
split_points.iter().map(|sp| sp.values()[col_idx].clone());
ScalarValue::iter_to_array(col_scalars)
})
.collect::<Result<Vec<_>>>()?;
Some(row_converter.convert_columns(&split_point_arrays)?)
};// datafusion/physical-plan/src/repartition/range.rs:583-596
#[inline]
fn partition_point_rows(sp_rows: &Rows, row: &Row<'_>) -> usize {
let mut left = 0;
let mut right = sp_rows.num_rows();
while left < right {
let mid = left + (right - left) / 2;
if sp_rows.row(mid) <= *row {
left = mid + 1;
} else {
right = mid;
}
}
left
}A composite-key range lookup is now O(log N) memcmps. This is a genuine
reuse: the sort path and the shuffle path share one encoding, so a query
that both sorts and range-shuffles does not pay two different serialization
costs.
3. The TopK heap
TopK keeps a bounded heap of the best k rows. The heap’s comparison key
is the row-format bytes, so the heap is a BinaryHeap<Row> and the eviction
test is a byte compare:
// datafusion/physical-plan/src/topk/mod.rs:515-535
fn find_new_topk_items(
&mut self,
items: impl Iterator<Item = usize>,
batch_entry: &mut RecordBatchEntry,
) -> usize {
let mut replacements = 0;
let rows = &mut self.scratch_rows;
for (index, row) in items.zip(rows.iter()) {
match self.heap.max() {
// heap has k items, and the new row is greater (by byte order,
// after row conversion) than the top K ==> it is not a new topk
Some(max_row) if row.as_ref() >= max_row.row() => {}
// don't yet have k items or new item is lower than the currently k low values
None | Some(_) => {
self.heap.add(batch_entry, row, index);
replacements += 1;
}
}
}
replacements
}TopK also keeps a second converter for the common sort prefix between
the input ordering and the TopK ordering, so it can early-exit the whole
operator when the input has moved past the heap boundary
(datafusion/physical-plan/src/topk/mod.rs:393-398 and :708-784). That
early exit is only expressible because “past the boundary” is a byte
comparison on a prefix row.
4. The generic GROUP BY fallback
GroupValuesRows is the fallback used for nested or unsupported group key
types. It interns keys by hashing the columnar input (so the hash is
computed on the cheap typed representation) and then uses the row format
only for the equality check:
// datafusion/physical-plan/src/aggregates/group_values/row.rs:152-163
for (row, &target_hash) in batch_hashes.iter().enumerate() {
let entry = self.map.find_mut(target_hash, |(exist_hash, group_idx)| {
// Somewhat surprisingly, this closure can be called even if the
// hash doesn't match, so check the hash first with an integer
// comparison first avoid the more expensive comparison with
// group value. https://github.com/apache/datafusion/pull/11718
target_hash == *exist_hash
// verify that the group that we are inserting with hash is
// actually the same key value as the group in
// existing_idx (aka group_values @ row)
&& group_rows.row(row) == group_values.row(*group_idx)
});So the hash is over typed columns, and only the tie-break on a hash
collision is a memcmp of two row buffers. That is the right split: hashing
is O(key width) per row either way, but the equality check only runs on
collisions, which are rare.
Why not just use a struct of arrays everywhere?
Because the row format has real costs.
Space. Every row pays a per-field sentinel byte, plus big-endian
padding for integers, plus the block padding for strings. A 3-column key of
(Utf8, Int32, Utf8) costs roughly 1 + len(country) + 1 + 4 + 1 + len(city) bytes plus block padding, versus the “natural” 4 + 4 + 8 for a
tight columnar layout. For a narrow key it is a wash; for a wide string key
it can be 2x.
Encode cost. You must serialize every row before you can compare any of
them. If the query only needs the first column for comparison (a common
case: ORDER BY a with b as a tiebreaker that rarely matters), the row
format does strictly more work than a single-column compare. This is why
DataFusion keeps a specialized single-column path:
// datafusion/physical-plan/src/sorts/streaming_merge.rs:279-292
// Special case single column comparisons with optimized cursor implementations
if expressions.len() == 1 {
let sort = expressions[0].clone();
let data_type = sort.expr.data_type(schema.as_ref())?;
downcast_primitive! {
data_type => (primitive_merge_helper, ...),
DataType::Utf8 => merge_helper!(StringArray, ...),
...
}
}Lossy for some types. The row format hydrates dictionaries and
run-end-encoded arrays to their plain value type during encoding
(arrow-row-60.0.0/src/lib.rs:305-312). DataFusion has to re-encode the
group keys on the way out to restore the declared schema type — see
encode_array_if_necessary in
datafusion/physical-plan/src/aggregates/group_values/row.rs:329-464, which
walks nested list/map/struct types and re-applies dictionary encoding to
each child. That is a nontrivial amount of code that exists purely to undo
a lossy step in the encoding.
Type-stability. The format is explicitly documented as unstable:
// arrow-row-60.0.0/src/lib.rs:187
/// *Note: The encoding of the row format may change from release to release.*
Rows produced by one RowConverter can only be compared with rows produced
by the same converter (arrow-row-60.0.0/src/lib.rs:1041-1045 asserts this
with Arc::ptr_eq). DataFusion therefore has to plumb a single converter
through the whole operator lifetime and never let two of them meet.
Failure modes to watch for
- Comparing rows from different converters. The
Arc::ptr_eqassertion inRowConverter::appendcatches the common case, but aRowthat outlives its converter’s fields is a use-after-free hazard in principle. DataFusion sidesteps this by keeping the converter alive in the same struct as the rows. - Float
totalOrdervs SQL equality. The row format says-0.0and+0.0are different; SQL says they are equal. Any use of the row format for equality must normalize first, exactly asGroupValuesRows::interndoes. - Null ordering drift.
SortField::new_with_optionscarriesnulls_first, and the encoding flips the null sentinel accordingly. Getting the direction wrong silently reorders nulls. DataFusion’sbuild_sort_fieldsthreads thePhysicalSortExpr’s options straight through, which is the right thing to do but leaves no room for a mistake. - Memory accounting.
Rowsholds aVec<u8>that grows to the high water mark of the batch. DataFusion’sReusableRowsindatafusion/physical-plan/src/sorts/stream.rs:97-162keeps one buffer per partition alive and re-reserves it against the memory pool, precisely because the retained capacity is what the pool is actually charged for.
The takeaway
The row format is a serialization format with a comparison contract. By
making the encoding order-preserving and self-deliminating at the same
time, Arrow gets a key type whose comparison is memcmp — no type
dispatch, no null branches, no per-column loop. DataFusion leans on this
everywhere a composite key is compared repeatedly: merges, range routing,
bounded heaps, and the generic grouping fallback. The price is encode cost,
space, and a documented-unstable wire format — which is why the single
column fast path still exists, and why the dictionary flattening in the
encoder has to be undone by hand on the way out.