Writing queries
This tutorial builds up the lazy query model from nothing: eager data, a plan, transformations, aggregation, and how to see what the engine will actually run. Everything here executes as shown.
Eager data, lazy queries
Data lives eagerly in Series and DataFrame. Queries are lazy: .lazy() starts a plan, each method adds to it, and nothing touches data until collect().
import keyten as kt
trades = kt.DataFrame([
kt.Series.string("sym", ["ev", "od", "ev", "od", "ev"]),
kt.Series.int("qty", [100, 250, 300, 150, 75]),
kt.Series.float("px", [10.0, 20.5, 10.2, 19.9, 10.1]),
])
print(trades)
The laziness is not a formality. Because the whole query is known before anything runs, the optimizer pushes filters into the scan, prunes columns nobody reads, and fuses adjacent steps — you write the clear version, the engine runs the fast one.
Filter, derive, select
q = (
trades.lazy()
.filter((kt.col("qty") >= 100) & (kt.col("sym") == "ev"))
.with_columns((kt.col("qty") * kt.col("px")).alias("notional"))
.select(["sym", "qty", "notional"])
)
out = q.collect()
assert out.column("notional").to_list() == [1000.0, 3060.0]
Note the two roles a string plays: inside select it names a column; as an operand of == it is a string literal. col("...") is always unambiguous.
Aggregate
group_by takes keys, agg takes aggregations; the result is a new frame with one row per group. Group output order is unspecified — sort if you care.
summary = (
trades.lazy()
.group_by("sym")
.agg([
kt.col("qty").sum().alias("total_qty"),
kt.col("px").mean().alias("avg_px"),
kt.col("qty").count().alias("n"),
])
.sort("sym")
.collect()
)
assert summary.column("total_qty").to_list() == [475, 400]
A select where every entry is an aggregation aggregates the whole frame in one row:
totals = trades.lazy().select([
kt.col("qty").sum().alias("qty"),
kt.col("px").max().alias("hi"),
]).collect()
assert totals.shape == (1, 2)
See the plan
explain() renders the optimized plan without running it. Reading it is the fastest way to understand what a query costs — and to confirm a filter really reached the scan.
plan = (
trades.lazy()
.filter(kt.col("qty") > 1)
.with_columns((kt.col("qty") * 10).alias("q10"))
.group_by("sym")
.agg(kt.col("q10").sum())
.explain()
)
assert "SCAN" in plan
print(plan)
The scan line lists only the columns the rest of the plan needs, and the filter appears on the scan itself — predicate pushdown and projection pruning, visible.
Mistakes are sticky, not fatal
An unknown column or a type mismatch is recorded in the plan where it happens; the chain keeps building, and the error surfaces — as the original error — when you collect.
q = trades.lazy().filter(kt.col("typo") > 1).select("sym")
try:
q.collect()
except kt.SchemaError as e:
print("caught:", e)
Where to go next
Data in and out covers files; joins and time series cover the two big multi-frame workflows. The full method-by-method contract lives in the Python API reference.