NEW Qlik to dbt migration AI Proof Pricing Book a demo Get the free assessment

Hard sources

Also parsed

Targets

Warehouses

Runtimes

Before migration

After migration

SAS to Databricks: 7 SAS Behaviors That Quietly Change Your Numbers in PySpark

Converted SAS code that runs is easy. Converted code that produces the same numbers is the whole job. These are the SAS behaviors that break parity, and how each one should land on Databricks.

September 29, 2026 · 12 min read · MigryX Team

Every SAS-to-Databricks program reaches the same moment. The converted notebooks run cleanly, the jobs go green in Lakeflow Jobs, and the first reconciliation report arrives: 0.3% of rows differ, a regulatory total is off by a few thousand, one segment has too many customers. Nobody made a syntax error. The code was translated faithfully, line by line, into a language with different rules.

SQL dialects already have a well-worn path to Databricks. SAS doesn't, because a SAS program isn't SQL. It is DATA steps with an implicit row loop, a macro language that writes code at compile time, and decades of defaults that analysts rely on without knowing it. This guide covers the seven defaults that most often change results, what each looks like in SAS, and the PySpark that keeps the original answer.

Where a SAS estate lands on Databricks Seven SAS components (programs, macros, libraries, datasets, formats, SAS/STAT models and schedules) each map to a Databricks component: PySpark and SQL notebooks, Python functions and job parameters, Unity Catalog schemas, Delta tables, lookup tables, MLflow models and Lakeflow Jobs. SAS ESTATE DATABRICKS MigryX parse · convert lineage · parity ProgramsDATA step · PROC SQL · PROCs Macros%macro · &vars · %include LibrariesLIBNAME Datasets.sas7bdat FormatsPROC FORMAT · PUT() ModelsSAS/STAT · scoring code SchedulesLSF · Control-M · cron PySpark / SQL notebooks Python functions · job parameters Unity Catalog catalogs · schemas Delta tables Lookup tables · CASE expressions MLflow models Lakeflow Jobs
Figure 1. The structural mapping is the easy part. The seven behaviors below decide whether the numbers still match.
The seven behaviors
  1. Missing values compare as the smallest number
  2. a + b and SUM(a, b) are different
  3. Running totals depend on row order and treat missing as zero
  4. NODUPKEY keeps the first row, always
  5. MERGE is not a join
  6. Dates count from 1960
  7. Trailing blanks don't count

One setup step supports several of the fixes below: carry a row sequence from ingest. SAS processes rows in dataset order, and plenty of logic depends on it without saying so. Spark promises no order. Add a _seq column when each SAS dataset is first landed in Delta, and every order-sensitive conversion has something deterministic to sort by.

1Missing values compare as the smallest number

In SAS, a numeric missing value (.) sorts before every number and compares as less than any of them, including large negatives. In Spark, any comparison with NULL returns NULL, and a when() treats NULL as false.

Evaluating balance < 0 in SAS and in Spark For balances of missing, minus 50, 0 and 120, SAS evaluates balance less than zero as true, true, false, false. Spark evaluates it as null, true, false, false, so the missing row is classified differently. IF balance < 0 THEN status = 'OVERDRAWN' balance . (missing) -50 0 120 SAS OVERDRAWN OVERDRAWN OK OK Naive PySpark OK OVERDRAWN OK OK NULL < 0 is NULL, so the row falls to otherwise()
Figure 2. One missing balance changes class. Across a portfolio, that becomes a different overdraft count on the first reconciliation.
SAS
data flagged;
  set accounts;
  if balance < 0 then status = 'OVERDRAWN';
  else status = 'OK';
run;
Naive PySpark · differs on missing
F.when(F.col("balance") < 0, "OVERDRAWN").otherwise("OK")
PySpark that matches SAS
F.when(F.col("balance").isNull() | (F.col("balance") < 0), "OVERDRAWN").otherwise("OK")

The same rule applies to every WHERE clause and every < or <= comparison. Sort order, by contrast, happens to agree: Spark's default ascending sort puts NULLs first, as PROC SORT does with missing values.

2a + b and SUM(a, b) are different

SAS gives you two additions. The + operator returns missing if any operand is missing. The SUM() function ignores missing values and returns missing only when every argument is missing. Analysts use SUM() precisely so that one blank field doesn't wipe out a total.

SAS
total_fees = sum(late_fee, service_fee, wire_fee);
PySpark that matches SAS
fees = ["late_fee", "service_fee", "wire_fee"]
all_missing = F.coalesce(*[F.col(c) for c in fees]).isNull()
total = sum((F.coalesce(F.col(c), F.lit(0)) for c in fees), F.lit(0))

df = df.withColumn("total_fees", F.when(all_missing, F.lit(None)).otherwise(total))

In words: coalesce each argument to zero and add them, unless every argument is missing, in which case the result stays NULL. Translating SUM() to +, or coalescing everything to zero, each gets a different set of rows wrong. MEAN(), MIN(), MAX() and N() follow the same ignore-missing rule.

3Running totals depend on row order and treat missing as zero

RETAIN with FIRST./LAST. maps naturally to a window function. Two details decide whether it matches.

SAS
proc sort data=txns; by acct_id txn_date; run;

data running;
  set txns;
  by acct_id;
  if first.acct_id then balance = 0;
  balance + amount;          /* sum statement */
run;
PySpark that matches SAS
from pyspark.sql import Window

w = (Window.partitionBy("acct_id")
           .orderBy("txn_date", "_seq")           # ties keep SAS input order
           .rowsBetween(Window.unboundedPreceding, Window.currentRow))

running = txns.withColumn("balance", F.coalesce(F.sum("amount").over(w), F.lit(0)))
The subtle part: the sum statement balance + amount; retains its value automatically and treats a missing amount as zero. A windowed sum() also skips NULLs, but when every row so far is NULL it returns NULL, where SAS shows 0. That is what the coalesce fixes. The _seq tiebreaker matters whenever two transactions share a date: without it, Spark can order them either way between runs, and intermediate balances flicker.

4NODUPKEY keeps the first row, always

PROC SORT NODUPKEY keeps the first observation for each key, in the order rows arrived, because SAS sorts are stable by default. Spark's dropDuplicates() keeps some row per key, and which one can change from run to run.

SAS
proc sort data=claims out=first_claim nodupkey;
  by member_id;
run;
Naive PySpark · nondeterministic
first_claim = claims.dropDuplicates(["member_id"])
PySpark that matches SAS
w = Window.partitionBy("member_id").orderBy("_seq")
first_claim = (claims.withColumn("_rn", F.row_number().over(w))
                     .filter("_rn = 1").drop("_rn"))

This is one of the most common causes of failures that come and go: the converted job passes reconciliation on Monday and fails on Tuesday with no code change.

5MERGE is not a join

A DATA step MERGE ... BY looks like an outer join, and for one-to-one and one-to-many keys it mostly behaves like one. It differs in two ways that matter.

SAS
data combined;
  merge accounts(in=a) contacts(in=c);
  by acct_id;
  if a;
run;
PySpark that matches SAS (one-to-many)
shared = {"acct_id", "region"}
combined = (accounts.alias("a")
    .join(contacts.alias("c"), F.col("a.acct_id") == F.col("c.acct_id"), "left")
    .select(
        F.col("a.acct_id"),
        *[F.col(f"a.{x}") for x in accounts.columns if x not in shared],
        *[F.col(f"c.{x}") for x in contacts.columns if x not in shared],
        # matched rows take contacts' value, even when it is missing
        F.when(F.col("c.acct_id").isNotNull(), F.col("c.region"))
         .otherwise(F.col("a.region")).alias("region")))

Many-to-many merges should be caught, not silently converted. A conversion should check key uniqueness on both sides and flag the program for review when both repeat. Usually that exposes a data problem the SAS log had been reporting for years.

6Dates count from 1960

A SAS date is the number of days since 1 January 1960. A SAS datetime is the number of seconds since midnight on that day. When SAS datasets are landed without their formats, dates arrive as plain numbers, and a naive conversion to Unix time is off by exactly 3,653 days.

PySpark
SAS_EPOCH_OFFSET_SECONDS = 315_619_200     # 1960-01-01 to 1970-01-01

df = (df
  .withColumn("open_date", F.date_add(F.lit("1960-01-01").cast("date"),
                                      F.col("open_date").cast("int")))
  .withColumn("last_login", F.timestamp_seconds(F.col("last_login")
                                                - SAS_EPOCH_OFFSET_SECONDS)))

Date functions need the same care. INTNX('month', d, 1) aligns to the start of the month by default, while add_months keeps the day of the month. INTCK counts interval boundaries crossed, not elapsed time. Each has a precise Spark equivalent, but none of them is the obvious one.

7Trailing blanks don't count

SAS character variables are fixed-length and padded with blanks, and SAS comparisons ignore trailing blanks: 'NY' = 'NY ' is true. In Spark those strings are different. This rarely shows up inside a single dataset. It shows up in joins, when one side is a SAS dataset and the other is a padded CHAR column or a fixed-width extract.

SAS
if state = 'NY' then region = 'NORTHEAST';
PySpark that matches SAS
F.when(F.rtrim(F.col("state")) == "NY", "NORTHEAST")

# and on join keys
a.join(b, F.rtrim(a.cust_code) == F.rtrim(b.cust_code))

Proving it: parity before cutover

None of these behaviors shows up in a code review. They show up in data. That is why the last step of a SAS-to-Databricks migration is not "the job runs" but "the output matches".

Parity loop between SAS and Databricks output The same inputs run through SAS and through the converted Databricks job. Outputs are compared on row counts, keys and every column with tolerances. Differences are traced to a behavior and fixed until the report is clean, then the job cuts over. Same inputsproduction snapshot SAS runoriginal program Databricks runconverted PySpark Comparecounts · keys · every columnnumeric tolerances Diff reporttraced to a behavior Cut overwhen clean fix the conversion rule, not the single program, then rerun
Figure 3. Differences get traced to one of the behaviors above and fixed once in the conversion rules, so every program that uses that pattern is corrected together.

Two practices keep this loop short. First, fix the pattern, not the program. When a difference traces back to NODUPKEY, the fix goes into the rule that converts every NODUPKEY, not into one notebook. Second, compare with tolerances you agree up front. Floating-point totals can differ in the last decimal place for legitimate reasons, and nobody should spend a week chasing 1e-12.

A pilot is the right place to learn this on your own code. With MigryX, a SAS-to-Databricks pilot typically covers about 10K lines of production SAS in 4 to 6 weeks. That includes Base, macros and PROC logic running on Databricks as PySpark, with row-level parity against the original outputs and lineage published to Unity Catalog.

SAS behaviorWhat breaksDatabricks fix
Missing is the smallest valueClassifications, filtersExplicit isNull() in comparisons
SUM() ignores missingTotals become NULL or wrongCoalesce, all-missing guard
Sum statement, RETAINRunning balancesWindow with _seq tiebreak, coalesce
NODUPKEY keeps firstIntermittent reconciliation failuresrow_number() over _seq
MERGE overwrite and positional pairingColumn values, row countsExplicit precedence, many-to-many check
1960 epochDates off by 3,653 daysOffset conversion, INTNX alignment
Trailing blanks ignoredMissed joinsrtrim on comparisons and keys

Key takeaways

See your own SAS program match on Databricks

Bring a SAS program with its inputs and outputs. We'll convert it, run it on Databricks, and show you the parity report row by row.

Book a demo   SAS to Databricks