By Hussain Sultan | May 7, 2026
Most production-grade semantic layers require a long-lived server. Cube, Looker (LookML), and the dbt Semantic Layer all run as centrally managed services, which buys teams governance and query federation but means a metric defined by your team can’t travel natively with the analyst who wants to use it in a notebook, or with an agent running in a sandbox.1
Boring Semantic Layer (BSL) — a joint project from xorq-labs and boring-data — and the Xorq catalog take a different shape. The semantic model is defined in Python, serialized into a git-tracked artifact, and queryable from the CLI without a running service. You give up centralized RBAC, and schema migrations move through git like any other code change. In return, the model becomes a lot more portable with notebooks, into CI, and into agent harnesses that already live in version-controlled repos.
Adding a 33-entry Xorq semantic catalog to Claude Code’s harness took Claude Haiku from 50% to 84% on DABStep.
Analytics serving hundreds of users behind a BI tool still belong on the hosted services. The rest of this post is about everywhere else.
Xorq connects to 9+ backends — Snowflake, Databricks, Postgres, BigQuery, DuckDB, and more — so the semantic model can sit on top of whatever warehouse or files the team already uses. The example below reads a parquet file for portability.
The flights table is sourced from the U.S. DOT Bureau of Transportation Statistics — download your own here.
# bsl_flights.py
import xorq.api as xo
from boring_semantic_layer import to_semantic_table
BASE_URL = "https://pub-a45a6a332b4646f2a6f44775695c64df.r2.dev"
flights_tbl = xo.deferred_read_parquet(f"{BASE_URL}/flights.parquet") # default xorq-datafusion backend
flights = (
to_semantic_table(flights_tbl, name="flights")
.with_dimensions(
origin=lambda t: t.origin,
carrier=lambda t: t.carrier,
dep_month=lambda t: t.dep_time.truncate("M"),
)
.with_measures(
flight_count=lambda t: t.count(),
avg_dep_delay=lambda t: t.dep_delay.mean(),
late_pct=lambda t: ((t.arr_delay > 15).sum().cast("float64") / t.count()) * 100,
)
)A dimension returns a column; a measure returns an aggregate. late_pct encodes the business definition which in this case means “late” is over fifteen minutes.
Cardinality and linking information can further be defined by using join_one and join_many variants of joins that preserve hierarchy as well as the join key relations.
The producer/consumer flow below is the high-level shape; the docs tutorial Build a semantic catalog walks through it step by step.
to_tagged turns the semantic model into Xorq expression, which is a set of metadata that round-trips to disk so the catalog can hash, cache, and even serve the model without re-running the original Python. On the consumer side, expr.ls.builder rehydrates the SemanticModel (ls is namespace accessor for the builder).
from boring_semantic_layer import to_tagged
expr = to_tagged(flights)Xorq vendors Ibis for its expression system, so to_tagged() returns the Xorq-vendored Ibis expression. An Ibis expression built against the upstream package can be lifted in via from_ibis.
Build the artifact and add it to a catalog:
❯ xorq uv build bsl_flights.py -e expr
Written 'expr' to builds/7a7a42291619
❯ xorq catalog -p git-catalogs/bsl-flights init
❯ xorq catalog -p git-catalogs/bsl-flights add builds/7a7a42291619/ \
-a semantic-flights
Added 7a7a42291619xorq catalog init runs git init under the hood, and every xorq catalog add invocation ends up as a commit. The catalog directory is a git repository in the ordinary sense — you can push it to GitHub, branch off it, and review changes through pull requests like any other code. A downstream repo (an analytics codebase, a CI pipeline, an agent harness) can pull it in as a submodule to pin to a specific revision.
Kleppmann et al. call Git “perhaps the closest thing we have to a true local-first software package” — fast, offline-capable, and excellent for asynchronous collaboration via pull requests. Asynchronous is the right cadence for semantic models: metric definitions evolve through review, not real-time co-editing.
Each entry bundles a wheel for the Python environment plus a YAML representation of the expression, zipped and checked into a git repo:
For example, the build folder looks like the following:
builds/7a7a42291619
├── build_metadata.json
├── expr.yaml
├── expr_metadata.json
├── profiles.yaml
├── requirements.txt
└── xorq-0.3.22-py3-none-any.whland this entry can then be added to the catalog with xorq catalog add command. Once in the catalog, the git repo contains the following:
❯ tree git-catalogs/bsl-flights/
git-catalogs/bsl-flights
├── aliases
│ └── semantic-flights.zip -> ../entries/7a7a42291619.zip
├── entries
│ └── 7a7a42291619.zip
├── metadata
│ └── 7a7a42291619.zip.metadata.yaml
└── catalog.yamlA teammate can clone the repo and query the model from the CLI without worrying about logging into a service or standing up Python venvs. Discovery starts with list-aliases and show.
❯ xorq catalog default --set ./bsl-flights
❯ xorq catalog list-aliases
semantic-flights -> 7a7a42291619
❯ xorq catalog show semantic-flights --json \
| jq '.expr_metadata.lineage.nodes[]
| select(.type=="Tag") | .tag_metadata
| {tag, name, dimensions, measures}'
{
"tag": "bsl",
"name": "flights",
"dimensions": [
["origin", [["description", null], ["is_entity", false], ...]],
["carrier", [...]],
["dep_month", [...]]
],
"measures": [
["flight_count", [...]],
["avg_dep_delay", [...]],
["late_pct", [...]]
]
}Each top-level entry is [name, attrs], where attrs is itself a list of [key, value] pairs (description, expression structure, BSL flags). Pulling just the names is a one-liner: jq '... | {dims: [.dimensions[][0]], meas: [.measures[][0]]}'.
With the cube’s shape in hand, the query is a one-liner:
❯ xorq catalog run semantic-flights \
-c 'source.ls.builder.query(
dimensions=["origin", "carrier"],
measures=["flight_count", "late_pct"]
).to_tagged()' \
-o - -f json --limit 10xorq catalog run is one-shot — the resolved expression isn’t persisted. The variant xorq catalog compose <entry> -c '...' -a flights-by-origin-carrier runs the same composition but catalogs the result as its own entry. That’s how the build inspected in the Lineage section gets minted.
{"origin":"MCO","carrier":"DL","flight_count":1640,"late_pct":16.6463414634}
{"origin":"MAF","carrier":"WN","flight_count":226,"late_pct":11.5044247788}
{"origin":"OAK","carrier":"WN","flight_count":3964,"late_pct":17.0030272452}
{"origin":"MSP","carrier":"NW","flight_count":8662,"late_pct":23.2278919418}
…Credentials flow through environment variables and profiles — local to the consumer, never sent to a service.
Semantic queries are re-issued constantly by dashboards and notebooks. There is a run-cached variant of the xorq catalog run command that wraps the resolves expression in a Parquet cache and persists the results to disk. Susequent runs of the same expression are read from disk. It provides different strategies for invalidating the cache key e.g. modification-time, snapshot and ttl.
❯ xorq catalog run-cached semantic-flights \
-c 'source.ls.builder.query(
dimensions=["origin", "carrier"],
measures=["flight_count", "late_pct"]
).to_tagged()' \
--cache-type snapshot
--cache-dir ./bsl-cache
-o - -f json --limit 10It is worth it to note that this is caching the resultset of the resolved expression not the cube structure for the semantic model.
Because the cahce is plain parquet under a determinsitc path, it survives outside the running process: another teamate can mount the same directory and inherit the prior results.
Under the hood, every catalog entry carries an expr_metadata.json sidecar with a structural dag of the underlying expression. you can read it from a build folder directly, or unzip any catalog entry to get the same JSON. No service to log into, no separate emitter.
The build b8da58368b26 is the entry produced by the xorq catalog compose ... -a flights-by-origin-carrier invocation from the Consumer side note — the same composition that run executed once, persisted as its own catalog entry.
❯ unzip -p git-catalogs/bsl-flights/entries/b8da58368b26.zip expr_metadata.json \
| jq '.lineage.nodes[]
| select(.type != "Field" and .type != "Project")
| {id, type, label}'
{ "id": "0", "type": "HashingTag", "label": "HashingTag" }
{ "id": "1", "type": "Tag", "label": "Tag" }
{ "id": "8", "type": "Aggregate", "label": "Aggregate" }
{ "id": "16", "type": "CountStar", "label": "CountStar" }
{ "id": "17", "type": "Multiply", "label": "Multiply" }
...
{ "id": "80", "type": "Greater", "label": "Greater" }
{ "id": "99", "type": "Literal", "label": "Literal: 15" }
{ "id": "100", "type": "Read", "label": "Read" }The graph runs HashingTag → Tag → Aggregate → Filter → Read along the spine, with the late_pct measure unfolding as Multiply → Divide → Cast → Sum → Greater, and dep_month carried through a TimestampTruncate. The single Read (id 100) is the parquet on R2.
Because the DAG is computed structurally rather than narrated by an emitter, two revisions of an entry diff cleanly. git reflog shows the audit trail.
The git-native approach meets the agent harnesses in version controlled repositories through tool use. The agent clones the catalog into a worktree, runs xorq catalog list-aliases to discover models, and queries them through source.ls.builder using its local credentials — exactly the same call a human would make from the CLI, because there is no second client. The full DABStep results write-up: Orientation Over Reasoning.