We added Jinja to our dbt project to avoid copy-pasting SQL. Then we needed loops. Then conditionals. Then macros that call other macros. Now we’re debugging template rendering errors at 2am, and somewhere along the way, our “configuration” became a full programming language - just one without a debugger, type system, or any of the tooling we’d expect from actual code.
That’s basically the state of data engineering nowadays. Actually, the state of any ops-oriented engineering work.
When we move further in the configuration complexity clock, we often find ourselves templating configuration files.
We start with clean YAML or SQL, then gradually pollute it with Jinja or any similar templating language. The YAML/SQL becomes unreadable - neither a declarative configuration nor proper code. These templating languages emerge as expression languages to bridge gaps, then become the problem themselves.
There’s a reason this problem feels more acute now. Software engineers are experiencing dramatic productivity gains from AI coding assistants - but data engineers and similar ops-oriented positions largely aren’t. At least not in the same way nor at the same scale.
Data is stateful, code is stateless. While software engineers work with stateless code that can be easily mocked and tested, data engineers are maintaining large, stateful systems where the code is in service of ops work - configuration, orchestration, and data pipelines that can’t be casually experimented without risking expensive mistakes. The tooling reflects this reality: we reach for declarative configs, then template them when they get complex, then debug the templates, stuck in a cycle hard for humans to debug and nearly impossible for AI to safely refactor.
In the early 2000s, browsers had inconsistent, verbose DOM APIs. jQuery emerged to provide a concise, uniform interface for element selection and manipulation, event handling, AJAX calls, etc.
jQuery could be dropped onto any page. A single <script> tag and developers immediately wrote:
It replaced dozens of lines of vanilla DOM code with one.
As applications grew, teams used jQuery for: templating HTML, state management, animation sequences, even rudimentary component systems.
The more jQuery was capable, the more it was used - even when its imperative, selector-driven approach wasn’t appropriate. Codebases became riddled with tangled event handlers, hidden state mutations, and performance bottlenecks.
Then React appeared. It basically introduced a two-layer pattern:
JSX - a declarative markup - defines what the UI should look like.
JavaScript - imperative, programmable - implements the logic.
By keeping UI description separate from application logic, React eliminated the need to intermix DOM queries and state updates across the codebase.
Components bundle markup, styling, and state. Instead of scattering jQuery selectors and mutations throughout hundreds of lines, React encapsulates logic in reusable, testable units:
function Button({ onClick, label }) { return <button onClick={onClick}>{label}</button>; }We can call this mismatch between what we want to express (our intent) and how verbose or awkward it is to express it in the available tools the “semantic impedance”.
jQuery’s story mirrors what’s happening in data engineering for years now. Just as jQuery bridged the gap between JavaScript and inconsistent DOM APIs, templating languages like Jinja bridge the gap between declarative - static - configuration (SQL, YAML) and dynamic runtime needs. And just like jQuery, it creates a semantic impedance. Even more: our codebase metastasizes.
And so the natural questions coming next are: should configuration or query languages be powerful enough from the start that they don’t need expression or templating layers? Should we move back to a programming language?
At Kestra, I see the following systemic pattern across different kinds of user maturity:
“I just need to reference an output...”: {{ outputs.task.value }}
“I need to handle missing data...”: {{ outputs.task.value ?? ‘default’ }}
“I need date formatting...”: {{ trigger.date ?? execution.startDate | dateAdd(-1, ‘DAYS’) | date(’yyyy-MM-dd’) }}
“I need conditional logic...”: {% if inputs.data.value | jq(”.[23]”) | first == 3%}...{% endif %}
Such a pattern is also common in all declarative-based tools, i.e., dbt with Jinja, Kubernetes with Helm, Terraform HCL with templating, etc.
Globally speaking, we can articulate it as follows:
Expression or templating language is introduced for legitimate narrow use case
Users discover it’s more convenient than switching to proper programming
Expression usage metastasizes across the codebase
Configuration becomes unreadable and unmaintainable.
Long story short: expression languages emerge as a necessary evil when configuration languages lack composability. And now our YAML looks like this monstrosity.
One can think we should go further with the DSL clock and build configuration and query languages powerful enough from the start that they don’t need expression layers.
In my opinion, this can be a good idea in some cases, but likely not enough on themselves.
For example, Cue brings a lot of things to YAML or JSON configuration by adding strong typing, inheritance, validation, and schema definition capabilities that automatically validate your data and reduce errors when scaling configuration-based codebase.
Same with Malloy which brings composability to SQL.
All these new DSL aims to fix the flaws of previous languages, usually by bringing composability, proper typing, and unification.
While they mainly succeed at this, we still need to evaluate predicates at runtime. If dev, then the target database shouldn’t be the same as if prod. At runtime, we need dynamic evaluation. The problem isn’t that expression or templating exists - it’s that there’s no boundary on where it should be used.
While drawing architectural boundaries is the primary prevention we should apply to maintain the inherent complexity of our codebase, there is a second point of intervention we should evaluate here.
The example below is a Cue file policy checking on bad patterns within dbt queries:
// dbt_policy.cue // Simple policy to catch bad Jinja patterns in dbt models package dbt import “strings” // Define what a dbt model should look like #DbtModel: { // The SQL content of your dbt model sql: string // Rule 1: No nested macros (macro calling macro calling macro) _macroCount: strings.Count(sql, “{{”) if _macroCount > 5 { _warning: “Too many Jinja expressions (\(_macroCount)) - consider simplifying” } // Rule 2: No loops inside WHERE clauses if strings.Contains(sql, “WHERE”) && strings.Contains(sql, “{% for”) { _violation: “Loop detected in WHERE clause - move logic to CTE or upstream” } // Rule 3: No complex date math in Jinja if strings.Contains(sql, “dateadd”) && strings.Contains(sql, “{{”) { _violation: “Date arithmetic in Jinja - use SQL date functions instead” } // Rule 4: No string concatenation in Jinja for SQL generation if strings.Contains(sql, “~ ‘”) || strings.Contains(sql, “’ ~”) { _violation: “String concatenation in Jinja - build SQL properly in model” } // Rule 5: Limit macro nesting depth if strings.Contains(sql, “{{ ref(”) && strings.Count(sql, “{{”) > 3 { _warning: “Multiple nested refs - consider breaking into separate models” } } // Example: Good dbt model good_model: #DbtModel & { sql: “”“ -- ✅ GOOD: Simple ref, clear SQL SELECT user_id, created_at, amount FROM {{ ref(’staging_orders’) }} WHERE created_at >= ‘{{ var(”start_date”) }}’ “”“ } // Example: Bad dbt model (this will fail validation) bad_model: #DbtModel & { sql: “”“ -- ❌ BAD: Loop in WHERE clause SELECT * FROM orders WHERE order_id IN ( {% for id in var(’order_ids’) %} {{ id }}{% if not loop.last %},{% endif %} {% endfor %} ) “”“ // This will trigger: “Loop detected in WHERE clause” } // Example: Another bad pattern bad_date_model: #DbtModel & { sql: “”“ -- ❌ BAD: Date math in Jinja SELECT * FROM orders WHERE created_at > ‘{{ dateadd(days, -7, execution_date) }}’ “”“ // This will trigger: “Date arithmetic in Jinja” }This is likely not a realistic example, but I’m sure you got it. Here, we check not only for pure code syntax, but also for bad templating patterns.
Like for classic lint checks, the goal is to find a way to secure our codebase - in a systemic way. Embedding such policies in a CI/CD pipeline sounds like a good start here.
The cost of a linting rule is measured in minutes. The cost of debugging template logic at 2 am is measured in hours - or careers. Choose wisely.
Linting is an intervention at the symptom level. But what would intervention at the cause look like?
In medicine, metastasis happens when cells lose their differentiation - they forget what type of cell they’re supposed to be. A liver cell that forgets it’s a liver cell doesn’t become healthy; it becomes cancer.
That’s exactly what’s happening in our codebases. A configuration that forgets it is a configuration doesn’t become flexible - it becomes Jinja templates with 8 levels of nesting. Code that forgets it is code doesn’t become declarative - it becomes YAML files that secretly execute arbitrary logic.
React didn’t solve the jQuery problem by making JavaScript better at DOM manipulation. It solved it by establishing a social contract enforced by tooling: JSX is for structure, JavaScript is for logic.
dbt compiles Jinja to SQL at build time, yes. But the source code you write, debug, review, and maintain is still the Jinja spaghetti. The AI trying to help you refactor sees Jinja spaghetti. Your colleague reviewing the PR sees Jinja spaghetti. The boundary between logic and configuration exists at build time, but not where it matters - at development time.
As explored earlier, we could try to fix this. Jinja but with better linting. Helm but with type checking. But we are likely solving the wrong problem.
React didn’t eliminate runtime evaluation. Components evaluate props and state at runtime constantly. What React eliminated was logic embedded inside the declarative structure.
Look at the difference:
// jQuery era - logic embedded in markup $(’#orders’).html(` <div> ${env === ‘prod’ ? ‘<span>Production</span>’ : ‘<span>Dev</span>’} ${orders.map(o => `<div>${o.amount}</div>`).join(’‘)} </div> `); // React - logic happens before structure function Orders({ env, orders }) { const label = env === ‘prod’ ? ‘Production’ : ‘Dev’; const items = orders .filter(o => o.amount > 100) .map(o => <div key={o.id}>{o.amount}</div>); return ( <div> <span>{label}</span> {items} </div> ); }Same runtime behavior. But in React, computation happens in a computation language, and then the structure is declared with computed values.
The structure itself contains no logic—only references to already-computed values.
Now look at our dbt models:
-- Logic embedded in structure SELECT user_id, {% if var(’env’) == ‘prod’ %} prod_column {% else %} dev_column {% endif %} as column_name FROM {{ ref(’staging_orders’) }} WHERE created_at >= {{ ‘CURRENT_DATE’ if var(’lookback’) == ‘today’ else “’” + var(’lookback’) + “’” }}The SQL structure contains conditional logic, string concatenation, and function calls. We’re doing computation inside the declarative query language using a different language.
What if we separated them?
# Computation happens in a computation language def get_orders_query(env: str, lookback: str) -> Query: column = ‘prod_column’ if env == ‘prod’ else ‘dev_column’ source = ref(’staging_orders’) where_clause = ‘CURRENT_DATE’ if lookback == ‘today’ else f”’{lookback}’” # Structure is declared with computed values return Query(f’‘’ SELECT user_id, {column} as column_name FROM {source} WHERE created_at >= {where_clause} ‘’‘)Still using string interpolation here - not perfect, this is just a simple specification exploration. But the key difference is: all logic is in Python, and the SQL is data being constructed. No language-switching mid-file.
Or with Malloy:
# Computation happens in Python def get_orders_query(env: str, lookback: str) -> MalloyQuery: column = ‘prod_column’ if env == ‘prod’ else ‘dev_column’ date_filter = ‘today’ if lookback == ‘today’ else lookback # Structure is declared in Malloy with computed values return malloy.query(f’‘’ query: orders -> {{ select: user_id, {column} where: created_at >= @{date_filter} }} ‘’‘)Still need runtime evaluation? Yes. Still need to check env? Yes. But the evaluation happens in a real programming language, then the result is passed to the declarative structure.
This is what React actually did to fix the impedance mismatch.
Computation layer: Evaluate predicates, transform data, make decisions. Use a programming language
Declaration layer: Describe structure with computed values. Use a declarative language
The cancer happens when we try to do computation inside the declaration layer. That’s when we reach for templates.
Jinja exists because we’re trying to compute inside SQL/YAML. Helm exists because we’re trying to compute inside YAML. HCL Templating exists because we’re trying to compute inside Terraform config.
Metastasis of templating languages in our codebases kills, not because it’s unstoppable, but because we intervene too late. The medical field learned this through decades of tragic outcomes. We don’t need to repeat that mistake in software. Software should be soft, and while we can have infinite debate over whether YAML or SQL is soft or not, we need better tooling around these to scale, help our AI handle them, and ultimately realize the promise of the declarative paradigm shift.
.png)


