ServicesAboutNotesContact Get in touch →
EN FR
Note

JavaScript vs Jinja in Analytics Engineering

The philosophical and practical differences between Dataform's JavaScript templating and dbt's Jinja2 — where they diverge, what each excels at, and how to convert between them.

Planted
dbtdataformdata engineeringdata modeling

Dataform lets you write actual JavaScript anywhere in your project. dbt uses Jinja2 templating. This difference sounds superficial — both generate SQL at compile time — but the philosophical gap affects every aspect of how you build and maintain a transformation project.

JavaScript is a full programming language. You get loops, conditionals, functions, modules, closures, and the entire Node.js ecosystem. Jinja2 is a templating language. You get variable substitution, control flow, macros, and filters. The boundary matters when your project gets complex.

Where the Syntax Differs

For basic operations, the translation is mechanical.

Dataform conditional:

${when(incremental(), `AND updated_at > (SELECT MAX(updated_at) FROM ${self()})`)}

dbt conditional:

{% if is_incremental() %}
AND updated_at > (SELECT MAX(updated_at) FROM {{ this }})
{% endif %}

Dataform variable:

const threshold = 30;

dbt variable:

{% set threshold = 30 %}

Dataform function call:

${helpers.unnest_event_param('page_location')}

dbt macro call:

{{ unnest_event_param('page_location') }}

These conversions are tedious but predictable. A search-and-replace script handles 80% of them. The remaining 20% is where things get interesting.

Macro Conversion: The Middle Ground

Dataform’s JavaScript includes map to dbt macros. The logic stays the same; the syntax changes significantly.

Dataform include:

includes/helpers.js
function unnest_event_param(param_name, value_type = 'string_value') {
return `(SELECT value.${value_type} FROM UNNEST(event_params) WHERE key = '${param_name}')`;
}
module.exports = { unnest_event_param };

dbt macro:

-- macros/unnest_event_param.sql
{% macro unnest_event_param(param_name, value_type='string_value') %}
(SELECT value.{{ value_type }} FROM UNNEST(event_params) WHERE key = '{{ param_name }}')
{% endmacro %}

This level of conversion is manageable. Functions that return SQL strings become macros that render SQL strings. The parameter syntax changes, the template interpolation changes, but the structure is recognizable.

Where it gets harder: JavaScript functions that use array methods, object manipulation, or conditional logic beyond simple if/else. Jinja has for loops, if blocks, set, and namespace — but no .map(), no .filter(), no .reduce(). You rewrite algorithms, not just syntax.

Dynamic Model Generation: The Real Gap

This is where the philosophical difference becomes a practical problem. Dataform’s JavaScript excels at dynamic model generation:

definitions/country_tables.js
const countries = ["US", "GB", "FR", "DE"];
countries.forEach(country => {
publish(`reporting_${country}`)
.dependencies(["source_table"])
.query(ctx => `SELECT * FROM ${ctx.ref("source_table")} WHERE country = '${country}'`);
});

Four lines of JavaScript produce four separate models, each with proper DAG dependencies. Add a country to the array, get a new model. This pattern scales to dozens or hundreds of generated models.

dbt has no equivalent. Jinja runs inside a single .sql file to produce a single model. It cannot create new files or new models at compile time. Your options:

Write individual models. If you have few variations (under 10), just create separate .sql files. This is verbose but debuggable. Each model is visible in the DAG, has its own test configuration, and can be run independently. For most teams, this is the right answer.

Use dbt_codegen. Generate the YAML and SQL files once with a script, then maintain them manually. This bridges the gap for initial creation but doesn’t give you the ongoing dynamism of Dataform’s approach.

External preprocessing. Use a Python script to generate .sql files before dbt run. This is the closest equivalent to Dataform’s approach but lives outside dbt’s awareness. Your CI pipeline runs the generation step, then dbt picks up the files. It works, but you’ve added a build step that dbt doesn’t know about.

dbt’s codegen + run-operation. For some patterns, you can use dbt run-operation generate_model_yaml to scaffold files. Limited to what the codegen package supports.

If a Dataform project relies heavily on dynamic model generation, the migration cost is high. This is the single biggest factor in the migration decision.

Subtle Behavioral Differences

Beyond syntax, JavaScript and Jinja handle edge cases differently. These differences are invisible in simple models but surface during migration of complex logic.

Null handling. JavaScript’s null, undefined, and empty string behave differently in comparisons than Jinja’s none and empty string. A JavaScript conditional if (value) that was truthy for 0 and "" won’t translate directly to Jinja’s truthiness rules.

Numeric precision. JavaScript uses 64-bit floating point for all numbers. Jinja uses Python’s type system (integers stay integers, floats are 64-bit). If your JavaScript includes perform arithmetic that feeds into SQL generation, verify that the generated values match after conversion.

String interpolation. JavaScript template literals `value: ${expr}` become Jinja’s "value: " ~ expr or "value: {{ expr }}" depending on context. The tilde operator (~) is Jinja’s concatenation operator — unfamiliar to most JavaScript developers.

Type coercion. JavaScript silently coerces types in comparisons and concatenation. Jinja is stricter. {{ 5 + "3" }} raises an error in Jinja; 5 + "3" produces "53" in JavaScript. If your JavaScript includes rely on implicit coercion, you’ll find bugs during conversion.

These differences caused one team’s ML pipeline to break post-migration. The generated SQL looked identical on casual inspection, but floating-point arithmetic in a JavaScript include produced slightly different threshold values than the Jinja equivalent. The model retrained on the new thresholds, and fraud detection accuracy dropped until someone traced the issue back to a 0.0001 difference in a generated WHERE clause.

When Each Approach Wins

JavaScript (Dataform) excels when:

  • You need dynamic model generation at scale
  • Your team has strong JavaScript skills
  • Complex data manipulation happens at the templating layer
  • You want programmatic control over the DAG itself

Jinja (dbt) excels when:

  • SQL is the primary language, and templating is secondary
  • You want a large package ecosystem and community support
  • Multi-warehouse support matters
  • You prefer explicit, visible model definitions over generated ones

Neither approach is universally better. JavaScript gives you more power at the cost of more complexity. Jinja constrains you to simpler patterns, which for most analytics engineering work is a feature, not a limitation. The typical dbt project doesn’t need dynamic model generation — it needs clean SQL with some variable substitution and code reuse. Jinja handles that well.

The question isn’t which templating language is better. It’s how much of your project’s complexity lives in the templating layer versus in the SQL itself. If the answer is “a lot,” think carefully before migrating away from JavaScript.