Guide · SAS → PySpark

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 constructs and their PySpark equivalents
SASPySparkWatch for
DATA step: SET, WHERE, IF/THEN, KEEPspark.table(), filter(), F.when(), select()Missing values in comparisons
PROC SQLspark.sql(), null-safe <=> joinsMissing keys in joins, date literals, remerged statistics
PROC IMPORTspark.read.csv() with optionsColumn types inferred from the data
PROC SORT, NODUPKEYorderBy(), dropDuplicates()Which duplicate survives
PROC TRANSPOSEgroupBy().pivot()Column names built from data values
PROC APPENDwrite.mode("append").saveAsTable()Columns must match the base table
%let, %macro, &varPython variables, functions and argumentsCode generated at run time
PROC FORMAT, PUT, INPUTSQL functions with CASE, date_format()BESTw. and informat edge cases
Enterprise Guide projects, DI jobsPySpark modules or notebooksFlow 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 BYjoin()Many-to-many merges do not behave like SQL joins
PROC MEANS / SUMMARYgroupBy().agg()_TYPE_, _FREQ_ and CLASS combinations
PROC FREQgroupBy().count() and a windowMissing 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.

SAS orders_clean.sas
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;
PySpark orders_clean.py
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")
Converter outputThe PySpark side is Alchemist's real output for this code. SAS reads a missing status as blank and ignores trailing blanks, so 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.

SAS region_sales.sas
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;
PySpark region_sales.py
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")
Converter outputThe PySpark side is Alchemist's real output for this code. SAS joins a missing key to another missing key; Spark never matches null to null, so the join uses the null-safe <=>. 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().

SAS orders_history.sas
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;
PySpark orders_history.py
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")
Converter + templates outputThe PySpark side is Alchemist's real output for this code, partly generated from Alchemist templates. 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.

SAS key_orders.sas
%macro key_orders(min);
  data work.key_orders;
    set sales.orders;
    where amt > &min;
    keep id region amt;
  run;
%mend;
%key_orders(1000);
PySpark key_orders.py
# 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")
Converter outputThe PySpark side is Alchemist's real output for this macro, verbatim, as on the home page. The macro becomes a function, its parameter an argument, and the DATA step a DataFrame chain published as a temp view.

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.

SAS report.sas
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;
PySpark report.py
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")
Converter + templates outputThe PySpark side is Alchemist's real output for this code, partly generated from Alchemist templates. PROC FORMAT becomes a temporary SQL function, so 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

  • The order the nodes run in, and which outputs feed which inputs
  • Task settings: query builder joins, filters, sort keys
  • Prompts and parameters the flow is run with
  • Table mappings and transformations in DI jobs

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, %include files 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 < 0 is true when amount is missing. In Spark, a comparison with null is null, and filter() 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: see coalesce and 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 explicit orderBy in a window.

  • BY-group processing

    FIRST.var and LAST.var flag the first and last row of each BY group. In PySpark they become row_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.

SAS first_orders.sas
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;
PySpark first_orders.py
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")
)
IllustrativeHand-written PySpark equivalent, not Converter output. Two orders on the same day: SAS keeps the one stored first, Spark keeps whichever 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.

  1. 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: Analysis
  2. Convert

    Convert in dependency order, and set the conventions once: which schema each libref maps to, whether the output is a .py module 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.

    Toolkit stage: Conversion
  3. 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: Automation
  4. Deploy 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.

Four ways to convert SAS to PySpark, compared
Manual rewriteLLM-onlyDeterministic
(syntax tree)
Deterministic +
LLM fallback
How it worksEngineers rewrite each program by handA model generates code for each snippet or fileSAS is parsed into a syntax tree and rewritten with rules and templatesThe same rules and templates; an optional LLM fallback, off by default, drafts only the constructs the rules do not cover
Same input, same outputDepends on who writes itNot guaranteed between runsYes, every runYes for rule-converted code; LLM-drafted parts can vary between runs
Context across programsWhat the engineer knowsWhat fits in the promptLineage mapped from the parsed estateLineage mapped from the parsed estate
Code without a ruleHandled with judgementConverted plausibly; needs reviewLeft with a TODO comment for manual workDrafted by the LLM fallback, checked with Ruff and Python, then reviewed
Fits bestSmall scope, or code being redesigned anywayPrototypes and one-off programsHundreds of programs, EG projects or DI jobsLarge 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.

Alchemist Code Analyzer network graph of a SAS estate: clusters of jobs and datasets linked by lineage, with the filter panel open and a Graph view / Lineage switch

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.

FAQ

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.