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.