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.