Release 4.11.0
Changes from 4.10.1 to 4.11.0
Nullability in CTable is rebuilt on Arrow's own model. A nullable column now
keeps its nulls in a per-column validity sidecar instead of reserving a value
from its own range, which is what makes it lossless — an int8 column can hold
-128, a utf8 one can hold "", and a float64 one can tell NaN from
missing. That is the new default for columns created from now on; nothing on
disk changes, and sentinel storage remains supported indefinitely, one keyword
away. Built on top of it: predicates follow three-valued (Kleene) logic, so
~(t.price > 10) no longer returns the null rows, and column indexes are
null-aware, which makes min/max over a nullable column answer from the
index instead of scanning.
On the packaging side, wheels are now a single Stable ABI (abi3) build per
platform covering CPython 3.11+, with free-threaded 3.14 and 3.15 shipping
alongside.
New features
Mask-based nullable columns for CTable, and they are now the default
- A nullable column keeps its nulls in a per-column validity sidecar —
Arrow's own model — instead of reserving a value from its own range. This is
what a barenullable=Trueresolves to, and what every nullable column
inferred from Arrow, Parquet or CSV gets:blosc2.bool(nullable=True)no
longer reserves255and keepsnp.bool_,blosc2.int8(nullable=True)has
all 256 values usable,blosc2.utf8(nullable=True)accepts any string
including""and"\x00", andblosc2.complex128(nullable=True)is
nullable at all for the first time. - This is what makes nullability lossless. A sentinel steals a value from
the dtype, so a nullableint8could not hold-128, a free-textutf8
column had no safe sentinel at all, and Arrow columns whose type had no value
to spare could not be imported.to_arrow(from_arrow(x))now returnsxfor
nullablebool, full-rangeint8/uint8,float64containing
nan/±inf/-0.0as values,utf8containing""and
"__BLOSC2_NULL__", andtimestampwithint64.minas a value — none of
which round-trip through a sentinel. Noneis how you write a null under mask storage (t.append((None,)),
t["price"][3] = None), which a fixed-width sentinel column cannot accept at
all.is_null()is unchanged and remains the uniform API across every kind.- Nothing on disk changes. The new default governs creation only: opening
a stored table never re-resolves anything, so every existing table keeps the
storage, dtype and sentinel it was written with, and every rewrite rule for
the reserved255stays permanently in place. - Sentinel storage is supported indefinitely and is one keyword away, per
column (null_storage="sentinel", or any explicitnull_value=) or globally
throughNullPolicy. Setting a type-wideNullPolicysentinel field still
implies sentinel storage for the kinds it covers, so existing
NullPolicy(float_value=...)code is unaffected — with one unavoidable
exception:255is the only value a nullable bool may reserve, so it is also
bool_value's default, andNullPolicy(bool_value=255)carries no
information to act on. A bool column that wants a sentinel has to say so with
null_storageorcolumn_null_values. - A table containing a mask column records schema version 3. Only such
tables do: a table with no nullable column still records version 1, exactly
as before, and a sentinel one does too. Readers older than 4.11.0 refuse a
version-3 table rather than misreading it, but their message is a bare
ValueError: Unsupported schema version 3— the hint naming
convert_nulls(to='sentinel')ships in 4.11.0, so only readers that can
already open the file will print it. - If some of your data has to stay readable by an earlier release, pin the
storage rather than discovering this downstream. Per column as above, or
process-wide — including for schemas inferred from Arrow, Parquet and CSV —
withblosc2.null_policy(blosc2.NullPolicy(null_storage="sentinel")). That
reinstates the sentinel's lossiness (a float column's nulls becomeNaN
again, and a type with no value to spare still cannot be imported), which is
the trade being made. Column.null_storagereports where a column keeps its nulls andinfo
tags each column (int64 nullable[mask]), soCTable.convert_nulls()can
move columns between the two in either direction — never implicitly, and
refusing rather than silently relabelling data when a sentinel is
unavailable.- One deliberate semantic difference: in a mask column
NaNis a value,
following Arrow, and only the sidecar marks a null. Sentinel float columns
keep NaN-as-null. See "Where nulls are stored" in the CTable reference.
Column indexes are null-aware
- Per-segment
min/maxare taken over the rows that carry a value. A
column's nulls are read from its validity channel and left out, and a segment
with no value at all is flagged rather than summarised. This applies to both
storages — anINT64_MINsentinel is exactly as invisible to a summary as a
mask column's fill. Column.min/Column.maxanswer from the index for a nullable column
(236x on a 20M-rowint64, measured), where before every nullable column
but a NaN-sentinel float had to scan.where()with anORover a nullable indexed column uses the index
instead of falling back to a full scan (1.6x on a 20M-row two-column
probe). The fallback existed because the only null filtering available was
global, and a global filter drops a row that is null in one branch but
matches the other; the segment path never needed it, because it evaluates
the predicate, which has been null-aware per leaf since the string-predicate
fix below.- Indexes written by an earlier release are read as not null-aware and keep
the old fallback, so nothing silently changes meaning;rebuild_index()
promotes them. - Building an index over a nullable column that holds nulls costs one
decompression pass (33 ms for a 20M-rowint64column), because the
incremental per-block summaries folded during writes carry no validity; a
nullable column with no nulls keeps that fast path untouched.
Predicates over nulls follow three-valued (Kleene) logic
- A comparison against a null is now unknown, the third value SQL and Arrow
both use, and&,|,^and~combine it by Kleene's rules instead of
collapsing it toFalseat the leaf.t.where(t.price > 10)gives the rows
definitely above 10,t.where(~(t.price > 10))the rows definitely not
above 10 — nulls in neither. where()keeps what a predicate is true for, so the rows it returns for
a plain comparison are unchanged. What this fixes is everything built on top
of one:~(t.price > 10)used to invert a null that had already been
collapsed toFalseand so returned every null row — the exact opposite of
the intent — and~((a > 10) & (b == 999))dropped rows that qualify,
becauseunknown & falseis false, not unknown, and only a real third
value can express that. Both query forms are covered and both now agree with
SQL: the string form carries the second channel through an AST rewrite under
negation.- A predicate can be asked about its unknown rows rather than only filtered
with:p.is_null()gives the rows it cannot answer for,p.null_count()
counts them, andt.where(p.fillna(True))keeps what cannot be ruled out.
fillna(False)is the other reading, and is whatwhere()applies
implicitly. - Predicates over non-nullable columns are untouched and cost nothing
extra; the result of a nullable comparison is still ablosc2.LazyExpr, so
it computes, indexes and plans exactly as before. Measured cost of the exact
answer: a negated two-column conjunction over a 20M-row nullable table runs
1.15x slower than the wrong answer it replaces; every other predicate shape
is unchanged. - Two consequences worth knowing.
t.where(dict_col != "x")no longer
returns the rows wheredict_colis null (its reserved code differs from
every value's, so it used to match);dict_col == Noneremains how to ask
for them. AndColumn.isin()stays deliberately two-valued — it returns a
materialized array and has its own spelling for nulls (Noneamong the
values).
Packaging and other changes
- Wheels are a single Stable ABI (abi3) build per platform. Instead of one
wheel per CPython minor version, each platform ships onecp311-abi3wheel
that serves CPython 3.11 and every later version, including ones released
after this one. Nothing changes forpip install blosc2; what changes is
that a new CPython no longer has to wait for a blosc2 release to be
installable from a wheel. CI installs that one wheel on 3.11 through 3.15 and
runs a slice of the suite on each, since cibuildwheel only tests a wheel on
the interpreter that built it. - Free-threaded builds are shipped too, as version-specific
cp314tand
cp315twheels — the free-threaded stable ABI (abi3t, PEP 803) starts at
3.15 and Cython cannot emit it yet. The limited API costs nothing measurable
here: across the Linux and Windows cells of a throwaway benchmark matrix the
worst ratio over every benchmark was 1.075x and 1.054x respectively,
including the call-heavy ones where an ABI cost would show up first. - CSV reads and writes take an
encoding=.from_csv()defaults to
utf-8-sig(plain UTF-8, absorbing a byte-order mark if present) and
to_csv()toutf-8, but either can be given another codec, which is what
an existing file written in a platform codec needs. - Three new optimization tips:
utf8versus fixed-width string columns, when adictionarycolumn pays
off, and what the@jitdecorator actually buys you (with a worked
Mandelbrot benchmark, including the compilation cost).
Bug fixes
asarray()corrupted arrays whose chunks overhang the shape. Above 16 MB
the fill goes chunk by chunk throughSChunk.update_data(), and its guard
only rejected partitions smaller than their container — so a chunk sticking
out of the shape (e.g. shape(146, 23802)with chunks(147, 23802)) passed
and got a slice one row short, after which reading the array back failed with
"Error while getting the buffer". Thanks to @Zentrik.group_byreturned the wrongminfor aboolvalue column. The
per-group accumulator was seeded from the dtype's opposite identity, andbool
had none, so an all-Truegroup reduced toFalse. Reachable with any plain
non-nullable bool column on the generic aggregation path.- Descending
sort_byon aboolcolumn raised, and on a signed-integer
column holding its dtype's minimum (-128forint8) that row sorted as if it
were the largest. The descending key negated in the column's own dtype, where
boolhas no unary minus and a narrow signed type wraps. add_column()aftercopy()backfilled one row short, and raised for a
variable-length column: the copy recorded its write watermark one below the
convention every other writer follows.- A string predicate over a nullable column returned its nulls as matches.
t.where("a > 10")compared the stored sentinel, so any sentinel satisfying
the predicate (null_value=999against> 10) came back as a match. The
operator form (t.where(t.a > 10)) was always correct. Fixed for both storages. - A nullable
uint8ndarray column came back asbool. Thebool → uint8
widening that sentinel storage needs was undone by dtype rather than by
whether it had been applied, so a column declareduint8was truncated to
flags. ~on a nullable bool column selected its nulls. SQLWHEREsemantics
say a null satisfies neither a predicate nor its negation; the mask path
inverted the storedFalsefill instead. (The sentinel path was already
correct, via its== 0rewrite.)- CSV import and export ignored a validity sidecar.
to_csvcompared
against the sentinel to find nulls, so a mask column wrote its fill as if it
were data, andfrom_csvhad nothing to put in an empty field and raised.
Both go through the sidecar now: an empty CSV field is a null in either
direction. Sentinel columns keep writing their sentinel, unchanged. - Reductions on a sorted view of a mask column read the wrong rows. The
null flags were gathered in the view's order and the values in physical
order, sosum()on a sorted view could returnNaNfrom a column whose
nulls are notNaN, andunique()could report the fill as data while
dropping a real value. Sentinel columns were unaffected. convert_nulls()flattened a nested column, even when it had nothing to
convert: the schema copy it makes dropped both the table metadata and the
logical parent of a nested group, so a struct column came back as its leaves.
An in-place conversion on a persistent table wrote that flattened schema to
disk. Saving and reopening a nested table dropped the same parent, which is
fixed alongside it.- Descending
sort_bymis-ordered the widest integers. The key was built
by negating, and negation has a fixed point:int64's minimum sorted as if
it were the largest, and auint64above 2**63 wrapped negative and sorted
below small values. - CSV was written and read in the platform's locale encoding, so text a
column can hold but cp1252 cannot encode — anything outside Latin-1 — raised
UnicodeEncodeErroron Windows. Both directions are UTF-8 now, and reading
absorbs a byte-order mark if one is present. - A timestamp column could not be written through
col[key] = value. A
datetimewas never encoded to the storedint64, so every key form failed;
extend()was unaffected. ISO strings anddatetime64are accepted too. - Assigning one value to many rows raised on a mask-storage column.
col[0:2] = 7— and the same write through a boolean mask or an index list —
failed withTypeError: iteration over a 0-d array, because the write path
looked for nulls inside a value that was a single cell rather than a batch.
Scalar broadcast works again, andcol[0:2] = Nonenow makes every selected
row null. extend()from another table lost nulls between storages. Copying rows
from a mask-backed column into a sentinel-backed one (or the reverse, or
between two sentinels reserving different values) wrote whatever stood in for
the null as real data. Nullity is translated now.- A row read showed a mask column's fill instead of
None.t[i],
iteration andreprsurfaced the placeholder that occupies a null slot, which
is not part of the format contract — and disagreed with avlstringcolumn in
the same row, which already readNone. Sentinel columns still show their
sentinel, which is the value you chose. to_numpy(masked=True)anddropna()raised on a nullable dictionary
column, which reported its nulls per physical slot rather than per live row.- A null in a complex column reached pandas as
nan+0jrather than as
missing. - A nullable
ndarrayofboolforgot it had been widened when reopened, so
converting it to mask storage left ituint8, and the guard against a
dtype-changing in-place conversion on a persistent table stopped firing. convert_nulls(to="sentinel")refused over a value in a deleted row. The
collision check scanned physical slots, so a proposed sentinel present only in
a row already deleted — unreadable, and dropped by the nextcompact()—
blocked the conversion. Only live rows are consulted now, and the row named in
the refusal is the logical one the caller can index rather than a physical
slot.