The Grammar of Data: From Definition to Execution

Part 2 applies the grammar of data to a real project: one xorq expression over RSS, Bluesky, PyPI, and GitHub Archive data, manifested into a diffable build and executed on DataFusion, DuckDB, or Snowflake.

The Grammar of Data: From Definition to Execution

By Simon Späti (guest) | September 9, 2026

← ALL POSTS

In Part 1, we discovered the grammar for data: a way to define a data project with its complex requirements and how we define it declaratively as a grammar in one sentence with nouns (sources), transformations (verbs), templates, and modifiers, essentially being able to define it once and run it anywhere with different execution engines.

This Part 2 will demonstrate how this looks in a data engineering digest project where we process data from RSS feeds, a live Bluesky firehose, and GitHub datasets, and find trends with an all-integrated horizontal data architecture running xorq based on the grammar described.

We use dlt for ingestion (outside the grammar), then Ibis, DataFusion/DuckDB/Snowflake for the engine, a cataloging feature to compress and discover metrics, and a small ML job. This article will guide you through that project and explain why xorq and the grammar of data are helpful to you.

Want to jump right into the code: GitHub Repo

Then follow along, the repository is at de-ecosystem-digest, the showcase we will go through as an example for the grammar of data below.

The Grammar of Data in Action

As a reminder, the grammar dedicated to data consists of these parts and constructs a full sentence as our data project:

The de-ecosystem-digest project read as one sentence, in five numbered steps: noun (source) is a con.table call on raw_github_events; verb (transform) is the .filter().mutate().group_by().agg() chain; template and modifier bind the sentence to any repo and any engine via star_velocity_30d(con, repo); manifest models it once, with xorq build writing builds/<hash>/expr.yaml; execute represents it everywhere with .execute().

The project read as one sentence - five grammar parts · one named, executable, re-runnable expression

In our data engineering digest example project we create a data engineering digest based on my DE RSS Feeds I collected over the years, Bluesky posts, raw PyPI downloads and GitHub Archive events as source data that we ingest with dlt. Here’s an overview of the project:

Four source nouns (RSS articles, Bluesky posts, PyPI downloads, GitHub events) feed a single sentence: take the PyPi Downloads noun, filter, agg and order them (verbs) for any repo (template) on any engine (modifier), named star_velocity_30d(con, repo). That sentence becomes a diffable, hashed manifest via xorq build, which then executes on DuckDB, DataFusion, or Snowflake.

Model once, represent everywhere - the transformation never changes, only the engine binding does | Read left to right: every part of speech maps to a xorq call

We transform the data with xorq expressions (mutate, filter, group by, aggregate, order by), use templates to bind the sentence to any repo (dbt-core, polars, …) and modifiers to bind the engine (or a fitted ML model), and manifest it as a unique hash. Each named expression can be registered as a content-addressed, git-versioned catalog entry with the catalog being the shelf of all of them (such as star_velocity_30d, download_trend_90d), each reproducible on its own because xorq bundles the source read at build time.

Then we run those reusable metric definitions on any engine with pre-existing pipelines to make it easier to run with make preview, which executes every named expression, while make catalog registers the curated ones as versioned entries. The default engine is DataFusion, but I added DuckDB and Snowflake, using xorq’s multi-compute engine capabilities.

Mapping the Grammar to Xorq

To illustrate the grammar and expression of the grammar in plain Python, here is how github.py could look, in six lines:

def star_velocity_30d(con, repo):          # TEMPLATE: bind this sentence to any repo
    cutoff = datetime.now() - timedelta(days=30)
    t = con.table("raw_github_events")      # NOUN: a lazy pointer, no computation yet
    return (
        t.filter([                          # VERB
            t.repo_name == repo,
            t.type == "WatchEvent",
            t.created_at > cutoff,
        ])
        .mutate(week=t.created_at.truncate("W"))   # VERB
        .group_by("week")                          # VERB
        .agg(stars=t.id.count())                   # VERB
        .order_by("week")                          # VERB
    )

Every one of these functions and the Makefile lets us run the grammar as steps of the grammatical grammar, building a sentence like this. I added this for illustration, but as an overview, if we map the commands to a xorq call, we can see the connection from the grammar of data to the xorq function:

make target xorq / Python call grammar part
make noun con.table(...) noun (source)
make verb .filter/.mutate/.agg (deferred expr) verbs (transform)
make template star_velocity_30d(con, repo) template (bind by arg)
make modifier settings.backend(engine) modifier (engine/fit)
make lineage expr.op() / ibis.to_sql / expr.ls lineage (what xorq sees)
make manifest xorq build expr.py -e star_velocity model once → expr.yaml
make catalog xorq catalog add … → ./catalog versioned entry store
make run-sentence digest.main().execute() execute the sentence
make engines settings.backend(x) + expr.execute() represent everywhere
Ingestion and installation are excluded on purpose here

To initialize, we also need make install to install dependencies and make ingest to load data with dlt locally. make run-sentence or make full-pipeline runs the full grammar of data. Additional commands preview catalog catalog-run summary digest ml test clean are added separately.

The Data Engineering Digest: What We Found

If we run the demo project with the 90-day windows (PyPI max provides this window without storing data ourselves), we get a couple of interesting insights that this demo project produces from digesting the full Data Engineering ecosystem. The digest ranks tools and terms of data engineering by their momentum with PyPI download growth and GitHub data, and enriches each tool with its Bluesky chatter on socials1.

This is how it looks with make digest:

package          growth_pct  recent_daily  total_downloads   buzz
xorq                  130.9           968          125,104     10
ibis-framework         49.5        90,384       13,655,147     15
sqlglot                44.7     2,484,432      379,638,408     17
duckdb                 42.5     1,712,837      263,396,944  3,200
dagster                39.7       283,339       43,951,132    275
polars                 35.8     2,160,110      339,334,602     61
pydantic               30.0    35,567,354    5,688,585,163     90
sqlmesh                28.6        17,964        2,885,574     13
pyiceberg              24.4     1,300,103      211,933,200    640
prefect                19.7       441,434       73,236,617     38
dbt-core                9.9     3,527,875      609,587,211    369
apache-airflow          8.6       702,582      122,123,125    113
dlt                   -13.8       237,792       46,500,226    178

Interesting to see that we get a rise of the dataframe & query engines: the fastest-growing DE packages over the last 90 days are all query/dataframe engines:

  • ibis-framework +49%, sqlglot +45%, DuckDB +42%, Polars +36%.

One caveat: I included xorq. It has the biggest growth, but it’s also the smallest package overall and still early, so the growth can have more spikes (we went from ~400 to ~968). And it’s worth mentioning that xorq, the tool we use for the grammar series, uses and is built on ibis, its great expression layer, as xorq builds on a rising dataframe for a composable, in-process engine.

We also see that sqlmesh keeps climbing even after the Fivetran acquisition and dbt Labs joining Fivetran:

  • sqlmesh grew +28.6% over 90 days, while dbt-core grew the slowest of the pack (+9.9%).

Not surprising, DuckDB wins social media attention. On Bluesky, its buzz score is 3200, 5× more than the next tool (pyiceberg 640, dbt 369). DuckDB is the tool that is both growing fast and the “loudest”.

Number 1 by raw downloads is pydantic. By absolute volume, pydantic tops everything at 5.64B downloads (~9× dbt-core’s 610M):

── Naive leaderboard: raw PyPI downloads (all-time) ──
package            downloads
pydantic       5,639,574,035
dbt-core         603,522,561
sqlglot          376,710,633
polars           335,901,439
duckdb           261,307,529

This is probably because Pydantic is powering half of PyData while it doesn’t really have a lot of hype, but is a Data Engineering Toolkit for data validation and settings management using Python type annotations, used by any data engineer. It’s a part of the grammar that makes sure re-runs run deterministically.

What the blogs (RSS) say

My RSS feeds were the noisiest signal: titles skew to whoever writes the most, e.g. Mr. Robin Moffatt (rmoff’s random ramblings alone are ~690 of ~1,600 articles) 😉. General sentiment clusters around the incumbents (dbt, dagster, Spark, Snowflake), while the surging engines (polars, SQLMesh) are barely mentioned. Blog coverage lags the download signal, which is precisely why the digest triangulates four sources instead of trusting just one.

The Whole Stack in One File: stack.yaml and the Exchangeable Engine

The key is really that model and execution are separated. Once the stack is defined (in our demo project I used stack.yaml as a declarative config), we can just change the engine by editing a YAML file, and everything else stays the same:

engine: datafusion              # duckdb | datafusion | snowflake  <- swap the engine here
db_path: de_ecosystem.duckdb

sources:                        # nouns — dlt ingests these (outside the grammar)
  bluesky:
    max_pages: 40
  github:
    slice: "data/raw/gharchive/*.json.gz"

momentum:                       # the digest metric
  window_days: 90               # 90 = laptop pulse; 365 = warehouse year-in-review
  tools: [dbt-core, dagster, dlt, ibis-framework, xorq, apache-airflow, polars,
          duckdb, great-expectations, pyiceberg, prefect, mage-ai, sqlmesh,
          soda-core, pydantic, sqlglot]

Imagine in your deploy scripts for dev you’d use DataFusion and on prod you’d just specify Snowflake as the variable. No implementation code is touched as in a typical imperative workflow.

With make manifest we can compile the full data stack’s expressions into a deferred execution file builds/<hash>/expr.yaml, which builds deterministically and is diffable. The demo shows that point well: if we make the above engine change to engine=duckdb from datafusion and rebuild, the semantic change shows up as a reviewable git diff.

Let’s run manifest with engine: datafusion:

make manifest
....
Written 'star_velocity' to builds/10671a1c33cf
builds/10671a1c33cf

Now changing engine: duckdb and re-running:

make manifest
....
Written 'star_velocity' to builds/f02f2c4dca81
builds/f02f2c4dca81

The diff shows a couple of interesting bits, e.g. xorq changed the scale for timestamps for duckdb (see both files datafusion and DuckDB):

-      scale: 9      # datafusion → nanosecond timestamps
+      scale: ~      # duckdb → microsecond timestamps

The profile.yaml shows the literal change we did:

-  con_name: xorq_datafusion
-    config: ~
+  con_name: duckdb
+    database: ":memory:"
+    read_only: false

This shows that swapping the engine by configuration has a real impact: DataFusion carries Timestamp(scale=9) (nanoseconds) and DuckDB defaults to microseconds. The manifest captures that difference explicitly even before we run anything, reviewable instead of a silent runtime error through the built expression graphs before executing them with one expression, many engines.

Additional Capabilities of the DE Digest Project

Apart from the grammar we look at, the project comes with catalog and ML functions to showcase the full capabilities of xorq and what you typically want to do in a data engineering project.

The full data lineage can also be tracked and shown.

The Catalog and ML Capabilities

The project has added a catalog to retrieve versioned entries of our metric and created artifacts. With make catalog, this project with xorq-catalog registers a curated set of expressions as versioned, content-addressed entries in a local, git-backed catalog at ./catalog. The catalog.yaml manifest is the shelf where entries are addressed by content hash, and aliases are the human-readable handles:

entries:                     # content hashes (a new hash = a new version)
  - 5adcf6bccba9             # dbt-star-velocity
  - a4f38c87079a             # dbt-download-trend
  - a636a49497fb             # dbt-momentum
  # …
aliases:
  - dbt-star-velocity
  - dbt-download-trend
  - dbt-momentum
  - duckdb-buzz
  - dbt-mentions
  - dbt-health
  - rising-tools

Each entry holds its own metadata sidecar with the expression + metadata + cached result, addressed by hash. Here is dbt-download-trend, the whole metric captured declaratively (kind, output schema, the compiled SQL over the source table, and the cache key):

md5sum: 5a7d75db1e0cf46dc59a713a6ffa9573
backends: [xorq_datafusion]
expr_metadata:
  kind: expr
  schema_out:
    date: timestamp(9)
    downloads: int64
    package: string
  cache_keys:
    key: xorq_cache-snapshot-4788a41e541c0526df207922813c0377
    relative_path: parquet
  sql_queries:
    - - main
      - xorq_datafusion
      - |-
        SELECT "t0"."date", "t0"."downloads", "t0"."package"
        FROM "raw_pypi_downloads" AS "t0"
        WHERE "t0"."package" = 'dbt-core'
          AND "t0"."category" = 'without_mirrors'
          AND "t0"."date" > DATE_TRUNC('DAY', '2026-05-14')
        ORDER BY "t0"."date" ASC

Because xorq bundles the source read at build time, an entry is self-contained: make catalog-run ALIAS=dbt-momentum re-executes it with no re-ingest. Edit an expression (say days=30 → 90) and its content hash changes, so it registers as a new version while the old one stays retrievable.

The project also ships a small ML task (build feature matrix, split, fit a sklearn LogisticRegression wrapped in a xorq Pipeline, predict an adoption label) to mimic prediction and training of a real-life project. It could be interesting to add more sophisticated logic once more data is downloaded, and potentially even more sources are added.

With that example, we use the grammar of data with xorq to get essentially needed steps as part of the Data Engineering Lifecycle. We can have full lineage, we get deterministic reruns, we get the metrics in the catalog. Plus, we get extensibility at each stage if we need it.

E.g. extend the metrics from ‘catalog -> Boring Semantic Layer’, or use dlt for ingestion as I did in this project to load data incrementally into a staging area.

The Lineage

For instance, make lineage shows everything xorq knows about a sentence before it touches a single row: its source nouns, output schema, bound engine, and the verbs compiled to SQL. This is how it looks:

LINEAGE — what xorq knows before any data is read:

>> expr.op().find(DatabaseTable)   — the source nouns this expression reads:
     ['raw_github_events']

>> expr.schema()                   — the output columns (resolved at build time):
     ibis.Schema { week: timestamp; stars: int64 }

>> expr.ls.backends                — the engine(s) bound to it (MODIFIER):
     ['Backend']

>> ibis.to_sql(expr)               — the VERB chain, compiled to SQL:
SELECT "t1"."week", COUNT("t1"."id") AS "stars"
FROM ( SELECT DATE_TRUNC('WEEK', "created_at") AS "week", "id"
       FROM "raw_github_events"
       WHERE "repo_name" = 'dbt-labs/dbt-core' AND "type" = 'WatchEvent' )
GROUP BY "t1"."week" ORDER BY "t1"."week"

If you use the xorq Desktop app, this is integrated into a nice UI like this:

The xorq Desktop app: on the left, an answer to the question of the busiest airport carries a VERIFIED banner with 2 of 2 obligations discharged, each figure linked to the expression that produced it. On the right, the Lineage tab shows the composed expression graph: LIMIT, SORT, AGGREGATE, REMOTETABLE and CACHEDNODE operations over one catalog-code source.

Lineage view in xorq’s upcoming Desktop app

Machine Learning as Another Modifier

The tool-adoption model reuses the same four parts of speech: fit(...) attaches a modifier - the fitted model rides along as metadata (xorq tracks a training_hash) without changing what the expression computes, which is precisely Part 1’s definition of a modifier. predict(...) is just a verb returning an Ibis table expression. And because predict is an expression, xorq build can manifest the inference pipeline too. Deploying a model collapses into the same write → manifest → execute cycle as deploying a metric.

Add Semantic Layer

Here we use the inbuilt catalog and metrics are defined as expressions in Ibis. If you like, you could lift the catalog metrics into the Boring Semantic Layer for dimensions/measures. BSL is built by Hussain, the creator of xorq, and is tightly integrated. Check GitHub - boringdata/boring-semantic-layer if that is of interest, or check a recent article I wrote, Why Semantic Layers Matter, with a practical example.

So What Did We Learn Applying the Grammar for Data?

Part 1 introduced the concept of grammar for data. With the data engineering digest project, we applied it to a demo project to capture the momentum of a tool in the data ecosystem. We mapped xorq’s features to the grammar to illustrate it better, and we’ve built a deterministic and versionable data stack with a single stack.yaml, providing the end-to-end data capabilities a data engineering project needs. Everything supports the case for a grammar for data.

xorq gives us the bottom-up approach, working with our data, mapping all parts of the DE lifecycle, running it locally on multiple engines and discovering errors at pre-run time. Ultimately, shifting left once more.

The grammar buys us two guarantees: answers that are faithful to the expression that produced them, and reproducible whenever we rerun it. But that doesn’t always mean correct. For example, the expression can still encode the wrong interpretation of the question. That third guarantee comes from reviewed definitions (the catalog entries and semantic models we built above). You can learn more about it in a follow-up article Faithful, Reproducible, Wrong, where a checker plus a reviewed semantic model takes an agent from 4/100 to 100/100 correct answers on the same question.

Another related question is: if the grammar defines and the manifest records, who verifies it? That’s what we look at next in this series. You can get a sneak peek with a checker inside an agent harness (pi), where every quantitative claim must be discharged by rerunning a content-addressed expression with its lineage intact. You can watch the loop in action in this recording and reproduce it from the pi-xorq-verification-example repo. More on verification in Part 3.

Appendix

Additional project information for running the GitHub project, if you are interested in running it yourself and having a closer look.

Quick Note on dlt Ingestion

All four sources use idempotent upsert, so re-running only adds new rows / updates existing ones (never duplicates):

  • RSS + Bluesky → dlt write_disposition="merge" on primary_key="id" (article URL / post URI)
  • PyPIINSERT OR REPLACE on (package, date, category)
  • GitHubINSERT OR IGNORE on event id

Loading 1 Year on Snowflake

To run a full year or more, just use Snowflake, for example by installing xorq[snowflake] and ingesting the data into Snowflake. Re-running is always safe (idempotent merge), so just pull more and re-run. Each source has its own ceiling:

# 1. More Bluesky history — edit src/de_ecosystem/ingest/bluesky.py
MAX_PAGES = 200            # 40 → 200, pages much further back in time

# 2. More GitHub events — download extra hours into data/raw/gharchive/ (gitignored)
for h in $(seq 0 23); do
  wget -nc -P data/raw/gharchive "https://data.gharchive.org/2026-08-05-$h.json.gz"
done

# 3. Re-ingest (safe, merges) and re-run the whole sentence
make ingest
make run-sentence

The one limit is that the live pypistats.org API only serves ~180 days of downloads, so PyPI momentum caps at a 90-day-vs-prior-90-day window.

A full-year digest needs a source that actually stores that history, and that is where Snowflake (or BigQuery’s public bigquery-public-data.pypi.file_downloads) comes in, both holding years of daily download stats.

Because the grammar separates what from where, you don’t rewrite the metric. You just point the noun at the warehouse and run the same sentence:

uv sync                       # the snowflake driver ships as a base dependency
# put SNOWFLAKE_ACCOUNT / USER / PASSWORD / ROLE / DATABASE / WAREHOUSE / SCHEMA
# in .env (or export them in your shell)
make engines                  # runs star_velocity on DuckDB, DataFusion AND Snowflake

And with the same verb and only the engine binding changed:

con = settings.backend("snowflake")                             # xo.snowflake.connect_env()
momentum = download_momentum(con, "duckdb", window_days=365)    # a full year

The catalog code itself has no engine awareness. settings.backend("duckdb" | "datafusion" | "snowflake") is the only thing that changes. Define once, represent it everywhere: your laptop for a 90-day pulse, a warehouse for the year-in-review.

The one-time grants (de_digest must exist and DLT_LOADER_ROLE needs CREATE TABLE and CREATE STAGE, since the loader stages the tables before copying them in):

-- run once as ACCOUNTADMIN; the demo materialises the 4 raw tables into de_digest.PUBLIC
USE ROLE ACCOUNTADMIN;

CREATE DATABASE IF NOT EXISTS de_digest;   -- PUBLIC schema is created automatically

GRANT USAGE          ON DATABASE  de_digest        TO ROLE DLT_LOADER_ROLE;
GRANT USAGE          ON SCHEMA    de_digest.PUBLIC TO ROLE DLT_LOADER_ROLE;
GRANT CREATE TABLE   ON SCHEMA    de_digest.PUBLIC TO ROLE DLT_LOADER_ROLE;
GRANT CREATE STAGE   ON SCHEMA    de_digest.PUBLIC TO ROLE DLT_LOADER_ROLE;  -- adbc/pandas bulk load
GRANT USAGE, OPERATE ON WAREHOUSE COMPUTE_WH       TO ROLE DLT_LOADER_ROLE;

GRANT ROLE DLT_LOADER_ROLE TO USER loader;   -- if not already

And the tables it created in Snowflake if everything works correctly:

Snowflake's Database Explorer sidebar showing the DE_DIGEST database, its PUBLIC schema, and four tables: articles, posts, raw_github_events and raw_pypi_downloads.

If you got interested and want to know more about how xorq works, check out the docs, or the open source repo on GitHub.

Also check out the upcoming xorq Desktop app (join the waitlist), which targets data analysts from the top down. It’s a desktop app on macOS and a trusted harness. It has additional features that do a verification check and more.

Footnotes

  1. Bluesky has a firehose and can simply be queried with DuckDB, e.g. see Querying Bluesky with DuckDB and SQL↩︎