Portable data pipelines with Dagster + Ibis: making migrations less painful
Table of Contents
One Ibis expression, many engines. A small Dagster repo exploring how future engine migrations could become configuration and integration work instead of another rewrite. This experiment started with a question that had been on my mind for a while: if a data pipeline has to move from one execution engine to another, how much of it should really need to be rewritten? Imagine a typical PySpark codebase that eventually needs to be rewritten to run on a warehouse such as BigQuery. Every Spark DataFrame call, Of course, migrations are sometimes necessary. Platforms differ in capability, performance, and cost, but the decision is not always purely technical. Regulation, data-sovereignty requirements, and broader political changes can also influence where data and workloads are allowed to run. Recent European proposals aimed at reducing dependence on foreign cloud providers are one example of how those pressures can shape infrastructure choices (Reuters). What interested me was whether the transformation logic had to be so tightly coupled to choices that may change for reasons beyond the code itself. So I decided to explore a narrower question: Can transformation logic be written so that switching execution engines becomes mostly configuration and integration work, rather than another rewrite? The same pipeline code, written once; the deployment config picks the backend. I built a small proof of concept to test that idea. While looking at possible approaches, I found Ibis, a dataframe-style expression API that compiles the same Python expressions for DuckDB, PySpark, BigQuery, Snowflake, Polars, and several other backends. Ibis minimizes the migration effort, but a translation layer alone doesn’t run a pipeline. The experiment also needed structure and orchestration, the part teams often fill with an orchestrator plus in-house framework code. Dagster can provide that with many features provided out-of-the-box. It is an orchestrator built around software-defined assets, giving the structure dbt users are used to and more: models, lineage, tests, schedules. With this combination, you can port your logic to Ibis once, and engine migrations become mostly config diffs. Notice the port to Ibis is real work, but the transformation layer no longer needs a full rewrite on every engine change. You can check the repository in github.com/luizvbo/ibis-dagster-example I deliberately kept the pipeline ordinary: bronze → silver → gold. The demo pipeline: a deliberately ordinary bronze → silver → gold flow. * I will come back to Each deployment bundles an execution backend with its storage configuration. Selecting one takes a single environment variable: The asset graph, transformations, and checks stay the same. Deployment configuration chooses the engine and storage. Regardless of the selected deployment, Dagster presents the same logical asset graph: There is an important boundary already: a backend the repository does not implement yet, such as BigQuery, still needs a connection branch in There is no That separation is the heart of the experiment. The transformation does not need to know where it will run. Dagster’s I/O manager became the boundary between logical assets and physical storage. Assets declare inputs and outputs as The I/O manager sits between backend-agnostic assets and the deployment-specific connection and storage. This means that changing the physical source from a local CSV to Parquet in a lake is configuration: This is also where the proof of concept is intentionally simple. Asset keys are flattened to table names, writes are full refreshes, and production concerns such as catalog/schema mapping, incremental loads, partitioning, and merge behavior would require more design. One of my first questions was whether portability would survive contact with data-quality checks. In this example, it does: the checks are also Ibis expressions evaluated by the selected backend. The repository includes equivalents of the usual dbt generic tests: Dagster also provides scheduling, so the example includes a daily schedule for the full job. It is stopped by default and can be enabled from the UI. This was the part of the experiment that made the idea feel concrete. The same The repository turns this into a CI check. A parametrized test compiles every transform for each target dialect. If someone introduces an operation that a target compiler cannot express, the test fails before deployment. Runtime tests still matter because successful compilation does not prove identical behavior, connector compatibility, or performance, but compilation is a useful first guardrail. A This is where the experiment became more valuable to me. It did not simply confirm the idea; it found the edge. “Backend-agnostic” means portable across the operations that each backend can express. It does not mean every Ibis expression works on every backend. The repository deliberately includes At the time of this experiment, Ibis’s Polars backend did not translate this window operation. Materializing that asset on Polars therefore fails with an I actually like that failure. It is early, deterministic, and explicit about the unsupported operation. It is much easier to reason about than a pipeline that runs and quietly returns different results. The repository demonstrates three possible responses: I also found a subtler trap: Before relying on a portability claim, check Ibis’s per-backend operation support matrix, then test the exact expressions and data types your pipeline uses. This is a fair question, especially because dbt already solves a large part of the portability and testing problem for SQL-centric teams. If your transformations are SQL and your targets are SQL warehouses, dbt is likely the simpler and more mature choice. Dagster + Ibis becomes interesting when the work extends beyond SQL, when you want one lineage graph across tables and non-table assets, or when engine portability is a deliberate architectural goal. The question I wanted to explore was this: if a codebase already needs to be rewritten to move to a different platform/engine, could choosing a portable abstraction make the next migration smaller? I started this experiment because I wanted to understand whether pipeline portability could be more than an architectural aspiration. In particular, I wanted to see how much transformation logic could genuinely survive a change of execution engine, and where the abstraction would begin to leak. The answer is not a universal “yes,” but it is more encouraging than I expected: I would not describe this as “write once, run anywhere.” That promise is too broad, and the experiment itself disproves it. A more honest summary is: Port the transformation logic once, test the portability boundary continuously, and make the next migration about configuration and explicit integration work rather than rewriting the business logic from scratch. For anyone curious about reducing the coupling between transformation logic and its execution engine, I think it is an approach worth exploring. Introduction
spark.sql(...) string, and .toPandas() escape may become migration work. After completing that rewrite, a future platform change could require much of the same work again.
flowchart LR
code["Transformation
logic
(written once)"]
code -->|"Deployment
configuration selects:
- Engine
- Storage"| dep
subgraph dep["Pick a backend"]
direction TB
a["duckdb
local csv files"] ~~~ b["polars
same csv files"] ~~~ c["pyspark
parquet lake"] ~~~ d["bigquery, trino, ...
a new connection branch + config"]
end
The demo
flowchart LR
subgraph sources["External sources:
- CSV locally
- Parquet/tables in production"]
events
products
end
subgraph bronze["Bronze:
- Land sources into managed tables"]
raw_events
raw_products
end
subgraph silver["Silver:
- Normalize types
- Trim/lower strings
- Dedupe
- Validate"]
cleaned_events
end
subgraph gold
daily_active_users
category_revenue
latest_event_per_user["latest_event_
per_user*"]
end
events --> raw_events
products --> raw_products
raw_events --> cleaned_events
cleaned_events --> daily_active_users
cleaned_events --> category_revenue
raw_products --> category_revenue
cleaned_events --> latest_event_per_user
latest_event_per_user. It became the most interesting part of the experiment.DAGSTER_DEPLOYMENT_NAME=local uv run dagster dev # duckdb + local CSVs
DAGSTER_DEPLOYMENT_NAME=polars uv run dagster dev # polars + same CSVs
DAGSTER_DEPLOYMENT_NAME=prod uv run dagster dev # pyspark + parquet "lake" sourcesIbisResource and its own storage configuration. The claim is not that adding an engine requires no work. It is that the transformation functions should not need to be rewritten with BigQuery-specific APIs. The three pieces
1. Transformations are pure Ibis expressions
# transforms.py: no engine-specific imports, only ibis
def clean_events(raw_events: ir.Table) -> ir.Table:
normalized = raw_events.mutate(
ts=_.ts.cast("timestamp"),
amount=_.amount.cast("float64"),
event_type=_.event_type.lower().strip(),
)
return (
normalized.mutate(
amount=_.amount.fill_null(0.0),
date=_.ts.truncate("D"),
)
.filter(_.event_id.notnull(), _.user_id.notnull())
.distinct()
)duckdb., spark., or pl. in these functions. They take an Ibis table expression and return another one. They build a lazy expression tree; execution happens later, when the I/O manager asks the selected backend to materialize the result. 2. An I/O manager owns data access and persistence
ir.Table; IbisIOManager decides how those tables are read and written.
flowchart LR
subgraph assets["Asset code (backend-agnostic)"]
fn["transform fn
ir.Table → ir.Table"]
end
subgraph io["IbisIOManager (the boundary)"]
li["load_input"]
ho["handle_output:
con.create_table"]
end
subgraph be["Deployment config picks"]
res["IbisResource
backend conn."]
st["storage:
- tables
- csv
- parquet"]
end
li --> fn --> ho
li --> res
ho --> res
res -->|reads / writes| st
load_input uses con.table(name) for pipeline tables, or con.read_csv(...), con.read_parquet(...), and similar methods for configured external sources.handle_output uses con.create_table(asset_name, expr, overwrite=True) and records useful metadata such as row count, a preview, and compiled SQL when the backend produces SQL.# Same logical source, different physical reads
"local": IbisIOManager(
sources={"events": {"format": "csv", "path": "data/raw_events.csv"}}, ...),
"prod": IbisIOManager(
sources={"events": {"format": "parquet", "path": "${DATA_LAKE}/landing/events/"}}, ...), 3. Quality gates use the same abstraction
@dg.asset_check(asset=cleaned_events, blocking=True)
def cleaned_events_no_null_user_ids(
ibis: IbisResource,
) -> dg.AssetCheckResult:
n = _violations(ibis, "cleaned_events", _.user_id.isnull())
return dg.AssetCheckResult(
passed=n == 0,
metadata={"null_user_ids": n},
)not_null, unique, accepted_values, and relationships, plus a few custom checks. A blocking check prevents downstream materialization when it fails. The moment it clicked: one expression, three dialects
daily_active_users(clean_events(raw_events)) expression can be compiled for multiple targets without running an engine, providing credentials, or starting a JVM. The relevant backend packages still need to be installed, but no live connection is required.-- DuckDB
... DATE_TRUNC('DAY', "t0"."ts") AS "date"
... TRIM(LOWER("t0"."event_type"), ' ')
-- PySpark
... DATE_TRUNC('day', `t0`.`ts`) AS `date`
... TRIM(' ' FROM LOWER(`t0`.`event_type`))
-- BigQuery
... TIMESTAMP_TRUNC(`t0`.`ts`, DAY) AS `date`
... TRIM(LOWER(`t0`.`event_type`), ' ')DATE_TRUNC versus TIMESTAMP_TRUNC, double quotes versus backticks, and different TRIM syntax are exactly the details I would rather not maintain by hand in three versions of the same pipeline.prek pre-push hook runs Ruff, ty, and the test suite before code is pushed. The honest part: portability has edges
latest_event_per_user, implemented with a window function:events.mutate(
rn=ibis.row_number().over(
ibis.window(
group_by="user_id",
order_by=[_.ts.desc(), _.event_id.desc()],
)
)
).filter(_.rn == 0)OperationNotDefinedError.latest_event_per_user FAILED:
ibis.common.exceptions.OperationNotDefinedError:
No translation rule for WindowFunctiongroup_by and joins. It is more verbose, but works across the tested backends.ibis.row_number() is zero-based. It compiles to ROW_NUMBER() - 1, so filtering on rn == 1 would return the second row rather than the first. That was a useful reminder that an abstraction can give us portable syntax, but it does not remove the need to understand its semantics. Why not just dbt?
dbt (+ adapters) Dagster + Ibis Transform language SQL Python expressions compiled to SQL/plans Engine portability Adapter-aware SQL and macros One expression API across backends Non-SQL engines Limited Polars, DataFusion, and others Python-native work such as ML, APIs, and files Separate or adapter-dependent Can live in the same asset graph Tests dbt test@asset_checkScheduling dbt Cloud or an external orchestrator Native schedules and sensors Incremental models Mature built-in model Requires partitions and backend-aware write design Ecosystem maturity Larger and battle-tested Younger combination with more custom plumbing Caveats I would keep in mind
What I took away from the experiment