Python quickstart
import ubik, run streaming SQL in-process over libubik, and take results as pyarrow, pandas or polars.
The Python module runs the engine inside your process over libubik. There is
nothing to deploy and nothing to connect to.
pip install ubik-sqlThe distribution is ubik-sql; the module is ubik.
One query
ubik.stream() runs SQL over a source and hands back a result you read however
you like.
import ubik
tbl = ubik.stream(
"SELECT merchant, count(*) AS n "
"FROM orders "
"GROUP BY merchant, TUMBLE(event_time, INTERVAL '1 MINUTE')",
from_="kafka://localhost:9092/orders",
).arrow()Read the whole result with .arrow(), .df() or .pl(), iterate the object for
record batches, or call .rows() for dicts.
A host DataFrame as a dimension
Arrow in, Arrow out. A pandas, polars or pyarrow table passed in tables enters
through the Arrow C stream and becomes a lookup dimension the stream joins
against.
import ubik
import pandas
merchants = pandas.DataFrame({"id": [10, 20], "region": ["EU", "US"]})
tbl = ubik.stream(
"SELECT m.region, count(*) AS c "
"FROM orders o JOIN merchants m ON o.merchant_id = m.id "
"GROUP BY m.region, TUMBLE(o.event_time, INTERVAL '1 MINUTE')",
from_="kafka://localhost:9092/orders",
tables={"merchants": merchants},
).arrow()A windowed dimension for a point-in-time join
A ubik windowed-aggregate changelog carries only window_start, and a window's
value is complete only at its end. An as-of join on window_start would attach a
value that folds in events after the probe row's own time. Wrap the frame with
its window and the engine synthesizes the window_end / valid_from the join
reads instead, and refuses a leaky as-of on window_start (PIT_LEAKAGE_RISK).
import ubik
tbl = ubik.stream(
"SELECT o.merchant, o.amount, f.hourly_count "
"FROM orders o ASOF JOIN feats f "
"ON o.merchant = f.merchant AND o.event_time >= f.valid_from",
from_="kafka://localhost:9092/orders",
tables={"feats": ubik.windowed(hourly_counts, "1 hour")},
).arrow()A second live stream (interval join)
Where tables binds a bounded dimension, streams binds a second live source for
a stream-stream interval join, the in-process twin of the CLI's --stream and the
sibling of tables. Pass streams={"b": "kafka://..."} (a file:// path also
works) on stream() or pipeline(), and join it in the SQL on an equi key plus a
time band. Its schema is inferred at start like from_; one stream in v1. The
result is byte-identical to the CLI --stream path and to the DuckDB batch oracle.
import ubik
tbl = ubik.stream(
"SELECT c.campaign, count(*) AS n "
"FROM clicks c JOIN impressions i "
"ON c.user_id = i.user_id "
"AND i.ts BETWEEN c.ts - INTERVAL '5' SECOND AND c.ts + INTERVAL '5' SECOND "
"GROUP BY c.campaign, TUMBLE(c.ts, INTERVAL '1 MINUTE')",
from_="kafka://localhost:9092/clicks",
streams={"impressions": "kafka://localhost:9092/impressions"},
).arrow()Durable live pipelines
ubik.pipeline() tails a topic without end and survives a process restart from
its checkpoint. A restart loses no event and double-counts none.
import ubik
Q = ("SELECT merchant, TUMBLE(ts, INTERVAL '1 MINUTE') AS w, count(*) AS c "
"FROM orders GROUP BY merchant, TUMBLE(ts, INTERVAL '1 MINUTE')")
with ubik.pipeline(Q, from_="kafka://localhost:9092/orders",
checkpoint="~/.ubik/orders") as p:
for window in p: # yields each closed window as it emits
react(window) # a pyarrow RecordBatchThe checkpoint path is the identity
The first run creates it; a later run resumes from it automatically, after a clean exit or after a crash. Point two runs at the same path and the second continues the first rather than starting over.
A prepared point transform
ubik.transform() compiles a stateless SELECT once and returns a callable you
apply to in-memory batches on demand. It runs on the caller's thread, so a
request path pays the compile cost once and reuses it per batch.
import ubik
import pyarrow as pa
f = ubik.transform(
"SELECT r.id, r.amount * fx.rate AS usd "
"FROM req r JOIN fx ON r.ccy = fx.ccy",
input={"req": pa.schema([("id", pa.int64()), ("amount", pa.float64()),
("ccy", pa.string())])},
tables={"fx": fx},
)
out = f(request_batch) # pyarrow.Table, compiled once, reused per requestValidate before you run
ubik.validate() parses and binds a query against its source without running it,
and returns the output schema. A mistake surfaces as the same UbikError a run
would raise, but before you commit to a long pass. Handy while writing a query in
a notebook.
import ubik
schema = ubik.validate(
"SELECT country, TUMBLE(ts, INTERVAL '1 MINUTE') AS w, sum(amount) AS v "
"FROM events GROUP BY country, TUMBLE(ts, INTERVAL '1 MINUTE')",
from_="file:///data/events.ndjson",
)Errors
Every failure raises ubik.UbikError, carrying .code, .message and .hint.
import ubik
try:
ubik.stream(sql, from_="kafka://localhost:9092/orders").arrow()
except ubik.UbikError as e:
print(e.code, e.hint)The codes are the same ones the CLI prints, so an error found in a notebook reads identically in a pipeline log.
Requirements
pyarrow>=14 is the result surface and is installed with ubik-sql. pandas
and polars are optional, imported lazily by .df() and .pl().