SAS-to-R migration

Lesson 12 — Clinical Programming with R

Lesson 12 of 12 Advanced ~80 min

Learning objectives

  • Translate common SAS idioms to R
  • Anticipate the differences that produce spurious QC findings
  • Plan a phased migration rather than a rewrite
  • Address the organisational obstacles, which are usually larger than the technical ones
  • Decide honestly when not to migrate

Why organisations migrate

The reasons that actually drive the decision, roughly in order:

  1. Hiring. Graduates learn R and Python. SAS programmers are increasingly scarce and expensive.
  2. Cost. SAS licensing is a substantial recurring expense.
  3. Capability. Modern statistical methods, machine learning, interactive applications and reproducible reporting all arrive in R first.
  4. Sponsor and partner requirements. Some collaborations now specify R.
  5. Integration. R sits naturally alongside Python, databases, Git and CI.

Reasons that do not justify it: fashion, and a belief that R is intrinsically better. SAS is a mature, well-understood tool with an enormous body of validated code. Replacing it is expensive and the benefit has to be real.

Idiom translation

Data manipulation

SAS R
data b; set a; run; b <- a
data b; set a; where age > 65; run; b <- filter(a, age > 65)
data b; set a; keep id age; run; b <- select(a, id, age)
data b; set a; drop temp; run; b <- select(a, -temp)
data b; set a; bmi = wt/(ht/100)**2; run; b <- mutate(a, bmi = wt/(ht/100)^2)
data b; set a; rename old=new; run; b <- rename(a, new = old)
proc sort data=a; by id; run; a <- arrange(a, id)
proc sort nodupkey; by id; distinct(a, id, .keep_all = TRUE)
data c; set a b; run; c <- bind_rows(a, b)
data c; merge a b; by id; run; c <- full_join(a, b, by = "id")
if first.id then ... slice_head(n = 1, by = id)
if last.id then ... slice_tail(n = 1, by = id)
retain x; x = sum(x, value); mutate(x = cumsum(value))
lag(x) dplyr::lag(x)
proc transpose pivot_wider() / pivot_longer()

Summaries

SAS R
proc means data=a; var age; summarise(a, mean(age, na.rm = TRUE))
proc means; class arm; var age; summarise(a, mean(age), .by = arm)
proc freq; tables arm; count(a, arm)
proc freq; tables arm*sex; count(a, arm, sex)
proc univariate; summary(), quantile()
proc sql; select ... group by ...; summarise(..., .by = ...)

Functions

SAS R
sum(of x1-x5) rowSums(select(d, x1:x5), na.rm = TRUE)
mean(of x1-x5) rowMeans(...)
substr(x, 1, 3) str_sub(x, 1, 3)
upcase(x) str_to_upper(x)
strip(x) str_trim(x)
cat(x, y) / \|\| paste0(x, y)
index(x, "a") str_detect(x, "a")
tranwrd(x, "a", "b") str_replace_all(x, "a", "b")
input(x, best.) as.numeric(x)
put(x, best.) as.character(x)
intck('day', a, b) as.numeric(b - a)
intnx('month', d, 1) d %m+% months(1)
today() Sys.Date()
missing(x) is.na(x)
coalesce(a, b) coalesce(a, b)

The differences that cause QC findings

These are the ones that produce differences between a SAS and an R implementation of the same correct specification.

1. Missing values sort differently

SAS:  missing values sort FIRST (smallest)
R:    NA sorts LAST by default
x <- c(3, NA, 1, 2)
sort(x)                       #> 1 2 3        (NA removed)
sort(x, na.last = TRUE)       #> 1 2 3 NA
sort(x, na.last = FALSE)      #> NA 1 2 3     (SAS behaviour)

arrange(df, x)                #> NA last
arrange(df, desc(is.na(x)), x)#> NA first

This matters wherever a --SEQ or an extreme-value flag depends on the sort.

2. Missing values in comparisons

/* SAS: missing is smaller than any number */
if age > 65 then flag = 'Y';   /* missing age -> flag not set, no note */
# R: comparison with NA gives NA
ifelse(age > 65, "Y", "N")     # NA age -> NA, not "N"
if_else(age > 65, "Y", "N", missing = "N")   # explicit

3. Rounding

SAS:  ROUND() rounds half away from zero.  ROUND(0.5) = 1,  ROUND(2.5) = 3
R:    round() rounds half to even.         round(0.5) = 0,  round(2.5) = 2
round_sas <- function(x, digits = 0) {
  posneg <- sign(x)
  z <- abs(x) * 10^digits + 0.5 + sqrt(.Machine$double.eps)
  trunc(z) / 10^digits * posneg
}

This produces last-digit differences throughout a table and is the single most common source of spurious QC findings in a parallel run.

4. Character padding

SAS:  character variables are FIXED length, blank-padded
R:    character vectors are variable length
/* In SAS, "A" stored in a length-3 variable is "A  " */
if x = 'A' then ...;   /* SAS trims trailing blanks in comparison */
x == "A"        # FALSE if x is "A  "
trimws(x) == "A"

haven::read_sas() trims trailing blanks by default, which is usually what you want — but it means an R-derived value and a SAS-derived value can differ in a way that only appears at write time.

5. Numeric precision

Both use 8-byte doubles, so arithmetic agrees. But the order of operations can differ, producing differences around 1e-15. Use a tolerance in QC comparison, and never compare with ==:

diffdf(a, b, keys = "USUBJID", tolerance = 1e-8)
dplyr::near(x, y)

6. Automatic type conversion

/* SAS: silently converts with a note in the log */
x = "123" + 0;   /* x = 123, NOTE in log */
"123" + 0
#> Error in "123" + 0 : non-numeric argument to binary operator

R’s refusal is safer, but it means SAS code relying on implicit conversion needs explicit as.numeric() when translated — and each one is a place to check whether the conversion was intended.

7. Date origins

SAS:  days since 1960-01-01
R:    days since 1970-01-01
as.Date(sas_date, origin = "1960-01-01")
as.numeric(r_date) + 3653      # R date -> SAS date value

haven handles this on read and write; the issue arises when a raw numeric date value is passed between systems.

8. PROC SORT NODUPKEY versus distinct()

proc sort data=a nodupkey; by id; run;   /* keeps the FIRST after sorting */
distinct(a, id, .keep_all = TRUE)        # keeps the first in CURRENT order
arrange(a, id, date) |> distinct(id, .keep_all = TRUE)   # explicit

The SAS version sorts first; distinct() does not. Always arrange() first.

Migration strategy

Do not rewrite everything

The failure mode is a two-year “R migration project” that delivers nothing until it delivers everything. Migrate incrementally, by layer or by study.

Phase 1 — Learn (3–6 months)

  • Train a small group properly, not everyone superficially
  • Set up the infrastructure: R installation, package manager, Git, renv
  • Pick a completed study and re-produce a few of its outputs in R
  • Compare against the SAS output; understand every difference

The completed study is the important choice. There is no delivery pressure, the correct answer is known, and every difference is a learning opportunity rather than a crisis.

Phase 2 — Parallel (6–12 months)

  • Choose one active study, low-risk
  • Produce ADaM in both SAS and R; compare with diffdf
  • Then TLFs in both
  • Build the standards package as the study needs it
  • Document every difference and its resolution

Parallel running is expensive. Time-box it — a fixed number of studies, not “until we are confident”.

Phase 3 — R-primary (12–24 months)

  • New studies start in R
  • SAS is used for QC on the first few, then dropped
  • Existing studies continue in SAS to completion — do not migrate mid-study
  • The standards package matures

Phase 4 — Steady state

  • R is the default
  • SAS retained for legacy studies and specific procedures
  • Both skills valued; neither is a badge
ImportantNever migrate a study mid-flight

A study that has delivered a database lock in SAS should complete in SAS. Changing toolchain mid-study means re-validating everything already delivered, for no benefit. The cost of maintaining two toolchains for eighteen months is much lower than the cost of that re-validation.

The organisational obstacles

These are larger than the technical ones and are routinely underestimated.

Obstacle Reality Approach
“R is not validated” Neither is SAS, in the abstract; validation is of a system Point to the R Foundation document and the R Consortium pilots
Quality function unfamiliarity Genuine; their processes assume SAS Involve them from the start, not at the end
Programmer resistance Often reasonable — twenty years of expertise devalued Training, time, and not framing SAS skill as obsolete
CRO and partner capability Variable; some are ahead, some are not Check early; it may constrain the timeline
Existing validated macro library Represents years of work Migrate selectively; keep what works
Loss of SAS expertise Happens faster than expected once people see the direction Retain deliberately; legacy studies run for years

The programmer resistance point deserves honesty. A statistical programmer with twenty years of SAS expertise is being told their most valuable skill is becoming less valuable. That is a real loss, and the way it is handled determines whether the migration succeeds. Training budget, protected learning time, and explicitly valuing the domain knowledge (which transfers completely) matter more than any technical decision.

Where SAS remains stronger

An honest list:

  • PROC MIXED and PROC GLIMMIX. R’s lme4 and nlme are excellent but do not implement every option, and Kenward-Roger degrees of freedom historically differed. mmrm has closed most of this gap for MMRM specifically, but it is worth verifying against SAS for a primary endpoint.
  • Very large datasets on limited memory. SAS streams from disk by default; R loads into memory. arrow, duckdb and data.table address this, but the default behaviour differs.
  • Institutional validated macro libraries. Twenty years of tested code is a real asset.
  • Regulatory familiarity. Reviewers have seen SAS output for decades. This is diminishing, but it is not zero.
  • Some specific procedures. PROC MULTTEST, certain PROC POWER options, and various niche procedures have no exact R equivalent.

The first point is the one that matters most in practice. If a primary efficacy analysis is an MMRM specified with Kenward-Roger degrees of freedom, verify the R implementation against SAS before committing to it.

When not to migrate

  • A small team with one or two studies a year — the fixed cost does not amortise
  • A CRO whose clients all require SAS deliverables
  • An organisation with no capacity for the training investment
  • A study already past database lock
  • No quality-function engagement — the migration will stall at validation

There is no virtue in migrating. The question is whether the benefit exceeds the cost for your organisation.

Common mistakes

Mistake Consequence Fix
Translating SAS line by line Unidiomatic, slow R Rewrite to R idioms
Migrating mid-study Re-validation of delivered output Complete in SAS
Not accounting for rounding differences Hundreds of spurious QC findings Agree the convention first
Underestimating the training investment Slow, frustrated adoption Budget properly; protect the time
Excluding the quality function Stalls at validation Involve from day one
“Big bang” migration Nothing delivered for two years Phase it
Discarding SAS expertise Legacy studies unsupported Retain deliberately
Framing SAS as obsolete Resistance from your best people Value the domain knowledge

Exercise 12.1 — Translate a SAS program

Translate this SAS program to idiomatic R.

proc sort data=adam.adae out=ae_sorted;
  by usubjid aedecod astdt;
run;

data ae_first;
  set ae_sorted;
  by usubjid aedecod astdt;
  if first.aedecod;
  if trtemfl = 'Y';
run;

proc freq data=ae_first noprint;
  tables trt01a*aedecod / out=ae_counts(drop=percent);
run;

proc sql;
  create table ae_final as
  select a.trt01a, a.aedecod, a.count,
         a.count / b.n * 100 as pct
  from ae_counts a
  left join (select trt01a, count(*) as n from adam.adsl
             where saffl='Y' group by trt01a) b
  on a.trt01a = b.trt01a
  order by a.trt01a, calculated pct desc;
quit;
Show solution

Direct translation — correct, follows the SAS structure:

library(dplyr)

ae_sorted <- adae |> arrange(USUBJID, AEDECOD, ASTDT)

ae_first <- ae_sorted |>
  slice_head(n = 1, by = c(USUBJID, AEDECOD)) |>
  filter(TRTEMFL == "Y")

ae_counts <- ae_first |> count(TRT01A, AEDECOD, name = "count")

denoms <- adsl |> filter(SAFFL == "Y") |> summarise(n = n(), .by = TRT01A)

ae_final <- ae_counts |>
  left_join(denoms, by = "TRT01A") |>
  mutate(pct = 100 * count / n) |>
  arrange(TRT01A, desc(pct))

Idiomatic R — one pipeline, no intermediate objects:

denoms <- adsl |>
  filter(SAFFL == "Y") |>
  summarise(N = n(), .by = TRT01A)

ae_final <- adae |>
  filter(TRTEMFL == "Y") |>                       # filter FIRST, see below
  arrange(USUBJID, AEDECOD, ASTDT, AESEQ) |>
  slice_head(n = 1, by = c(USUBJID, AEDECOD)) |>  # first occurrence per subject/term
  count(TRT01A, AEDECOD, name = "count") |>
  left_join(denoms, by = "TRT01A") |>
  mutate(pct = 100 * count / N) |>
  arrange(TRT01A, desc(pct))

Three things the translation exposes

1. The SAS program has a bug. It takes first.aedecod and then filters trtemfl = 'Y'. If a subject’s first occurrence of a term is not treatment-emergent but a later one is, that subject is dropped entirely — they should be counted.

if first.aedecod;        /* takes the first, whether TEAE or not */
if trtemfl = 'Y';        /* then discards it if not a TEAE */

The idiomatic version filters first, so “first occurrence” means “first treatment-emergent occurrence”. Whether that is the intended behaviour is a specification question — but the SAS code makes it accidental, and the translation makes it visible.

2. The sort is non-deterministic. by usubjid aedecod astdt does not break ties when a subject has two records for the same term on the same date. SAS will pick one; R will pick one; they may differ. Adding AESEQ to the arrange() makes it deterministic.

3. This should not be in the table program at all. AOCCPFL in ADAE (lesson 4) already flags the first treatment-emergent occurrence per subject and preferred term:

ae_final <- adae |>
  filter(SAFFL == "Y", TRTEMFL == "Y", AOCCPFL == "Y") |>
  count(TRT01A, AEDECOD, name = "count") |>
  left_join(denoms, by = "TRT01A") |>
  mutate(pct = 100 * count / N) |>
  arrange(TRT01A, desc(pct))

Six lines, and the incidence logic lives in ADaM where it is tested once and used by every table. That is the actual lesson of translating SAS: the line-by-line version works, but the exercise reveals where logic has been duplicated into table programs that should have been derived upstream.

Verification

# Row counts should match the SAS output
nrow(ae_final)

# The bug means counts may legitimately differ — investigate, do not assume
affected <- adae |>
  filter(TRTEMFL == "Y") |>
  summarise(first_is_teae = first(TRTEMFL[order(ASTDT)]) == "Y",
            .by = c(USUBJID, AEDECOD)) |>
  filter(!first_is_teae)
nrow(affected)
#> [1] 7      <- seven subject/term combinations the SAS program drops
Seven differences that look like a translation error and are in fact a pre-existing defect. Finding these is a common and underrated benefit of migration.

Exercise 12.2 — Write a migration plan

A 40-person biometrics department runs 12 studies a year, entirely in SAS, with a validated macro library built over 15 years. Leadership wants to move to R. Write the plan.

Show solution

Migration plan — Biometrics, SAS to R


Scope and objective

Move new-study statistical programming to R over 24 months, retaining SAS for in-flight studies and specific procedures. Success is measured by: new studies delivering in R without a SAS parallel run, and no increase in delivery timelines or QC findings.

Explicitly not in scope: migrating in-flight studies, or eliminating SAS.


Phase 0 — Foundation (months 1–3)

Infrastructure

  • R installation on the validated environment, alongside SAS
  • Posit Package Manager with dated repository snapshots
  • Git/GitHub Enterprise, with branch protection and PR review
  • Container platform for reproducible execution
  • renv policy: one library per study, lockfile committed

People

  • Identify 4 early adopters — programmers with existing R interest or side-project experience. Not necessarily the most senior.
  • Engage the QA function now. Their process documents assume SAS; updating them takes months and cannot start late.

Governance

  • Package assessment process, using riskmetric plus manual review
  • Approved package list, tiered by criticality
  • Programming conventions document: naming, structure, style, haven_labelled policy, rounding convention

Deliverable: working environment, 4 trained people, a draft conventions document, QA engaged.


Phase 1 — Learn on a closed study (months 4–9)

Take a completed, locked study. Reproduce ADSL, ADAE, ADLB and six tables in R. Compare everything against the delivered SAS output.

Why a closed study: no delivery pressure, the correct answer is known, every difference is a learning opportunity.

Expected outputs:

  • A catalogue of SAS-vs-R differences (rounding, sorting, missing handling) with agreed conventions for each
  • The first version of the standards package
  • Four programmers who have genuinely done the work, not attended a course

Training: 2 days formal R, then project work with a mentor. Formal training alone does not produce capability; supervised real work does.

Deliverable: reproduced outputs, difference catalogue, standards package v0.1, conventions document finalised.


Phase 2 — Parallel on one active study (months 10–18)

Select a low-risk active study — ideally a Phase 1 or a small Phase 2, with a cooperative statistician and no immediate submission.

Approach:

  • ADaM produced in both SAS and R; diffdf comparison; every difference resolved and documented
  • SAS remains the deliverable; R is verification
  • Then TLFs in both, same approach
  • Standards package matures against real requirements

Time-box this. Two studies maximum in parallel mode. Parallel running is expensive and the temptation to extend it indefinitely is strong.

Team growth: the 4 early adopters mentor 8 more. Deliberate pairing, not “ask if you get stuck”.

QA: update SOPs based on what the parallel run actually revealed, not on what was anticipated in Phase 0.

Deliverable: one study verified in R, standards package v1.0 validated, updated SOPs, 12 capable programmers.


Phase 3 — R-primary for new studies (months 19–30)

  • All new studies start in R
  • SAS QC on the first two, then R-on-R independent double programming
  • In-flight SAS studies continue to completion, unchanged
  • Remaining programmers trained as studies allocate them

Legacy macro library: migrate selectively. Analyse usage — typically 20% of macros account for 80% of calls. Migrate those; leave the rest in SAS for legacy studies. Do not attempt a wholesale port.

Deliverable: new studies delivering in R, standards package v2.x, majority of the department capable.


Phase 4 — Steady state (month 30+)

  • R is the default
  • SAS retained for legacy studies, MMRM verification, and specific procedures
  • Both skills valued in hiring and development

Risks and mitigations

Risk Mitigation
QA cannot approve the process Engaged from month 1; SOPs updated iteratively from real evidence
Key programmers leave Training is retention; frame R as career development, not replacement
Delivery slips during transition Parallel running absorbs the risk; no in-flight study migrated
Standards package becomes a bottleneck Multiple maintainers from the start; contribution process
CRO partners cannot receive R deliverables Survey in Phase 0; deliverables are XPT and RTF regardless of the tool
MMRM results differ from SAS Verify mmrm against SAS in Phase 1, before any commitment
Migration stalls after Phase 2 Executive sponsor with a named accountability; quarterly review

Resourcing

Item Cost
Training (40 people × 2 days + mentoring time) ~120 person-days
Phase 1 closed-study reproduction ~80 person-days
Phase 2 parallel running (2 studies) ~200 person-days
Standards package development and validation ~150 person-days
QA process updates ~40 person-days
Infrastructure Platform and licensing costs
Total ~590 person-days over 30 months

Roughly 2.5 FTE-years, spread across 40 people and 30 months — about 6% of capacity. That is the honest number, and it is the number most migration plans understate.

Offsetting: SAS licence reduction (partial, since SAS is retained), and hiring from a much larger candidate pool.


What would make me recommend against this

  • No QA engagement — the migration will stall at validation and waste everything spent to that point
  • No executive sponsor with real accountability
  • Fewer than 6 studies a year — the fixed cost does not amortise
  • CRO partners uniformly unable to work in R
  • No capacity for a 6% capacity reduction over 30 months
If any two of these are true, the honest recommendation is to defer, build capability opportunistically through side projects, and revisit in two years. A failed migration is worse than none: it burns credibility and makes the next attempt harder.

Recap

  • Migrate for hiring, cost and capability — not for fashion
  • The differences that cause spurious QC findings: rounding, missing-value sorting, character padding
  • Never migrate a study mid-flight
  • Phase it: learn on a closed study, run parallel on one active study, then go R-primary
  • Organisational obstacles exceed technical ones — engage QA from day one
  • SAS remains stronger for some mixed models, very large data, and legacy macro libraries
  • Translation often reveals pre-existing defects in the SAS code
  • A failed migration is worse than none; be honest about capacity

Course complete. You now have the full clinical pipeline in R: study structure, source data, SDTM, ADaM with admiral, metadata-driven programming, TLFs, validation, define.xml, xportr, the pharmaverse ecosystem, and the organisational side of migration.

Where next:

  • R Shiny — build review and monitoring tools on these datasets
  • R Programming — deepen the package, testing and Git foundations
  • Python — reading clinical data and building apps in a second language
Back to top