Storage and export¶
This page covers Arrow type mapping, null handling, the string arena, numeric/temporal parsing, dictionary encoding, unification, and the finish path. See Columnar storage for the storage types themselves.
Arrow types produced¶
| ColumnBuilder variant | Arrow DataType | Array type |
|---|---|---|
| String(StrColumn) | Utf8 | StringArray (OffsetBuffer + ScalarBuffer + NullBuffer) |
| Int64(PrimColumn |
Int64 | Int64Array (ScalarBuffer + NullBuffer) |
| Float64(PrimColumn |
Float64 | Float64Array |
| Boolean(PrimColumn |
Boolean | BooleanArray (BooleanBuffer) |
| Date32(PrimColumn |
Date32 | Date32Array |
| Timestamp(TimeUnit, Option<Box |
Timestamp(unit, None) | PrimitiveArray |
| Decimal128(u8, PrimColumn |
Decimal128(38, scale) | Decimal128Array |
| Dictionary | Dictionary(Int32, Utf8) | DictionaryArray |
arrow_datatype() maps each variant to its Arrow type. to_arrow_array()
moves internal buffers into the native array via std::mem::take (zero-copy
for StrColumn and Dictionary). PrimColumn<T> exports collect into a fresh
ScalarBuffer, and to_arrow_bool repacks into a BooleanBuffer, so
primitive and boolean exports copy.
Null handling¶
StrColumn¶
ValidityBitmap stores one bit per row. to_arrow builds NullBuffer
only if some validity is false; otherwise None (all valid). Offsets
advance by 0 for null entries so offsets[i] == offsets[i+1]. The null
string occupies zero bytes in the data arena.
PrimColumn¶
ValidityBitmap + Vec<T>. to_arrow() preserves nulls via NullBuffer.
The boolean specialization to_arrow_bool() packs Vec<bool> into
BooleanBuffer with one bit per value.
Missing columns¶
In engines_to_record_batches, columns present in some chunks but missing
in others become null_array(&types[name], e.row_count): a NullArray
of the unified type. This is a block fill, not per-cell.
Value::Null and unparseable strings¶
Both become None in the column builder. push_str returns (); the column
stores None for unparseable strings (e.g., "abc" into Int64 column).
Value::Null is explicitly null. Exception: with plan.strict_types set,
a non-null string that fails parses_as for the declared field_types type
is recorded as a strict violation and surfaced as an error by finish() (see
the finish path below) instead of becoming None.
String arena¶
StrColumn stores strings in a contiguous byte arena with offset indexing:
push¶
push(Some("hello")) appends 5 bytes to data, pushes data.len() (=5)
to offsets, pushes true to validity. push(None) appends nothing to
data, so the current offset is repeated, and pushes false to validity.
No per-cell allocation beyond arena growth.
pop¶
Pops validity, pops offsets, truncates data to offsets.last(). Used
for last-write-wins (duplicate field in same row) and row rejection.
append¶
Merges another column by base-shifting offsets: base = self.data.len().
Then extends self.offsets with other.offsets[1..].map(|o| o + base) and
copies other.data via extend_from_slice. O(n) in offsets plus O(n) in
bytes for the arena memcpy.
Capacity¶
with_capacity(cap) reserves cap+1 offsets and cap*16 data bytes.
Pipeline passes cap = (bytes.len() / est).max(64) where
est = estimate_bytes_per_row(sample).max(512).
Numeric and temporal parsing¶
When a Value::Str is pushed to a typed column, the string is parsed:
- Int64:
lexical::parse::<i64, _>(s.as_bytes()): fast, SIMD-optimized - Float64:
lexical::parse::<f64, _>(s.as_bytes()) - Boolean:
s.parse::<bool>(): accepts only "true"/"false"; other spellings become null (the wider "1"/"0"/"yes"/"no" set is used only by theis_type_attype-detection path) - Date32:
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")minus Unix epoch - Decimal128:
parse_decimal128(s, scale): sign, integer, and fraction parts are combined into a scaledi128; extra fraction digits beyondscaleare truncated - Timestamp: if the column declares
format=, the custom chrono format is tried first (asNaiveDateTime, thenNaiveDateat midnight). Then the built-ins:"%Y-%m-%dT%H:%M:%S%.f","%Y-%m-%d %H:%M:%S%.f", and bare date as midnight. Converts viaand_utc()to the target TimeUnit.
Unparseable strings become None. Cross-type conversions mostly succeed:
Value::Int64 into Float64 widens, into String stringifies, into
Dictionary encodes; Float64 into Int64 truncates; Bool converts into
String/Dictionary; Date32 and Timestamp convert into
Int64/Float64/String/Dictionary. Cases with no defined conversion
become None.
Dictionary¶
Dictionary {
codes: NullableColumn<i32>,
data: Vec<u8>,
offsets: Vec<i32>,
index: FxHashMap<Box<str>, i32>,
}
data: contiguous byte buffer for all dictionary values (likeStrColumn).offsets: byte offsets intodatafor each dictionary entry.index: hash lookup from value to code. Average O(1).dict_code: hash lookup inindex, insert if missing (append todata/offsets).extend_owned: remaps right dictionary into left via a remap vector. For each value in right's data, look up its code in left's index. If missing, append to left's data. Then translate right's codes.try_upgrade_to_dict: see Columnar.
Unification and promotion¶
When merging chunks with different storage types:
unify_variants("int64", "float64")→Some("float64")unify_variants("string", "dictionary")→Some("dictionary")unify_variants("int64", "string")→None(error)
promote_to_variant("float64") on an Int64 column: takes the Vec<i64>,
maps each element to f64, produces a new PrimColumn<f64>.
Used in merge::extend and engines_to_record_batches.
Finish path¶
TableBuilder::finish() does:
- Deferred errors: returns
Errifunknown_error(frozen-schema violation) orstrict_error(strict_typesviolation) was recorded during ingestion. - Observer hook: calls
observer.on_chunk_finished(accepted + rejected, accepted, rejected)when the plan declares an observer. - normalize: truncate any column with
len > row_count(partial row from truncated chunk), clearrow_dirty. Idempotent. - Early return with
RecordBatch::new_emptyifcolumn_orderis empty. - auto_dict_upgrade: if
plan.auto_dict, iterate columns and upgrade String builders with low cardinality to Dictionary. Threshold: 5% distinct ratio, max 256 entries, min 512 rows. The distinct cap is floored at 16 entries ((len * ratio).max(16)) so tiny columns can still upgrade. - Schema projection (when
schema_orderis non-empty): build fields and arrays directly fromschema_order. Columns declared but absent from the data are null-filled; non-schema columns are projected out.sort_columnsis not called on this branch. - sort_columns (only when
schema_orderis empty, where it no-ops): reordercolumn_orderbyschema_orderrank. Does not reordercolumnsorfield_index; those stay insertion-ordered. - Build fields + arrays: iterate
column_order, look up each builder viafield_index, callarrow_datatypeandto_arrow_array. - Schema::new(fields) + RecordBatch::try_new.
No borrowed bytes outlive finish. StrColumn owns its data and numeric
Vecs are owned. to_arrow_array moves string and dictionary buffers into
Arrow arrays via std::mem::take (zero-copy); primitive exports collect
into fresh buffers.
Compare filter reapplication¶
apply_compare_filter in arrow_export.rs re-applies pure Compare and
And trees using Arrow compute kernels. Other trees (Or, Not, Equal,
NotEqual) are returned unchanged because per-row evaluation is
authoritative.
This exists for callers that filter an already-built RecordBatch (e.g.,
after merging chunks). It is not the per-row filter. Per-row
FilterPredicate::check is authoritative; apply_compare_filter only
reapplies pure Compare/And via compare_columns (casts to Float64 or Utf8,
then gt/lt/gt_eq/lt_eq/eq/neq/and) and filter_record_batch.
Memory layout¶
For a 533 MB XML file with 480K rows and 10 columns:
- StrColumn (strings): ~480K offsets (1.9 MB) + ~200 MB data + ~60 KB validity
- PrimColumn
: ~3.8 MB data + ~60 KB validity - PrimColumn
: ~3.8 MB data + ~60 KB validity - Dictionary: ~500 entries × ~20 bytes = ~10 KB dict + ~1.9 MB codes
- Total per chunk (16 chunks): ~15 MB per chunk, ~240 MB total
- Arrow export: string and dictionary buffers move (zero-copy); primitive exports collect into fresh buffers
The arena-based StrColumn is the dominant memory consumer. For files with
many unique strings, the arena can grow to 2-3× the raw data size. For
files with low cardinality, dictionary encoding reduces this significantly.