Cheat sheet
Every topic, code only
This page is short SAS-to-R code pairs from each topic. Open the topic page for more detail.
Basics
Mutate
library(dplyr)
df |>
mutate(
AVAL = VALUE,
AVALC = as.character(VALUE)
)| R | SAS |
|---|---|
mutate(NEW = expr) |
DATA step new = expr; |
mutate(VAR = expr) |
overwrite existing variable |
several columns in one mutate() |
several assignments in one DATA step |
Trap: mutate() returns a new table; assign it or pipe onward. It does not change the input object in place unless you overwrite the name.
Select and rename
library(dplyr)
df |>
select(USUBJID, AVAL, AVALC)
df |>
select(-TEMP)
df |>
rename(AVAL = VALUE)| R | SAS |
|---|---|
select(a, b) |
KEEP a b; (keep listed) |
select(-a) |
DROP a; |
rename(new = old) |
RENAME old = new; (note name order) |
Trap: SAS RENAME old=new puts the old name first. dplyr::rename(new = old) puts the new name first.
Filter
library(dplyr)
df |>
filter(PARAMCD == "SYSBP")
df |>
filter(PARAMCD == "SYSBP", !is.na(AVAL))| R | SAS |
|---|---|
filter(cond) |
where cond; or subsetting if cond; |
filter(a, b) |
where a and b; (both must be true) |
filter(a \| b) |
where a or b; |
Trap: filter() keeps rows where the condition is TRUE. Rows with NA in the condition are dropped. SAS where also treats missing as not true for most comparisons.
If else and case when
library(dplyr)
df |>
mutate(
AGEGR1 = if_else(AGE < 65L, "<65", ">=65")
)
df |>
mutate(
AESEVN = case_when(
AESEV == "MILD" ~ 1L,
AESEV == "MODERATE" ~ 2L,
AESEV == "SEVERE" ~ 3L,
TRUE ~ NA_integer_
)
)| R | SAS |
|---|---|
if_else(cond, true, false) |
IFN / IFC, or if then ... else |
case_when(cond ~ val, ...) |
SELECT / nested IF-THEN-ELSE |
case_when(..., TRUE ~ other) |
OTHERWISE / final else |
Trap: dplyr::if_else() is stricter on types than base ifelse(). Prefer if_else() in dplyr pipelines. case_when() stops at the first true condition.
Coalesce
library(dplyr)
coalesce(x, y) # first non-NA argument; later args are fallbacks
coalesce(na_if(spy, ""), coded) # specify, then coded. blanks are not NA
na_if(x, "") # "" -> NA so coalesce will skip it
na_if(x, "NA") # the literal string "NA" is not missing either| R | SAS |
|---|---|
dplyr::coalesce() |
COALESCE (numeric), coalescec (character) |
na_if(x, "") |
SAS already treats blank as missing |
if_else(cond, a, b) |
IFN / IFC |
Three traps: coalesce() skips NA only, not "". A specify field of "" therefore beats the coded value. Convert blanks with na_if() first, then prefer specify over coded: coalesce(na_if(spy, ""), coded).
Text
Substrings
# SAS: SUBSTR(string, start, length)
# R: substr(string, start, stop) stop is an end position, not a length
substr(usubjid, 5, 3) # "" (stop < start: empty range)
substr(usubjid, 5, 7) # positions 5 through 7
substr(usubjid, 5, 5 + 3 - 1) # SAS length 3, written as start+len-1
substring(usubjid, 5, 7) # same idea; arguments recycle; can assign
# stringr::str_sub() also takes end positions, not a length| R | SAS |
|---|---|
substr(x, start, stop) |
SUBSTR(x, start, length) |
substring(x, start, stop) |
SUBSTR with recycled bounds |
stringr::str_sub(x, start, end) |
SUBSTR (end, not length) |
Three traps: the third argument is a stop position, not a length. substr(x, 5, 3) is not "three characters from 5"; it is the empty range 5-to-3. Write start+len-1, or count the end position. substring() and str_sub() use end positions too.
Dates
Study day
library(dplyr)
# CDISC --DY has no day 0. The reference date is day 1, not day 0.
if_else(date < rfstdt, date - rfstdt, date - rfstdt + 1)
# same rule, no branch:
as.integer(date - rfstdt) + as.integer(date >= rfstdt)| R | SAS |
|---|---|
date - rfstdt |
date minus date (raw, includes day 0) |
if_else(date < rfstdt, date - rfstdt, date - rfstdt + 1) |
--DY (no day 0) |
as.integer(date - rfstdt) + as.integer(date >= rfstdt) |
the same --DY rule, compact |
Three traps: raw subtraction is not --DY. On the reference date the raw diff is 0; --DY is 1. Dates before the reference stay negative and do not shift. Missing either date must stay missing, not become day 0.
Partial dates
library(dplyr)
library(lubridate)
# year-only "2025" -> first / mid / last day of year
ymd(paste0(year_txt, "-01-01"))
ymd(paste0(year_txt, "-06-30")) # common mid-year convention
ymd(paste0(year_txt, "-12-31"))
# year-month "2024-04" -> first / mid / last day of month
ymd(paste0(ym_txt, "-01"))
ymd(paste0(ym_txt, "-15")) # common mid-month convention
(ceiling_date(ymd(paste0(ym_txt, "-01")), "month") - days(1)) |> as.Date()| R (after parsing the known part) | SAS idea |
|---|---|
| first day of month/year | impute to period start (INTNX beginning) |
| mid day (often 15 / 30 Jun) | sponsor mid-point rule |
| last day of month/year | impute to period end (INTNX end) |
Traps: document the mid rule (15 vs true midpoint). ceiling_date(..., "month") alone is next month start, not month end. Missing completely stays missing. Keep an imputation flag (--DTF) when the standard requires it.
Combining Data
Joins
library(dplyr)
# --- the six joins ------------------------------------------------------
inner_join(dm, ae, by = "subjid") # rows matching on BOTH sides
left_join( dm, ae, by = "subjid") # all of dm, annotated from ae <- the usual one
right_join(dm, ae, by = "subjid") # all of ae (just swap the args instead)
full_join( dm, ae, by = "subjid") # everything from either side
anti_join( dm, ae, by = "subjid") # in dm, NOT in ae <- "who is missing?"
semi_join( dm, ae, by = "subjid") # filter dm to matches, add no columns
# --- keys ---------------------------------------------------------------
left_join(dm, ae, by = c("subjid" = "SUBJID")) # different names, left first
left_join(lb, visits, by = c("subjid", "visit")) # both keys must match
# --- state what you expect; the join aborts if the data disagrees -------
left_join(dm, ae, by = "subjid", relationship = "one-to-many")
# "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many"| R | SAS DATA step |
SQL |
|---|---|---|
inner_join() |
if a and b; |
INNER JOIN |
left_join() |
if a; |
LEFT JOIN |
right_join() |
if b; |
RIGHT JOIN |
full_join() |
no subsetting if |
FULL JOIN |
anti_join() |
if a and not b; |
WHERE ... NOT IN |
semi_join() |
if a and b; then dedupe |
WHERE EXISTS |
Three traps: NA after a join means "no match", not "missing value". inner_join() throws away your data errors and your event-free subjects together, without saying so. And a duplicate key on the right silently multiplies rows, which is what relationship = is for.
Bind rows
library(dplyr)
bind_rows(ae_site_a, ae_site_b)
bind_rows(
list(SITEA = ae_site_a, SITEB = ae_site_b),
.id = "SITE"
)| R | SAS |
|---|---|
bind_rows(a, b) |
set a b; (stack; align names with rename= if needed) |
bind_rows(..., .id = "SRC") |
add a source variable before set |
bind_rows() by column name |
PROC SQL OUTER UNION CORR |
Trap: bind_rows() matches columns by name, not position. A type clash on the same name (character vs numeric) errors or coerces; fix types before stacking.
Derivations
Sequence numbers
library(dplyr)
df |>
arrange(USUBJID, TUEVAL, TULNKID, VISITNUM, TUDY) |> # order first
group_by(USUBJID) |>
mutate(SEQ = as.double(row_number())) |> # --SEQ, XPT-shaped
ungroup()| R | SAS |
|---|---|
arrange(...) then row_number() |
PROC SORT then _N_ within BY |
group_by(USUBJID) |
BY USUBJID |
as.double(row_number()) |
SAS numeric is always double |
Three traps: row_number() follows the current row order, so skip arrange() and --SEQ is whatever the last PROC left behind. Equal dates need tie-breakers in arrange(). Integer SEQ is not how SAS XPT stores it; use as.double().
Last observation before exposure
library(dplyr)
last_before <- tu |>
filter(!is.na(TUDTC), !is.na(RFSTDTC), TUDTC <= RFSTDTC) |>
group_by(USUBJID, TULNKID) |>
slice_max(TUDTC, n = 1, with_ties = TRUE) |>
ungroup() |>
distinct(USUBJID, TULNKID, TUDTC) |>
mutate(TULOBXFL = "Y")
tu |>
left_join(last_before, by = c("USUBJID", "TULNKID", "TUDTC")) |>
mutate(TULOBXFL = coalesce(TULOBXFL, ""))| R | SAS |
|---|---|
filter(TUDTC <= RFSTDTC) |
subset on or before RFSTDTC |
slice_max(TUDTC, n = 1, with_ties = TRUE) |
last date in that subset |
left_join(...) then coalesce(..., "") |
merge a "Y" flag; blank otherwise |
Three traps: group by the entity (TULNKID), not only the subject. An empty qualifying set stays blank, not "Y" and not NA. with_ties = TRUE flags every row that shares the last date; distinct() on the flag keys so the join does not multiply rows. SDTM wants "", not NA.