colstore Viewing & Editing Features

colstore exposes two surfaces for working with a store: the reader (and the dataset, for many files at once) for viewing data, and the frame for filtering and editing it into a new file. Both share one col() expression language. This document is a reference to their features.


Opening stores

open, concat, and append accept shell-style glob patterns for file paths:

ds = colstore.open("runs/*.cstore")        # all matching files, naturally sorted

Column names are always exact — column selection never takes a wildcard.


Viewing data

A reader or dataset gives lazy views over the file. Indexing returns a view, never a copy; nothing is read until you materialize a result.

Filtering rows with query() and col()

query() takes a predicate string and returns a lazy view:

hot = ds.query("energy > 100 and -2.5 < eta < 2.5")
ds.query("pt > @cut and region == 'SR'", params={"cut": 30})   # parameters via @name

col() builds the same predicates as composable expressions — arithmetic, comparisons, .isin(), combined with & | ~ — and stays lazy until a terminal:

ds[(col("pt") > 30) & (col("region") == "SR")]
ds.where(col("pt").isin([30, 40, 50]))        # where() is the explicit verb
ds[col("pt") > 30, ["pt", "eta"]]             # filter rows and project columns at once

evaluate() resolves a predicate to a concrete view.

Projecting columns with select() / drop()

ds.select("pt", "eta")                         # keep these columns
ds.drop("weight")                              # all columns except this one
ds.query("pt > 30").select("eta")             # filter rows, then project

Previewing with head() / tail()

head() and tail() return a small sample that renders as a table in a notebook and in the terminal; config.set_preview_rows(n) sets how many rows. A still-lazy query() view previews the matching rows without materializing the whole selection.


Editing data

ds.edit() (or view.edit()) returns a ColStoreFrame: a lazy specification of a new file. A pipeline reads top to bottom:

cf = ds.edit()
cf = cf.assign(ratio=cf["close"] / cf["open"])     # add a derived column
cf = cf.where(col("ratio") > 1.0, "gains")         # filter rows (cuts compose)
cf = cf.astype({"open": "float32"}).drop("noise")  # cast and project
cf = cf.select("close", "open", "ratio")

In place, or a new frame

The most common pitfall. Every frame methodassign, where / filter, select, drop, astype — returns a new frame and leaves the original unchanged, so its result must be captured (or pass inplace=True). Item assignment, cf["name"] = ..., is the exception: it always edits the frame in place.

cf.where(col("pt") > 30)                 # NO EFFECT — the returned frame is discarded
cf = cf.where(col("pt") > 30)            # correct: capture the result
cf.where(col("pt") > 30, inplace=True)   # or edit this frame in place
cf["pt2"] = cf["pt"] ** 2                # item assignment is always in place

Building columns

Refer to a column with cf["close"] and combine it with operators, comparisons, and NumPy functions (np.log, np.where, np.clip, ufuncs). Attach the result either way — with assign(...), which returns a new frame, or with cf["name"] = ..., which sets it in place; the value is the same:

cf = cf.assign(ratio=cf["close"] / cf["open"])      # returns a new frame
cf["ratio"] = cf["close"] / cf["open"]              # same value, set in place
cf = cf.assign(logpt=np.log(cf["pt"]),
               tag=np.where(cf["pt"] > 30, 1, 0))   # several columns at once

col("close") names a column the same way and is interchangeable with cf["close"] in operator, comparison, and NumPy-function expressions (np.log, np.where, np.clip, …) — col() is the usual form inside where() filters. Build each expression in one style: col() and cf[...] are not combined in a single expression.

astype(...) casts columns: cf.astype({"open": "float32"}).

Custom column functions with apply()

When a column is awkward to write as a NumPy expression, apply() runs your function on the named columns:

cf["r"] = cf.apply(lambda u, v: np.hypot(u, v), "b", "c")

The function receives each column as an array and returns a 1-D array of the same length (an element-wise computation). The result dtype is detected automatically; pass out_dtype= when the function cannot run on empty input or its dtype depends on the data.

Filtering and projecting

where() (alias filter()) takes a col() predicate or a query string and cuts rows; select() / drop() choose columns. Successive cuts compose. cf.n_rows is the number of selected rows.


Getting data out

Both surfaces share the same terminals, which materialize the selected rows:

ds.dict()              # {name: 1-D ndarray}
ds.recarray()          # one structured ndarray
ds.array("price")      # one column as a 1-D ndarray
ds.frame()             # a pandas DataFrame
ds.arrow()             # a pyarrow Table (zero-copy)
ds.to_root("out.root") # a ROOT file

On a frame they materialize the edited result the same way (cf.dict(), cf.recarray(), cf.array("ratio"), cf.frame()).

Reads without copying: copy=False

dict, array, and frame accept copy=False to return read-only arrays backed directly by the file, with no copy, when the layout allows it; otherwise the call raises rather than copying silently. recarray always repacks.

d = ds.dict(copy=False)            # read-only arrays over the file
total = d["energy"].sum()
ds[100:200, ["price", "qty"]].frame(copy=False)

NumPy and pandas

np.asarray / np.array materialize any reader, dataset, or view: a single column becomes a 1-D array; several columns, or a whole store, become a structured record array (one field per column, result[name] is the column).

np.asarray(ds["price"])              # 1-D ndarray
np.asarray(ds)                       # structured record array, one field per column
np.asarray(ds[:, ["price", "qty"]])  # likewise, for the selection

For a pandas DataFrame, call ds.frame() — it builds the columns directly and takes copy=False. pd.DataFrame(ds.dict()) is equivalent; passing a reader itself to pd.DataFrame(ds) does not expand it into columns, so prefer ds.frame().

Interop with external formats

colstore.interop exchanges colstore data with Apache Arrow (zero-copy), NumPy .npz, and ROOT through ds.to("arrow") / ds.arrow(), ds.saveas(path), and colstore.ingest(path, dest). See Interop for the full registry, the export/import verbs, and the ROOT kernels.


Streaming large results

When a result is too large to hold at once, stream it a batch at a time.

for batch in cf.iter_batches("256 MiB"):   # each batch is a frame; pick any format
    process(batch.recarray())

cf.write("out.cstore")                      # stream the edited frame to a new file

iter_batches(batch_size) yields each batch as a materialized frame, so you choose its output format (dict / recarray / write, or further edits). batch_size is a row count, an IEC string such as "256 MiB", or None for the default. copy=False makes each batch a read-only view valid only until the next batch is drawn — for consumers that finish with a batch before moving on (accumulating a total, writing each batch out).


Cutflow reports

A where / filter cut can be named and weighted, and report() summarizes the cuts:

cf = (ds.edit()
        .where(col("pt") > 30, "trigger", weight="mc_weight")
        .where(col("njets") >= 2, "two jets"))
print(cf.report())     # per-cut entering / passing / efficiency, plus weighted counts

report() returns a CutflowReport that renders as text or HTML and exposes the rows via .records().


Reductions

Scalar reductions run a full pass over the (optionally filtered) frame:

cf.sum("ratio")    cf.mean("pt")    cf.min("eta")    cf.max("eta")    cf.count()

sum / mean take numeric columns; min / max also take datetimes; count() is the number of selected rows.