August 9, 2026

No Jinja: What Replaces Templating When SQL Is an AST

Interlace has no templating language. A model is a .sql file containing SQL, or a .py file containing a function. There is no {{ }}, no {% %}, and no macro system.

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.

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

Job one: {{ ref() }} — declaring dependencies

This is the most common use and the least necessary. ref() 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.

But the dependency is already in the query. It is the FROM clause. Interlace parses the SQL with sqlglot and reads the edges out of the AST:

>>> from interlace.ir.canonicalize import parse, table_references
>>> sql = """SELECT o.id, c.tier FROM orders o
...          JOIN customers c USING (id)
...          WHERE o.d > (SELECT max(d) FROM watermark)"""
>>> sorted(table_references(parse(sql)))
['customers', 'orders', 'watermark']

Three dependencies, including one inside a correlated subquery, with nothing to declare. The model is:

SELECT customer_id, name, tier FROM raw_customers

That is the whole file. It is also valid SQL you can paste into any client, which a Jinja template is not.

Job two: {% for %} — generating many models or many columns

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.

Interlace’s answer is that .py model files are imported and executed when the project loads. Registering a model is a function call, so a loop over a list is a loop over a list:

# models/per_tenant.py
from interlace.dsl.decorators import REGISTRY, ModelDef

def get_tenants():                    # any Python: a query, a file, an env var
    return ["acme", "globex", "initech"]

for tenant in get_tenants():
    REGISTRY.register_model(ModelDef(
        name=f"orders_{tenant}",
        sql=f"SELECT order_id, amount FROM raw WHERE tenant_id = '{tenant}'",
        strategy="merge",
        key=("order_id",),
    ))

Three real models, each with its own snapshot table, environment view, fingerprint, plan entry and checks:

 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

Note what get_tenants() 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.

This is not a feature we built. It falls out of model files being ordinary Python that runs.

Job three: macros — reusable SQL fragments

Jinja macros exist because SQL has no functions of its own that reach across files. Python has had this for thirty years — a function that returns a SQL fragment:

# macros.py — an ordinary module you can import and unit-test
def revenue_expr(currency: str) -> str:
    return f"sum(amount) FILTER (WHERE ccy = '{currency}') AS revenue_{currency}"

A macro is only useful if you can drop it into a model, and you can — the same import-and-register from Job two, with the fragment interpolated into the SQL:

# models/revenue.py
from interlace.dsl.decorators import REGISTRY, ModelDef
from macros import revenue_expr

REGISTRY.register_model(ModelDef(
    name="revenue",
    sql=f"SELECT customer_id, {revenue_expr('usd')}, {revenue_expr('eur')} "
        f"FROM orders GROUP BY customer_id",
))

That is a real model — its own snapshot, view, fingerprint, plan entry and checks — built from orders, with revenue_usd and revenue_eur columns. The difference from a Jinja macro is not expressive power; it is that revenue_expr has a type signature, a debugger, and a test framework (assert revenue_expr("usd").startswith("sum(")), and a Jinja macro has none of those.

One wrinkle worth stating plainly: model files are imported by path, not as part of a package, so the project root is not on sys.path and that from macros import … will not resolve on its own. Run with the root on the path — PYTHONPATH=. interlace apply — or install your helpers as a real package. Neither is difficult; both are easy to trip over.

And because a model’s fingerprint is its rendered SQL, editing the macro re-plans only the models whose SQL actually changed — not, as in dbt, every model that happens to import it.

Job four: {{ config() }} — per-model settings

Interlace puts configuration in a leading block comment, namespaced under interlace:

/* interlace:
  strategy: merge
  key: customer_id
  checks:
    - not_null: customer_id
    - unique: customer_id
*/
SELECT customer_id, name, tier FROM raw_customers

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.

What the AST buys that text cannot

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.

Interlace fingerprints the canonical form of the AST, not the file. So changes that cannot affect the output do not rebuild anything:

  original           6b5428631ca23e11  same → reuse
  reformatted        6b5428631ca23e11  same → reuse
  comment added      6b5428631ca23e11  same → reuse
  extra whitespace   6b5428631ca23e11  same → reuse
  SEMANTIC CHANGE    259d4f1398cc67fa  DIFFERENT → rebuild

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 sum to avg, and everything downstream of it rebuilds.

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.

The same parsed tree is what makes column-level lineage, the breaking-change classification in interlace plan, 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.

What you give up

Honestly: dbt’s macro ecosystem. dbt_utils and its relatives are a large body of tested, shared SQL, and “write a Python function” is not the same as installing a package that thousands of people already use. If your project leans on that ecosystem, this is a real cost and you should weigh it.

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 {% if %}.


More in Dynamic Models and SQL Models, or install it:

pip install interlaced