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
Create or change columns
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.
Keep, drop, and rename columns
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.
Keep rows that match a condition
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; |
Traps: filter() keeps rows where the condition is TRUE. NA in the condition drops the row. SAS where aval < 100 keeps missing numeric rows (missing is smaller than any number). Use !is.na(AVAL) when missing must not pass.
Conditional values
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,
.default = 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(..., .default = other) |
OTHERWISE / final else |
Traps: dplyr::if_else() is stricter on types than base ifelse(). Prefer if_else() in dplyr pipelines. case_when() stops at the first true condition. Older dplyr used TRUE ~ instead of .default. SAS ifc(age < 65, "<65", ">=65") puts missing age in "<65"; R if_else() returns NA.
First non-missing
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 |
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).
Convert character, numeric, and date
library(dplyr)
as.character(num) # strip(put(num, best.))
as.numeric(chr) # input(chr, best.)
as.numeric(na_if(trimws(chr), "")) # blank text is not NA until you convert it
sprintf("%03d", num) # put(num, z3.); integer only; NA -> " NA"
as.Date(iso) # input(iso, yymmdd10.) as an R Date
as.character(as.Date(iso)) # put(date, yymmdd10.)
format(as.Date(iso), "%Y-%m-%d") # same character date
as.Date(sas_date, origin = "1960-01-01") # SAS date number -> Date
as.integer(r_date - as.Date("1960-01-01")) # Date -> SAS date number
as.POSIXct(txt, tz = "UTC") # date only; time after T is dropped
as.POSIXct(txt, format = "%Y-%m-%dT%H:%M:%S", tz = "UTC")
as.POSIXct(sas_dt, origin = "1960-01-01", tz = "UTC")
as.numeric(as.character(fac)) # labels, not factor codes
as.numeric(sub(",", ".", dec_comma, fixed = TRUE)) # one decimal comma, no thousands mark| R | SAS |
|---|---|
as.character(x) |
strip(put(x, best.)) |
as.numeric(x) |
input(x, best.) |
sprintf("%03d", x) |
put(x, z3.) |
as.Date(x) |
input(x, yymmdd10.) |
as.character(d) / format(d, "%Y-%m-%d") |
put(date, yymmdd10.) |
as.Date(n, origin = "1960-01-01") |
n is already a SAS date |
as.POSIXct(n, origin = "1960-01-01", tz = "UTC") |
n is a SAS datetime (E8601DT.) |
as.numeric(sub(",", ".", x, fixed = TRUE)) |
input(x, commax.) for a single decimal comma |
Traps: as.numeric("001") is 1. Leading zeros do not survive a numeric. "" and " " are not NA; as.numeric() of either is NA and does not warn. as.numeric("1,25") is NA and warns NAs introduced by coercion. as.numeric() does not read a decimal comma, and OutDec only changes printing. as.Date(n) with no origin counts from 1970-01-01, not from the SAS epoch 1960-01-01. as.Date() reads; put(date, yymmdd10.) is as.character(d) or format(d, "%Y-%m-%d"). as.numeric() on a factor returns the level code, not the label. put(x, best.) is BEST12. and can contain blanks; as.character() does not pad. sprintf("%03d", x) needs an integer. NA becomes " NA". A non-integer numeric errors. as.POSIXct("2025-06-03T14:30:00", tz = "UTC") with no format does not error: the date parses and the time is dropped (2025-06-03 UTC). Pass format to keep the clock time.
Text
Extract part of a character value
# 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) |
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.
Change text case
toupper(x) # UPCASE
tolower(x) # LOWCASE
tools::toTitleCase(tolower(x)) # stand-in for PROPCASE; not equal to it| R | SAS |
|---|---|
toupper(x) |
UPCASE(x) |
tolower(x) |
LOWCASE(x) |
tools::toTitleCase(tolower(x)) |
PROPCASE(x) for plain words only |
Traps: tools::toTitleCase() is not PROPCASE. PROPCASE capitalizes every word, including of. toTitleCase() leaves English function words lowercase, and it does not treat . as a word break (N.C. becomes N.c. after tolower()). An all-caps word is left unchanged unless you tolower() first. toupper() on a factor returns character, not a factor. toupper(NA) is NA. "" stays "".
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 |
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()
# AE start: only when known year-month equals first dose and first-of-month
# is still before dose, set ASTDT to RFXSTDT; otherwise keep first-of-period
# year-only partials: compare format(..., "%Y") instead of "%Y-%m"
ASTDT <- ymd(paste0(AESTDTC, "-01"))
if_else(
format(ASTDT, "%Y-%m") == format(RFXSTDT, "%Y-%m") & ASTDT < RFXSTDT,
RFXSTDT,
ASTDT
)| 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. Imputation flags (ASTDTF, ADTF) are ADaM; SDTM keeps partial --DTC. Impute AE start to first dose only when the known year-month matches first dose and first-of-period is still before dose (year-only partials compare on "%Y"). Do not clamp every earlier month onto the dose date. The exact rule, including AE end-date checks, comes from the SAP.
Combining Data
Merge on keys
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 |
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.
Stack datasets
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") |
set a b indsname=src; |
bind_rows() by column name |
PROC SQL OUTER UNION CORR |
Traps: bind_rows() matches columns by name, not position. Character versus numeric on the same name errors in dplyr; fix types before stacking. SAS SET takes character length from the first dataset and can truncate later values; bind_rows() does not truncate.
Transpose rows and columns
library(dplyr)
library(tidyr)
# long -> wide. PROC TRANSPOSE; by keys; id paramcd; var aval;
lb |>
pivot_wider(names_from = PARAMCD, values_from = AVAL)
# wide -> long. NAME= is names_to; rename COL1 to the value column
wide |>
pivot_longer(c(ALT, AST), names_to = "PARAMCD", values_to = "AVAL")
# LET keeps the last duplicate ID. Without values_fn, pivot_wider warns.
lb_dup |>
pivot_wider(names_from = PARAMCD, values_from = AVAL, values_fn = last)
# SUPPAE onto AE. QVAL stays character. IDVARVAL is character; AESEQ is integer.
supp_wide <- suppae |>
filter(RDOMAIN == "AE", IDVAR == "AESEQ") |>
mutate(AESEQ = as.integer(IDVARVAL)) |>
pivot_wider(
id_cols = c(STUDYID, USUBJID, AESEQ),
names_from = QNAM,
values_from = QVAL
)
ae |>
left_join(
supp_wide,
by = c("STUDYID", "USUBJID", "AESEQ"),
relationship = "one-to-one"
)| R | SAS |
|---|---|
pivot_wider(names_from, values_from) |
PROC TRANSPOSE with BY, ID, VAR |
names_prefix = "AVAL_" |
PREFIX=AVAL_ when ID is set |
pivot_longer(cols, names_to, values_to) |
PROC TRANSPOSE with VAR and NAME=; values land in COL1 |
values_fn = last |
LET (last duplicate ID in the BY group) |
left_join() after the widen |
MERGE / SQL LEFT JOIN of SUPPxx onto the parent |
Traps: QVAL stays character. IDVARVAL is character and AESEQ is numeric; the join does not match until those types agree. A blank QVAL stays "". A qualifier with no SUPP row is NA after pivot_wider(). In SAS both are character missing. A repeated ID in a BY group makes PROC TRANSPOSE error unless LET is set. pivot_wider() without values_fn warns and returns list-columns. PROC TRANSPOSE requires the BY variables to be sorted. SUPPxx is SDTM collected structure, not an ADaM imputation. A standard parent variable such as AEDUR is not a SUPP QNAM.
Derivations
Within-subject sequence
library(dplyr)
df |>
arrange(USUBJID, TUEVAL, TULNKID, VISITNUM, TUDY) |> # order first
group_by(USUBJID) |>
mutate(SEQ = as.double(row_number())) |> # --SEQ; double for SAS compares
ungroup()| R | SAS |
|---|---|
arrange(...) then group_by(USUBJID) then row_number() |
PROC SORT then if first.usubjid then seq = 0; seq + 1; |
group_by(USUBJID) |
BY USUBJID |
as.double(row_number()) |
SAS numeric is double |
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(). _N_ is not a within-BY counter. Cast to double so waldo/diffdf match SAS numeric; XPT has no separate integer type.
Last observation before exposure
library(dplyr)
vs <- vs |>
mutate(
VSDT = as.Date(substr(VSDTC, 1, 10)), # ISO character --DTC -> Date
RFXSTDT = as.Date(substr(RFXSTDTC, 1, 10))
)
last_before <- vs |>
filter(!is.na(VSDT), !is.na(RFXSTDT), !is.na(VSSTRESN), VSDT <= RFXSTDT) |>
group_by(USUBJID, VSTESTCD) |>
slice_max(VSDT, n = 1, with_ties = TRUE) |>
ungroup() |>
distinct(USUBJID, VSTESTCD, VSDT) |>
mutate(VSLOBXFL = "Y")
vs |>
left_join(last_before, by = c("USUBJID", "VSTESTCD", "VSDT")) |>
mutate(VSLOBXFL = coalesce(VSLOBXFL, ""))| R | SAS |
|---|---|
filter(VSDT <= RFXSTDT) |
subset on or before RFXSTDTC |
slice_max(VSDT, n = 1, with_ties = TRUE) |
last date in that subset |
left_join(...) then coalesce(..., "") |
merge a "Y" flag; blank otherwise |
Traps: group by the entity (VSTESTCD here), not only the subject. Drop missing numeric results (!is.na(VSSTRESN)) before picking last. 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. Same calendar day as first dose with no times needs a study rule; <= is one choice. The flag is "", not NA. Compare Date columns (VSDT, RFXSTDT), not mixed-precision --DTC strings.