The mental model¶
Omniframes borrows PySpark's shape, not its semantics. This page is the set of ideas that make everything else predictable. Read it once and the API stops surprising you.
About the examples
Every Python block below has been executed, verbatim, against a wire-faithful fake of the
Omni query API — the outputs are what it printed. Model ecommerce, topic order_items,
joined to users and products (the bench model).
import omniframes as of
from omniframes import functions as F
session = of.OmniSession.builder.host("acme.omniapp.co").api_key_from_env().get_or_create()
orders = session.read.topic("ecommerce", "order_items")
1. Measure-first: the selection is the group-by¶
In SQL you write GROUP BY and then decide what to aggregate. In Omni's semantic layer the
model already knows what every measure means, so asking for a dimension and a measure together
is the group-by. There is no separate grouping step to get wrong.
That returns one row per state. group_by().agg() is sugar for the same thing — literally the
same query object on the wire:
same = orders.group_by("users.state").agg(F.measure("order_items.count"))
assert by_state.explain() == same.explain()
Use whichever reads better. group_by() with no keys aggregates the whole frame to one row.
The practical consequences:
- A frame with no measure returns raw rows.
orders.select("users.state")is 10 000 rows, not 21. - Adding a measure changes the row count, because it changes the query from a row scan into an aggregate. That is the semantic layer working, not a bug.
- You cannot "un-group". To get raw rows back, drop the measure.
Regrouping a selected topic DataFrame¶
For a topic query with governed measures, group_by(...).agg(...) can use a subset of the
dimensions in an earlier select(). The group keys and measures become the final selection:
by_state_and_user = orders.select(
"users.state",
"users.id",
F.measure("order_items.total_sale_price"),
)
state_revenue = by_state_and_user.group_by("users.state").agg(
F.measure("order_items.total_sale_price")
)
direct_state_revenue = orders.select("users.state", F.measure("order_items.total_sale_price"))
assert (
state_revenue.sort("users.state")
.to_arrow()
.equals(direct_state_revenue.sort("users.state").to_arrow())
)
The final query sends only users.state and order_items.total_sale_price. users.id drops
out, and Omni evaluates the governed measure at state grain using its model definition. There
is no intermediate per-user result to sum locally. This matters for measures such as averages
and distinct counts, whose values cannot generally be summed across groups. The original
by_state_and_user frame remains unchanged.
After an explicit select(), every grouping field and governed measure used by this chain
must be available from that selection. For example, grouping by_state_and_user by
users.age, or asking it for F.measure("order_items.count"), raises CompileError when the
plan is compiled (by explain() or an action): the field is "not available after the previous
select()". Include the needed fields in the earlier selection, or start the new query from
orders.
This describes a select(...).group_by(...).agg(F.measure(...)) chain on a topic source.
Ad-hoc aggregates such as F.sum(...) use the SQL or local execution paths described below;
operations that require an intermediate result, such as map_pandas(), also change the plan.
2. Governed measures vs. ad-hoc aggregations¶
Two things look similar and behave completely differently. Getting this distinction is most of the value of the library.
F.measure("order_items.total_sale_price") |
F.sum("order_items.sale_price") |
|
|---|---|---|
| What it is | a governed model measure | an ad-hoc aggregation over a raw column |
| Who defines it | the Omni model, server-side | you, right here |
| Where it runs | always remotely, tier 1 | tier 2 SQL when expressible, else tier 3 |
| Governance | join paths, row-level security, the org's one definition of the metric | none beyond the reference query's own filters |
| Default column name | order_items.total_sale_price |
sum(order_items.sale_price) |
governed = orders.group_by("users.state").agg(F.measure("order_items.total_sale_price"))
ad_hoc = orders.group_by("users.state").agg(F.sum("order_items.sale_price").alias("revenue"))
Both answer "revenue by state", and against this model they agree to the cent. They are still not the same thing: the governed measure is whatever the model says revenue is today — if the model starts excluding returns tomorrow, the governed frame follows and the ad-hoc one does not.
Omniframes never emulates a governed measure locally. Its definition lives in the model; a
local re-implementation would be a different number wearing the same name. F.measure(...)
therefore pins at least one remote step into every plan that mentions it.
Ad-hoc aggregations are F.sum, F.avg, F.min, F.max, F.count and F.count_distinct.
They take a raw column, never a measure — wrapping a measure raises immediately:
'order_items.total_sale_price' is a governed measure and is already aggregated; select it with F.measure(...) instead of wrapping it in an ad-hoc aggregation
Filtering after aggregation is a real HAVING¶
A predicate on a governed measure compiles to a measure-keyed wire filter, which the server
applies after the group-by — a genuine HAVING, not a client-side pass. It works whether the
measure is selected or not, and whether you write the filter before or after the group_by():
big_states = (
orders.group_by("users.state")
.agg(F.measure("order_items.count").alias("orders"))
.filter(F.measure("order_items.total_sale_price") > 50_000)
)
order_items.total_sale_price never becomes a column here; the server force-adds it to the
aggregate so the HAVING can see it, then projects it away. A predicate on an ad-hoc
aggregate's output column is also a HAVING — written into tier-2 SQL instead (§3).
3. What runs where¶
At action time the planner tries, at every node of the plan, the highest tier that can express the subtree: tier 1 (governed semantic query) → tier 2 (SQL job) → tier 3 (local Arrow compute). The result is a DAG, not a prefix — a single frame can use all three at once.
explain() prints the answer every time. There is no silent local fallback, ever.
Tier 1 — the governed semantic query¶
Scan + projections (dimensions, grains, measures) + compilable filters + sorts + limit/offset. Fully governed.
tier_one = (
orders.select("order_items.id", "users.state", "order_items.status")
.filter(F.col("users.state") == "California")
.sort(F.col("order_items.id").desc())
.limit(5)
)
print(tier_one.explain())
== Physical plan ==
Remote [tier 1 · semantic → POST /api/v1/query/run]
topic: order_items model: ecommerce
fields: [order_items.id, users.state, order_items.status]
filters: users.state = 'California'
sort: order_items.id DESC limit: 5 version: 9
Local [pandas]
(none — fully pushed down)
Tier 2 — one governed OmniSQL statement¶
When tier 1 cannot express something — an ad-hoc aggregation, a cross-field OR, a computed
column, a HAVING on an ad-hoc aggregate — omniframes writes OmniSQL: ordinary SQL in which
${order_items} is the topic and ${users.state} is a model field. Omni parses it against the
model, so the topic's joins, the measures' definitions and the row-level policies all apply, and
the statement runs as one governed job. The rows never leave the warehouse.
tier_two = (
orders.group_by("users.state")
.agg(F.count_distinct("users.id").alias("buyers"))
.filter(F.col("buyers") > 25)
)
print(tier_two.explain())
== Physical plan ==
Remote [tier 2 · sql → POST /api/v1/query/run]
topic: order_items model: ecommerce
sql:
SELECT
${users.state},
COUNT(DISTINCT ${users.id}) AS of_expr_1
FROM ${order_items}
GROUP BY
1
HAVING
COUNT(DISTINCT ${users.id}) > 25
… (+1 more lines)
Local [pandas]
(none — fully pushed down)
Two details worth reading off that statement. of_expr_1 is a generated alias, not your name:
Omni names result columns itself and prefixes an expression's alias with a view it picks, so
omniframes matches the column back by suffix and renames it to buyers before you see it. And
the LIMIT lives in the text — on this path the query object's own limit is ignored, so the
statement always carries one (the … (+1 more lines) above is it).
Only the 21 result rows come back: the aggregation happened in the warehouse.
Tier 3 — local, over the largest remote prefix¶
UDFs, map_pandas, cross-frame joins and unions, and anything above them run in this process on
Arrow compute. The query underneath is still pushed down as far as it goes:
def is_coastal(state):
return state in {"California", "Oregon", "Washington"}
tier_three = (
orders.select("users.state", "order_items.status")
.filter(F.col("order_items.status") == "complete") # compilable → rides remote
.filter(F.udf(is_coastal)("users.state")) # Python → runs here
)
print(tier_three.explain())
== Physical plan ==
Remote step 1 [tier 1 · semantic → POST /api/v1/query/run]
topic: order_items model: ecommerce
fields: [users.state, order_items.status]
filters: order_items.status = 'complete'
sort: (none) limit: 50000 version: 9
Local [arrow compute]
filter over step 1: is_coastal(users.state)
project: [users.state, order_items.status]
Note what the plan is telling you: the status filter went to the server, the Python predicate did not, and 50 000 rows will be fetched to evaluate it. That is the honest cost of a UDF, and it is on the screen before you pay it.
Mixing a governed measure with an ad-hoc one¶
${order_items.total_sale_price} is a legal select item beside COUNT(DISTINCT …) — the server
expands the measure to its governed SQL inside the same statement — so an agg() that mixes the
two kinds is still one request.
mixed = orders.group_by("users.state").agg(
F.measure("order_items.total_sale_price").alias("revenue"),
F.count_distinct("users.id").alias("buyers"),
)
print(mixed.explain())
== Physical plan ==
Remote [tier 2 · sql → POST /api/v1/query/run]
topic: order_items model: ecommerce
sql:
SELECT
${users.state},
${order_items.total_sale_price},
COUNT(DISTINCT ${users.id}) AS of_expr_1
FROM ${order_items}
GROUP BY
1
LIMIT 50000
Local [pandas]
(none — fully pushed down)
When tier 2 cannot write the statement — the shapes §3 lists as out of reach — the same frame
falls back to a decomposition: a tier-1 query for the measures, a raw scan for the ad-hoc half,
and a local align-join on the group keys. explain() names all three. That align-join is
not a SQL join: it pairs the two halves' NULL-state groups with each other, because they are
the same group. A user-written df.join(...) has SQL semantics instead (§7).
Things that pin the frontier¶
- A user
.limit(n)rides remote, and operations written above it run on the limited result.page.filter(...)after.limit(20)filters those 20 rows — that is what you asked for. - A UDF or
map_pandasmakes everything above it local. - A raw-SQL scan (
session.read.sql), a saved query or asession.ask(...)frame is opaque: omniframes did not write that query and will never re-derive it, so operations on top run locally over its result.
4. Limits and truncation¶
The library always sends an explicit limit. There is no such thing as an unbounded query by accident.
| you write | wire limit |
|---|---|
| nothing | 50000 (DEFAULT_FETCH_LIMIT) |
.limit(200) |
200 |
.limit(None) |
null — genuinely unlimited, at your own risk |
Limits compose to the tightest: df.limit(50).limit(10) is 10 rows. offset(n) travels with
the limit as SQL's LIMIT n OFFSET k.
When a result comes back with exactly the applied limit, the rows are probably not all of them, and omniframes says so:
TruncationWarning: the result has exactly 3 rows, which is the applied limit — rows are probably
missing. Raise .limit(n), use .limit(None) for everything, or narrow the query.
Raise it to an error while you develop:
import warnings
from omniframes import TruncationWarning
warnings.simplefilter("error", TruncationWarning)
Two deliberate exceptions: show(n) and first() never warn about the limit they imposed
themselves — but they do still warn about an intermediate scan nobody asked for. And
count() counts the materialized frame (post-limit, PySpark-consistent), inheriting the same
warning; for a governed row count select F.measure("order_items.count") instead.
5. Aliases are client-side¶
The Omni query API has no aliasing. .alias() is recorded in the plan, applied as a rename
after the result comes back, and reverse-resolved to the wire name when a filter or sort
mentions it.
month = F.col("order_items.created_at").grain("month").alias("month")
aliased = (
orders.group_by(month)
.agg(F.measure("order_items.total_sale_price").alias("revenue"))
.sort(F.col("month").desc()) # written against the alias
.limit(3)
)
On the wire that query asks for order_items.created_at[month] and sorts by
order_items.created_at[month]; the columns you get back are month and revenue. explain()
shows the map on its own aliases: line.
A grain column is a timestamp, not a label. When the model formats a grain, Omni returns it
twice — the truncated timestamp and a formatted string ("2026-03") — and omniframes keeps the
timestamp. So a month grain has the same dtype whether or not somebody formatted it in the
model, it sorts and joins chronologically, and it groups like a date. The consequence is that the
column can read differently here than in the Omni UI, which shows the formatted string; format it
yourself (strftime, dt.to_period) if you want the label.
Two collisions fail at build time, where you wrote them, rather than at action time:
try:
orders.select(F.col("users.state").alias("x"), F.col("users.age").alias("x"))
except of.CompileError as error:
print(error)
try:
orders.select(F.col("users.state").alias("users.age"), F.col("users.age"))
except of.CompileError as error:
print(error)
alias 'x' is used twice in one select(); aliases must be unique
alias 'users.age' shadows the selected field 'users.age'; pick a different name
Aliases are also how you make a join or a union line up, since both match on output column names.
6. between(): numbers include both ends, dates do not¶
This is the one asymmetry in the API, and it is deliberate.
numbers = orders.select("order_items.quantity").filter(
F.col("order_items.quantity").between(3, 5)
) # 3 <= quantity <= 5 — both ends included, PySpark-consistent
Numbers compile to a composite of >= and <=, not to the wire's BETWEEN kind, whose
upper bound is exclusive.
from datetime import date
dates = orders.select("order_items.created_at").filter(
F.col("order_items.created_at").between(date(2026, 6, 1), date(2026, 7, 1))
) # 2026-06-01 <= created_at < 2026-07-01 — half-open
Dates are half-open. The wire's date filter offers only ON_OR_AFTER (>=) and BEFORE
(<), so an inclusive upper bound is not exactly expressible — and omniframes refuses to
synthesize one by adding "the smallest unit" to a literal that may be relative or truncated.
Half-open is also what a month or quarter window actually wants: [2026-06-01, 2026-07-01) is
June, exactly, with no last-microsecond question to get wrong.
<= and > on a date are not expressible either. Use the next boundary.
Which arm applies is decided by the literal's Python type, because the compiler has no field
types: == "2026-03" is a string filter; pass a date/datetime, or use .grain(), for a
date filter.
7. NULLs: SQL's three-valued logic, everywhere¶
Omniframes commits to SQL semantics — including in the local engine, which is why it runs on
Arrow compute (Kleene and/or/not, null-propagating comparisons, a real NULL group key)
rather than on pandas. The consequence is that remote and local execution of the same
predicate agree, which is exactly what a hybrid engine has to promise.
Concretely, over 10 000 order items of which 300 have a NULL returned flag:
returned = F.col("order_items.returned")
print(orders.select("order_items.id").filter(returned.is_null()).limit(None).count())
print(orders.select("order_items.id").filter(~(returned == True)).limit(None).count()) # noqa: E712
~(returned == True) keeps 9 051 rows, not 9 351. The 300 NULL rows are not kept: NULL =
TRUE is NULL, NOT NULL is NULL, and a filter drops a row whose predicate is NULL. This is SQL,
not a bug — but it is the thing that catches people, so:
IS NULL/IS NOT NULLare the only predicates that ask about NULL — use.is_null()/.is_not_null().- A negated predicate never resurrects NULLs.
~col.isin("California")excludes the NULL-state rows too (8 150 of 10 000, not 8 740). - Aggregations skip NULLs;
count(col)counts non-null values andcount_distinct(col)excludes NULL entirely.
NULL is a real group key. group_by("users.state") returns a row whose state is None,
holding every order item whose buyer has no state — including the ones whose user_id matches no
user at all, because the topic's joins are LEFT joins. Dropping that group silently is how
totals stop adding up, so omniframes keeps it.
In a user join, NULL keys never match. df.join(other, "users.state") follows SQL: a
NULL-keyed row takes no part in matching and reappears only as an unmatched row — dropped by an
inner join, kept with the other side NULL by a join type that preserves its side. An outer
join of two frames that each have a NULL-state group therefore produces two NULL rows, not
one. (The align-join inside a decomposed aggregate does the opposite, on purpose — §3.)
8. Everything is lazy and immutable¶
Every transformation returns a new DataFrame wrapping a new plan; nothing mutates and
nothing runs. The network is touched only by an action: collect, to_arrow, to_pandas,
to_polars, show, count, first, write.*, omni_url, the schema property, and
explain(analyze=True).
explain() on its own performs no I/O — it is free to call, always, and it is the answer to
"what is this going to do?".
See also¶
- API reference for every method and its exact contract.
- Architecture and the hybrid engine / the SQL tier for the design behind the tiers.
- Wire contract for what actually goes on the wire, and why.