List columns#
Some columns do not hold one value per row. A list column stores a variable-length sequence of values per row, including nested sequences, while keeping every byte in ordinary, chunked, filterable HDF5 datasets.
The offsets layout#
A list column is a group (a direct child of the table group) with
CLASS = "LIST_COLUMN" and KIND = "OFFSETS" attributes. Its storage follows the same
offsets encoding used by Arrow’s list arrays: all elements are stored
back-to-back in a values member, and a uint64 OFFSETS dataset marks the
boundaries:row i’s elements are VALUES[OFFSETS[i]:OFFSETS[i+1]], with
OFFSETS[0] = 0 and the offsets non-decreasing. A nullable list column adds
a MASK dataset that records which rows are null.
A nullable list-of-strings column, for example, is laid out as:
GROUP "tags" CLASS = "LIST_COLUMN"
│ KIND = "OFFSETS"
├── DATASET "OFFSETS" uint64 row boundaries into VALUES
├── DATASET "MASK" which rows are null lists
└── GROUP "VALUES" CLASS = "STRING_VALUES"
├── DATASET "OFFSETS" uint64 per-string boundaries into CHARS
├── DATASET "CHARS" the UTF-8 bytes, back to back
└── DATASET "MASK" which string elements are null
The values member takes one of three shapes, and this is where the layout becomes recursive:
a leaf dataset: a rank-1 dataset of any scalar column datatype (declared with
LeafValuesSpec);a
STRING_VALUESgroup: variable-length UTF-8 strings stored as an offsets-plus-bytes pair (StringValuesSpec), so even variable text lives in chunked, compressible datasets rather than on the HDF5 global heap;another list level (
NestedListSpec), giving lists of lists to any depth.
Variable-length HDF5 datatypes are deliberately forbidden anywhere below a list column; the offsets encoding exists precisely to avoid them.
Declaring and writing#
A list column is declared with ListColumnSpec alongside
ordinary columns:
from h5col import LeafValuesSpec, ListColumnSpec, StringValuesSpec, Table
columns = [
ListColumnSpec(name="depths", values=LeafValuesSpec(dtype="float32"), units="m"),
ListColumnSpec(name="tags", values=StringValuesSpec(nullable=True), nullable=True),
]
table = Table.create(f.create_group("profiles"), columns)
table.append(
{
"depths": [[0.5, 1.0, 2.0], [], [0.7]],
"tags": [["qc", "raw"], None, []],
}
)
Rows are Python sequences; None writes a null row (permitted because tags
is nullable). Reading returns row-wise Python lists, with None for null
rows and decoded strings for string values:
table["tags"].read()
[['qc', 'raw'], None, []]
For deeper nesting, wrap another level — this declares list<list<int32>>:
ListColumnSpec(
name="pairs",
values=NestedListSpec(values=LeafValuesSpec(dtype="int32"), nullable=True),
)
Null, empty, and missing are three different things#
The layout distinguishes states that formats without a mask conflate:
A null row (
None): the row has no value at all. Recorded in the top-levelMASK; only nullable list columns can hold one. This is also what new rows become when a nullable list column is omitted from an append — and why a non-nullable list column must be provided in every append.An empty row (
[]): the row’s value is a sequence with zero elements — two equal consecutive offsets. Any list column can hold one.A missing element inside a row: leaf values reuse the ordinary fill-value mechanism, and string values use the
STRING_VALUESgroup’s own element mask.
ListColumn.is_missing() returns the
null-row mask.
Storage control#
Each piece of the layout accepts its own chunks and filters: on the
ListColumnSpec they apply to the top-level OFFSETS
dataset, and on a LeafValuesSpec to the element dataset. On
a StringValuesSpec, filters applies to the CHARS byte
buffer — usually the one worth compressing — while chunks sets the chunk
size of both the group’s inner OFFSETS and CHARS. See
filters.
Limits worth knowing#
List columns are stored and appended like any other column, but they stand outside two features: they cannot serve as a table’s row-index column, and they cannot carry search indexes — predicates on list columns are not supported by the query layer. Where selection matters, keep the selective keys in scalar columns beside the list.