Skip to content

Joins

Four equi-join flavors and a time-series asof join, all lazy and all running through the same optimizer as everything else — filters push into the join sides where that cannot change the answer.

Enriching: inner and left

import keyten as kt

orders = kt.DataFrame([
    kt.Series.int("order_id", [1, 2, 3, 4]),
    kt.Series.int("cust_id", [10, 20, 10, 99]),
    kt.Series.float("amount", [50.0, 30.0, 20.0, 10.0]),
])
customers = kt.DataFrame([
    kt.Series.int("id", [10, 20]),
    kt.Series.string("name", ["ann", "bo"]),
])

inner = orders.lazy().inner_join(customers.lazy(), [("cust_id", "id")]).sort("order_id").collect()
assert inner.shape[0] == 3            # order 4 has no customer

left = orders.lazy().left_join(customers.lazy(), [("cust_id", "id")]).sort("order_id").collect()
assert left.shape[0] == 4
assert left.column("name").to_list()[-1] is None   # unmatched -> nulls

The contract: null keys never match, output is the left columns then the right columns minus its keys, and a right column colliding with a left name gets a _right suffix. Join row order is unspecified, so the examples sort explicitly before asserting positions.

Filtering by membership: semi and anti

When the right side only decides which left rows survive — not what columns they carry — say so. A semi or anti join emits left columns only, never duplicates a left row however many matches exist, and lets the engine probe with membership structures instead of materializing the join.

active = kt.DataFrame([kt.Series.int("id", [10])])

kept = orders.lazy().semi_join(active.lazy(), [("cust_id", "id")]).sort("order_id").collect()
assert kept.column("order_id").to_list() == [1, 3]

dropped = orders.lazy().anti_join(active.lazy(), [("cust_id", "id")]).sort("order_id").collect()
assert dropped.column("order_id").to_list() == [2, 4]

Multi-key joins

on takes as many (left, right) pairs as the key needs:

a = kt.DataFrame([
    kt.Series.string("sym", ["x", "x", "y"]),
    kt.Series.int("day", [1, 2, 1]),
    kt.Series.int("v", [1, 2, 3]),
])
b = kt.DataFrame([
    kt.Series.string("sym", ["x", "y"]),
    kt.Series.int("day", [2, 1]),
    kt.Series.int("w", [20, 30]),
])
out = a.lazy().inner_join(b.lazy(), [("sym", "sym"), ("day", "day")]).sort(["sym", "day"]).collect()
assert out.column("w").to_list() == [20, 30]

The asof join

join_asof is the time-series workhorse. The default matches each left row to the latest right row at or before it, per group. strategy="forward" selects the earliest row at or after it, while strategy="nearest" selects the closest candidate and breaks ties backward. tolerance rejects a candidate that is too far away without dropping the left row. See the time series tutorial for worked examples.

trades.lazy().join_asof(
    quotes.lazy(),
    ("ts", "ts"),
    by=[("sym", "sym")],
    strategy="backward",
    tolerance="5s",
)

Both sides must arrive sorted ascending on the on key with no nulls. The engine verifies that — from block metadata when it can prove it cheaply — and raises rather than silently sorting your data.

Reading a join plan

explain() shows the join tree and which filters were pushed into which side. A filter mentioning only left columns sinks into the left subtree for every flavor; right-only filters sink for inner joins but deliberately not for left joins (that would resurrect rows) or asof joins (removing a right row can change which row is "latest").