DataFrame builder#

db.table(...) starts a lazy query you build up with methods instead of writing a SQL string. Nothing runs until a terminal method like .collect().

from h5i_db import col

(db.table("trades", as_of="2026-07-01T00:00:00Z")
   .filter(col("symbol").is_in(["AAPL", "MSFT"]))
   .group_by("symbol")
   .agg(col("price").mean().alias("px"))
   .collect())

The builder is a compiler, not a second engine. Every verb lowers to SQL run through Database.sql(), so a built query sees the same session, the same table functions and the same version pins as the equivalent string. .sql() shows exactly what it produced:

SELECT "symbol", avg("price") AS "px"
FROM h5i('trades', '2026-07-01T00:00:00Z')
WHERE "symbol" IN ('AAPL', 'MSFT')
GROUP BY "symbol"

When to reach for it

For a query you write once, SQL is usually shorter and clearer. The builder pays off when queries are generated (a factor library sweeping windows and columns in a loop), where f-string SQL means quoting bugs, and when you want a partially-built pipeline you can reuse and extend. Neither surface is second-class; .sql() is the door between them.

Reading a table#

Database.table#

table(name, version=None, as_of=None, snapshot=None) -> LazyFrame

Start a query. Pass at most one read point; passing two raises InvalidInputError.

Call Reads
db.table("trades") The bare table name, snapshot-bound for the query, so two references to it inside one query always agree
db.table("trades", version=42) h5i('trades', 42)
db.table("trades", as_of="2026-07-01T00:00:00Z") h5i('trades', '…'), the latest version committed at or before that instant
db.table("trades", snapshot="eod-2026-07-18") h5i('trades', 'eod-…')

Because pins lower to h5i(), a pinned builder query is bound at the source exactly like hand-written SQL, including under a research-mode pin.

Verbs#

Every verb returns a new frame, so a partial pipeline is safe to reuse as a base for several queries.

Verb Does
.filter(*preds) Keep matching rows; several predicates are ANDed
.select(*exprs, **named) Replace the projection
.with_columns(*exprs, replace=None, **named) Add columns, keeping the rest
.group_by(*keys).agg(...) Aggregate; keys are projected alongside
.group_by(*keys).count() Rows per group
.sort(by, descending=False) Order the result
.limit(n, offset=0) / .head(n) Take rows (still lazy)
.unique() SELECT DISTINCT
.join(other, on=…, how=…) Join two frames
.join_asof(other, on=…, by=…) ASOF join via asof_join
.pipe(fn, *args) fn(frame, *args), for reusable helpers

Strings are column names wherever an expression is accepted, so .group_by("symbol") and .group_by(col("symbol")) are the same thing. Keyword arguments name the result: with_columns(ret=col("close") - 1).

with_columns adds. Naming a column that already exists is an error, because SELECT * would then carry two of it. Say so explicitly to overwrite one:

db.table("trades").with_columns(price=col("price") * 2, replace="price")
SELECT * EXCEPT ("price"), "price" * 2 AS "price"
FROM "trades"

The builder never reads the schema, which is what keeps it lazy, so a replace name that does not exist is caught by the engine rather than at build time.

Terminal methods#

Method Returns
.collect(memory_limit=, timeout=, max_rows=) QueryResult
.to_arrow() / .to_pandas() / .to_polars() The frame, converted
.sql() The generated SQL, as a string
.explain(analyze=False) EXPLAIN / EXPLAIN ANALYZE of it
.schema() Result schema via a LIMIT 0 run; no data read

.collect() takes the same guardrails as db.sql(): max_rows raises LimitError as soon as the result exceeds it, and timeout raises TimeoutError.

Expressions#

col(name) references a column, lit(value) a constant. The name is one identifier and is never split on ., so a column really named a.b works; pass col("price", relation="l") to qualify a side of a join.

from h5i_db import col, lit, when

col("price") * col("size")              # arithmetic
(col("price") > 100) & (col("size") < 5)   # & | ~ , not and/or/not
col("symbol").is_in(["AAPL", "MSFT"])
col("px").is_null()
col("ts").between(t0, t1)
col("symbol").like("A%")
col("size").cast("DOUBLE")
when(col("price") > 100).then(lit(1)).otherwise(lit(0))

Use &, |, ~

Python cannot overload and / or / not, so an Expr raises TypeError if used as a truth value. Mind the precedence too: & binds tighter than >, so the comparisons need parentheses.

Operators mean what SQL means

Expressions compile to SQL and keep SQL's semantics, not Python's. Most visibly, / between two integer columns is integer division, so col("size") / 4 truncates. Cast for true division: col("size").cast("DOUBLE") / 4. The rule is deliberate: the same expression must not mean one thing here and another in db.sql().

Identifiers and literals are quoted at a single site, and identifiers are always quoted so case survives (col("Symbol") finds a field named Symbol, which bare SQL would fold to lowercase). A value containing '; DROP TABLE trades; -- is a string, never syntax.

Aggregates are methods: .sum(), .mean(), .min(), .max(), .count(), .n_unique(), .std(), .var(), .median(), .quantile(q), .first(order_by=), .last(order_by=). So are scalars: .abs(), .log(), .log10(), .exp(), .sqrt(), .sign(), .round(n), .floor(), .ceil(), .coalesce(...), .greatest(...), .least(...). Plus count_star(), vwap(price, size), wavg(weight, value) and time_bucket(interval, ts) as functions.

A when(...).then(...) chain is already a complete expression (a CASE with no ELSE yields NULL), so it can be aliased without .otherwise().

The idiomatic OHLCV query, built:

from h5i_db import col, time_bucket, vwap

(db.table("trades")
   .group_by(time_bucket("5m", col("ts")).alias("bar"), "symbol")
   .agg(col("price").first("ts").alias("open"),
        col("price").max().alias("high"),
        col("price").min().alias("low"),
        col("price").last("ts").alias("close"),
        col("size").sum().alias("volume"),
        vwap(col("price"), col("size")).alias("vwap"))
   .sort("bar"))
SELECT time_bucket('5m', "ts") AS "bar", "symbol",
       first_value("price" ORDER BY "ts") AS "open", max("price") AS "high",
       min("price") AS "low", last_value("price" ORDER BY "ts") AS "close",
       sum("size") AS "volume", vwap("price", "size") AS "vwap"
FROM "trades"
GROUP BY "bar", "symbol"
ORDER BY "bar"

Rolling and cross-sectional operators#

Rolling methods take a window and an order_by, and optionally a partition_by. The window is either a row count or a duration string ('30s', '5m', '1.5h', '1d', '1w', '1mo', '1y').

col("close").rolling_mean(20, order_by="ts", partition_by="symbol")
avg("close") OVER (PARTITION BY "symbol" ORDER BY "ts"
                   ROWS BETWEEN 19 PRECEDING AND CURRENT ROW)

Unlike the rolling_avg SQL sugar, these carry a PARTITION BY, so they do not mix symbols on a multi-symbol table.

Method Lowers to
.rolling_mean .rolling_sum .rolling_min .rolling_max avg sum min max
.rolling_std .rolling_var .rolling_count stddev var_samp count
.rolling_mad .rolling_skew .rolling_kurt mad skew kurt
.rolling_rank ts_rank, the percentile of the current value in the window
.rolling_idxmax .rolling_idxmin idxmax idxmin, 1-based position
.rolling_corr(other, …) .rolling_cov(other, …) ts_corr ts_cov
.ewma(alpha, order_by, partition_by) ewma

Cross-sectional methods rank a value against its peers at the same instant, so they take the bucket to compare within:

Method Lowers to
.cs_rank(partition_by) cs_rank(x) OVER (PARTITION BY …)
.cs_winsorize(lower, upper, partition_by) cs_winsorize(x, lo, hi) OVER (…)
.cs_demean(partition_by) x - avg(x) OVER (…), plain SQL
.cs_zscore(partition_by) (x - avg(x) OVER (…)) / stddev(x) OVER (…)

For a raw window frame, .over(partition_by=, order_by=, rows=, duration=) applies to a single aggregate, where rows is a trailing count or a (preceding, following) pair with None for unbounded. SQL attaches OVER to one function call, so window each part of a compound expression separately: col("a").sum().over(...) / count_star().over(...), not (col("a").sum() / count_star()).over(...), which is rejected.

Joins#

.join() renders both sides as subqueries aliased l and r. Those aliases are part of the contract: reach a specific side with col("price", relation="l"), or an arbitrary condition with predicate=sql_expr('l."a" > r."b"').

how= is inner (default), left, right, full, cross, semi or anti. Keys come from on= (same name both sides) or left_on=/right_on=.

Column names are not deduplicated

SELECT * over a join of two tables sharing a column name yields both copies. Project explicitly to avoid the ambiguity.

Comparing one table at two pinned versions, the "same query across N versions" pattern:

def mean_px(version):
    return (db.table("trades", version=version)
              .group_by("symbol")
              .agg(col("price").mean().alias("px")))

drift = (mean_px(1).join(mean_px(2), on="symbol")
         .select(col("symbol", relation="l").alias("symbol"),
                 (col("px", relation="r") - col("px", relation="l")).alias("drift")))
SELECT "l"."symbol" AS "symbol", "r"."px" - "l"."px" AS "drift"
FROM (
  SELECT "symbol", avg("price") AS "px"
  FROM h5i('trades', 1)
  GROUP BY "symbol"
) AS "l"
INNER JOIN (
  SELECT "symbol", avg("price") AS "px"
  FROM h5i('trades', 2)
  GROUP BY "symbol"
) AS "r"
  ON "l"."symbol" = "r"."symbol"

join_asof#

join_asof(other, on=None, by=None, direction="backward", tolerance=None,
          left_on=None, right_on=None) -> LazyFrame

For each left row, take the most recent right row at or before it ("backward") or the first at or after it ("forward"). The result is LEFT and 1:1 with the left side. tolerance is an integer in the time column's raw units; for a timestamp[us] column, 5000000 is five seconds.

db.table("trades").join_asof(db.table("quotes"), on="ts", by="symbol",
                             tolerance=5_000_000)
SELECT *
FROM asof_join('trades', 'quotes', 'ts', 'ts', 'symbol', 'backward', 5000000)

Both sides must be plain, unpinned tables

The asof_join table function takes table names and reads both at latest. So join_asof refuses a side that already has verbs applied, and refuses a pinned side outright rather than silently ignoring the pin. Filter after the join, or for a pinned ASOF use db.sql() with the ASOF JOIN keyword form over session-bound names.

How pipelines become SQL#

Most pipelines compile to one flat SELECT. A stage that reads a column an earlier stage computed gets its own level, because SQL resolves WHERE and SELECT against the FROM, not against sibling projections:

(db.table("bars")
   .with_columns(ret=col("close") / col("open") - 1)
   .filter(col("ret") > 0)          # reads a computed column -> subquery
   .sort("ret", descending=True)
   .limit(10))
SELECT *
FROM (
  SELECT *, "close" / "open" - 1 AS "ret"
  FROM "bars"
) AS "_s1"
WHERE "ret" > 0
ORDER BY "ret" DESC
LIMIT 10

Filtering a base column instead stays flat, and independent with_columns calls coalesce into one projection. Aggregation, LIMIT and DISTINCT always close a level, since whatever follows acts on their output. So does a projection holding a window function or aggregate: SQL evaluates WHERE before the select list, so filtering in the same level would recompute the window over only the surviving rows.

Because levels close, a stage can only see what the stage before it emitted. Filtering or sorting by a column an earlier .select() dropped is an error naming that column, as it would be for a DataFrame.

The generated SQL is deterministic, so it is safe to snapshot-test or diff.

Escape hatch#

sql_expr() embeds a raw fragment anywhere an expression is accepted. Full SQL coverage through builder verbs is deliberately not a goal, so reach for this rather than waiting for a method:

from h5i_db import sql_expr

(db.table("trades")
   .group_by("symbol")
   .agg(sql_expr("approx_percentile_cont(price, 0.99)").alias("p99")))

Its text is inserted verbatim, so it is the one place quoting is yours to get right; never build it from untrusted input. Because its column references are opaque to the builder, it conservatively forces a subquery when the current stage defines any computed name.

When a pipeline outgrows the builder, .sql() gives you the query to paste into db.sql() and keep going from there.