SAS to PySpark conversion: a practical guide
How the constructs a SAS estate is built from map to PySpark: DATA steps, PROC SQL, procedures such as IMPORT, SORT and TRANSPOSE, macros, formats, Enterprise Guide projects and DI jobs. What breaks quietly, how to validate the result, and the order a migration runs in. The conversion works the same way whether the PySpark runs on Databricks, Snowflake or Microsoft Fabric.
The short answer
Converting SAS to PySpark means rewriting each construct as its DataFrame or Spark SQL equivalent: DATA steps become DataFrame chains, PROC SQL becomes Spark SQL, macros become Python functions, formats become expressions or functions. The syntax maps cleanly. The work is in what SAS does implicitly: missing values, row order, BY-group processing, macros resolved at run time. Plan for those, and validate the output data against SAS, not only the code.
How does SAS code map to PySpark?
Construct by construct, and most constructs have a direct equivalent. Row-by-row DATA step logic becomes column expressions on a DataFrame, SQL stays SQL, procedures become DataFrame reads, writes and aggregations, macros become Python. The table is the quick reference; the sections below show each mapping as SAS and PySpark side by side, with the behaviour the conversion has to keep.
| SAS | PySpark | Watch for |
|---|---|---|
| DATA step: SET, WHERE, IF/THEN, KEEP | spark.table(), filter(), F.when(), select() | Missing values in comparisons |
| PROC SQL | spark.sql(), null-safe <=> joins | Missing keys in joins, date literals, remerged statistics |
| PROC IMPORT | spark.read.csv() with options | Column types inferred from the data |
| PROC SORT, NODUPKEY | orderBy(), dropDuplicates() | Which duplicate survives |
| PROC TRANSPOSE | groupBy().pivot() | Column names built from data values |
| PROC APPEND | write.mode("append").saveAsTable() | Columns must match the base table |
| %let, %macro, &var | Python variables, functions and arguments | Code generated at run time |
| PROC FORMAT, PUT, INPUT | SQL functions with CASE, date_format() | BESTw. and informat edge cases |
| Enterprise Guide projects, DI jobs | PySpark modules or notebooks | Flow order and mappings that live outside the code |
| RETAIN, LAG, FIRST. / LAST. | Window functions: lag(), row_number() | Spark rows have no order until you give one |
| MERGE with BY | join() | Many-to-many merges do not behave like SQL joins |
| PROC MEANS / SUMMARY | groupBy().agg() | _TYPE_, _FREQ_ and CLASS combinations |
| PROC FREQ | groupBy().count() and a window | Missing levels and percentages |
How does a DATA step convert to PySpark?
A DATA step processes one row at a time; PySpark works on whole columns. SET becomes spark.table(), WHERE becomes filter(), assignments become withColumn(), and KEEP or DROP become select() or drop(). IF/THEN chains map to F.when(). Steps that carry values between rows (RETAIN, LAG, FIRST./LAST.) need window functions instead.
data work.orders_clean;
set raw.orders;
where status ne 'CANCELLED';
if amount >= 10000 then
tier = 'Large';
else if amount >= 1000 then
tier = 'Mid';
else tier = 'Small';
net = amount - discount;
keep order_id customer_id tier net;
run;from pyspark.sql import functions as F
df_orders_clean = spark.table("RAW.ORDERS").filter(
"NOT rtrim(coalesce(STATUS, '')) <=> 'CANCELLED'"
).withColumn(
"TIER",
F.when(
F.expr(
"coalesce(AMOUNT, cast('-inf' AS FLOAT)) >= 10000"
),
F.expr("'Large'"),
).when(
F.expr(
"coalesce(AMOUNT, cast('-inf' AS FLOAT)) >= 1000"
),
F.expr("'Mid'"),
).otherwise(F.expr("'Small'")),
).withColumn("NET", F.expr("AMOUNT - DISCOUNT")).select(
"ORDER_ID",
"CUSTOMER_ID",
"TIER",
"NET",
)
df_orders_clean.createOrReplaceTempView("ORDERS_CLEAN")rtrim(coalesce(STATUS, '')) with the null-safe <=> keeps those rows, where a plain != would drop them. A missing number is smaller than any number in SAS, hence the coalesce to -inf before each comparison: a missing amount lands in Small, as it does in SAS.How does PROC SQL convert to Spark SQL?
Most PROC SQL runs in Spark SQL with small edits, so teams often keep it as SQL inside spark.sql() and rewrite only what differs. Three SAS habits need attention: how missing values behave in joins and comparisons, summary statistics remerged onto detail rows, and SAS functions and date literals that Spark spells differently, such as '01JAN2026'd.
proc sql;
create table work.region_sales as
select r.region_name,
sum(o.net) as total_net,
sum(o.net) / count(*)
as avg_net
from sales.orders o
inner join ref.regions r
on o.region_id = r.region_id
where o.order_date >= '01JAN2026'd
group by r.region_name
having sum(o.net) > 50000;
quit;df_region_sales = spark.sql(
"""
SELECT
R.REGION_NAME,
sum(O.NET) AS TOTAL_NET,
sum(O.NET) / count(*) AS AVG_NET
FROM
SALES.ORDERS AS O
INNER JOIN REF.REGIONS AS R
ON O.REGION_ID <=> R.REGION_ID
WHERE cmp_timestamps_null_safe(
O.ORDER_DATE, '>=', DATE '2026-01-01'
)
GROUP BY R.REGION_NAME
HAVING coalesce(sum(O.NET), cast('-inf' AS FLOAT)) > 50000
"""
)
df_region_sales.createOrReplaceTempView("REGION_SALES")<=>. The date filter, through cmp_timestamps_null_safe from the Alchemist runtime library, and HAVING keep SAS's rule that a missing value is smaller than any other, and the SAS date literal becomes DATE '2026-01-01'. The work table becomes a temporary view that later steps query by name.How do PROC IMPORT, SORT, TRANSPOSE and APPEND convert?
Each procedure becomes a DataFrame operation. PROC IMPORT becomes spark.read, PROC SORT becomes orderBy(), PROC TRANSPOSE becomes groupBy().pivot(), and PROC APPEND becomes a write in append mode. The summary procedures follow the same pattern: PROC MEANS becomes groupBy().agg(), PROC FREQ groupBy().count().
proc import datafile="&input./orders.csv"
out=work.orders
dbms=csv
replace;
getnames=yes;
run;
proc sort data=work.orders
out=work.orders_sorted;
by region_id;
run;
proc transpose data=work.orders_sorted
out=work.orders_transposed
(drop=_name_);
by region_id;
id product;
var amount;
run;
proc append base=sales.orders_history
data=work.orders_transposed;
run;from pyspark.sql import functions as F
df_orders = (
spark.read.option("header", "true")
.option("inferSchema", "true")
.option("sep", ",")
.csv(f"{input}/orders.csv")
)
df_orders_sorted = spark.table("ORDERS").orderBy(
F.asc("REGION_ID")
)
df_orders_sorted.createOrReplaceTempView("ORDERS_SORTED")
df_orders_transposed = (
spark.table("ORDERS_SORTED")
.groupBy("REGION_ID")
.pivot("PRODUCT")
.agg(F.first("AMOUNT"))
)
df_orders_transposed.createOrReplaceTempView("ORDERS_TRANSPOSED")
(
spark.table("ORDERS_TRANSPOSED")
.write.mode("append")
.saveAsTable("SALES.ORDERS_HISTORY")
)
df_sales_orders_history = spark.table("SALES.ORDERS_HISTORY")getnames=yes becomes the header option, and the &input macro variable a Python f‑string. PROC TRANSPOSE with ID and VAR becomes a pivot: one row per region, one column per product, and no _NAME_ column to drop. PROC APPEND writes to the base table in append mode, which later steps then read back.For PROC SORT, dropDuplicates() keeps an arbitrary row per key, while NODUPKEY keeps the first one in input order; when it matters which row survives, rank the rows with a window and an explicit order instead. Alchemist keeps sort steps by default, because it cannot always tell whether a later step needs the order; Spark drops all but the last sort, so the cost is small. Where you know the order is unused, a node filter leaves PROC SORT out of the output.
For PROC MEANS and PROC FREQ, watch what SAS counts. N= counts non-missing values, which is F.count() on the column, not count(*). PROC FREQ leaves missing levels out of its percentages unless the MISSING option is set, so filter the nulls before you compute them. PROC MEANS also writes _TYPE_ and _FREQ_; add them only if a later step reads them.
How do SAS macros and macro variables convert?
Macro variables (%let, &var) become Python variables or function parameters, and %macro definitions become Python functions that build DataFrames. The pair below is real Converter output. Macro code that writes other code at run time, such as %do loops over table names or CALL EXECUTE, needs the most review, because that code exists only once SAS has generated it.
%macro key_orders(min);
data work.key_orders;
set sales.orders;
where amt > &min;
keep id region amt;
run;
%mend;
%key_orders(1000);# Converted by Alchemist
def key_orders(min):
df_key_orders = (
spark.table("SALES.ORDERS")
.filter(f"AMT > {min}")
.select(
"ID",
"REGION",
"AMT",
)
)
df_key_orders.createOrReplaceTempView(
"KEY_ORDERS"
)
key_orders("1000")A %let cutoff = 1000; at the top of a program becomes cutoff = 1000 in Python, and each &cutoff becomes a reference to it. Macros that only parameterise a step convert cleanly. Macros that assemble table names, column lists or whole steps from strings are where a reviewer should read the output line by line.
How do SAS formats and informats convert?
A format controls how a value is displayed; an informat, how text is read. In PySpark, a display format becomes an expression such as date_format(), and a custom PROC FORMAT becomes a function: a Spark SQL function with a CASE expression that later steps call where SAS code calls PUT. Some formats, such as BESTw., have no exact Spark equivalent.
proc format;
value $region_fmt
'N' = 'North'
'S' = 'South'
other = 'Other';
run;
data work.report;
set sales.orders;
region_label =
put(region, $region_fmt.);
order_month = put(order_date, yymmn6.);
run;spark.sql("""
CREATE OR REPLACE TEMPORARY FUNCTION region_fmt(x STRING)
RETURNS STRING
RETURN CASE
WHEN x = 'N' THEN 'North'
WHEN x = 'S' THEN 'South'
ELSE 'Other'
END
""")
df_report = (
spark.table("SALES.ORDERS")
.withColumn(
"REGION_LABEL",
F.expr("region_fmt(REGION)"),
)
.withColumn(
"ORDER_MONTH",
F.expr("date_format(ORDER_DATE, 'yyyyMM')"),
)
)
df_report.createOrReplaceTempView("REPORT")put(region, $region_fmt.) becomes region_fmt(REGION), and the OTHER= range becomes the ELSE branch, which also catches blank and missing regions. yymmn6. prints a date as 202601, which is the yyyyMM pattern of date_format().For BESTw., Alchemist generates a regular cast, handled differently for values with and without decimals, and the conversion notes document where the output differs from SAS, together with the INPUT and $w. informat caveats. If your estate keeps formats in a catalog, export it with the code: a format referenced but never defined is a common gap in an inventory.
How do Enterprise Guide projects and DI jobs convert?
An Enterprise Guide project or a DI Studio job is more than SAS code. The order of the flow, the settings of each task and the table mappings live in the project's metadata, and a conversion that reads only the generated code loses them. Alchemist converts EG projects (.egp) and DI jobs (.spk) from their metadata, not only their code.
In the code
- Program nodes and the SAS they contain
- Code that tasks generate at run time
Only in the metadata
This is why an inventory should export the projects themselves, not the code a developer copied out of them. DI jobs can also target Prophecy, for teams that want visual pipelines after the move.
What makes a SAS migration hard?
Rarely the syntax. The work is in what SAS does without being asked: how it treats missing values, the row order a DATA step relies on, BY-group flags, macro code that exists only at run time, and dependencies nobody listed. Each converts cleanly alone and breaks quietly at scale, which is why validation matters more than conversion speed.
Dependencies nobody listed
Programs read each other's outputs through librefs,
%includefiles and autoexec setup, and an EG project can depend on a table another team's job writes at night. Map the lineage before converting, so the work runs in dependency order and nothing is converted twice.Missing values
In SAS, a missing number is smaller than any number, so
if amount < 0is true whenamountis missing. In Spark, a comparison with null is null, andfilter()drops the row. A missing character value is a blank, which equals''; in Spark, null and''are different values. The converted code keeps the SAS rules: seecoalesceand the null-safe<=>in the DATA step and PROC SQL pairs.Row order
A DATA step reads rows in the order they are stored, and code that looks at the previous row relies on it. A Spark DataFrame has no row order unless you give it one. Anything positional (
LAG,RETAIN, first and last rows) needs an explicitorderByin a window.BY-group processing
FIRST.varandLAST.varflag the first and last row of each BY group. In PySpark they becomerow_number()over a window partitioned by the BY variables. SAS breaks ties by input order; Spark needs the tie-break written down, as in the pair below. The same holds for converted code: give it an explicit order on the non-BY columns, or its result can differ from run to run.
proc sort data=sales.orders
out=work.orders_sorted;
by customer_id order_date;
run;
data work.first_orders;
set work.orders_sorted;
by customer_id;
if first.customer_id;
run;from pyspark.sql import Window, functions as F
w = Window.partitionBy("customer_id").orderBy(
"order_date", "order_id" # explicit tie-break
)
first_orders = (
spark.table("sales.orders")
.withColumn("_rn", F.row_number().over(w))
.filter(F.col("_rn") == 1)
.drop("_rn")
)order_id sorts first. Pick the tie-break that matches the business rule, and test it.What are the steps of a SAS to PySpark migration?
Four steps, in this order: inventory and assess the estate, convert, validate against SAS, then deploy and cut over. Assessment sets the scope and the order of work; conversion is the step tools shorten most; validation is where the time goes. Even with automation, an end‑to‑end migration takes at least 2–3 months, mostly organizational time in larger companies.
Inventory and assess
Count programs, macros, EG projects and DI jobs; map which tables each one reads and writes; size the estate before anyone estimates it. The free Code Analyzer dashboard does the first pass on any number of SAS assets and reports a token count, the executable types and the workload mix.
Toolkit stage: AnalysisConvert
Convert in dependency order, and set the conventions once: which schema each libref maps to, whether the output is a
Toolkit stage: Conversion.pymodule or a notebook, naming and formatting. In Alchemist these are configuration, not hand edits. With a deterministic converter, the same input gives the same output, so a changed template shows up as a reviewable diff instead of a fresh rewrite.Validate against SAS
Run the SAS job and the PySpark on the same input data and compare the outputs: row counts, keys, column totals, then row-level differences. Expect differences from missing values, row order, rounding and dates, and decide case by case which result is right.
Toolkit stage: AutomationDeploy and cut over
Schedule the converted pipelines on Databricks, Snowflake or Microsoft Fabric, run them next to SAS for a period, and cut over by business unit or data domain rather than all at once. Keep the comparison from the validation step as a regression test.
Toolkit stage: Automation
The four steps follow the three stages of the Alchemist SAS‑to‑PySpark migration toolkit: Analysis, Conversion and Automation.
Where does the converted PySpark run?
On Databricks, Snowflake and Microsoft Fabric alike. Alchemist writes PySpark as a .py module or a notebook. Databricks and Fabric run it on their own Spark runtimes; Snowflake runs it through Snowpark Connect for Spark. The syntax tree and the rules are the same for all three; what changes per platform is configuration and deployment.
Databricks
- Runs
- Runs on the Databricks Spark runtime, as notebooks or Python files scheduled as jobs.
- Configure
- Map each SAS library to a Unity Catalog schema in the libref configuration.
Snowflake
- Runs
- Runs through Snowpark Connect for Spark, which executes the DataFrame code on Snowflake compute.
- Configure
- Map each SAS library to a Snowflake schema in the libref configuration.
Microsoft Fabric
- Runs
- Runs on the Fabric Spark runtime, as notebooks or Spark job definitions in a workspace.
- Configure
- Map each SAS library to a lakehouse schema in the libref configuration.
Manual, LLM-only or deterministic conversion?
Each approach can convert a single program. They differ at scale: whether two runs on the same code give the same result, whether macros and dependencies across programs are taken into account, and how much of the output someone must read line by line. Choose by the size of the estate and the review capacity you have.
| Manual rewrite | LLM-only | Deterministic (syntax tree) | Deterministic + LLM fallback | |
|---|---|---|---|---|
| How it works | Engineers rewrite each program by hand | A model generates code for each snippet or file | SAS is parsed into a syntax tree and rewritten with rules and templates | The same rules and templates; an optional LLM fallback, off by default, drafts only the constructs the rules do not cover |
| Same input, same output | Depends on who writes it | Not guaranteed between runs | Yes, every run | Yes for rule-converted code; LLM-drafted parts can vary between runs |
| Context across programs | What the engineer knows | What fits in the prompt | Lineage mapped from the parsed estate | Lineage mapped from the parsed estate |
| Code without a rule | Handled with judgement | Converted plausibly; needs review | Left with a TODO comment for manual work | Drafted by the LLM fallback, checked with Ruff and Python, then reviewed |
| Fits best | Small scope, or code being redesigned anyway | Prototypes and one-off programs | Hundreds of programs, EG projects or DI jobs | Large estates with a long tail of rare constructs |
In Alchemist projects, the conversion step ran up to 20× faster with 10× less manual effort than LLM-only conversion. That figure covers the conversion step only, not the whole migration. A detailed comparison of deterministic and LLM-only conversion is in preparation.
Where does Alchemist fit?
Alchemist is a SAS‑to‑cloud migration acceleration toolkit by T1A. The Code Analyzer measures and maps the estate, the Code Converter turns SAS into PySpark deterministically, and the Full Kit adds automation for validation and deployment. The output is PySpark that runs on Databricks, Snowflake and Microsoft Fabric; production migrations so far, since 2022, were delivered on Databricks.

Code Analyzer: the dependency graph of a SAS estate, the map the conversion order follows.
- Analysis
Code Analyzer
Parses programs, EG projects and DI jobs into a syntax tree: statement statistics, complexity, and data and macro lineage as a dependency graph. The free dashboard runs on any number of assets.
- Conversion
Code Converter
Pattern matching on the syntax tree, with templates you configure. Same input, same output. An optional LLM fallback for unsupported constructs, off by default, never the engine.
- Automation
E2E Migration Framework
Analysis, conversion, validation and deployment as one repeatable, agent‑orchestrated workflow, with conversion at scale (100+ assets). Full Kit, configured for your environment with T1A services.
Free trial account. Code Analyzer on any number of SAS assets, no time limit, no usage limit.
Questions about SAS to PySpark conversion
What evaluators ask about converting SAS code: what converts, where it runs, how long it takes and how to check the result.
Can SAS code be converted to PySpark automatically?
Yes, for the code data pipelines are made of: DATA steps, PROC SQL, common procedures and macro-driven workflows. A deterministic converter rewrites those by rule, and code without a rule is left with a TODO comment for manual work. SAS/STAT analytics and reporting layers are outside Alchemist's scope. Validate every converted table against SAS output before cutover.
Does the converted PySpark run on Databricks, Snowflake and Microsoft Fabric?
Yes. Alchemist writes PySpark as a .py module or a notebook. Databricks and Microsoft Fabric run it on their own Spark runtimes; Snowflake runs it through Snowpark Connect for Spark. The rules are the same for all three; what changes per platform is configuration and deployment, such as which schema each SAS library maps to. Delivered production migrations so far are on Databricks.
Should SAS become Spark SQL or the PySpark DataFrame API?
Both compile to the same Spark execution plan, so choose for the people who will maintain the code. PROC SQL often stays SQL inside spark.sql(); DATA step logic usually reads better as a DataFrame chain. Mixing both in one pipeline is normal. Alchemist's output format, code structure and formatting are configurable.
How long does a SAS to PySpark migration take?
Even with automation, an end‑to‑end migration takes at least 2–3 months, mostly organizational time in larger companies. Mid-size projects have taken 4–6 months to move the business process fully. The conversion step alone ran up to 20× faster with 10× less manual effort than LLM-only conversion; that figure covers conversion, not the whole migration.
How do you validate converted SAS code?
Run the SAS job and the converted PySpark on the same input data and compare the outputs: row counts, keys, column totals, then row-level differences. Differences usually trace back to missing values, row order, rounding or date handling. Decide case by case which result is correct, and keep the comparison as a regression test.
Can an LLM convert SAS to PySpark?
For a single program, often yes, and the result is a useful draft. Across an estate, the same code can convert differently on different runs, often without taking into account SAS quirks, and the model sees only its prompt, not the macros and upstream tables the code depends on. Alchemist converts deterministically. Its optional LLM fallback for unsupported constructs is off by default, its output is checked with Ruff and Python, and it can use a self-hosted endpoint.
Does Alchemist migrate the data too?
No. Alchemist converts SAS code, pipelines and process logic, not the data itself. Moving tables to Databricks, Snowflake or Microsoft Fabric is a separate step in your own data movement process. For the period when SAS and the new platform run side by side, the Full Kit includes a SAS–Cloud Coexistence Framework, on request.