Working with the catalog
In Build a semantic catalog you tagged a BSL flights model and dropped it into a local catalog. That catalog is a regular git repository—every entry, alias, and revision is a git commit. The moment you push it to a remote, anyone with access can clone it, query the model, propose changes, and run the model against their own backend.
This tutorial walks you through the collaboration loop end-to-end, driven from the xorq command line (xorq uv build, xorq catalog ...) rather than the Python API. You’ll play both sides:
- User A publishes the catalog to GitHub.
- User B clones it, recovers the model, and proposes a new entry via pull request.
- User A reviews, merges, and pulls the changes back.
You’ll also see how to swap the connection profile at recovery time, so a downstream user can run the cataloged expression against their own backend without modifying the entry.
The foundation tutorial cataloged the model with the Python API (catalog.add(expr)). This tutorial does the same job from the shell: xorq uv build packages a script into a build artifact, and xorq catalog add <build-dir> files that artifact into the catalog. The build artifact carries a dependency-pinned wheel of your project, so the command-line flow is what you’d reach for in CI or a Makefile—no Python entry point to maintain. The two Python-only steps that remain (recovering the BSL model, swapping the profile) are called out where they appear.
Prerequisites
You need:
Completed Build a semantic catalog—same model definition, same project layout. This tutorial recreates the catalog at a stable path here, since the foundation tutorial used a temp directory.
User A’s
flights-tutorial/project directory from the foundation tutorial. This tutorial assumes it lives at~/flights-tutorial/so it sits as a sibling of User B’s~/flights-tutorial-userb/(created later in the tutorial). If you put it somewhere else, either move it now (mv path/to/flights-tutorial ~/flights-tutorial) or substitute your actual path wherever you see~/flights-tutorialbelow. (User B gets their own project; you’ll create it later in the tutorial.)The
sqliteextra installed in that project. The foundation tutorial installedxorq[bsl,duckdb]; addsqlitehere to demonstrate the profile-swap section against a different backend than the one User A built the entry with. From inside~/flights-tutorial/:uv add "xorq[bsl,duckdb,sqlite]"Git installed locally and authenticated with GitHub (the
ghcommand-line tool is convenient but not required).
Xorq’s catalog stores entries as files in a git repository: catalog.yaml is the index, aliases/ holds alias pointers, entries/ holds the entry zips themselves, and metadata/ holds a sidecar yaml per entry. Every xorq catalog add is one git commit. That means GitHub’s permission model, branch protection rules, and pull requests Just Work—there’s no separate object store, no extra service to provision.
Build the model into an artifact (User A)
The command line catalogs build artifacts, not live Python objects, so the first step is to produce one. Save the model definition below as flights_model.py in User A’s ~/flights-tutorial/ project. It’s the same model as the foundation tutorial, trimmed to just the definition and a final to_tagged(...) assignment to expr, the variable xorq uv build looks for:
# flights_model.py
from boring_semantic_layer import to_semantic_table, to_tagged
import xorq.api as xo
flights = xo.memtable(
{
"origin": ["JFK", "LAX", "ORD", "JFK", "LAX", "ORD", "JFK", "LAX"],
"destination": ["LAX", "ORD", "JFK", "ORD", "JFK", "LAX", "LAX", "JFK"],
"carrier": ["AA", "UA", "AA", "UA", "AA", "UA", "AA", "UA"],
"dep_delay": [10.0, -5.0, 30.0, 15.0, -2.0, 45.0, 5.0, 20.0],
"distance": [2475, 1745, 740, 1300, 2475, 1745, 2475, 2475],
},
name="flights",
)
flights_model = (
to_semantic_table(flights)
.with_dimensions(
origin=lambda t: t.origin,
destination=lambda t: t.destination,
carrier=lambda t: t.carrier,
)
.with_measures(
flight_count=lambda t: t.count(),
avg_dep_delay=lambda t: t.dep_delay.mean(),
total_distance=lambda t: t.distance.sum(),
)
)
expr = to_tagged(flights_model)Build it from inside the project directory. -e expr names the variable to build; xorq uv build resolves the project root by searching upward for pyproject.toml, so the cwd matters:
cd ~/flights-tutorial
uv run xorq uv build flights_model.py -e expr
# prints the build directory, e.g. builds/a1b2c3d4e5f6The command prints the build directory it created (under builds/) as the last line—you’ll pass that path to catalog add next. To capture it in a shell variable instead of copying by hand:
BUILD_A=$(cd ~/flights-tutorial && uv run xorq uv build flights_model.py -e expr | tail -1)xorq uv build
xorq uv build packages your project as a wheel and stores it inside the build artifact, so the cataloged expression keeps a frozen record of the dependencies it was built against. You’ll see Building wheel..., running egg_info, and Successfully built ...whl in the output, plus a UserWarning about local filesystem paths from the inline memtable—both are expected and not errors. If OTel console export pollutes stdout and breaks the tail -1 capture, pass --emit-build-path-to build_path.txt and read the path from that file.
Publish the catalog to GitHub (User A)
You don’t need a GitHub account to work through this tutorial—a local bare git repo behaves the same way for clone/push/pull. Each remote-using step below has a Local bare repo tab next to the GitHub one; pick one and stay on it.
Initialize the catalog at a stable path (~/work/flights-catalog-usera) so the rest of the tutorial has somewhere persistent to point at, then add the build artifact under the flights-model alias. init creates the catalog’s leaf directory but not its parent, so make sure ~/work/ exists first:
mkdir -p ~/work
cd ~/flights-tutorial
uv run xorq catalog --path ~/work/flights-catalog-usera init
uv run xorq catalog --path ~/work/flights-catalog-usera add "$BUILD_A" -a flights-model
uv run xorq catalog --path ~/work/flights-catalog-usera list-aliases
# flights-model(--path is a group option, so it comes before the subcommand. If you didn’t capture $BUILD_A, substitute the builds/<hash> path the build step printed.)
Wire up a remote and push. xorq catalog set-remote adds the git remote; the first push still needs raw git push -u to set upstream tracking, which xorq catalog push doesn’t do:
Create an empty repository on GitHub (web UI: “New repository” → leave empty), then point the catalog at it and push:
uv run xorq catalog --path ~/work/flights-catalog-usera set-remote https://github.com/<you>/flights-catalog.git
git -C ~/work/flights-catalog-usera push -u origin mainOr, equivalently, with the gh command-line tool from inside the catalog directory—--source=. means “create the repo from this working directory,” so the cd matters. gh adds the origin remote and pushes for you, so you don’t need set-remote:
cd ~/work/flights-catalog-usera
gh repo create <you>/flights-catalog --public --source=. --remote=origin --pushInitialize a local bare repo to act as the “remote”—same git semantics, no GitHub account needed:
git init --bare ~/work/flights-catalog-remote.gitThen point the catalog at it and push:
uv run xorq catalog --path ~/work/flights-catalog-usera set-remote "file://$HOME/work/flights-catalog-remote.git"
git -C ~/work/flights-catalog-usera push -u origin mainEvery subsequent publish can use xorq catalog push, which runs git push against the configured remote:
uv run xorq catalog --path ~/work/flights-catalog-usera pushVerify the remote sees what you expect:
gh repo view --web # opens the repo on GitHubYou should see catalog.yaml (the index) at the top level, plus aliases/, entries/, and metadata/ directories. Click into aliases/ and you’ll see flights-model.zip—the alias you just added.
A bare repo has no working tree to ls, so list its files at main directly:
git -C ~/work/flights-catalog-remote.git ls-tree -r mainYou should see catalog.yaml, aliases/flights-model.zip, an entries/<hash>.zip, and a matching metadata/<hash>.zip.metadata.yaml—the same files that would appear under “Files” on GitHub.
Set up User B’s project
Switch hats. User B is on a different machine (or pretending to be—same laptop, different working directory and venv). Give them their own uv project so they aren’t sharing User A’s pyproject.toml or .venv:
mkdir ~/flights-tutorial-userb && cd ~/flights-tutorial-userb
uv init --bare
uv add "xorq[bsl,duckdb,sqlite]"
printf '\n[tool.setuptools]\npy-modules = []\n' >> pyproject.tomlThis is the same setup the foundation tutorial walked you through for User A, just under a different directory name. From here on, every User B command runs with uv run xorq ... from inside ~/flights-tutorial-userb/. The uv run command picks up that project’s .venv automatically, so you never have to deactivate User A’s venv to run User B’s code.
With User A’s flights-tutorial/ and User B’s flights-tutorial-userb/, you have two .venvs on disk. uv run xorq ... looks at the pyproject.toml of the directory you’re in and uses that venv—so as long as you cd into the right project before each command, the right venv is used. No source, no deactivate.
Clone the catalog (User B)
User B clones the catalog with one command:
cd ~/flights-tutorial-userb
uv run xorq catalog clone https://github.com/<you>/flights-catalog.git --path ~/work/flights-catalog-userb
uv run xorq catalog --path ~/work/flights-catalog-userb list-aliases
# flights-modelcd ~/flights-tutorial-userb
uv run xorq catalog clone "file://$HOME/work/flights-catalog-remote.git" --path ~/work/flights-catalog-userb
uv run xorq catalog --path ~/work/flights-catalog-userb list-aliases
# flights-modelUser B never saw User A’s flights_model.py, never saw the original to_semantic_table(...) call. All they have is a git clone—and that’s enough.
Recover and query the model (User B)
Recovering the BSL SemanticModel so you can call .query(...) is a Python step. from_tagged rebuilds the semantic layer from the cataloged expression. Because the catalog is plain git, the entry contents arrived during clone, so from_tagged can read them immediately. Save the snippet below as recover_model.py in ~/flights-tutorial-userb/ and run it with uv run python recover_model.py:
# recover_model.py
from pathlib import Path
from boring_semantic_layer import from_tagged
from xorq.catalog.catalog import Catalog
catalog = Catalog.from_repo_path(Path("~/work/flights-catalog-userb").expanduser(), init=False)
flights_entry = catalog.get_catalog_entry("flights-model", maybe_alias=True)
flights_model = from_tagged(flights_entry.expr)
print(
flights_model.query(
dimensions=("origin",),
measures=("flight_count", "avg_dep_delay"),
).order_by("origin").execute()
)The recovered SemanticModel has the same dimensions and measures User A defined. The data User A used (the inline xo.memtable(...)) is serialized inside the entry, so the query runs locally—no shared filesystem, no out-of-band data transfer.
Propose a change via pull request (User B)
User B wants to publish a refined view: same model, but filtered to American Airlines only. Because the catalog is a git repo, they branch, commit, push, and open a PR—exactly like any other code change.
git -C ~/work/flights-catalog-userb checkout -b add-aa-only-modelBuild the new entry the same way User A built the first one. User B has the same flights data—it’s the inline memtable—so they reconstruct it in a script. Save the snippet below as aa_flights_model.py in ~/flights-tutorial-userb/:
# aa_flights_model.py
from boring_semantic_layer import to_semantic_table, to_tagged
import xorq.api as xo
flights = xo.memtable(
{
"origin": ["JFK", "LAX", "ORD", "JFK", "LAX", "ORD", "JFK", "LAX"],
"destination": ["LAX", "ORD", "JFK", "ORD", "JFK", "LAX", "LAX", "JFK"],
"carrier": ["AA", "UA", "AA", "UA", "AA", "UA", "AA", "UA"],
"dep_delay": [10.0, -5.0, 30.0, 15.0, -2.0, 45.0, 5.0, 20.0],
"distance": [2475, 1745, 740, 1300, 2475, 1745, 2475, 2475],
},
name="flights",
)
# Same model shape, restricted to AA
aa_flights = flights.filter(flights.carrier == "AA")
aa_model = (
to_semantic_table(aa_flights)
.with_dimensions(
origin=lambda t: t.origin,
destination=lambda t: t.destination,
)
.with_measures(
flight_count=lambda t: t.count(),
avg_dep_delay=lambda t: t.dep_delay.mean(),
)
)
expr = to_tagged(aa_model)Build it from User B’s project, then add it under a new alias with --no-sync:
cd ~/flights-tutorial-userb
BUILD_B=$(uv run xorq uv build aa_flights_model.py -e expr | tail -1)
uv run xorq catalog --path ~/work/flights-catalog-userb add "$BUILD_B" -a flights-aa-only --no-sync
uv run xorq catalog --path ~/work/flights-catalog-userb list-aliases
# flights-aa-only
# flights-modelThat commits a new entry on the add-aa-only-model branch in your clone—--no-sync deliberately keeps it local so you can review and push the branch yourself in the next step.
--no-sync
By default xorq catalog add pulls then pushes the catalog’s git remote after committing; --no-sync skips that, leaving the new commit purely local. The default is harmless when no remote is configured yet (User A’s first add earlier had nothing to pull or push), but User B’s clone does have an origin—so --no-sync is what keeps the entry on the local feature branch until you push it yourself in the next step, after reviewing the diff.
Push the feature branch and open the PR:
cd ~/work/flights-catalog-userb
git log --oneline -3 # confirm: "add: <hash> (aliases flights-aa-only)"
git push -u origin add-aa-only-model
gh pr create --title "Add AA-only flights model" --body "Adds an AA-filtered view of the flights model under alias flights-aa-only."User A reviews the PR on GitHub. Because each xorq catalog add is a single commit, the diff is small and readable: a new alias under aliases/, a new entry under entries/, a new sidecar under metadata/, and an update to catalog.yaml.
git -C ~/work/flights-catalog-userb log --oneline -3 # confirm: "add: <hash> (aliases flights-aa-only)"
git -C ~/work/flights-catalog-userb push -u origin add-aa-only-modelThere’s no PR—User A reviews the change as a regular fetched branch (see the next section). The diff is the same: a new alias under aliases/, a new entry under entries/, a new sidecar under metadata/, and an update to catalog.yaml.
Merge the PR and pull the changes (User A)
User A reviews the diff, approves the PR, and clicks Merge pull request in the GitHub UI. (The gh command-line tool equivalent is gh pr merge --squash <pr-number> from a clone—but a tutorial reader doing this step manually is the most common path.)
There’s no PR UI to merge through, so User A pulls the branch into their catalog and merges by hand:
cd ~/work/flights-catalog-usera
git fetch origin add-aa-only-model
git diff main..origin/add-aa-only-model # review the change
git merge --no-ff origin/add-aa-only-model -m "Merge: add AA-only flights model"
git push origin main--no-ff keeps the merge commit so the history matches what GitHub would have produced from a “Merge pull request” click.
Once main has moved on the remote, User A pulls. xorq catalog pull runs git pull against the configured remote, fast-forwarding the local main:
cd ~/flights-tutorial
uv run xorq catalog --path ~/work/flights-catalog-usera pull
uv run xorq catalog --path ~/work/flights-catalog-usera list-aliases
# flights-aa-only
# flights-modelpull is effectively a no-op
You merged in your local clone and pushed up—your local main is already at the merged tip, so there’s nothing for pull to fast-forward. The command is still worth running because it’s the same one-liner User A would use after a teammate clicked “Merge pull request” on GitHub; here it just confirms the new alias is visible.
The same alias is now visible everywhere the catalog is cloned. Anyone with access can recover and query flights-aa-only exactly the way they recover flights-model.
Swap the profile at recovery time
The catalog stores expressions, not connections. When User A built the entry they used the default Xorq backend (a xorq_datafusion session); when User B recovers it, they may want to execute against a different profile—perhaps a SQLite database they’ve configured locally, a Postgres instance with extra resources, or a Snowflake warehouse. Rebinding to a connection is a Python step.
A profile in Xorq is a named connection configuration: con_name plus connection kwargs, serialized to disk. Save one with Profile.from_con(con).save(alias=...), load it with Profile.load(...).
This section demonstrates the swap by moving to SQLite—a genuinely different backend than the default, no server to provision, and the adbc-driver-sqlite connector already came in via the sqlite extra in the prereqs. Save the snippet below as profile_swap.py in ~/flights-tutorial-userb/ and run it with uv run python profile_swap.py:
# profile_swap.py
from pathlib import Path
from xorq.vendor.ibis.backends.profiles import Profile
import xorq.api as xo
from xorq.catalog.catalog import Catalog
catalog = Catalog.from_repo_path(Path("~/work/flights-catalog-userb").expanduser(), init=False)
# Capture User B's preferred connection as a named profile
sqlite_con = xo.sqlite.connect() # in-memory SQLite
Profile.from_con(sqlite_con).save(alias="local_dev_sqlite", clobber=True)
# Later—possibly in a different script—load the profile and bind the entry to it
profile = Profile.load("local_dev_sqlite")
con = profile.get_con()
expr = catalog.load("flights-model", con=con)
print("Executing against backend:", con.name)
print(
expr.group_by("origin")
.agg(
flight_count=expr.count(),
avg_dep_delay=expr.dep_delay.mean(),
)
.order_by("origin")
.execute()
)Executing against backend: sqlite
origin flight_count avg_dep_delay
0 JFK 3 10.000000
1 LAX 3 4.333333
2 ORD 2 37.500000
The Executing against backend: sqlite line is the proof—User A cataloged the entry against xorq_datafusion, User B loaded it bound to a SQLite connection, and .execute() shipped the work to SQLite. catalog.load(name, con=...) returns the underlying Xorq expression—the flights table, in this case—bound to whichever connection you pass; you compose any group-by / aggregation you like on top, and .execute() runs it on the chosen backend. The entry on disk is unchanged; the swap is purely a runtime decision.
catalog.load vs from_tagged
from_tagged(entry.expr) rebuilds the BSL SemanticModel so you can call .query(...) against it—that’s the right tool when you want the semantic-layer interface back. catalog.load(name, con=...) skips the BSL layer and gives you the underlying Xorq expression bound to a connection of your choosing—that’s the right tool when you want to redirect execution to a specific backend without touching the entry. They compose: profiles for the connection, BSL for the dimensions and measures.
What you learned
- A Xorq catalog is a git repository: every
xorq catalog addis one commit, and the diff is small enough to review on GitHub. xorq uv build <script> -e exprpackages a model script into a dependency-pinned build artifact;xorq catalog add <build-dir> -a <alias>files it into the catalog.- Sharing the catalog is
xorq catalog push(after a one-timexorq catalog set-remote+git push -u origin main); cloning usesxorq catalog clone <url> --path .... - Collaboration uses the GitHub workflow you already know: branch, commit, push, open a PR. The reviewer sees alias and entry files in the diff; merging makes the new alias available everywhere the catalog is cloned.
from_tagged(flights_entry.expr)recovers the BSL model on the consumer side—a Python step, the same call as in the foundation tutorial, regardless of where the entry came from.catalog.load(name, con=...)rebinds a cataloged expression to a different connection at recovery time. Combined with namedProfiles, downstream users can pick their own execution backend—SQLite, Postgres, anything Xorq supports—without modifying the entry.
Next steps
- Your first build—package the cataloged model into a portable build artifact.
- Switch backends—see what kinds of profiles Xorq supports.
- Explore caching—keep the recovered query path fast for downstream users.