The DataFrame builder: queries as Python objects

db.table(...) starts a lazy query that you assemble with method calls instead of a SQL string. Nothing runs until a terminal call such as .collect().

It is a compiler, not a second engine. Every verb lowers to SQL that goes through db.sql(), so a built query sees the same session, the same table functions and the same version pins as the string you would have written by hand. .sql() shows you exactly what it produced.

The payoff for a research desk is generated queries. A factor library that sweeps windows and columns in a loop builds SQL with f-strings today, and that is where quoting bugs and ' injection live. Here the identifiers are quoted at one site, and a partially-built pipeline is an ordinary Python value you can pass around, extend and reuse.

import h5i_db
from h5i_db import col, count_star, lit, sql_expr, time_bucket, vwap, when

import cookbook_utils as cu

db = h5i_db.Database(cu.fresh_db("00_dataframe_builder"), create=True)

The data#

Two tables, because the builder's verbs split along the same line. Tick-level trades from cu.make_trades exercise bucketing and aggregation.

column type meaning
ts timestamp[us, tz=UTC] trade timestamp, ascending
symbol string ticker
price float64 trade price
size int64 shares traded
exchange string reporting venue
side string B buyer-initiated, S seller-initiated
trades = cu.make_trades(symbols=["AAPL", "MSFT", "NVDA"], days=3, trades_per_day=20_000)
print(f"trades: {trades.num_rows:,} rows x {trades.num_columns} columns")
trades.to_pandas().head()
output
trades: 195,277 rows x 6 columns
ts symbol price size exchange side
0 2026-06-01 13:30:00.111237+00:00 NVDA 319.22 1 NASDAQ S
1 2026-06-01 13:30:00.168881+00:00 NVDA 319.27 1 ARCA B
2 2026-06-01 13:30:00.204185+00:00 NVDA 319.28 1 IEX B
3 2026-06-01 13:30:00.329485+00:00 MSFT 363.06 1 NASDAQ B
4 2026-06-01 13:30:00.381450+00:00 NVDA 319.22 300 ARCA S

A daily OHLCV panel from cu.make_daily_prices, 50 names over 500 sessions, exercises the window and cross-sectional verbs: ts, symbol, open, high, low, close, volume.

prices = cu.make_daily_prices(days=500)  # 50 names x 500 sessions
print(f"prices: {prices.num_rows:,} rows x {prices.num_columns} columns")
prices.to_pandas().head()
output
prices: 25,000 rows x 7 columns
ts symbol open high low close volume
0 2023-01-02 20:00:00+00:00 STK000 25.80 25.88 25.73 25.86 451179
1 2023-01-02 20:00:00+00:00 STK001 240.29 241.67 240.06 240.88 208895
2 2023-01-02 20:00:00+00:00 STK002 243.93 244.20 242.34 243.66 388381
3 2023-01-02 20:00:00+00:00 STK003 197.96 198.65 196.13 198.47 776701
4 2023-01-02 20:00:00+00:00 STK004 77.51 78.01 77.39 77.51 348154
db.create_table("trades", trades.schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("trades", trades)

db.create_table("prices", prices.schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("prices", prices)

db.tables()
output
['prices', 'trades']

1. A frame is a query that has not run#

db.table("trades") is the whole table as a starting point. Verbs return new frames, so nothing is mutated and a partial pipeline is safe to reuse. .sql() renders it and .collect() runs it.

liquid = db.table("trades").filter(col("symbol").is_in(["AAPL", "NVDA"]))
print(liquid.sql())
output
SELECT *
FROM "trades"
WHERE "symbol" IN ('AAPL', 'NVDA')

Here is the idiomatic OHLCV rollup, built. group_by(...).agg(...) projects the keys alongside the aggregates. .first("ts") and .last("ts") are the first_value(x ORDER BY ts) idiom, which gives bar opens and closes without a self-join.

bars = (
    db.table("trades")
    .group_by(time_bucket("1m", 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", "symbol"])
)
print(bars.sql())
output
SELECT time_bucket('1m', "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", "symbol"
bars.to_pandas().head(6)
output
bar symbol open high low close volume vwap
0 2026-06-01 13:30:00+00:00 AAPL 265.05 265.23 265.01 265.17 10308 265.137426
1 2026-06-01 13:30:00+00:00 MSFT 363.06 363.17 362.76 363.12 9544 362.944815
2 2026-06-01 13:30:00+00:00 NVDA 319.22 319.43 318.97 319.29 12402 319.220094
3 2026-06-01 13:31:00+00:00 AAPL 265.16 265.28 265.13 265.22 11005 265.199838
4 2026-06-01 13:31:00+00:00 MSFT 363.11 363.11 362.73 362.75 10846 362.922534
5 2026-06-01 13:31:00+00:00 NVDA 319.38 319.38 318.78 319.31 10606 319.094032

2. Expressions#

col(name) is a column and lit(value) a constant. Arithmetic and comparisons build up from there. Two traps are worth meeting early:

signed = (
    db.table("trades")
    .filter((col("price") > 0) & (col("size") >= 100))
    .select(
        "ts",
        "symbol",
        "price",
        notional=col("price") * col("size"),
        lots=col("size").cast("DOUBLE") / 100,
        direction=when(col("side") == "B").then(lit(1)).otherwise(lit(-1)),
    )
)
print(signed.sql())
output
SELECT "ts", "symbol", "price", "price" * "size" AS "notional", CAST("size" AS DOUBLE) / 100 AS "lots", CASE WHEN "side" = 'B' THEN 1 ELSE -1 END AS "direction"
FROM "trades"
WHERE "price" > 0 AND "size" >= 100
signed.to_pandas().head(4)
output
ts symbol price notional lots direction
0 2026-06-01 19:43:31.725041+00:00 MSFT 371.50 37150.0 1.0 1
1 2026-06-01 19:43:31.806613+00:00 MSFT 371.51 37151.0 1.0 1
2 2026-06-01 19:43:31.906285+00:00 AAPL 267.47 26747.0 1.0 -1
3 2026-06-01 19:43:32.388267+00:00 AAPL 267.51 26751.0 1.0 1

Identifiers are always quoted, so case survives: col("Symbol") finds a field named Symbol, which bare SQL would fold to lowercase. And a string literal is always a string, never syntax:

print(db.table("trades").filter(col("symbol") == "'; DROP TABLE trades; --").sql())
output
SELECT *
FROM "trades"
WHERE "symbol" = '''; DROP TABLE trades; --'

3. How a pipeline becomes SQL#

Most pipelines compile to one flat SELECT. Independent with_columns calls coalesce, and filtering a base column stays in the same WHERE.

A stage that reads a column an earlier stage computed gets its own level, because SQL resolves WHERE against the FROM rather than against sibling entries in the select list. Aggregation, LIMIT and DISTINCT also close a level, since whatever follows them acts on their output.

movers = (
    db.table("prices")
    .with_columns(ret=col("close") / col("open") - 1)
    .filter(col("ret") > 0.01)  # reads a computed column -> subquery
    .sort("ret", descending=True)
    .limit(5)
)
print(movers.sql())
output
SELECT *
FROM (
  SELECT *, "close" / "open" - 1 AS "ret"
  FROM "prices"
) AS "_s1"
WHERE "ret" > 0.01
ORDER BY "ret" DESC
LIMIT 5
movers.to_pandas()
output
ts symbol open high low close volume ret
0 2023-07-06 20:00:00+00:00 STK003 192.62 195.55 192.03 195.05 514942 0.012616
1 2024-08-14 20:00:00+00:00 STK020 131.80 133.81 131.03 133.38 270545 0.011988
2 2023-01-09 20:00:00+00:00 STK016 30.51 30.94 30.42 30.85 480125 0.011144
3 2023-01-06 20:00:00+00:00 STK008 96.41 97.76 96.36 97.47 339522 0.010995
4 2023-07-04 20:00:00+00:00 STK043 342.13 347.28 341.83 345.81 292791 0.010756

Knowing where levels close is the one thing worth internalizing, because it decides what the next stage can see.

While the pipeline stays flat, a verb still reaches the base table. select("ts", "symbol").sort("close") resolves fine, because SQL's ORDER BY reads the FROM, not the select list. Once an aggregate closes the level, that column is genuinely gone and the engine says so:

try:
    db.table("prices").group_by("symbol").agg(count_star().alias("n")).sort("close").collect()
except h5i_db.H5iError as e:
    print(f"{type(e).__name__}: {str(e)[:180]}")
output
H5iError: [query] Error during planning: Column in ORDER BY must be in GROUP BY or an aggregate function: While expanding wildcard, column "prices.close" must appear in the GROUP BY clause o

4. The payoff: queries you generate#

This is where the builder earns its place. A sweep over several lookbacks is a Python loop over frames rather than string surgery, and each frame is a value you can hold, name and reuse. Below: the gap between price and its own trailing mean, at three windows.

Rolling methods take a window and an order_by, and optionally a partition_by. Unlike the rolling_avg SQL sugar, they carry a real PARTITION BY, so they do not mix symbols on a multi-symbol table.

WINDOWS = (5, 20, 60)

base = db.table("prices").filter(col("symbol").is_in(["STK000", "STK001", "STK002"]))

ma_gap = base.with_columns(
    **{
        f"gap_{n}d": col("close") / col("close").rolling_mean(n, order_by="ts", partition_by="symbol") - 1
        for n in WINDOWS
    }
)
print(ma_gap.sql())
output
SELECT *, "close" / avg("close") OVER (PARTITION BY "symbol" ORDER BY "ts" ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) - 1 AS "gap_5d", "close" / avg("close") OVER (PARTITION BY "symbol" ORDER BY "ts" ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) - 1 AS "gap_20d", "close" / avg("close") OVER (PARTITION BY "symbol" ORDER BY "ts" ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) - 1 AS "gap_60d"
FROM "prices"
WHERE "symbol" IN ('STK000', 'STK001', 'STK002')
ma_gap.select("ts", "symbol", *[f"gap_{n}d" for n in WINDOWS]).sort(["ts", "symbol"]).to_pandas().tail(6)
output
ts symbol gap_5d gap_20d gap_60d
1494 2024-11-28 20:00:00+00:00 STK000 0.000589 0.046048 0.064262
1495 2024-11-28 20:00:00+00:00 STK001 -0.002695 0.028779 0.034177
1496 2024-11-28 20:00:00+00:00 STK002 -0.019274 -0.018988 0.043117
1497 2024-11-29 20:00:00+00:00 STK000 0.014864 0.060963 0.083427
1498 2024-11-29 20:00:00+00:00 STK001 -0.017498 0.010656 0.018326
1499 2024-11-29 20:00:00+00:00 STK002 0.015216 0.017048 0.080730

Cross-sectional operators rank a value against its peers at the same instant, so they take the bucket to compare within. Combining a few z-scored signals into one composite is the whole shape of a factor build:

combo = (
    db.table("prices")
    .with_columns(
        z_ret=(col("close") / col("open") - 1).cs_zscore(partition_by="ts"),
        z_vol=col("volume").cast("DOUBLE").cs_zscore(partition_by="ts"),
    )
    .with_columns(score=(col("z_ret") - col("z_vol")) / 2)
    .select("ts", "symbol", "z_ret", "z_vol", "score")
    .sort(["ts", "score"], descending=[False, True])
)
combo.to_pandas().head(5)
output
ts symbol z_ret z_vol score
0 2023-01-02 20:00:00+00:00 STK034 1.935241 -0.691824 1.313533
1 2023-01-02 20:00:00+00:00 STK040 0.791453 -1.559526 1.175490
2 2023-01-02 20:00:00+00:00 STK021 0.728941 -1.457394 1.093167
3 2023-01-02 20:00:00+00:00 STK001 1.070606 -1.111451 1.091028
4 2023-01-02 20:00:00+00:00 STK005 0.683265 -1.265949 0.974607

5. Version pins and joins#

A read point passes straight to db.table() and lowers to h5i(), so a pinned builder query is bound at the source exactly like hand-written SQL.

.join() renders both sides as subqueries aliased l and r, and those aliases are the contract for reaching a specific side. Together they make the "same query across N versions" comparison a function call:

db.append("trades", cu.make_trades(symbols=["AAPL", "MSFT", "NVDA"], days=1, start="2026-06-04", seed=8))


def per_symbol(version=None):
    return db.table("trades", version=version).group_by("symbol").agg(
        count_star().alias("n"), col("ts").max().alias("last_ts")
    )


drift = per_symbol(1).join(per_symbol(), on="symbol").select(
    symbol=col("symbol", relation="l"),
    trades_added=col("n", relation="r") - col("n", relation="l"),
)
print(drift.sql())
output
SELECT "l"."symbol" AS "symbol", "r"."n" - "l"."n" AS "trades_added"
FROM (
  SELECT "symbol", count(*) AS "n", max("ts") AS "last_ts"
  FROM h5i('trades', 1)
  GROUP BY "symbol"
) AS "l"
INNER JOIN (
  SELECT "symbol", count(*) AS "n", max("ts") AS "last_ts"
  FROM "trades"
  GROUP BY "symbol"
) AS "r"
  ON "l"."symbol" = "r"."symbol"
drift.sort("symbol").to_pandas()
output
symbol trades_added
0 AAPL 24438
1 MSFT 14065
2 NVDA 22443

.join_asof() lowers to the asof_join table function. That function takes table names and reads both at latest, so the builder refuses a side that already has verbs applied or a pin, rather than silently ignoring it. Filter after the join instead.

tape, quotes = cu.make_trades_and_quotes(days=2)  # shared base prices
for name, data in (("tape", tape), ("quotes", quotes)):
    db.create_table(name, data.schema, time_column="ts", sort_key=["ts", "symbol"])
    db.append(name, data)

try:
    db.table("tape").filter(col("symbol") == "AAPL").join_asof(db.table("quotes"), on="ts", by="symbol")
except h5i_db.InvalidInputError as e:
    print(f"{type(e).__name__}: {e}\nhint: {e.hint}")
output
InvalidInputError: [invalid_input] join_asof() needs a plain table on the left side, but operations have already been applied
hint: join first and filter afterwards, or materialise the side with .collect() and write it back
tq = (
    db.table("tape")
    .join_asof(db.table("quotes"), on="ts", by="symbol", tolerance=5_000_000)
    .filter(col("symbol") == "AAPL")
    .select("ts", "symbol", "price", "bid", "ask", mid=(col("bid") + col("ask")) / 2)
)
print(tq.sql())
output
SELECT "ts", "symbol", "price", "bid", "ask", ("bid" + "ask") / 2 AS "mid"
FROM asof_join('tape', 'quotes', 'ts', 'ts', 'symbol', 'backward', 5000000)
WHERE "symbol" = 'AAPL'
tq.to_pandas().head(4)
output
ts symbol price bid ask mid
0 2026-06-01 18:49:25.682786+00:00 AAPL 263.23 268.26 268.29 268.275
1 2026-06-01 18:49:28.862195+00:00 AAPL 263.13 268.33 268.37 268.350
2 2026-06-01 18:49:31.289757+00:00 AAPL 263.26 268.27 268.30 268.285
3 2026-06-01 18:49:31.955769+00:00 AAPL 263.26 268.28 268.31 268.295

6. The escape hatch, and the door back to SQL#

Full SQL coverage through verbs is deliberately not a goal. sql_expr() drops a raw fragment anywhere an expression is accepted. Its text goes in verbatim, which makes it the one place where quoting is yours to get right.

tails = (
    db.table("prices")
    .group_by("symbol")
    .agg(
        p01=sql_expr("approx_percentile_cont(close, 0.01)"),
        p99=sql_expr("approx_percentile_cont(close, 0.99)"),
    )
    .sort("symbol")
    .limit(4)
)
tails.to_pandas()
output
symbol p01 p99
0 STK000 25.7775 68.6225
1 STK001 172.5800 259.4775
2 STK002 209.8525 357.6050
3 STK003 110.8075 215.2300

The escape hatch you will reach for most is lag. There is no .lag() method, but a sql_expr fragment is windowable, so it takes .over() like any aggregate. That covers lag, lead, row_number and the rest of the SQL window catalogue. Daily returns are the single most common shape in this cookbook:

PREV_CLOSE = sql_expr("lag(close)").over(partition_by="symbol", order_by="ts")

rets = (
    db.table("prices")
    .with_columns(prev_close=PREV_CLOSE)
    .with_columns(ret=col("close") / col("prev_close") - 1)
    .filter(col("ret").is_not_null())
    .select("ts", "symbol", "ret")
)
print(rets.sql())
output
SELECT "ts", "symbol", "ret"
FROM (
  SELECT *, "close" / "prev_close" - 1 AS "ret"
  FROM (
    SELECT *, lag(close) OVER (PARTITION BY "symbol" ORDER BY "ts") AS "prev_close"
    FROM "prices"
  ) AS "_s1"
) AS "_s2"
WHERE "ret" IS NOT NULL
rets.sort(["ts", "symbol"]).to_pandas().head(4)
output
ts symbol ret
0 2023-01-03 20:00:00+00:00 STK000 0.015468
1 2023-01-03 20:00:00+00:00 STK001 -0.001578
2 2023-01-03 20:00:00+00:00 STK002 0.025035
3 2023-01-03 20:00:00+00:00 STK003 0.015922

Note the two-step with_columns. ret reads prev_close, which the stage before it computed, so the builder closes a level rather than emit SQL that would not resolve. Binding the fragment to a Python name once, as PREV_CLOSE here, and reusing it is the habit that keeps a factor library honest.

When a pipeline outgrows the builder, .sql() hands you the query to paste into db.sql() and keep going from there. The two surfaces are one system with a door in the middle, and the generated SQL is deterministic, so it is safe to snapshot-test or diff.

Some things stay in db.sql() because there is no verb for them and the string is genuinely clearer: UNION ALL, deep multi-CTE chains, scalar subqueries, and the table functions gapfill, resample and tail. Stacking two read points into one labelled result is the everyday example:

db.sql(
    """
    SELECT 'version 1' AS read_point, count(*) AS rows FROM h5i('trades', 1)
    UNION ALL
    SELECT 'latest',                  count(*)         FROM trades
    """
).to_pandas()
output
read_point rows
0 version 1 195277
1 latest 256223

Takeaways#

db.close()