SAS-to-R migration
Lesson 12 — Clinical Programming with R
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:
- Hiring. Graduates learn R and Python. SAS programmers are increasingly scarce and expensive.
- Cost. SAS licensing is a substantial recurring expense.
- Capability. Modern statistical methods, machine learning, interactive applications and reproducible reporting all arrive in R first.
- Sponsor and partner requirements. Some collaborations now specify R.
- 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 firstThis 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") # explicit3. 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 operatorR’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 valuehaven 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) # explicitThe 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
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 MIXEDandPROC GLIMMIX. R’slme4andnlmeare excellent but do not implement every option, and Kenward-Roger degrees of freedom historically differed.mmrmhas 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,duckdbanddata.tableaddress 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, certainPROC POWERoptions, 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 dropsExercise 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
renvpolicy: 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
riskmetricplus manual review - Approved package list, tiered by criticality
- Programming conventions document: naming, structure, style,
haven_labelledpolicy, 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;
diffdfcomparison; 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
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