January 28, 2026
Why We Built a Unified Abstraction
Modern data teams are drowning in tools. A typical stack in 2026 might include dbt for SQL transformations, Airflow or Dagster for orchestration, dlt or Airbyte for ingestion, custom Python scripts for anything dbt cannot express, and a growing pile of glue code to connect it all. Each tool brings its own configuration syntax, deployment model, testing framework, and monitoring approach. The result is that data engineers spend more time managing tools than building pipelines.
We built Interlace because we believe there is a better way. A single @model abstraction that handles transformations, orchestration, and materialization — regardless of whether you write Python or SQL.
The Fragmentation Problem
Consider what happens when a data engineer joins a new team today. Before writing a single transformation, they need to learn:
- dbt for SQL models, sources, tests, and macros
- Airflow or Dagster for scheduling, sensors, and retry logic
- dlt, Airbyte, or Fivetran for data ingestion
- Python scripts for transformations that do not fit SQL
- Spark or Pandas for heavier processing
- Custom YAML/JSON for pipeline configuration
Each tool solves one piece of the puzzle well, but the seams between them create real costs. While dbt supports both SQL and Python models, Python support is limited to certain platforms, and you still need an orchestrator to bridge dbt with ingestion and other pipeline stages. Your ingestion layer (dlt, Airbyte) writes to a landing zone that your transformation layer (dbt) reads from — but the handoff is implicit and fragile. Testing a dbt model uses one framework; testing a Python script uses another; testing a dlt pipeline uses yet another. Deploying dbt is one workflow; deploying Dagster is a completely different one.
These are not hypothetical problems. When tools do not share context, failures fall through the cracks — stale data goes unnoticed, upstream failures silently cascade, and debugging means jumping between dashboards that each tell part of the story.
Lessons from Software Engineering
Software engineering solved similar fragmentation problems decades ago through abstraction:
- Functions unified computation — you do not need different tools for arithmetic vs. string processing
- Interfaces decoupled implementation from usage — callers do not need to know how something works internally
- Package managers unified dependency resolution — no more manual dependency tracking
Data engineering is following the same arc, just a decade behind. We are still in the era where each concern (transformation, orchestration, testing, deployment) has a dedicated tool with its own mental model.
The @model Abstraction
Interlace is built around a single idea: a model is a function that takes zero or more input tables and returns one output table, with metadata about how to persist and update the result.
@model(
name="customer_lifetime_value",
materialize="table",
strategy="merge_by_key",
primary_key=["customer_id"]
)
def customer_lifetime_value(
customers: ibis.Table,
orders: ibis.Table
) -> ibis.Table:
return (
orders
.join(customers, "customer_id")
.group_by("customer_id")
.agg(
total_spend=orders.amount.sum(),
order_count=orders.amount.count(),
first_order=orders.created_at.min(),
)
) This is a complete pipeline step. No YAML configuration. No separate orchestration definition. No external scheduler. From this single decorator, Interlace derives:
- Dependencies: The function parameters
customersandordersare upstream models — Interlace builds the DAG automatically - Materialization:
materialize="table"means Interlace creates or replaces the output table - Update strategy:
merge_by_keyoncustomer_idmeans incremental updates merge into existing rows - Execution order: Topological sort of the dependency graph determines when this model runs
The same abstraction works for SQL:
-- models/active_users.sql
-- @model(name="active_users", materialize="table")
SELECT * FROM users WHERE status = 'active' No ref() function needed — Interlace parses your FROM and JOIN clauses to build the dependency graph automatically.
The same abstraction extends to ingestion. A model with no input parameters is a source — it pulls data in from the outside world:
@model(
name="raw_events",
materialize="table",
strategy="append",
)
def raw_events():
import httpx
response = httpx.get("https://api.example.com/events")
return response.json() # list of dicts → Interlace converts automatically This is just a Python function that returns data. You can use httpx, dlt, a CSV reader, or anything else — the @model interface stays the same. Downstream models depend on raw_events like any other model. No separate ingestion config, no implicit handoff between tools, and the same testing pattern applies.
Python models can depend on SQL models. SQL models can depend on Python models. Ingestion models feed into transformation models. The interface is the same.
What This Unlocks
Ingestion as a First-Class Citizen
Most tools treat ingestion as a separate concern. You configure Airbyte connectors or write dlt pipelines, then hope the data lands where your transformations expect it. With Interlace, ingestion is just another model — it participates in the same DAG, the same lineage graph, and the same testing framework. When an API changes its response format, you find out in the same place you find out about a broken join.
Tools like dlt are excellent at what they do — schema inference, incremental loading, and normalization. Interlace does not try to replace them. Instead, you can use dlt inside a model, getting the best of both worlds: dlt handles the extraction mechanics while Interlace handles the orchestration, dependencies, and lineage.
Reduced Cognitive Load
One abstraction replaces five tools. A new team member learns @model and can immediately read, write, and debug any pipeline step — ingestion, transformation, Python, or SQL.
Natural Composability
Because every model has the same shape (tables in, table out), composition is trivial. You do not need adapters, bridges, or glue code to connect Python and SQL transformations.
Consistent Testing
One testing pattern works everywhere. Mock the input tables, call the function, assert on the output. No separate testing frameworks for SQL vs. Python.
def test_customer_lifetime_value():
customers = [
{"customer_id": 1, "name": "Alice"},
{"customer_id": 2, "name": "Bob"},
]
orders = [
{"customer_id": 1, "amount": 100, "created_at": "2026-01-01"},
{"customer_id": 1, "amount": 200, "created_at": "2026-01-15"},
{"customer_id": 2, "amount": 50, "created_at": "2026-01-10"},
]
result = customer_lifetime_value(customers, orders)
assert result.count().execute() == 2 Backend Portability
The same model runs on DuckDB during development and PostgreSQL in production. Ibis’s deferred execution means your transformation logic is not coupled to a specific database.
The Road Ahead
We are not claiming Interlace replaces every tool in every scenario. dbt has a massive ecosystem. Airflow handles complex cross-system orchestration. dlt is excellent at data loading. Spark processes data at scales Interlace is not designed for.
But for the core workflow of building, testing, and running data transformations — the work data engineers do every day — we believe a unified abstraction is simpler, faster, and more reliable than stitching together multiple tools.
Get started with Interlace or explore the documentation to see how the @model abstraction works in practice.