<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mark Alford</title>
    <description>The latest articles on DEV Community by Mark Alford (@5c4989ca297ed).</description>
    <link>https://dev.to/5c4989ca297ed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4078264%2Fc70b760a-399a-4a1b-8d98-62a7f51e1efe.jpg</url>
      <title>DEV Community: Mark Alford</title>
      <link>https://dev.to/5c4989ca297ed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/5c4989ca297ed"/>
    <language>en</language>
    <item>
      <title>Two Frameworks - dbt &amp; SQLMesh, One Owner</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/two-frameworks-dbt-sqlmesh-one-owner-4p3n</link>
      <guid>https://dev.to/5c4989ca297ed/two-frameworks-dbt-sqlmesh-one-owner-4p3n</guid>
      <description>&lt;p&gt;On September 2025, Fivetran &lt;a href="https://www.fivetran.com/press/fivetran-acquires-tobiko-data-to-power-the-next-generation-of-advanced-ai-ready-data-transformation" rel="noopener noreferrer"&gt;acquired Tobiko Data&lt;/a&gt;, the company behind SQLMesh and SQLGlot. A month later it announced an all-stock merger with dbt Labs, which &lt;a href="https://www.fivetran.com/press/fivetran-dbt-labs-complete-merger-to-create-the-data-infrastructure-for-trusted-ai-agents" rel="noopener noreferrer"&gt;completed on 1 June 2026&lt;/a&gt;.&lt;br&gt;
One company now owns both open-source SQL transformation frameworks.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part where we do not cry wolf
&lt;/h2&gt;

&lt;p&gt;The obvious next paragraph writes itself: the open-source versions will be starved, the good features will move behind a login, you should get out now.&lt;/p&gt;

&lt;p&gt;We are not going to write that paragraph, because the evidence points the other way.&lt;/p&gt;

&lt;p&gt;In March 2026 Fivetran &lt;a href="https://www.linuxfoundation.org/press/linux-foundation-welcomes-sqlmesh-project" rel="noopener noreferrer"&gt;contributed SQLMesh to the Linux Foundation&lt;/a&gt; under open governance, with outside organisations as founding members. On the day the dbt merger closed, dbt Core v2.0 shipped with the Rust engine that had previously been &lt;a href="https://docs.getdbt.com/blog/dbt-core-v2-is-here" rel="noopener noreferrer"&gt;ELv2 relicensed to Apache 2.0&lt;/a&gt; and moved into the &lt;code&gt;dbt-core&lt;/code&gt; repository. Both projects are more permissively licensed after the deals than before them.&lt;/p&gt;

&lt;p&gt;If your worry was "will the licence get worse", the honest answer so far is no, and anyone telling you otherwise is selling something.&lt;/p&gt;

&lt;p&gt;We should also declare an interest. Interlace's entire intermediate representation is &lt;a href="https://github.com/tobymao/sqlglot" rel="noopener noreferrer"&gt;sqlglot&lt;/a&gt; — the parser that came with the Tobiko acquisition. Every model we compile is parsed, canonicalised and transpiled by a library Fivetran now stewards. We are not neutral observers of this consolidation; we are downstream of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that is actually true
&lt;/h2&gt;

&lt;p&gt;Here is the thing that is left after the FUD is stripped out, and it is not nothing.&lt;/p&gt;

&lt;p&gt;There were two independent answers to "what should a transformation framework be", built by two teams who disagreed with each other in public, and now there is one owner and one roadmap. That roadmap has been stated clearly and repeatedly: data infrastructure for AI agents, with a managed platform at the centre of it. It is a coherent bet and it may well be the right one.&lt;/p&gt;

&lt;p&gt;But a category with one roadmap is a category where certain arguments no longer have anywhere to happen. If you think the interesting problem is something other than agentic AI over a managed warehouse — if you think, for instance, that the interesting problem is that a working data platform currently requires four tools that do not know about each other — there is now one fewer venue for that argument.&lt;/p&gt;

&lt;p&gt;We started building before either deal closed. The consolidation did not create the reason; it just made the gap easier to describe.&lt;/p&gt;
&lt;h2&gt;
  
  
  Clean models, in Python or SQL
&lt;/h2&gt;

&lt;p&gt;That is the whole premise, and everything else is downstream of it.&lt;/p&gt;

&lt;p&gt;A model is a &lt;code&gt;.sql&lt;/code&gt; file containing SQL, or a &lt;code&gt;.py&lt;/code&gt; file containing a function that returns Arrow. Not SQL with a templating language wrapped around it, and not Python that generates SQL strings — either language, used as itself, in the same DAG, with dependencies read out of the code rather than declared alongside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- models/stg_orders.sql&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;subtotal&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_orders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# models/customer_risk.py
&lt;/span&gt;&lt;span class="nd"&gt;@model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;customer_risk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# a plain function over Arrow
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SQL file is valid SQL — you can paste it into any client. The Python file is a plain function — you can call it in a unit test with no warehouse. Neither is a template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things that follow
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nothing else has to run it.&lt;/strong&gt; Scheduling is in the tool: &lt;code&gt;schedule: {cron: "0 * * * *"}&lt;/code&gt; or &lt;code&gt;{every: "5m"}&lt;/code&gt; on a model, a durable work queue with leases, retries and cancellation, and &lt;code&gt;interlace serve&lt;/code&gt; as the long-running process that holds them. dbt is the clear contrast — orchestration is explicitly out of scope, and the answer is Airflow, Dagster or dbt Cloud: a second system, with its own deployment, that has to be told about your DAG.&lt;/p&gt;

&lt;p&gt;SQLMesh is closer, but the difference is worth being precise about, because "SQLMesh has a scheduler" is easy to say and slightly wrong. A model's &lt;code&gt;cron&lt;/code&gt; there declares how often that model is &lt;em&gt;due&lt;/em&gt;; &lt;code&gt;sqlmesh run&lt;/code&gt; works out what is due and evaluates it. Something still has to invoke &lt;code&gt;sqlmesh run&lt;/code&gt;, and &lt;a href="https://sqlmesh.readthedocs.io/en/stable/guides/scheduling/" rel="noopener noreferrer"&gt;their own guide&lt;/a&gt; says so directly: "You must run this command periodically with a cron job, a CI/CD tool like Jenkins, or in a similar fashion." That is a scheduler in the sense of knowing what ought to run. It is not a process that runs it, and the thing you end up deploying is still a crontab or a Kubernetes CronJob wrapped around a CLI. &lt;code&gt;interlace serve&lt;/code&gt; is that process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Events are a first-class input.&lt;/strong&gt; A &lt;code&gt;@stream&lt;/code&gt; is a durable HTTP ingestion endpoint: publishes land in a write-ahead log and are fsynced before the 200, then micro-batched into the warehouse exactly once via an in-warehouse watermark. The closest thing in shape is &lt;a href="https://blog.cloudflare.com/cloudflare-data-platform/" rel="noopener noreferrer"&gt;Cloudflare's Data Platform&lt;/a&gt;, where Pipelines ingests to durable streams and writes Iceberg into R2 — the same idea, and a good one. The difference is where it runs: theirs is a hosted platform billed per GB, ours is a process on your machine writing to your warehouse. If your events already belong on Cloudflare, use theirs.&lt;br&gt;
If they belong next to your models, there was not previously an option that was also a transformation framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Column-level lineage, in the open-source tool.&lt;/strong&gt; &lt;code&gt;interlace impact orders.amount&lt;/code&gt; gives the column-level blast radius — every downstream column derived from that one — and the same graph drives change classification, so a change that provably touches only columns nothing consumes does not rebuild the consumer. SQLMesh has column-level lineage too, and had it first. The comparison that matters is with dbt, where lineage of this kind lives in dbt Explorer rather than in the thing you run locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It can write to tables it does not own.&lt;/strong&gt; This is the one people are surprised by. A model can materialise into an external database — an attached Postgres or DuckDB, a serving table your application reads — with &lt;code&gt;merge&lt;/code&gt;, &lt;code&gt;append&lt;/code&gt;, &lt;code&gt;full_merge&lt;/code&gt; or windowed &lt;code&gt;incremental&lt;/code&gt; semantics, and it never drops that table. Or into a Parquet/CSV/JSON file. SQLMesh's &lt;a href="https://sqlmesh.readthedocs.io/en/stable/concepts/models/external_models/" rel="noopener noreferrer"&gt;external models&lt;/a&gt; are the mirror image: they describe tables it does not manage so it can &lt;em&gt;read&lt;/em&gt; them and include them in lineage. Reading outward is solved in both. Writing outward — reverse ETL as an ordinary model with an ordinary strategy, gated by the same checks — is the part we added.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where we are a cousin, not an original
&lt;/h2&gt;

&lt;p&gt;It would be dishonest to present the architecture as novel. Fingerprint the canonical form of a model, store the result in a versioned physical table, point a per-environment view at it, and promote by moving the view: that is SQLMesh's design, and they published it first. Our snapshot tables and virtual environments are the same idea, arrived at for the same reasons, on top of the same parser.&lt;/p&gt;

&lt;p&gt;What we did differently is scope. SQLMesh is a transformation framework that knows when its models are stale. Interlace is trying to be the whole spine — transformation, orchestration, ingestion and the control plane — in one process, on the theory that the seams between those four tools are where data platforms actually break.&lt;/p&gt;

&lt;p&gt;That is a bet, not a proof. It might be wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;No package ecosystem. &lt;code&gt;dbt_utils&lt;/code&gt; is a large body of tested SQL that thousands of people use, and we have the mechanism to write your own but not the library. No semantic layer: MetricFlow has no equivalent here. Far fewer battle-tested adapters — DuckDB and Postgres are exercised properly, the rest are honest alpha. And a project this young has had a fraction of the hostile production exposure that either of the others survived years ago.&lt;/p&gt;

&lt;p&gt;If you are running dbt or SQLMesh happily today, none of the above is a reason to move. Both are still open source, both got more open in the last year, and both have a company behind them that is considerably better resourced than we are.&lt;/p&gt;

&lt;p&gt;The argument for this one is narrower: if you have found yourself running a transformation tool plus an orchestrator plus an ingestion service plus the glue between them, and the glue is what keeps breaking, it is worth an afternoon.&lt;/p&gt;



&lt;p&gt;Start with the &lt;a href="https://dev.to/docs/getting-started"&gt;introduction&lt;/a&gt;, read &lt;a href="https://dev.to/blog/why-unified-abstraction"&gt;why a unified abstraction&lt;/a&gt; for the longer version of the argument, or install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;interlaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sql</category>
      <category>opensource</category>
      <category>python</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>interlace.sh: A Macro Is an Expression, Not a Template</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Sat, 15 Aug 2026 14:09:51 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/a-macro-is-an-expression-not-a-template-479c</link>
      <guid>https://dev.to/5c4989ca297ed/a-macro-is-an-expression-not-a-template-479c</guid>
      <description>&lt;p&gt;Here is &lt;code&gt;cents_to_dollars&lt;/code&gt; from dbt's own demo project. It converts an integer column of cents into a decimal of dollars, and it is five macros:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;macro&lt;/span&gt; &lt;span class="n"&gt;cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cents_to_dollars'&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%-&lt;/span&gt; &lt;span class="n"&gt;endmacro&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;macro&lt;/span&gt; &lt;span class="n"&gt;default__cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;({{&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%-&lt;/span&gt; &lt;span class="n"&gt;endmacro&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;macro&lt;/span&gt; &lt;span class="n"&gt;postgres__cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;({{&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="p"&gt;}}::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%-&lt;/span&gt; &lt;span class="n"&gt;endmacro&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;macro&lt;/span&gt; &lt;span class="n"&gt;bigquery__cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;(({{&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;endmacro&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;macro&lt;/span&gt; &lt;span class="n"&gt;fabric__cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;({{&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;endmacro&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dispatcher and four implementations, for one division.&lt;/p&gt;

&lt;p&gt;The variants are not gratuitous. Postgres would do integer division on &lt;code&gt;/&lt;/code&gt;, so it casts first.&lt;br&gt;
BigQuery spells the type &lt;code&gt;NUMERIC&lt;/code&gt; and wants an explicit &lt;code&gt;round&lt;/code&gt;. Each engine genuinely needs different SQL, and someone had to know that and write it down five times — and will have to write it a sixth time for the next warehouse.&lt;/p&gt;

&lt;p&gt;The reason it is five, rather than one, is that Jinja renders &lt;strong&gt;text&lt;/strong&gt;. A macro is a string template, the output is a string, and a string that is correct on DuckDB is wrong on Postgres. There is nowhere for the knowledge of dialects to live except in more templates.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a macro actually is
&lt;/h2&gt;

&lt;p&gt;Strip the templating away and &lt;code&gt;cents_to_dollars&lt;/code&gt; is a named expression with a parameter. Not a string, not a code generator — an expression. &lt;code&gt;(amount / 100)&lt;/code&gt; cast to a decimal, with a hole in it where a column goes.&lt;/p&gt;

&lt;p&gt;SQL has syntax for exactly this, and DuckDB implements it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- macros/money.sql&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MACRO&lt;/span&gt; &lt;span class="n"&gt;cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interlace reads &lt;code&gt;macros/*.sql&lt;/code&gt; at project load and makes those callable from any model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- models/stg_orders.sql&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tax_paid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tax_paid&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_orders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole feature, as far as the syntax goes. One definition, called like a function, in a file that is still valid SQL.&lt;/p&gt;

&lt;p&gt;The interesting part is the next question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do you expand it?
&lt;/h2&gt;

&lt;p&gt;A macro has to be substituted for its body at some point, and there are three places to do it. The choice looks like an implementation detail and is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the text, before parsing.&lt;/strong&gt; This is Jinja, and it is where the five variants come from. Substitution happens before anything understands the SQL, so the only thing that can vary by engine is more text — hence adapter dispatch. You also lose the tree: until the template is rendered there is no query to reason about, and once it is rendered the structure is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the warehouse.&lt;/strong&gt; DuckDB and Postgres both support this natively — &lt;code&gt;CREATE MACRO&lt;/code&gt; and &lt;code&gt;CREATE FUNCTION&lt;/code&gt; — and it is genuinely tempting. Register the macro against the engine once, and every model can call it. The SQL stays short and the engine does the work.&lt;/p&gt;

&lt;p&gt;It also quietly breaks the thing that decides what to rebuild.&lt;/p&gt;

&lt;p&gt;Interlace fingerprints a model's &lt;strong&gt;canonical SQL&lt;/strong&gt;. If &lt;code&gt;cents_to_dollars&lt;/code&gt; lives in the warehouse, then &lt;code&gt;SELECT cents_to_dollars(subtotal) FROM raw_orders&lt;/code&gt; is the model's SQL, and it is byte-identical whether the macro divides by 100 or by 1000. Change the macro, and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every model that calls it has an unchanged fingerprint;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;plan&lt;/code&gt; reports no changes;&lt;/li&gt;
&lt;li&gt;the tables that already exist keep the old arithmetic, indefinitely, with nothing anywhere indicating that they disagree with the definition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the same failure as editing a seed CSV and being told there is nothing to do — except a seed is data and this is logic. It is the worst kind of bug: silent, plausible, and discovered by someone reconciling a number three weeks later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the AST, at compile time.&lt;/strong&gt; This is what Interlace does. The call is replaced by the body while the model compiles — after parsing, and before the fingerprint, the lineage graph and the transpiler ever see the model.&lt;/p&gt;

&lt;p&gt;That ordering is the whole design, and everything below falls out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One definition, every dialect
&lt;/h2&gt;

&lt;p&gt;The expansion produces AST, not text, and AST is what the transpiler consumes. So the dialect-specific knowledge stays where it already lived — in sqlglot — instead of in four more macros. The single line above compiles to:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;engine&lt;/th&gt;
&lt;th&gt;rendered&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DuckDB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CAST((subtotal / 100) AS DECIMAL(16, 2))&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CAST((CAST(subtotal AS DOUBLE PRECISION) / NULLIF(100, 0)) AS DECIMAL(16, 2))&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snowflake&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CAST((subtotal / NULLIF(100, 0)) AS DECIMAL(16, 2))&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BigQuery&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CAST((subtotal / NULLIF(100, 0)) AS NUMERIC)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Postgres gets its cast, because sqlglot knows Postgres does integer division. BigQuery gets &lt;code&gt;NUMERIC&lt;/code&gt;. Nobody wrote &lt;code&gt;postgres__cents_to_dollars&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The honest version of this claim: sqlglot's dialect knowledge is doing the work, and it is not infallible — a macro that leans on something genuinely engine-specific will still need your attention. But the ordinary case, which is most of them, is handled by the layer that already exists for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editing a macro rebuilds its callers
&lt;/h2&gt;

&lt;p&gt;Because the expansion is part of the canonical SQL, the fingerprint covers the macro body.&lt;br&gt;
Changing &lt;code&gt;100&lt;/code&gt; to &lt;code&gt;1000&lt;/code&gt; in &lt;code&gt;macros/jaffle.sql&lt;/code&gt; and running &lt;code&gt;plan&lt;/code&gt; against dbt's demo project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Model          Change     Category   Build
 stg_orders     modified   breaking   rebuild
 stg_products   modified   breaking   rebuild
 stg_supplies   modified   breaking   rebuild
 products       modified   breaking   rebuild
 order_items    modified   breaking   rebuild
 supplies       modified   breaking   rebuild
 orders         modified   breaking   rebuild
 customers      modified   breaking   rebuild
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three models call the macro. The other five are downstream of those three. Nobody declared that relationship; it is the dependency graph doing its ordinary job on SQL that changed.&lt;br&gt;
Note also what is &lt;em&gt;not&lt;/em&gt; in that list. The project has nineteen models; eleven of them neither call the macro nor sit downstream of one that does, and they are untouched. Invalidation is on the rendered SQL, not on "this file changed" — the same mechanism that lets you reformat a model without rebuilding anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  Lineage sees the body, not just the call
&lt;/h2&gt;

&lt;p&gt;Column lineage reads the AST, so it sees whatever the macro's body touches. This matters when the body references something the call site does not mention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MACRO&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;shipping_fee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;net_total&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_orders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where does &lt;code&gt;net_total&lt;/code&gt; come from? With the macro expanded, both of its real sources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;net_total ← raw_orders.subtotal, raw_orders.shipping_fee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Left as an opaque function call, lineage can only follow the argument — &lt;code&gt;raw_orders.subtotal&lt;/code&gt; — and &lt;code&gt;shipping_fee&lt;/code&gt; disappears from the graph, along with every impact analysis and column-pruning decision that depends on it.&lt;/p&gt;

&lt;p&gt;For a simple pass-through macro there is no difference; the argument is a column reference either way. The difference appears exactly when the macro is doing something interesting, which is when you want lineage to be right.&lt;br&gt;
The same reasoning gives you dependency edges for free. A macro body may reference a model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MACRO&lt;/span&gt; &lt;span class="n"&gt;in_gbp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fx_rates&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because expansion runs before dependency resolution, every model calling &lt;code&gt;in_gbp&lt;/code&gt; gains a real edge to &lt;code&gt;fx_rates&lt;/code&gt; and builds after it. There is nothing to declare, for the same reason there is nothing to declare for a &lt;code&gt;FROM&lt;/code&gt; clause: the reference is in the tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules
&lt;/h2&gt;

&lt;p&gt;Small enough to state completely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scalar expressions only.&lt;/strong&gt; A table macro (&lt;code&gt;AS TABLE SELECT ...&lt;/code&gt;) has no call site to expand into. That is a model, and models are the thing this tool is made of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Macros may call macros&lt;/strong&gt;, to a depth of ten. Recursion is a compile error naming the model, not a hang.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arity is checked&lt;/strong&gt; at compile time, naming the macro and the file it came from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Definitions live in &lt;code&gt;macros/*.sql&lt;/code&gt;&lt;/strong&gt;, configurable with &lt;code&gt;macro_paths&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;The macro does not exist in the warehouse. This is the real cost of expanding at compile time, and it is worth being plain about: if you open a SQL client against the built tables, you cannot call &lt;code&gt;cents_to_dollars&lt;/code&gt;. It is a build-time abstraction, and ad-hoc queries do not get it.&lt;/p&gt;

&lt;p&gt;If that matters more to you than rebuild correctness, the engine's own &lt;code&gt;CREATE MACRO&lt;/code&gt; is right there and Interlace will not stop you from running it — you will just be responsible for noticing when a macro changes.&lt;/p&gt;

&lt;p&gt;And the larger gap is unchanged: this is the mechanism, not the library. &lt;code&gt;dbt_utils&lt;/code&gt; is a body of tested SQL that thousands of people already use, and writing &lt;code&gt;generate_surrogate_key&lt;/code&gt; yourself — which is what dbt's demo project needs, and what &lt;a href="https://interlace.sh/blog/migrating-jaffle-shop" rel="noopener noreferrer"&gt;our conversion of it&lt;/a&gt; does — is not the same as installing it.&lt;/p&gt;




&lt;p&gt;Macros are documented under &lt;a href="https://interlace.sh/docs/core-concepts/models" rel="noopener noreferrer"&gt;Models&lt;/a&gt;, and &lt;a href="https://github.com/interlace-sh/interlace/tree/master/examples/jaffle-shop" rel="noopener noreferrer"&gt;&lt;code&gt;examples/jaffle-shop&lt;/code&gt;&lt;/a&gt; uses them for both of the cases dbt's project has: a project macro, and a package macro with no package to install. Or install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;interlaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sql</category>
      <category>python</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Migrating jaffle_shop: A Real dbt Project, End to End</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Sat, 15 Aug 2026 14:04:19 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/migrating-jaffleshop-a-real-dbt-project-end-to-end-185l</link>
      <guid>https://dev.to/5c4989ca297ed/migrating-jaffleshop-a-real-dbt-project-end-to-end-185l</guid>
      <description>&lt;p&gt;Every tool in this space claims migration is easy. The claim is usually made by someone who has not migrated anything, so here is an actual one: &lt;code&gt;jaffle-shop-classic&lt;/code&gt;, dbt's own demo project, converted to Interlace, with the friction written down rather than edited out.&lt;/p&gt;

&lt;p&gt;It is a small project — five models, three seed CSVs, twenty tests — which makes it a fair subject for a walkthrough and a poor one for extrapolating effort. A real project has hundreds of models and its own accumulated strangeness. What transfers from this exercise is the &lt;em&gt;shape&lt;/em&gt; of the work: which parts are mechanical, and which parts need a person.&lt;/p&gt;

&lt;p&gt;The end state, before the details:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checks: 20/20 passed
Built 8 model(s); promoted 8 to 'prod'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty tests in, twenty checks out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeds become models
&lt;/h2&gt;

&lt;p&gt;dbt has a separate concept and a separate command for seeds: CSVs in &lt;code&gt;seeds/&lt;/code&gt;, loaded by &lt;code&gt;dbt seed&lt;/code&gt;. Interlace has no seed concept, because a seed is just a model with no upstreams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- models/raw_customers.sql&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;read_csv_auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'seeds/raw_customers.csv'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three files, one line each. This is a genuine simplification — the CSV now participates in the DAG, gets a fingerprint, and rebuilds downstream models when it changes, which &lt;code&gt;dbt seed&lt;/code&gt; does not do on its own. But it is undocumented as a migration step, and we only worked it out by trying. That is a docs gap on our side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four of five models: a two-line regex
&lt;/h2&gt;

&lt;p&gt;The staging models are the ordinary case, and the conversion is entirely mechanical. Here is dbt's &lt;code&gt;stg_customers.sql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;

    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;#-&lt;/span&gt;
    &lt;span class="n"&gt;Normally&lt;/span&gt; &lt;span class="n"&gt;we&lt;/span&gt; &lt;span class="n"&gt;would&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;here&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;but&lt;/span&gt; &lt;span class="n"&gt;we&lt;/span&gt; &lt;span class="k"&gt;are&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;seeds&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="k"&gt;load&lt;/span&gt;
    &lt;span class="n"&gt;our&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;
    &lt;span class="o"&gt;#&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="k"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'raw_customers'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;

&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two transformations cover it — strip Jinja comments, and turn &lt;code&gt;{{ ref('x') }}&lt;/code&gt; into &lt;code&gt;x&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\{#-?.*?-?#\}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\{\{\s*ref\(\s*[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"]([^&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"]+)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"]\s*\)\s*\}\}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the diff in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    {#-
-    Normally we would select from the table here, but we are using seeds to load
-    our data in this project
-    #}
-    select * from {{ ref('raw_customers') }}
&lt;/span&gt;&lt;span class="gi"&gt;+    select * from raw_customers
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CTEs, the joins, the column list — untouched. Four of the five models converted this way, because Interlace reads the dependency out of the &lt;code&gt;FROM&lt;/code&gt; clause instead of asking you to declare it. &lt;code&gt;customers.sql&lt;/code&gt;, with its three CTEs and two left joins, needed nothing but the regex.&lt;/p&gt;

&lt;p&gt;Do not over-read this. &lt;code&gt;ref()&lt;/code&gt; substitution is the easy half of any dbt project. The regex above handles &lt;code&gt;ref('x')&lt;/code&gt; and would need extending for &lt;code&gt;ref('package', 'x')&lt;/code&gt;, &lt;code&gt;source()&lt;/code&gt;, and anything built by a macro.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one place the mechanical pass broke
&lt;/h3&gt;

&lt;p&gt;A subdirectory becomes part of the model's name. dbt's staging models live in &lt;code&gt;models/staging/&lt;/code&gt;, and Interlace names a model after its path, so &lt;code&gt;stg_customers&lt;/code&gt; came out as &lt;code&gt;staging.stg_customers&lt;/code&gt; — and &lt;code&gt;customers.sql&lt;/code&gt;'s &lt;code&gt;from stg_customers&lt;/code&gt; stopped resolving. The regex is not wrong; the layout is load-bearing in a way dbt's is not.&lt;/p&gt;

&lt;p&gt;Either flatten the directory, or pin the name in the model's config block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
interlace:
  name: stg_customers
*/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line per staging model, in a config block those models already needed for their checks. It is a small thing, and worth knowing before you run the regex over two hundred files and wonder why the marts cannot see anything.&lt;/p&gt;

&lt;p&gt;If every model of yours lives in a subdirectory — &lt;code&gt;models/staging/&lt;/code&gt;, &lt;code&gt;models/marts/&lt;/code&gt;, and nothing loose in &lt;code&gt;models/&lt;/code&gt; — there is a tidier fix: list the leaf directories as the model paths, and the names come out bare with dbt's layout untouched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;model_paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;models/staging&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;models/marts&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They must not overlap, though: listing both &lt;code&gt;models&lt;/code&gt; and &lt;code&gt;models/staging&lt;/code&gt; registers every staging model twice. jaffle_shop keeps &lt;code&gt;customers.sql&lt;/code&gt; and &lt;code&gt;orders.sql&lt;/code&gt; directly in &lt;code&gt;models/&lt;/code&gt;, so it cannot use this and pins the names instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fifth model is the real work
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;orders.sql&lt;/code&gt; uses Jinja for what Jinja is actually for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;payment_methods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'credit_card'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'coupon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bank_transfer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'gift_card'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;payment_method&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payment_methods&lt;/span&gt; &lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;payment_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{{ payment_method }}'&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{{&lt;/span&gt; &lt;span class="n"&gt;payment_method&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="n"&gt;_amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;endfor&lt;/span&gt; &lt;span class="o"&gt;-%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A loop generating four pivot columns, twice — once in the aggregate, once in the passthrough. There is no regex for this, and this is where a migration tool would hand you back a broken file.&lt;/p&gt;

&lt;p&gt;Whichever way you go, the Jinja list becomes a Python list — there is no templating layer to put it in. What differs is whether the pivot itself ends up in Python or stays in SQL. Both are below; both were built and produce byte-identical output.&lt;/p&gt;

&lt;p&gt;(There is always a third option, and for a list this stable it may be the right one: expand the four lines by hand and keep a plain &lt;code&gt;.sql&lt;/code&gt; file. Nothing below is mandatory.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1 — a Python model
&lt;/h3&gt;

&lt;p&gt;Write it as a function. Parameters name the upstreams, so &lt;code&gt;stg_orders&lt;/code&gt; and &lt;code&gt;stg_payments&lt;/code&gt; are the dependency edges, and the pivot is an ordinary loop over Arrow columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# models/orders.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow.compute&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;

&lt;span class="n"&gt;PAYMENT_METHODS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credit_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coupon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bank_transfer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gift_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="nd"&gt;@model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stg_orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stg_payments&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stg_payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# The Jinja {% for %} pivot, as a Python loop over Arrow columns.
&lt;/span&gt;    &lt;span class="n"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PAYMENT_METHODS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;is_m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payment_method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;if_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;per_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aggregate&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PAYMENT_METHODS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;per_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;per_method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rename_columns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PAYMENT_METHODS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;stg_orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per_method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;join_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left outer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the version to reach for if the logic is heading somewhere SQL cannot follow — a model call, a rate-limited API, a library with no SQL equivalent. It is also a plain function, so you can call it in a unit test with no warehouse.&lt;/p&gt;

&lt;p&gt;The cost is that the aggregation now happens in the Interlace process rather than in the engine. On jaffle_shop's 113 payment rows that is irrelevant. On 25 million it would not be — DuckDB should do that work, not PyArrow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2 — a dynamic model
&lt;/h3&gt;

&lt;p&gt;Keep the SQL, and generate it with the Python loop. Model files are imported and executed at project load, so registering a &lt;code&gt;ModelDef&lt;/code&gt; &lt;strong&gt;is&lt;/strong&gt; declaring a model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# models/orders.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CheckSpec&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace.dsl.decorators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;REGISTRY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ModelDef&lt;/span&gt;

&lt;span class="n"&gt;PAYMENT_METHODS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credit_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;coupon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bank_transfer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gift_card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;pivot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;        &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sum(case when payment_method = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; then amount else 0 end) as &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PAYMENT_METHODS&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;passthrough&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;        &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_payments.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PAYMENT_METHODS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;REGISTRY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ModelDef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    with order_payments as (
        select order_id, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pivot&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, sum(amount) as total_amount
        from stg_payments group by order_id
    )
    select stg_orders.order_id, stg_orders.customer_id, stg_orders.order_date,
           stg_orders.status, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;passthrough&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;,
           order_payments.total_amount as amount
    from stg_orders
    left join order_payments on stg_orders.order_id = order_payments.order_id
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;checks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;CheckSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unique&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)),&lt;/span&gt;
        &lt;span class="nc"&gt;CheckSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_null&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)),&lt;/span&gt;
        &lt;span class="nc"&gt;CheckSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_null&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)),&lt;/span&gt;
        &lt;span class="nc"&gt;CheckSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_null&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)),&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CheckSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_null&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PAYMENT_METHODS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;CheckSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted_values&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
                  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;values&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;placed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;returned&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}),&lt;/span&gt;
        &lt;span class="nc"&gt;CheckSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relationships&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
                  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;field&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is &lt;code&gt;orders&lt;/code&gt;' whole &lt;code&gt;schema.yml&lt;/code&gt;, ten tests, in the file that defines the model. The checks loop too — the four &lt;code&gt;not_null&lt;/code&gt;s on the pivot columns come from the same list that generated them, so adding a payment method adds its column and its check together.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;relationships&lt;/code&gt; check is the interesting one: it reads &lt;code&gt;customers&lt;/code&gt;, which is a sibling in the DAG rather than an upstream of &lt;code&gt;orders&lt;/code&gt;. Interlace picks that up and schedules the check after &lt;code&gt;customers&lt;/code&gt; builds, so the ordering is not yours to get right.&lt;/p&gt;

&lt;p&gt;This is the closer translation of what the Jinja was doing, and the better default: the &lt;code&gt;{% set %}&lt;/code&gt; becomes a Python list, the &lt;code&gt;{% for %}&lt;/code&gt; becomes a generator expression, and the generated SQL still runs in the engine where it belongs. The structure maps almost line for line — which is the point, because the templating language was standing in for a programming language, and now there is one.&lt;/p&gt;

&lt;p&gt;The cost is that you are building SQL with string joins, and a malformed f-string produces a parse error rather than a type error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Both were checked
&lt;/h3&gt;

&lt;p&gt;Each version was built and queried against the invariant that matters — every pivot column sums to the total:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mismatched_rows&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;credit_card_amount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;coupon_amount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bank_transfer_amount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gift_card_amount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="c1"&gt;-- 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero for both, with matching rows throughout. &lt;strong&gt;Prefer Option 2 unless the transformation needs&lt;br&gt;
Python&lt;/strong&gt; — keeping the work in the engine is the difference that shows up at scale.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tests become checks
&lt;/h2&gt;

&lt;p&gt;All four dbt test types used by jaffle_shop map one-to-one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;dbt&lt;/th&gt;
&lt;th&gt;Interlace&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unique&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;unique&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;not_null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;not_null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accepted_values&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;accepted_values&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;relationships&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;relationships&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The difference is location. dbt keeps tests in a separate &lt;code&gt;schema.yml&lt;/code&gt;; Interlace puts them in the model's own config block, so the model and its contract are one file. This is &lt;code&gt;stg_orders.sql&lt;/code&gt; in full, header and all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
interlace:
  name: stg_orders
  checks:
    - unique: order_id
    - not_null: order_id
    - accepted_values:
        column: status
        values: [placed, shipped, completed, return_pending, returned]
*/&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;raw_orders&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth type, &lt;code&gt;relationships&lt;/code&gt;, is on &lt;code&gt;orders&lt;/code&gt; — in the &lt;code&gt;CheckSpec&lt;/code&gt; form above, because that model is Python.&lt;/p&gt;

&lt;p&gt;Whether that is better is taste. It is fewer files and less indirection; it is also a longer header on models with many checks. What is not taste: Interlace checks &lt;strong&gt;gate promotion&lt;/strong&gt; by default, where &lt;code&gt;dbt test&lt;/code&gt; is a separate command you have to remember to run in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not tell you
&lt;/h2&gt;

&lt;p&gt;jaffle_shop has no macros beyond one loop, no packages, no snapshots, no incremental models, no custom materialisations, and no &lt;code&gt;dbt_utils&lt;/code&gt;. A real project has several of those, and the honest answer for each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Macros&lt;/strong&gt; — &lt;code&gt;macros/*.sql&lt;/code&gt; holds &lt;code&gt;CREATE MACRO&lt;/code&gt; definitions, expanded into each model's AST at compile time. One definition covers every engine (the transpiler handles the dialect, so there is no &lt;code&gt;postgres__&lt;/code&gt; variant to write), and because the expansion lands before the fingerprint, editing a macro rebuilds its callers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dbt_utils&lt;/code&gt; and packages&lt;/strong&gt; — still no equivalent, and this is the largest genuine gap. A macro you write yourself is not the same as a shared, tested package other people already use; you get the mechanism, not the library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots&lt;/strong&gt; — &lt;code&gt;strategy: scd&lt;/code&gt; covers Type 2 history, but the migration is not textual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental models&lt;/strong&gt; — &lt;code&gt;incremental&lt;/code&gt; maps closely for SQL. Python models need a &lt;code&gt;key&lt;/code&gt; for it; without one, &lt;code&gt;cursor&lt;/code&gt; with &lt;code&gt;merge&lt;/code&gt; is the equivalent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom materialisations&lt;/strong&gt; — no equivalent, by design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are considering a migration and want a second pair of eyes on the awkward parts, we would genuinely like to do one with you — partly to help, mostly because doing this one surfaced rough edges we had stopped noticing, and fixed them.&lt;/p&gt;




&lt;p&gt;Everything above is reproducible from &lt;a href="https://github.com/dbt-labs/jaffle-shop-classic" rel="noopener noreferrer"&gt;&lt;code&gt;jaffle-shop-classic&lt;/code&gt;&lt;/a&gt;, and the converted project ships with Interlace as &lt;a href="https://github.com/interlace-sh/interlace/tree/master/examples/jaffle-shop-classic" rel="noopener noreferrer"&gt;&lt;code&gt;examples/jaffle-shop-classic&lt;/code&gt;&lt;/a&gt; — &lt;code&gt;interlace apply --env prod&lt;/code&gt; in that directory is where the 20/20 above comes from.&lt;/p&gt;

&lt;p&gt;dbt's &lt;em&gt;current&lt;/em&gt; jaffle_shop is converted alongside it, as &lt;a href="https://github.com/interlace-sh/interlace/tree/master/examples/jaffle-shop" rel="noopener noreferrer"&gt;&lt;code&gt;examples/jaffle-shop&lt;/code&gt;&lt;/a&gt;: nineteen models, twenty-seven checks, and the things this project has none of — &lt;code&gt;source()&lt;/code&gt;, a project macro, a &lt;code&gt;dbt_utils&lt;/code&gt; package macro, and a semantic layer that does not come across at all. Start with the &lt;a href="https://interlace.sh/docs/getting-started" rel="noopener noreferrer"&gt;introduction&lt;/a&gt;, or install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;interlaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sql</category>
      <category>opensource</category>
      <category>python</category>
      <category>data</category>
    </item>
    <item>
      <title>No Jinja: What Replaces Templating When SQL Is an AST</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Sat, 15 Aug 2026 13:52:41 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/no-jinja-what-replaces-templating-when-sql-is-an-ast-2eb4</link>
      <guid>https://dev.to/5c4989ca297ed/no-jinja-what-replaces-templating-when-sql-is-an-ast-2eb4</guid>
      <description>&lt;p&gt;Interlace has no templating language. A model is a &lt;code&gt;.sql&lt;/code&gt; file containing SQL, or a &lt;code&gt;.py&lt;/code&gt; file containing a function. There is no &lt;code&gt;{{ }}&lt;/code&gt; and no &lt;code&gt;{% %}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Updated August 2026: Interlace has since gained macros — as SQL expressions expanded into the AST, not as templates. Job three below is rewritten to match; see &lt;a href="https://interlace.sh/blog/a-macro-is-an-expression" rel="noopener noreferrer"&gt;A Macro Is an Expression, Not a Template&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The reasonable first reaction is that we have simply removed capability. Jinja is doing real work in a dbt project, and if you delete it you owe an answer for that work.&lt;/p&gt;

&lt;p&gt;The answer is that Jinja is doing &lt;strong&gt;four unrelated jobs&lt;/strong&gt;, which is why one mechanism doing all four feels both indispensable and awkward. Separated, each has a better answer than a templating language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job one: &lt;code&gt;{{ ref() }}&lt;/code&gt; — declaring dependencies
&lt;/h2&gt;

&lt;p&gt;This is the most common use and the least necessary. &lt;code&gt;ref()&lt;/code&gt; exists because dbt does not parse your SQL; it needs you to declare the edge separately, in the text, and then it substitutes the physical name.&lt;/p&gt;

&lt;p&gt;But the dependency is already in the query. It is the &lt;code&gt;FROM&lt;/code&gt; clause. Interlace parses the SQL with sqlglot and reads the edges out of the AST:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace.ir.canonicalize&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_references&lt;/span&gt;

&lt;span class="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;SELECT o.id, c.tier FROM orders o
         JOIN customers c USING (id)
         WHERE o.d &amp;gt; (SELECT max(d) FROM watermark)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;table_references&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="err"&gt;»&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;customers&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;orders&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;watermark&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three dependencies, including one inside a correlated subquery, with nothing to declare. The model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_customers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole file. It is also valid SQL you can paste into any client, which a Jinja template is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job two: &lt;code&gt;{% for %}&lt;/code&gt; — generating many models or many columns
&lt;/h2&gt;

&lt;p&gt;This is Jinja's legitimate job, and the one people reach for when a project has fifty tenants or a pivot over a list of payment methods.&lt;/p&gt;

&lt;p&gt;Interlace's answer is that &lt;code&gt;.py&lt;/code&gt; model files are &lt;strong&gt;imported and executed&lt;/strong&gt; when the project loads. Registering a model is a function call, so a loop over a list is a loop over a list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# models/per_tenant.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace.dsl.decorators&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;REGISTRY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ModelDef&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_tenants&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;                    &lt;span class="c1"&gt;# any Python: a query, a file, an env var
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;globex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;initech&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;get_tenants&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;REGISTRY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ModelDef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT order_id, amount FROM raw WHERE tenant_id = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three real models, each with its own snapshot table, environment view, fingerprint, plan entry and checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Model            Output    Strategy   Depends on   Rows   Time
 raw              virtual   replace    —              +4   0.06s
 orders_acme      virtual   merge      raw            +2   0.12s
 orders_globex    virtual   merge      raw            +1   0.08s
 orders_initech   virtual   merge      raw            +1   0.05s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what &lt;code&gt;get_tenants()&lt;/code&gt; can be. In Jinja it must be something the templating context can reach. Here it is Python, so it can query a database, read a file, or call an API — and you can unit-test it, because it is a function.&lt;/p&gt;

&lt;p&gt;This is not a feature we built. It falls out of model files being ordinary Python that runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job three: macros — reusable SQL fragments
&lt;/h2&gt;

&lt;p&gt;Jinja macros exist because SQL has no functions of its own that reach across files. But a macro like dbt's &lt;code&gt;cents_to_dollars&lt;/code&gt; is not really a template — it is a named expression with a hole in it, and SQL has syntax for that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- macros/money.sql&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MACRO&lt;/span&gt; &lt;span class="n"&gt;cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any model can call it, and the call is expanded into the model's AST while it compiles — before the fingerprint, before lineage, before transpilation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cents_to_dollars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;subtotal&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_orders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the expansion happens in the tree rather than in text, one definition covers every engine: sqlglot renders Postgres's integer-division cast and BigQuery's &lt;code&gt;NUMERIC&lt;/code&gt; from that one line, where dbt needs &lt;code&gt;default__&lt;/code&gt;, &lt;code&gt;postgres__&lt;/code&gt; and &lt;code&gt;bigquery__&lt;/code&gt; variants plus a dispatcher. And because it happens before the fingerprint, editing a macro re-plans every model that calls it — which a macro registered in the warehouse could not do, since the callers' SQL would not have changed.&lt;/p&gt;

&lt;p&gt;That is its own post: &lt;a href="https://dev.to/blog/a-macro-is-an-expression"&gt;A Macro Is an Expression, Not a Template&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;When a macro is doing something SQL cannot express, the Python route is still there — a function that returns a SQL fragment, interpolated into a &lt;code&gt;ModelDef&lt;/code&gt; the same way as Job two. A model may import a helper module sitting beside it (&lt;code&gt;from _macros import ...&lt;/code&gt;; files starting with &lt;code&gt;_&lt;/code&gt; are not models), which is the shape to reach for when the "macro" is really a program.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job four: &lt;code&gt;{{ config() }}&lt;/code&gt; — per-model settings
&lt;/h2&gt;

&lt;p&gt;Interlace puts configuration in a leading block comment, namespaced under &lt;code&gt;interlace&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* interlace:
  strategy: merge
  key: customer_id
  checks:
    - not_null: customer_id
    - unique: customer_id
*/&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_customers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is YAML inside a SQL comment. The file stays valid SQL — a comment is a comment — so your editor, your formatter and your database client all still work on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the AST buys that text cannot
&lt;/h2&gt;

&lt;p&gt;Removing Jinja is not the interesting part. The interesting part is what becomes possible once the model is a parsed tree rather than a string to be expanded.&lt;/p&gt;

&lt;p&gt;Interlace fingerprints the &lt;strong&gt;canonical form&lt;/strong&gt; of the AST, not the file. So changes that cannot affect the output do not rebuild anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  original           6b5428631ca23e11  same → reuse
  reformatted        6b5428631ca23e11  same → reuse
  comment added      6b5428631ca23e11  same → reuse
  extra whitespace   6b5428631ca23e11  same → reuse
  SEMANTIC CHANGE    259d4f1398cc67fa  DIFFERENT → rebuild
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first four are the same query written four ways — reflowed across lines, a comment added, spacing changed. Same fingerprint, so downstream models are reused rather than rebuilt. The last one changes &lt;code&gt;sum&lt;/code&gt; to &lt;code&gt;avg&lt;/code&gt;, and everything downstream of it rebuilds.&lt;/p&gt;

&lt;p&gt;Run a formatter across your whole project and nothing rebuilds. That is not a heuristic about whitespace; it is a consequence of hashing a tree rather than a file.&lt;/p&gt;

&lt;p&gt;The same parsed tree is what makes column-level lineage, the breaking-change classification in &lt;code&gt;interlace plan&lt;/code&gt;, and cross-dialect transpilation possible. None of those can be built on templated text, because until the template is rendered there is no query to reason about — and after it is rendered, the structure that made it comprehensible is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you give up
&lt;/h2&gt;

&lt;p&gt;Honestly: dbt's macro ecosystem. Macros themselves have an answer now (Job three), but &lt;code&gt;dbt_utils&lt;/code&gt; and its relatives are a large body of tested, shared SQL, and writing &lt;code&gt;generate_surrogate_key&lt;/code&gt; yourself is not the same as installing a package that thousands of people already use. You get the mechanism, not the library. If your project leans on that ecosystem, this is a real cost and you should weigh it.&lt;/p&gt;

&lt;p&gt;You also give up templating inside SQL as a general escape hatch. When you want conditional SQL, the answer is to build the string in Python and register it — which is more explicit and slightly more verbose than an inline &lt;code&gt;{% if %}&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;More in &lt;a href="https://dev.to/docs/guides/dynamic-models"&gt;Dynamic Models&lt;/a&gt; and &lt;a href="https://dev.to/docs/guides/sql-models"&gt;SQL Models&lt;/a&gt;, or install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;interlaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sql</category>
      <category>opensource</category>
      <category>python</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>Python Models: dbt and Interlace, Side by Side</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Sat, 15 Aug 2026 13:47:44 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/python-models-dbt-and-interlace-side-by-side-1m8j</link>
      <guid>https://dev.to/5c4989ca297ed/python-models-dbt-and-interlace-side-by-side-1m8j</guid>
      <description>&lt;p&gt;We have written before that Python and SQL models are interchangeable nodes in the same graph. That is true of Interlace, and it is worth being careful about what it implies about dbt — because the most common version of this comparison, including one we drafted ourselves, gets it wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dbt Python models participate fully in dbt's DAG.&lt;/strong&gt; A SQL model can &lt;code&gt;ref()&lt;/code&gt; a Python model, and a Python model can &lt;code&gt;dbt.ref()&lt;/code&gt; a SQL one. The dbt documentation is explicit about it, with examples in both directions. Anyone claiming dbt Python models are "not real DAG nodes" has not read the page.&lt;/p&gt;

&lt;p&gt;The differences are real, but they are elsewhere. Every claim below about dbt is from dbt's current &lt;a href="https://docs.getdbt.com/docs/build/python-models" rel="noopener noreferrer"&gt;Python models documentation&lt;/a&gt;; every claim about Interlace was checked against the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where you can run one
&lt;/h2&gt;

&lt;p&gt;This is the largest difference and the one that decides most cases.&lt;/p&gt;

&lt;p&gt;dbt Python models require a data platform with a fully featured Python runtime. In practice that means &lt;strong&gt;Snowflake (Snowpark), BigQuery (BigFrames), or Databricks (PySpark)&lt;/strong&gt;. The code runs &lt;em&gt;in the warehouse&lt;/em&gt;, on the warehouse's Python runtime.&lt;/p&gt;

&lt;p&gt;Interlace Python models run in the Interlace process, so they work on &lt;strong&gt;any&lt;/strong&gt; engine — including DuckDB and Postgres, where dbt has no Python model story at all.&lt;/p&gt;

&lt;p&gt;The practical consequence: on a DuckDB or Postgres project, a dbt Python model is not slower or more limited, it is unavailable. If your stack is one of the three supported warehouses, dbt's approach has a real advantage in return — the computation happens next to the data, and never crosses the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a model actually is
&lt;/h2&gt;

&lt;p&gt;dbt's Python model is a function with a required signature, called by dbt with two arguments it supplies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dbt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dbt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;materialized&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dbt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stg_orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interlace's is an ordinary function whose &lt;strong&gt;parameters name its upstreams&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;

&lt;span class="nd"&gt;@model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;user_ltv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference that matters is testability. &lt;code&gt;@model&lt;/code&gt; registers the model and returns the function &lt;strong&gt;unchanged&lt;/strong&gt;, so it is callable in a unit test with no warehouse, no session and no dbt object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;models.user_ltv&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;user_ltv&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;callable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_ltv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;user_ltv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fake_batches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# a plain call, no infrastructure
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;dbt's &lt;code&gt;model(dbt, session)&lt;/code&gt; requires both a &lt;code&gt;dbt&lt;/code&gt; object and a live warehouse session, so testing it means testing through dbt.&lt;/p&gt;

&lt;p&gt;The data type differs too. dbt hands you a warehouse DataFrame — Snowpark, BigFrames or PySpark depending on the platform, each with a different API, which is a portability cost if you ever change warehouse. Interlace hands you Arrow, streamed as &lt;code&gt;RecordBatch&lt;/code&gt;es, so memory stays bounded on inputs far larger than RAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can materialise
&lt;/h2&gt;

&lt;p&gt;Both tools restrict Python models, in different places.&lt;/p&gt;

&lt;p&gt;dbt supports &lt;code&gt;table&lt;/code&gt; (default) and &lt;code&gt;incremental&lt;/code&gt;. It does not support &lt;code&gt;view&lt;/code&gt; or &lt;code&gt;ephemeral&lt;/code&gt;, and Python models &lt;strong&gt;cannot reference ephemeral models&lt;/strong&gt; at all.&lt;/p&gt;

&lt;p&gt;Interlace supports only &lt;code&gt;virtual&lt;/code&gt; — its owned, snapshot-backed default. &lt;code&gt;view&lt;/code&gt; and &lt;code&gt;ephemeral&lt;/code&gt; are rejected because both require SQL the engine can evaluate; &lt;code&gt;table&lt;/code&gt; and &lt;code&gt;file&lt;/code&gt; are rejected because terminal delivery is SQL-only for now. The errors say so directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python models cannot be ephemeral; ephemeral requires SQL (it is inlined as a CTE)
Python models cannot materialise as 'table' yet; write a SQL model over this model's output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interlace Python models &lt;strong&gt;can&lt;/strong&gt; read an ephemeral SQL model, which dbt's cannot. The upstream is inlined as a CTE and never materialises:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Model        Output    Strategy   Depends on   Rows   Time
 raw          virtual   replace    —              +3   0.06s
 pyconsumer   virtual   replace    mid            +3   0.06s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mid&lt;/code&gt; is the ephemeral model. It does not appear, because it was compiled into its consumer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental work
&lt;/h2&gt;

&lt;p&gt;Here dbt is straightforwardly ahead. dbt Python models support &lt;code&gt;incremental&lt;/code&gt; with the same incremental strategies as SQL models, subject to adapter support.&lt;/p&gt;

&lt;p&gt;Interlace Python models can use &lt;code&gt;incremental&lt;/code&gt; &lt;strong&gt;when they declare a &lt;code&gt;key&lt;/code&gt;&lt;/strong&gt; — the Arrow output is staged and the window's rows are upserted. Without a key it is refused, because a Python function has already computed everything before the window could narrow it; the window would bound what is written, not what is done. &lt;code&gt;cursor&lt;/code&gt; is the tool for bounding the fetch.&lt;/p&gt;

&lt;p&gt;So the gap is narrower than it looks, but real: dbt pushes the incremental predicate into the query it generates, and for a Python model we cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;dbt&lt;/th&gt;
&lt;th&gt;Interlace&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Platforms&lt;/td&gt;
&lt;td&gt;Snowflake, BigQuery, Databricks&lt;/td&gt;
&lt;td&gt;any engine (DuckDB, Postgres, …)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where the code runs&lt;/td&gt;
&lt;td&gt;in the warehouse runtime&lt;/td&gt;
&lt;td&gt;in the Interlace process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL model can depend on it&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It can depend on a SQL model&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can read an &lt;code&gt;ephemeral&lt;/code&gt; upstream&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Materialisations&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;table&lt;/code&gt;, &lt;code&gt;incremental&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;virtual&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;view&lt;/code&gt; / &lt;code&gt;ephemeral&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;keyed only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data type at the boundary&lt;/td&gt;
&lt;td&gt;Snowpark / BigFrames / PySpark DataFrame&lt;/td&gt;
&lt;td&gt;Arrow &lt;code&gt;RecordBatch&lt;/code&gt; stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Callable in a unit test without the tool&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reuse functions across models&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes — ordinary Python imports&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Choosing
&lt;/h2&gt;

&lt;p&gt;Neither column is a win. The honest decision rule:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;dbt&lt;/strong&gt; if your warehouse is Snowflake, BigQuery or Databricks and you want computation to stay next to the data, or if you need incremental Python models. Both are real advantages and we do not have an answer to either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interlace&lt;/strong&gt; if your engine is DuckDB or Postgres, where dbt Python models do not exist; if you want to unit-test transformation logic as plain functions; or if the memory profile of streaming Arrow matters more than warehouse-side execution.&lt;/p&gt;

&lt;p&gt;The thing worth avoiding is picking on a claim neither tool actually makes. dbt Python models are full DAG participants. Ours cannot do incremental. Both statements are inconvenient for the usual marketing, and both are true.&lt;/p&gt;




&lt;p&gt;Start with the &lt;a href="https://interlace.sh/docs/guides/python-models" rel="noopener noreferrer"&gt;Python models guide&lt;/a&gt;, or install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;interlaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>python</category>
      <category>opensource</category>
      <category>sql</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Twenty-Five Million Rows in Five Seconds, and How to Check</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Sat, 15 Aug 2026 13:34:25 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/twenty-five-million-rows-in-five-seconds-and-how-to-check-4k1g</link>
      <guid>https://dev.to/5c4989ca297ed/twenty-five-million-rows-in-five-seconds-and-how-to-check-4k1g</guid>
      <description>&lt;p&gt;Benchmarks in this industry are mostly untrustworthy, and usually for the same reason: the person running them chose the shape of the work. So let us be precise about what this is and what it is not.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;not&lt;/strong&gt; a comparison against dbt. dbt is not an execution engine — it generates SQL and hands it to your warehouse — so "Interlace versus dbt" would be measuring DuckDB against whatever you pointed dbt at, dressed up as a tool comparison. That number would tell you nothing.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;capability demonstration&lt;/strong&gt;: a real DAG doing real work, on hardware you can buy, with every command written down. The point is not that a number is small. The point is that you can run the same thing in the next ten minutes and get your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the work
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;examples/benchmark&lt;/code&gt; project generates 25 million synthetic events in-engine — nothing to download, fully deterministic — and pushes them through a deliberately awkward fan-out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;events (25M rows) ── enriched (ephemeral: inlined into every consumer)
                      ├─ by_user ────┬─ user_ltv       (Python, Arrow batches, merge)
                      │              └─ user_history   (scd — Type 2 history)
                      ├─ by_product ─┬─ top_products   (view)
                      │              └─ product_catalog (full_merge, composite key)
                      ├─ by_device
                      └─ by_day
events ───────────── daily_revenue (incremental, 1d grain)
                      ├─ revenue_report (parquet file)
                      └─ daily_feed     (append → external DuckDB, reverse ETL)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make this harder than it looks. &lt;code&gt;enriched&lt;/code&gt; is &lt;strong&gt;ephemeral&lt;/strong&gt;, so it is inlined as a CTE into every consumer rather than materialised once — each of the four &lt;code&gt;by_*&lt;/code&gt; branches scans the full 25 million rows independently. And the branches share no edges, so they are eligible to build concurrently.&lt;/p&gt;

&lt;p&gt;Between them, twelve models exercise six of the seven strategies — &lt;code&gt;replace&lt;/code&gt;, &lt;code&gt;incremental&lt;/code&gt;, &lt;code&gt;merge&lt;/code&gt;, &lt;code&gt;full_merge&lt;/code&gt;, &lt;code&gt;scd&lt;/code&gt; and &lt;code&gt;append&lt;/code&gt; (only &lt;code&gt;hash_merge&lt;/code&gt; is absent) — across &lt;code&gt;virtual&lt;/code&gt;, &lt;code&gt;ephemeral&lt;/code&gt;, &lt;code&gt;view&lt;/code&gt;, &lt;code&gt;file&lt;/code&gt; and an external &lt;code&gt;table&lt;/code&gt;. There is a Python model in the hot path, not bolted on at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Machine: AMD Ryzen 9 9955HX3D (16 cores, 32 threads), 60 GB RAM, Linux. Python 3.12.3, DuckDB 1.5.4, PyArrow 23.0. A DuckLake warehouse on local disk.&lt;br&gt;
Three consecutive cold builds — full teardown of the warehouse between each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;run 1: 5.19s
run 2: 5.10s
run 3: 4.98s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With CPU accounting on the same cold build:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wall clock&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.11s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU (user + system)&lt;/td&gt;
&lt;td&gt;13.83s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU utilisation&lt;/td&gt;
&lt;td&gt;270%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak resident memory&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.84 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap between 13.83s of CPU and 5.11s of wall is the whole point of scheduling the true DAG: independent branches build at the same time, and DuckDB parallelises inside each query. The memory figure matters more than the time one — 25 million rows moved through a Python model and five strategies without the process exceeding two gigabytes, because data crosses as streamed Arrow &lt;code&gt;RecordBatch&lt;/code&gt;es rather than being materialised into pandas at each boundary.&lt;/p&gt;

&lt;p&gt;Per-model, from the build table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Model             Output    Strategy              Rows      Time
 events            virtual   replace         25,000,000     3.75s
 by_user           virtual   replace            100,000     0.46s
 top_products      view      replace                  —     0.37s
 product_catalog   virtual   full_merge          15,000     0.35s
 daily_feed        table     append                  29     0.31s
 daily_revenue     virtual   incremental     29     0.29s
 by_product        virtual   replace             15,000     0.21s
 user_ltv          virtual   merge              100,000     0.16s
 by_device         virtual   replace                  4     0.11s
 user_history      virtual   scd                100,000     0.09s
 by_day            virtual   replace                 30     0.07s
 revenue_report    file      replace                 29     0.07s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generating the 25 million rows is 3.75s of the 5.11s. Everything downstream — five strategies, a Python model, a Parquet write and a delivery into an external database — is the remaining 1.4 seconds of wall time.&lt;/p&gt;

&lt;p&gt;Do not take the row counts on trust. Ask the warehouse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;interlace query &lt;span class="s2"&gt;"SELECT count(*) FROM events"&lt;/span&gt;
&lt;span class="c"&gt;# 25000000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The parts that are not about speed
&lt;/h2&gt;

&lt;p&gt;Raw throughput is the least interesting thing here, because it is mostly DuckDB's. The interesting behaviour is what happens on the second run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A repeated incremental window does nothing at all.&lt;/strong&gt; &lt;code&gt;daily_revenue&lt;/code&gt; is &lt;code&gt;incremental&lt;/code&gt; at a one-day grain, and completed intervals are recorded in a ledger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;interlace run &lt;span class="nt"&gt;--select&lt;/span&gt; daily_revenue &lt;span class="nt"&gt;--start&lt;/span&gt; 2026-06-01 &lt;span class="nt"&gt;--end&lt;/span&gt; 2026-07-01
&lt;span class="c"&gt;# Ran 0 model(s) (0 task(s)); promoted 1 to 'prod'.     0.35s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero tasks — not a fast rebuild, no rebuild. Reprocessing is a separate verb, so asking for it is deliberate rather than accidental:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;interlace restate &lt;span class="nt"&gt;--select&lt;/span&gt; daily_revenue &lt;span class="nt"&gt;--start&lt;/span&gt; 2026-06-08 &lt;span class="nt"&gt;--end&lt;/span&gt; 2026-06-15
&lt;span class="c"&gt;# Checks: 7/7 passed&lt;/span&gt;
&lt;span class="c"&gt;# Restated 1 model(s) (1 task(s)); promoted 1 to 'prod'.   0.67s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A one-line change rebuilds one branch.&lt;/strong&gt; Add a column to &lt;code&gt;by_device&lt;/code&gt;, one of four branches hanging off a 25-million-row ephemeral scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/avg_ticket/avg_ticket, min(amount) AS min_ticket/'&lt;/span&gt; models/by_device.sql
interlace plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Model       Change     Category   Build
 by_device   modified   breaking   rebuild
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One model, and &lt;code&gt;apply&lt;/code&gt; then refuses to run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plan has breaking changes (by_device); re-run with --force to proceed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That refusal is the feature. Adding a column changes the shape of what consumers see, so it is classified breaking and the build stops &lt;strong&gt;before touching the warehouse&lt;/strong&gt; — the classification is a gate, not a report. Forcing it through rebuilds only what changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;interlace apply &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;span class="c"&gt;# by_device   virtual   replace   +4   0.10s&lt;/span&gt;
&lt;span class="c"&gt;# Built 1 model(s); promoted 13 to 'prod'.      0.48s wall&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0.48s against 5.11s for the full build, and the other eleven models were never touched, because their fingerprints did not move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/interlace-sh/interlace
&lt;span class="nb"&gt;cd &lt;/span&gt;interlace/examples/benchmark
pip &lt;span class="nb"&gt;install &lt;/span&gt;interlaced

&lt;span class="nb"&gt;time &lt;/span&gt;interlace apply
interlace query &lt;span class="s2"&gt;"SELECT count(*) FROM events"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make the machine work harder, raise &lt;code&gt;range(25000000)&lt;/code&gt; in &lt;code&gt;models/events.sql&lt;/code&gt;. At 100 million the DAG shape does not change — each branch simply scans four times as much through the inlined CTE.&lt;/p&gt;

&lt;p&gt;If your numbers differ from ours, that is useful information and we would like to see them. Different hardware, a different filesystem, a spinning disk, a container with two cores — all of those will move these figures, and none of them are hidden behind a marketing chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not show
&lt;/h2&gt;

&lt;p&gt;A single machine. Every number above comes from one process on one box, and that is the case Interlace is built for. If your warehouse does not fit on one machine, this benchmark is not evidence about your workload, and we would rather say so than let a chart imply otherwise.&lt;/p&gt;

&lt;p&gt;It also says nothing about the engines we have not proven. These runs are DuckDB and DuckLake, which are tested in CI. Spark is beta; Redshift, Snowflake, BigQuery and MotherDuck are alpha — dialect-correct and unit-tested, but not yet run against a live account.&lt;/p&gt;




&lt;p&gt;The DAG above is in &lt;a href="https://github.com/interlace-sh/interlace/tree/master/examples/benchmark" rel="noopener noreferrer"&gt;&lt;code&gt;examples/benchmark&lt;/code&gt;&lt;/a&gt;.&lt;br&gt;
Start with the &lt;a href="https://interlace.sh/docs/getting-started" rel="noopener noreferrer"&gt;introduction&lt;/a&gt;, or install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;interlaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>opensource</category>
      <category>sql</category>
      <category>python</category>
      <category>data</category>
    </item>
    <item>
      <title>interlace.sh: A Python Model Is Just a Function</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Sat, 15 Aug 2026 13:18:20 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/a-python-model-is-just-a-function-4gk8</link>
      <guid>https://dev.to/5c4989ca297ed/a-python-model-is-just-a-function-4gk8</guid>
      <description>&lt;p&gt;Most tools that support both SQL and Python support one of them properly. Python arrives as an escape hatch: a different execution path, a different set of available features, often a different platform requirement, and a strong implicit suggestion that you should have written SQL instead.&lt;/p&gt;

&lt;p&gt;In Interlace a &lt;code&gt;.py&lt;/code&gt; model and a &lt;code&gt;.sql&lt;/code&gt; model are the same kind of node. Either can depend on the other, in either direction, and the planner does not distinguish between them. This post shows that claim running rather than asserts it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The graph
&lt;/h2&gt;

&lt;p&gt;The benchmark project in the repository is a ten-model DAG over 25 million synthetic events. It is not a toy: the numbers are generated in-engine so there is nothing to download, and the fan-out does real, repeated work.&lt;/p&gt;

&lt;p&gt;The chain that matters here is four models long:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;events.sql  →  by_user.sql  →  user_ltv.py  →  top_products.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Python model sits in the middle. Its upstream is SQL. Its downstream is SQL. Nothing about the surrounding models acknowledges that the middle one is written in a different language.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Python model
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A Python model in the hot path: 100k user rows stream through Arrow.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow.compute&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;


&lt;span class="nd"&gt;@model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;depends_on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;by_user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;user_ltv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RecordBatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_arrays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
            &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ltv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things are worth pulling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The parameter is the dependency.&lt;/strong&gt; &lt;code&gt;by_user&lt;/code&gt; is not a string to be resolved later; it is the name of another model, and the function signature is the edge. For SQL models the same edge comes from parsing the &lt;code&gt;FROM&lt;/code&gt; clause. Both produce identical entries in one graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It streams.&lt;/strong&gt; &lt;code&gt;by_user.reader()&lt;/code&gt; yields Arrow &lt;code&gt;RecordBatch&lt;/code&gt;es, and the function is a generator. Memory stays bounded regardless of how far you scale &lt;code&gt;events.sql&lt;/code&gt; — you can raise the row count by an order of magnitude and this model's footprint does not move.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The strategy is the same strategy.&lt;/strong&gt; &lt;code&gt;merge&lt;/code&gt; here is the same keyed upsert a SQL model gets, compiled the same way, running as SQL in the warehouse. Python produced the rows; it did not take over the write path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SQL either side
&lt;/h2&gt;

&lt;p&gt;Upstream, plain SQL with no header at all, which means it takes the defaults — &lt;code&gt;materialise: virtual&lt;/code&gt;, &lt;code&gt;strategy: replace&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;spend&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;enriched&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Downstream, a model that reads a Python model's output as an ordinary relation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* interlace:
  materialise: view
*/&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;by_product&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the edge other tools cannot express freely. A SQL model selecting &lt;code&gt;FROM&lt;/code&gt; the output of a Python model is not a bridge, an adapter or a special case. It is a table reference that happens to resolve to a model that happens to be Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes it work
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/blog/the-ir-is-a-sqlglot-ast"&gt;Arrow wire format&lt;/a&gt;. A model boundary is a &lt;code&gt;RecordBatchReader&lt;/code&gt; in both directions, so a Python function and a SQL query are interchangeable at that boundary by construction. There is no conversion step to go wrong and no DataFrame round-trip to blow up memory.&lt;/p&gt;

&lt;p&gt;The handle you receive is single-pass and gives you a choice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Returns&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.table()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pyarrow.Table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;eager, whole-table work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.reader()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pyarrow.RecordBatchReader&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;streaming, bounded memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.schema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pyarrow.Schema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;inspecting before consuming&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Call one of them, once. A handle consumed twice is an error rather than a silent second scan.&lt;/p&gt;

&lt;p&gt;You can return a &lt;code&gt;pyarrow.Table&lt;/code&gt;, a &lt;code&gt;RecordBatch&lt;/code&gt;, a &lt;code&gt;RecordBatchReader&lt;/code&gt;, or — as above — yield batches from a generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the symmetry actually stops
&lt;/h2&gt;

&lt;p&gt;It would be easy to end here, and dishonest. There are three real limits, and they follow from the design rather than from missing work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python models are always &lt;code&gt;virtual&lt;/code&gt;.&lt;/strong&gt; They cannot be a &lt;code&gt;view&lt;/code&gt; or &lt;code&gt;ephemeral&lt;/code&gt;, because both of those require SQL the engine can evaluate directly — a view is a query, and an ephemeral model is inlined as a CTE. There is nothing to inline when the model is a Python function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Python models cannot deliver to a terminal destination.&lt;/strong&gt; &lt;code&gt;materialise: table&lt;/code&gt; and &lt;code&gt;materialise: file&lt;/code&gt; are SQL-only. If you want a Python model's output in an external system, write a one-line SQL model that selects from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* interlace:
  materialise: table
  target: crm.main.user_scores
  strategy: merge
  key: user_id
*/&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ltv&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;user_ltv&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Python models need a &lt;code&gt;key&lt;/code&gt; to use &lt;code&gt;incremental&lt;/code&gt;.&lt;/strong&gt; With one, the function's Arrow output is staged and the window's rows are upserted into the target — the same keyed semantics a SQL model gets. Without one it is refused, and that refusal is the honest part: a SQL model has the window predicate pushed into its query, so the engine only ever computes the window, whereas a Python function has already produced everything by the time the window could be applied. An unkeyed windowed rewrite would look incremental while doing the full work every run. Use &lt;code&gt;cursor&lt;/code&gt; to bound what the function fetches instead.&lt;/p&gt;

&lt;p&gt;The first two raise at definition time, the moment the decorator runs; the third at plan time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Because the decorator registers the model and returns the function &lt;strong&gt;unchanged&lt;/strong&gt;, a Python model is still an ordinary function. Call it with Arrow tables and assert on what comes back.&lt;br&gt;
No fixtures, no warehouse, no separate framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pyarrow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_user_ltv&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;by_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_batches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;user_ltv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;FakeHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ltv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_pylist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;13.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part that tends to convert people. A pipeline step you can call in a unit test, with no infrastructure, is a different kind of object from a pipeline step you can only observe by running it.&lt;/p&gt;

&lt;p&gt;The next post is about the other end of the system, where a promise is much harder to keep: what it takes for an HTTP 200 to actually mean the data is safe.&lt;/p&gt;




&lt;p&gt;Read the &lt;a href="https://interlace.sh/docs/guides/python-models" rel="noopener noreferrer"&gt;Python models guide&lt;/a&gt; for handles, cursors and the &lt;code&gt;this&lt;/code&gt; parameter, or &lt;a href="https://interlace.sh/docs/guides/testing" rel="noopener noreferrer"&gt;testing&lt;/a&gt; for the layered safety net around them.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>python</category>
      <category>opensource</category>
      <category>data</category>
    </item>
    <item>
      <title>interlace.sh: Sandboxes That Cost Nothing</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Sat, 15 Aug 2026 12:54:40 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/sandboxes-that-cost-nothing-3i82</link>
      <guid>https://dev.to/5c4989ca297ed/sandboxes-that-cost-nothing-3i82</guid>
      <description>&lt;p&gt;If you work with external APIs, you know the problem. There is no &lt;code&gt;dev.github.com&lt;/code&gt;, no &lt;code&gt;staging.api.companieshouse.gov.uk&lt;/code&gt;, no test endpoint for the thing you actually depend on. Production is the only source. So how do you get a development environment without re-fetching everything you already have?&lt;/p&gt;

&lt;p&gt;The usual answer is to copy: duplicate the warehouse, or keep a separate dev database and sync it periodically. Both are slow, both drift, and both cost storage in proportion to the number of people on the team.&lt;/p&gt;

&lt;p&gt;Interlace does something else. &lt;strong&gt;An environment is not a copy of your data, it is a set of views over it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fingerprints first
&lt;/h2&gt;

&lt;p&gt;Every model gets a fingerprint: a hash of its canonical SQL — or its Python source — together with its strategy configuration and its upstream fingerprints. A build writes an immutable physical table named after that fingerprint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;interlace__main.orders__a1b2c3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That table never changes. If the model's definition changes, the new version gets a new fingerprint and a new table, and the old one stays exactly where it is.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;environment&lt;/strong&gt; is then just a set of views pointing at fingerprinted tables. Production is the unprefixed namespace; every other environment prefixes its schema.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;View for &lt;code&gt;main.orders&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;main.orders&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dev__main.orders&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pr-142&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pr-142__main.orders&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Consumers and BI tools connect to &lt;code&gt;main.orders&lt;/code&gt; and never learn that a fingerprint exists. There is no environment list to configure, either — an environment exists once something has been promoted to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the sandbox is free
&lt;/h2&gt;

&lt;p&gt;Here is where the re-fetching problem disappears. Applying to a sandbox does not rebuild models whose fingerprint already exists. It points the sandbox's views at the tables production already built.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;interlace apply &lt;span class="nt"&gt;--env&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change one model out of forty and the sandbox builds one model. The other thirty-nine are reused — not copied, reused, the same physical tables production is reading. The expensive source extract that ran this morning is the table your sandbox reads this afternoon.&lt;/p&gt;

&lt;p&gt;It goes further than "did this model change". Because the IR is &lt;a href="https://dev.to/blog/the-ir-is-a-sqlglot-ast"&gt;an AST&lt;/a&gt;, impact analysis runs at column level: a semantic change invalidates only the consumers of the columns it actually touched. A downstream model whose output is provably identical is marked &lt;code&gt;reuse&lt;/code&gt; in the plan and is not rebuilt at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;interlace plan
&lt;span class="go"&gt; Model         Change    Category      Build
 orders        modified  non_breaking  rebuild
 order_stats   modified  non_breaking  reuse
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;order_stats&lt;/code&gt; sits downstream of a changed model and still does not rebuild, because the analysis proved its output cannot differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Promotion is a view swap
&lt;/h2&gt;

&lt;p&gt;Since the tables are immutable and the environment is only a pointer, promoting to production is an atomic view swap rather than a data migration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;interlace apply &lt;span class="nt"&gt;--env&lt;/span&gt; dev   &lt;span class="c"&gt;# iterate in a sandbox&lt;/span&gt;
interlace plan              &lt;span class="c"&gt;# see what prod would get&lt;/span&gt;
interlace apply             &lt;span class="c"&gt;# promote&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties fall out of this, and both matter more than they sound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed apply changes nothing.&lt;/strong&gt; Views move only after data-quality checks pass. There is no half-promoted state, because promotion is one operation rather than a sequence of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollback is promotion run backwards.&lt;/strong&gt; Every promote records the environment's full mapping as a generation in the promotion history. Rolling back repoints the views at an earlier generation — the tables have not gone anywhere, so nothing rebuilds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;interlace &lt;span class="nb"&gt;env &lt;/span&gt;rollback &lt;span class="nt"&gt;--list&lt;/span&gt;
interlace &lt;span class="nb"&gt;env &lt;/span&gt;rollback &lt;span class="nt"&gt;--to&lt;/span&gt; 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the marquee benefit of content-addressed snapshots. Recovery is not a restore. It is a pointer move, and it takes about as long as a &lt;code&gt;CREATE OR REPLACE VIEW&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reclaiming the space
&lt;/h2&gt;

&lt;p&gt;Immutable tables accumulate, so &lt;code&gt;gc&lt;/code&gt; reclaims them — reference-aware, so a snapshot production still uses, or that another environment reuses, survives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;interlace &lt;span class="nb"&gt;env &lt;/span&gt;drop dev            &lt;span class="c"&gt;# views and prefixed schemas go; tables remain&lt;/span&gt;
interlace gc                      &lt;span class="c"&gt;# 7-day grace by default&lt;/span&gt;
interlace gc &lt;span class="nt"&gt;--grace&lt;/span&gt; 12h &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropping an environment deliberately leaves the underlying tables alone. They simply become reclaimable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sandboxes cannot touch production systems
&lt;/h2&gt;

&lt;p&gt;There is one thing a virtual environment must never do, which is silently fan side-effecting writes out to production.&lt;/p&gt;

&lt;p&gt;Models that deliver outside the warehouse — an external table, a file — are &lt;strong&gt;environment-gated&lt;/strong&gt;. By default they fire only on a &lt;code&gt;prod&lt;/code&gt; apply. A sandbox apply builds the model and reports the delivery as &lt;em&gt;gated&lt;/em&gt; rather than writing to a live external table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* interlace:
  materialise: table
  target: crm.main.accounts
  strategy: merge
  key: id
  environments: [dev, prod]
*/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Widening the gate is explicit, and the gating list is part of the fingerprint — so widening it re-plans the model rather than classifying it unchanged and never delivering.&lt;/p&gt;

&lt;p&gt;This is the property that makes the whole thing usable in practice. You can run &lt;code&gt;interlace apply --env dev&lt;/code&gt; against real production source tables without any risk that a half-finished model writes into your CRM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Storage for one immutable table per fingerprint you have not yet collected, and a view per model per environment. That is the entire overhead. There is no second warehouse, no sync job, and no per-developer copy.&lt;/p&gt;

&lt;p&gt;The next post is about the other half of the graph: why a Python model is not a special case.&lt;/p&gt;




&lt;p&gt;Read the &lt;a href="https://interlace.sh/docs/guides/environments" rel="noopener noreferrer"&gt;environments guide&lt;/a&gt; for drift, generations and &lt;code&gt;gc&lt;/code&gt;, or &lt;a href="https://interlace.sh/docs/guides/schema-evolution" rel="noopener noreferrer"&gt;schema evolution&lt;/a&gt; for how changes get classified.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>opensource</category>
      <category>data</category>
      <category>python</category>
    </item>
    <item>
      <title>interlace.sh: The IR Is a sqlglot AST</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Sat, 15 Aug 2026 12:01:03 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/the-ir-is-a-sqlglot-ast-e40</link>
      <guid>https://dev.to/5c4989ca297ed/the-ir-is-a-sqlglot-ast-e40</guid>
      <description>&lt;p&gt;Every data tool has an intermediate representation, whether it admits to one or not. It is the thing a model becomes after parsing and before execution, and it quietly decides what the tool can do.&lt;/p&gt;

&lt;p&gt;dbt's IR is templated text, which is why &lt;code&gt;ref()&lt;/code&gt; is a string and why a macro can produce SQL no analyser can reason about. An earlier iteration of Interlace effectively had a pandas DataFrame as its IR — every model boundary ran an eager &lt;code&gt;.execute()&lt;/code&gt; and fed the result back in — which cost us laziness, dialect portability and any hope of semantic change detection, all at once.&lt;br&gt;
Three properties, one root cause. That is what an IR does: it sets the ceiling.&lt;/p&gt;

&lt;p&gt;So the current design committed to one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The canonical IR is a sqlglot AST + Arrow schema. The canonical wire format is an Arrow &lt;code&gt;RecordBatchReader&lt;/code&gt;. Materialisation happens exactly once, at the sink, as a single native SQL statement executed inside the owning engine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything else in Interlace 2.0 follows from that.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why not Ibis
&lt;/h2&gt;

&lt;p&gt;Ibis is a good library and 0.x was built on it. Dropping it was the single largest deletion in the rebuild, so it deserves a real justification rather than a preference.&lt;/p&gt;

&lt;p&gt;Ibis was doing two jobs for us. As a &lt;strong&gt;data plane&lt;/strong&gt;, it moved results between models — and it sits on Arrow underneath, so we were paying for a wrapper around a format we could use directly. As an &lt;strong&gt;expression builder&lt;/strong&gt;, it turned Python into SQL — and it compiles to sqlglot, which is the AST we had already decided to make canonical.&lt;/p&gt;

&lt;p&gt;Both roles were covered without it. Removing Ibis dropped a heavyweight dependency and its governance risk for zero lost capability. Remote engines connect over ADBC rather than Ibis backends, which is a narrower and better-specified contract.&lt;/p&gt;

&lt;p&gt;That is the whole argument. Not "Ibis is bad" — "Ibis is a layer over the two things we had already chosen".&lt;/p&gt;
&lt;h2&gt;
  
  
  What an AST IR buys
&lt;/h2&gt;

&lt;p&gt;A sqlglot AST is a parsed, structured, dialect-neutral representation of the query. Three consequences matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategies become AST builders, not string templates.&lt;/strong&gt; A strategy is the thing that turns a model's query into a table. In 0.x, each one wrote DuckDB SQL by hand. Now each one takes the model's query expression and its target, and emits a short list of SQL statements that &lt;code&gt;apply&lt;/code&gt; runs in one transaction. Dialect appears only at &lt;code&gt;transpile()&lt;/code&gt;, at the very end.&lt;/p&gt;

&lt;p&gt;The payoff is that no strategy needs the model's column list. A model's schema can change without a hand-written migration, because a definition change simply mints a new snapshot table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependencies are parsed, not declared.&lt;/strong&gt; Because we hold the AST, the &lt;code&gt;FROM&lt;/code&gt; and &lt;code&gt;JOIN&lt;/code&gt; clauses are structure rather than text. A reference whose name — or whose last dotted segment — matches another model becomes a DAG edge. CTEs are excluded, because the parser knows what a CTE is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* interlace:
  strategy: merge
  key: order_id
*/&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_orders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;ref()&lt;/code&gt; to write. &lt;code&gt;raw_orders&lt;/code&gt; is a real table reference that happens to resolve to a model, and it is rewritten to the right snapshot at build time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change classification can be semantic.&lt;/strong&gt; This is the one that pays for the whole decision.&lt;br&gt;
Because we can compare two ASTs rather than two strings, &lt;code&gt;interlace plan&lt;/code&gt; can tell the difference between a change that alters a model's output and one that does not — and then narrow that further, per column, to work out which downstream models are genuinely affected.&lt;/p&gt;

&lt;p&gt;The analysis is deliberately conservative. Adding &lt;code&gt;avg(amount) AS avg_amount&lt;/code&gt; to a &lt;code&gt;SELECT&lt;/code&gt; is additive. Anything touching existing expressions — or anything the analyser cannot prove safe, such as a &lt;code&gt;SELECT *&lt;/code&gt; rewrite, a &lt;code&gt;DISTINCT&lt;/code&gt;, or a positional &lt;code&gt;GROUP BY&lt;/code&gt; — is treated as breaking. Ambiguity always errs toward rebuilding, never toward a false skip.&lt;/p&gt;
&lt;h2&gt;
  
  
  Arrow at the boundaries
&lt;/h2&gt;

&lt;p&gt;The wire format is an Arrow &lt;code&gt;RecordBatchReader&lt;/code&gt;. Data crossing into a Python model arrives as Arrow and leaves as Arrow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;

&lt;span class="nd"&gt;@model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;depends_on&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;by_user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;user_ltv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it is a reader rather than a table, a Python model can stream. Memory stays bounded no matter how large the upstream is, which is the property 0.x's &lt;code&gt;.execute()&lt;/code&gt; → pandas → &lt;code&gt;memtable()&lt;/code&gt; round-trip destroyed at every hop.&lt;/p&gt;

&lt;p&gt;pandas and Polars are still available — as optional extras, at the edges, when you actually want a DataFrame. They are not in the core and they are not the interchange format.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rebuild refused
&lt;/h2&gt;

&lt;p&gt;Three refusals are worth stating plainly, because each one is a thing many peers do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No Jinja.&lt;/strong&gt; Python is the macro language. If you need to generate forty models from a config, write a loop — the &lt;a href="https://dev.to/docs/guides/dynamic-models"&gt;dynamic models guide&lt;/a&gt; covers the patterns and the traps. A templating DSL inside SQL strings is a second language with no parser, no types and no editor support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;ref()&lt;/code&gt;-as-text.&lt;/strong&gt; References resolve at the AST level, as above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No pandas in the core.&lt;/strong&gt; Arrow only.&lt;/p&gt;

&lt;p&gt;The result is a core install with nine runtime dependencies: sqlglot, duckdb, pyarrow, pydantic, typer, rich, cronsim, tenacity and pyyaml. The HTTP daemon is an optional &lt;code&gt;service&lt;/code&gt; extra.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape it produced
&lt;/h2&gt;

&lt;p&gt;Five lines describe the whole system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The IR is a sqlglot AST; the wire format is an Arrow &lt;code&gt;RecordBatchReader&lt;/code&gt;; strategies are AST builders and dialect appears only at &lt;code&gt;transpile()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Storage defaults to DuckLake — Parquet with a SQL catalog — opened as DuckDB's primary database.&lt;/li&gt;
&lt;li&gt;The control plane, holding snapshots, intervals, the run queue, events and API keys, is SQLite in WAL mode.&lt;/li&gt;
&lt;li&gt;Streams live in their own durable log; the materialiser commits data and watermark in one warehouse transaction, giving exactly-once without distributed coordination.&lt;/li&gt;
&lt;li&gt;No Jinja, no pandas in core, no external orchestrator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The next post takes the second and third of those apart: how fingerprinted snapshots and environment views make a development sandbox cost nothing.&lt;/p&gt;




&lt;p&gt;Read the &lt;a href="https://interlace.sh/docs/core-concepts" rel="noopener noreferrer"&gt;core concepts&lt;/a&gt; for how the IR becomes a plan, or &lt;a href="https://interlace.sh/docs/core-concepts/dependencies" rel="noopener noreferrer"&gt;dependencies&lt;/a&gt; for the resolution rules in detail.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>opensource</category>
      <category>python</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>interlace.sh: Why We Built a Unified Abstraction</title>
      <dc:creator>Mark Alford</dc:creator>
      <pubDate>Fri, 14 Aug 2026 23:07:02 +0000</pubDate>
      <link>https://dev.to/5c4989ca297ed/interlacesh-why-we-built-a-unified-abstraction-c66</link>
      <guid>https://dev.to/5c4989ca297ed/interlacesh-why-we-built-a-unified-abstraction-c66</guid>
      <description>&lt;p&gt;Consider what a data engineer has to learn before writing a single useful transformation on a typical 2026 data stack.&lt;/p&gt;

&lt;p&gt;dbt, for SQL models, sources, tests and macros. Airflow or Dagster, for scheduling, sensors and retry logic. dlt, Airbyte or Fivetran, for ingestion. Python scripts for anything SQL cannot express. Then YAML to configure all of it, and a growing pile of glue to hold the pieces together.&lt;/p&gt;

&lt;p&gt;Each of those tools is good. dbt genuinely standardised SQL transformation. Airflow's operator ecosystem is unmatched. dlt does schema inference and incremental loading better than most hand-written extractors. The problem is not the tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem is the seams.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where failures actually live
&lt;/h2&gt;

&lt;p&gt;Your ingestion layer writes to a landing zone. Your transformation layer reads from it. Nothing in either tool knows that this handoff exists — it is a convention, held together by a table name and an assumption about timing.&lt;/p&gt;

&lt;p&gt;That is where things break, and they break quietly. An upstream API changes its response shape; the loader happily writes the new columns; the transformation reads the ones it expects and silently produces a narrower result. A load runs late; the transformation runs on schedule anyway and publishes yesterday's numbers. A model fails; the orchestrator retries it; the retry succeeds against half-written data.&lt;/p&gt;

&lt;p&gt;The debugging story is worse than the failure. Testing a dbt model uses one framework. Testing a Python script uses another. Testing a dlt pipeline uses a third. When something goes wrong at 2am, the answer is spread across three dashboards, and none of them knows about the other two.&lt;/p&gt;

&lt;p&gt;None of this is hypothetical. It is the ordinary experience of running a stack where no single component can see the whole path from source to dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Software engineering solved this
&lt;/h2&gt;

&lt;p&gt;The same fragmentation existed in general programming and got resolved through abstraction rather than integration.&lt;/p&gt;

&lt;p&gt;Functions unified computation — nobody uses a different tool for arithmetic than for string processing. Interfaces decoupled implementation from use. Package managers unified dependency resolution, replacing a manual, error-prone, entirely social process.&lt;/p&gt;

&lt;p&gt;In each case the win was not a better tool for each job. It was one concept that covered all of them, so the seams disappeared instead of being managed.&lt;/p&gt;

&lt;p&gt;Data engineering is on the same arc, roughly a decade behind. We still have a dedicated tool per concern, each with its own mental model, and we still spend real effort on the glue.&lt;/p&gt;

&lt;h2&gt;
  
  
  One abstraction
&lt;/h2&gt;

&lt;p&gt;Interlace is built on a single idea: &lt;strong&gt;a model is a query or a function that produces a table, and everything is a model.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A SQL model is a file. Dependencies come from the query itself — there is no &lt;code&gt;ref()&lt;/code&gt; to write, because the parser can see the &lt;code&gt;FROM&lt;/code&gt; clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* interlace:
  strategy: merge
  key: customer_id
  checks:
    - not_null: customer_id
*/&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_customers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Python model is a function. Its parameters name its upstreams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;interlace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;

&lt;span class="nd"&gt;@model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;user_ltv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are not two systems with a bridge between them. They are two spellings of one node type.&lt;br&gt;
A SQL model can select from a Python model, and a Python model can take a SQL model as a parameter, because both resolve to entries in the same graph.&lt;/p&gt;

&lt;p&gt;Ingestion is the same abstraction again. A model with no upstreams is a source — it pulls from the outside world and participates in the DAG like anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;raw_events&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pyarrow&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example.com/events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pylist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can call dlt inside that function if you want its schema inference. The point is not to replace it — the point is that the extraction is now a node in the graph rather than a separate system writing to an agreed table name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What collapses
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The handoff becomes an edge.&lt;/strong&gt; When ingestion is a model, the dependency between extraction and transformation is a real edge in a real graph. When the API changes shape, you find out in the same place you find out about a broken join.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One testing pattern.&lt;/strong&gt; The decorator registers the model and returns the function &lt;em&gt;unchanged&lt;/em&gt;, so a Python model is an ordinary function you can call in a unit test with no warehouse and no fixtures. SQL models are covered by the same checks that gate production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One deployment.&lt;/strong&gt; &lt;code&gt;interlace serve&lt;/code&gt; runs the scheduler, the HTTP API, stream ingestion and a web UI in one process. There is no orchestrator to deploy alongside it and no broker to operate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One vocabulary.&lt;/strong&gt; A new engineer learns what a model is, and can then read, write and debug any step — extraction, transformation, Python or SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  One abstraction is not enough
&lt;/h2&gt;

&lt;p&gt;This is the part where the argument would usually stop, and it should not.&lt;/p&gt;

&lt;p&gt;A shared interface removes the seams. It does not, by itself, tell you whether a change is safe — and "safe" is what actually keeps people awake. Two further things are doing that work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changes are previewed, not discovered.&lt;/strong&gt; &lt;code&gt;interlace plan&lt;/code&gt; fingerprints every model and classifies each change before touching the warehouse. Because the comparison is between parsed queries rather than file hashes, a downstream model whose output is provably identical is reused rather than rebuilt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ interlace plan
 Model         Change    Category      Build
 orders        modified  non_breaking  rebuild
 order_stats   modified  non_breaking  reuse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plan containing breaking changes refuses to apply without &lt;code&gt;--force&lt;/code&gt;, which makes the classification an automated review gate rather than a report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingestion is durable, not best-effort.&lt;/strong&gt; Models cover data you pull on a schedule. They do not cover events arriving continuously, so streams fill that in — without introducing a second mental model. Declare one, POST to it, and the row is durable before the response returns, deduplicated by idempotency key, and materialised exactly once into a table that SQL models read like any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not replace
&lt;/h2&gt;

&lt;p&gt;Interlace is not the answer to everything, and pretending otherwise would undermine the rest of the argument.&lt;/p&gt;

&lt;p&gt;dbt has an ecosystem we will not match for years, and an adapter for every warehouse you might already be paying for. Airflow handles cross-system orchestration — the kind that reaches outside your data platform entirely — far better than we intend to. Spark processes volumes Interlace is not designed for.&lt;/p&gt;

&lt;p&gt;Engine coverage is closer than it looks — DuckDB, DuckLake, MotherDuck, Postgres, Redshift, Snowflake, BigQuery and Spark all run models, with near-full strategy support on each. What is not comparable is how proven they are: only the DuckDB family and Postgres are tested in CI, Spark is beta, and the four cloud warehouses are alpha. They are dialect-correct and unit-tested, but they have not yet run against a live account, and that is a real difference from a tool with years of production mileage on those platforms.&lt;/p&gt;

&lt;p&gt;What Interlace is for is the common case: a team whose warehouse fits on one machine, who are running four tools to do one job, and who would rather spend the effort on pipelines than on the glue between them.&lt;/p&gt;

&lt;p&gt;The next post is about the decision that made it possible — what a model actually compiles to, and why that choice determines everything else.&lt;/p&gt;




&lt;p&gt;Interlace is MIT-licensed and requires Python 3.12+. Start with the&lt;br&gt;
&lt;a href="https://interlace.sh/docs/getting-started" rel="noopener noreferrer"&gt;introduction&lt;/a&gt;, or install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s1"&gt;'interlaced[service]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>data</category>
      <category>opensource</category>
      <category>python</category>
      <category>sql</category>
    </item>
  </channel>
</rss>
