If you have spent years writing SAS macros, the transition to dbt's Jinja macro system can feel disorienting. The surface syntax is different, the execution model is different, and the conventions are different. But the underlying purpose is identical: both systems exist to generate SQL dynamically, reduce code duplication, and parameterize logic so that one definition can serve many use cases.
This article walks through the fundamental paradigm differences between SAS macros and dbt Jinja macros. Each section examines a core SAS macro pattern and explains why the translation to Jinja is deeper than a syntax swap — involving changes in scoping, execution model, and project architecture. These are the real-world challenges that MigryX solves automatically when converting SAS programs into production-ready dbt projects.
1. Two Macro Worlds
Before comparing specific constructs, it helps to understand the fundamental architecture of each system.
SAS Macros: Text Substitution at Compile Time
The SAS macro processor is a text substitution engine that runs before the SAS compiler sees the code. When you write %LET year = 2025; and later reference &year, the macro processor replaces &year with 2025 in the text stream before the SAS compiler parses it. %MACRO and %MEND define named blocks of text (which can contain SAS code, other macro calls, or both) that are inserted wherever the macro is invoked.
This design means SAS macros can generate any SAS syntax — DATA steps, PROC SQL statements, entire programs — because they operate on raw text, not on a parsed abstract syntax tree. The power is immense but the debugging experience is notorious: when something goes wrong, you must mentally simulate what the macro processor produced before you can understand what the SAS compiler received.
dbt Jinja: Template Engine Rendering SQL
dbt uses Jinja2, a Python-based template engine originally designed for rendering HTML. In dbt's context, Jinja renders SQL. When dbt compiles a model, the Jinja engine processes all {{ }} expressions, {% %} statements, and macro calls, producing a plain SQL string that dbt then sends to the warehouse for execution.
The conceptual model is the same as SAS: a preprocessing layer generates text (SQL) before the database engine sees it. But the implementation differs in important ways. Jinja is a general-purpose template engine with a well-defined expression language, proper scoping rules, and built-in support for filters, tests, and template inheritance. dbt extends Jinja with domain-specific functions like ref(), source(), config(), and this that are aware of the dbt project graph.
| Concept | SAS | dbt Jinja |
|---|---|---|
| Macro definition | %MACRO name; ... %MEND; | Jinja macro functions with explicit parameters and block-scoped bodies |
| Variable resolution | &var with dot-termination rules | Expression interpolation with no termination ambiguity — but fundamentally different scoping rules |
| Looping | %DO i=1 %TO n; with manual index management | Native iterator protocol with built-in loop metadata (index, first/last, length) |
dbt — enterprise migration powered by MigryX
2. Variable Resolution: %LET/&var vs {% set %}/{{ var }}
Variable assignment and resolution is the most common macro operation in both systems. The patterns are directly parallel but the syntax differs significantly.
SAS Pattern
%LET start_date = 2025-01-01;
%LET end_date = 2025-12-31;
%LET schema = prod_analytics;
PROC SQL;
CREATE TABLE &schema..monthly_summary AS
SELECT month,
SUM(amount) AS total_amount
FROM &schema..transactions
WHERE txn_date BETWEEN "&start_date"d AND "&end_date"d
GROUP BY month;
QUIT;
In SAS, %LET assigns a value to a macro variable. The & prefix triggers resolution: the macro processor replaces &start_date with 2025-01-01 before the SQL compiler sees the code. Double dots (&schema..monthly_summary) are needed because a single dot terminates the macro variable name.
The Translation Challenge
MigryX translates %LET variable assignments and their downstream references into dbt's {{ var() }} and {{ config() }} patterns, handling scope differences automatically. This is not a simple find-and-replace: SAS variables have global-or-local scope controlled by %GLOBAL and %LOCAL statements, while Jinja uses block scoping. SAS's dot-termination rules (&schema..table_name) have no parallel in Jinja, and table references must be restructured into dependency-aware function calls that register in the project graph. Each %LET must be analyzed in context to determine whether it maps to a Jinja set, a dbt var(), or a config() value.
Dynamic Variable Construction
SAS double-ampersand resolution (&&var&i) has no direct Jinja equivalent — MigryX decomposes these into list-based access patterns, analyzing the resolution chain to determine the correct data structure and indexing approach in the target framework.
MigryX: Purpose-Built for Enterprise SAS Migration
MigryX was designed from the ground up for enterprise SAS migration. Its SAS parser understands every construct — DATA steps, PROC SQL, PROC SORT, PROC MEANS, PROC FREQ, PROC TRANSPOSE, macros, formats, informats, hash objects, arrays, ODS output, and even SAS/STAT procedures like PROC REG and PROC LOGISTIC. This is not a generic code translator — it is the most comprehensive SAS migration platform in the industry.
3. Conditional Logic: %IF/%THEN vs {% if %}/{% endif %}
Both systems support conditional code generation, where different SQL (or SAS code) is produced based on parameter values. This is critical for macros that must adapt to different environments, data sources, or business rules.
SAS Pattern
%MACRO load_data(env=prod);
%IF &env = prod %THEN %DO;
LIBNAME src ORACLE path="prod_db";
%END;
%ELSE %IF &env = dev %THEN %DO;
LIBNAME src ORACLE path="dev_db";
%END;
%ELSE %DO;
%PUT ERROR: Unknown environment &env;
%ABORT;
%END;
PROC SQL;
CREATE TABLE work.staged AS
SELECT * FROM src.transactions
%IF &env = dev %THEN %DO;
WHERE rownum <= 10000 /* Limit rows in dev */
%END;
;
QUIT;
%MEND load_data;
The Translation Challenge
This SAS pattern interleaves three concerns that dbt separates into different layers: connection management (LIBNAME), conditional SQL generation (%IF/%THEN), and environment detection. In dbt, connection switching is handled by the configuration layer (profiles.yml), environment detection uses a context variable, and conditional SQL uses Jinja's block-scoped control flow. Translating this correctly means decomposing the SAS macro into these separate concerns — not just swapping %IF for a Jinja conditional. MigryX identifies which parts of a SAS macro represent configuration, which represent environment logic, and which represent genuine SQL generation, routing each to the appropriate dbt pattern.
SAS conditional macros use a text-substitution model while Jinja uses a template-rendering model — the translation requires restructuring the logic, not just swapping syntax. Operator semantics differ (SAS comparisons are case-insensitive by default, Jinja's are not), block termination rules differ (%END vs. {% endif %}), and SAS's implicit type coercion in comparisons has no Jinja equivalent.
4. Looping: %DO/%END vs {% for %}
Loops in macros typically generate repetitive SQL — iterating over a list of columns, tables, or date ranges to produce SQL fragments. Both systems support this, but dbt's Jinja loop syntax is considerably more expressive.
SAS Pattern: Iterating Over a List
%MACRO sum_columns(table=, cols=);
PROC SQL;
SELECT
%LET i = 1;
%DO %WHILE(%SCAN(&cols, &i, %STR( )) NE );
%LET col = %SCAN(&cols, &i, %STR( ));
%IF &i > 1 %THEN ,;
SUM(&col) AS total_&col
%LET i = %EVAL(&i + 1);
%END;
FROM &table;
QUIT;
%MEND;
%sum_columns(table=sales, cols=revenue cost margin);
The Translation Challenge
The SAS version requires manual index management (%LET i, %EVAL, %SCAN) and careful comma placement logic. Translating this into Jinja's native iterator model is not a line-by-line conversion — the entire loop structure must be rethought. The %SCAN function parses a delimited string, which has no direct Jinja parallel; the counter-based loop becomes an iterator-based loop; and the comma-placement logic changes from index-checking to a metadata property. MigryX recognizes these SAS loop idioms and restructures them into the appropriate Jinja patterns.
SAS Pattern: Numeric Range Loop
%MACRO yearly_union(start_year=, end_year=);
%DO yr = &start_year %TO &end_year;
%IF &yr > &start_year %THEN UNION ALL;
SELECT *, &yr AS fiscal_year
FROM transactions_&yr
%END;
%MEND;
%yearly_union(start_year=2020, end_year=2025);
The Translation Challenge
SAS's %DO yr = &start_year %TO &end_year uses inclusive bounds, while Jinja's equivalent uses Python-style exclusive upper bounds — an off-by-one error waiting to happen. Beyond the range semantics, the dynamic table reference (transactions_&yr) relies on SAS's implicit macro concatenation, which must be converted into explicit string concatenation and wrapped in a dependency-tracking function. Each dynamically constructed table name must be registered in the project graph, something SAS never required. MigryX handles these range-boundary and dynamic-reference conversions automatically.
MigryX auto-documentation captures every transformation decision, creating audit-ready migration records automatically
How MigryX Handles the Hard Parts of SAS Migration
Every SAS shop has code that makes migration teams nervous — deeply nested macros that generate dynamic code, DATA step merge logic with complex BY-group processing, hash object lookups, RETAIN statements that carry state across rows, and PROC IML matrix operations. These are exactly the constructs where MigryX excels. Its combination of deterministic AST parsing and Merlin AI means even the most complex SAS patterns are converted accurately.
5. Code Reuse: %INCLUDE vs Macros and Packages
SAS's %INCLUDE statement literally inserts the contents of another file at the point of inclusion — a textual copy-paste operation. dbt takes a fundamentally different approach to code reuse that is more modular and maintainable.
SAS Pattern: %INCLUDE
/* /shared/macros/standard_cleanup.sas */
%MACRO standard_cleanup(ds=);
PROC SORT DATA=&ds NODUPKEY; BY _ALL_; RUN;
DATA &ds;
SET &ds;
WHERE NOT MISSING(account_id);
RUN;
%MEND;
/* In a program: */
%INCLUDE '/shared/macros/standard_cleanup.sas';
%standard_cleanup(ds=work.accounts);
The %INCLUDE statement must be executed before the macro can be called. If the include path is wrong, the macro is undefined. If two included files define macros with the same name, the last one wins silently. There is no dependency management, no versioning, and no namespace isolation.
The Translation Challenge
SAS's %INCLUDE is a textual insertion — the included file's contents are spliced into the calling program as raw text. dbt's code reuse model is fundamentally different: macros are auto-discovered from a directory, namespaced, and invoked as functions that return rendered SQL. Translating a %INCLUDE-based codebase means identifying which included files define macros (and should become dbt macro files), which contain standalone programs (and should become models), and which mix both (and must be decomposed). Include-path resolution, which in SAS depends on the runtime environment, must be replaced with the project's directory structure. MigryX traces %INCLUDE chains, resolves the dependencies, and maps each included file to the correct dbt artifact type.
dbt Packages: The Evolution of %INCLUDE Libraries
For cross-project code reuse, SAS teams typically maintain shared network drives or Git repositories of macro files that are %INCLUDE-ed by multiple projects. dbt formalizes this pattern with packages, which are dbt projects that are installed as dependencies via packages.yml:
-- packages.yml
packages:
- package: dbt-labs/dbt_utils
version: [">=1.0.0", "<2.0.0"]
- package: calogica/dbt_expectations
version: [">=0.10.0", "<1.0.0"]
Running dbt deps downloads the packages and makes their macros, tests, and models available to your project. The most widely used package, dbt-utils, provides macros like pivot(), unpivot(), surrogate_key(), date_spine(), and union_relations() — utility functions that every SAS programmer will recognize as analogous to common macro library patterns.
SAS's hierarchy of code reuse mechanisms — %INCLUDE, AUTOCALL libraries, and SASAUTOS concatenation — each map to different dbt concepts (project macros, packages, and cross-project dependencies), and the correct mapping depends on how the SAS code was organized and shared across teams.
Key Differences That Trip Up SAS Developers
Beyond the construct-by-construct mappings, several conceptual differences consistently cause confusion during migration:
- Quoting. SAS macro quoting (
%STR,%NRSTR,%BQUOTE,%SUPERQ) is a complex subsystem that exists because the macro processor and the SAS compiler share the same character set. Jinja has no equivalent because its expression language is separate from SQL — single quotes in Jinja strings do not conflict with SQL quotes. - Scope. SAS macro variables are either global or local to the macro that created them, with
%GLOBALand%LOCALstatements controlling visibility. Jinja variables follow Python-like block scoping within macros, and the{% set %}...{% endset %}pattern captures block output into a variable. - Side effects. SAS macros routinely produce side effects: creating datasets, modifying global macro variables, writing to the log. dbt macros are primarily functional — they return text (SQL) and should not produce side effects. dbt's
run_query()macro can execute SQL and return results, but this is used sparingly and deliberately. - Debugging. SAS's
OPTIONS MPRINT SYMBOLGEN MLOGIC;prints the resolved macro text to the log. dbt's equivalent is thetarget/compiled/directory, which contains the fully rendered SQL for every model after Jinja processing. Runningdbt compilegenerates these files without executing them — the direct analog ofMPRINToutput.
MigryX SAS Macro Translation
MigryX's SAS parser fully expands macro invocations, resolves &variable references, and generates equivalent Jinja macros — preserving the parameterization pattern while adapting to dbt's ref() and source() conventions. Nested macro calls, conditional compilation, and %INCLUDE chains are all resolved and translated into clean, idiomatic Jinja that follows dbt community best practices.
A Complete Translation Example
To tie everything together, here is a realistic SAS macro that generates a monthly aggregation for a configurable list of metrics across a date range. It illustrates why manual translation is so error-prone.
SAS Version
%MACRO monthly_agg(table=, date_col=, metrics=, start_dt=, end_dt=);
PROC SQL;
CREATE TABLE work.monthly_summary AS
SELECT
INTNX('MONTH', &date_col, 0, 'B') AS report_month FORMAT=DATE9.,
COUNT(*) AS record_count
%LET i = 1;
%DO %WHILE(%SCAN(&metrics, &i, %STR( )) NE );
%LET m = %SCAN(&metrics, &i, %STR( ));
, SUM(&m) AS total_&m
, AVG(&m) AS avg_&m
%LET i = %EVAL(&i + 1);
%END;
FROM &table
WHERE &date_col BETWEEN "&start_dt"d AND "&end_dt"d
GROUP BY CALCULATED report_month
ORDER BY report_month;
QUIT;
%MEND;
%monthly_agg(
table=claims.detail,
date_col=service_date,
metrics=paid_amount charged_amount copay_amount,
start_dt=01JAN2025,
end_dt=31DEC2025
);
Why This Is Hard to Translate Manually
A real-world SAS macro like this touches variable iteration (%SCAN/%DO %WHILE), dynamic SQL generation (column-level aggregation from a parameter list), date-handling functions (INTNX with SAS date literals), and metric aggregation patterns — all interleaved in a single macro body. MigryX translates all of these patterns into idiomatic dbt Jinja, generating models that follow community best practices: the loop becomes an iterator, the date function maps to the warehouse's DATE_TRUNC, the table reference becomes a dependency-tracked ref(), and the macro is split into a reusable macro file and a model file that invokes it.
Manual translation requires understanding not just the syntax mapping, but the architectural decomposition: what belongs in a macro, what belongs in a model, how parameters should be typed, and how the result registers in the dependency graph. Each of these decisions is a potential source of bugs when done by hand.
For SAS developers making the transition, the conceptual leap is smaller than the syntax change suggests. Both systems are fundamentally about generating SQL through parameterized templates. The difference is that dbt Jinja provides a cleaner syntax, better scoping, native loop constructs, and deep integration with the dependency graph — advantages that become increasingly apparent as the project grows beyond a handful of macros.
Why Every SAS Migration Needs MigryX
The challenges described throughout this article are exactly what MigryX was built to solve. Here is how MigryX transforms this process:
- Complete SAS coverage: MigryX handles every SAS construct — DATA steps, PROC SQL, macros, formats, hash objects, arrays, ODS, and 20+ PROCs.
- 4-8x faster than manual: What takes consulting teams months of manual conversion, MigryX accomplishes in weeks with higher accuracy.
- 60-85% cost reduction: Enterprises report dramatic cost savings compared to manual migration approaches.
- Production-ready output: MigryX generates clean, idiomatic Python, PySpark, Snowpark, or SQL — not rough drafts that need extensive rework.
MigryX combines precision AST parsing with Merlin AI to deliver 99% accurate, production-ready migration — turning what used to be a multi-year manual effort into a streamlined, validated process. See it in action.
Migrating SAS macros to dbt?
See how MigryX automatically translates SAS macro logic into idiomatic dbt Jinja — preserving parameterization, resolving dependencies, and following community best practices.
Schedule a Demo