Importing SAS, CSV and Excel files
Lesson 4 — R Programming
Learning objectives
- Read
.sas7bdatand.xptfiles withhavenand keep the metadata - Handle SAS labels, formats and
haven_labelledcolumns - Read CSV files with explicit column types instead of guessing
- Read Excel workbooks, including merged headers and multiple sheets
- Diagnose encoding, locale and date-parsing problems
- Write an import layer that fails loudly rather than silently
The import layer is where studies go wrong
Almost every reproducibility problem starts at import. A column read as character in one run and numeric in another; a date parsed as US format on one laptop and European on another; a decimal comma turning 3,5 into NA. The fix is always the same: specify types explicitly, never rely on guessing.
Reading SAS files
haven reads all three SAS formats.
library(haven)
dm <- read_sas("data/raw/dm.sas7bdat") # native SAS dataset
ae <- read_xpt("data/raw/ae.xpt") # SAS transport (V5/V8)
cat <- read_sas("dm.sas7bdat", catalog_file = "formats.sas7bcat")read_sas() returns a tibble in which SAS metadata survives as attributes:
str(dm$AGE)
#> num [1:306] 45 52 38 61 29 ...
#> - attr(*, "label")= chr "Age"
#> - attr(*, "format.sas")= chr "BEST"Pull the labels out into a data dictionary:
library(dplyr)
library(purrr)
dictionary <- tibble(
variable = names(dm),
label = map_chr(dm, ~ attr(.x, "label") %||% NA_character_),
format = map_chr(dm, ~ attr(.x, "format.sas") %||% NA_character_),
type = map_chr(dm, ~ class(.x)[1])
)
dictionary
#> # A tibble: 24 x 4
#> variable label format type
#> <chr> <chr> <chr> <chr>
#> 1 STUDYID Study Identifier NA character
#> 2 USUBJID Unique Subject Identifier NA character
#> 3 AGE Age BEST numeric
#> 4 SEX Sex NA characterhaven_labelled columns
When a SAS variable has a user-defined format applied, haven returns a haven_labelled vector — the underlying code plus a lookup of value labels.
class(dm$RACEN)
#> [1] "haven_labelled" "double"
attr(dm$RACEN, "labels")
#> WHITE BLACK ASIAN
#> 1 2 3These are useful but not all functions handle them. Three ways forward:
library(haven)
as_factor(dm$RACEN) # -> factor with the value labels
zap_labels(dm$RACEN) # -> plain numeric, labels discarded
zap_labels(zap_formats(dm)) # -> whole dataset stripped
# Convert every labelled column at once
dm_f <- dm |> mutate(across(where(is.labelled), as_factor))Whether you keep haven_labelled, convert to factor, or strip to plain types is an architectural decision for the whole study. Mixing approaches across programs produces datasets that compare unequal for reasons nobody can find. Write it into the study’s programming conventions.
Character encoding
SAS datasets from European or Asian sites frequently arrive in a non-UTF-8 encoding, producing mojibake in investigator names or verbatim AE terms.
ae <- read_sas("ae.sas7bdat", encoding = "latin1")
# Diagnose
Encoding(ae$AETERM[1:5])
validUTF8(ae$AETERM) |> all()
#> [1] FALSE <- something is wrong
# Repair after the fact
ae$AETERM <- iconv(ae$AETERM, from = "latin1", to = "UTF-8")Large files
read_sas() supports column and row selection, which avoids loading a 2 GB lab dataset in full:
lb <- read_sas(
"lb.sas7bdat",
col_select = c(USUBJID, LBTESTCD, LBSTRESN, LBDTC, VISITNUM),
n_max = Inf
)
# Peek at structure without reading everything
read_sas("lb.sas7bdat", n_max = 0) |> names()Reading CSV
Use readr, and use the col_types argument. Always.
library(readr)
# Guessing — fine for exploration, not for production
dm <- read_csv("data/raw/dm.csv")
#> Rows: 306 Columns: 24
#> -- Column specification --------------------------
#> chr (18): STUDYID, USUBJID, SEX, RACE, ARM, ...
#> dbl (5): AGE, HEIGHT, WEIGHT, BMI, SITEID
#> date (1): RFSTDTCCopy that specification and make it explicit:
dm <- read_csv(
"data/raw/dm.csv",
col_types = cols(
STUDYID = col_character(),
USUBJID = col_character(),
SUBJID = col_character(), # keeps leading zeros
SITEID = col_character(), # "007" must not become 7
AGE = col_integer(),
SEX = col_factor(levels = c("M", "F")),
RFSTDTC = col_date(format = "%Y-%m-%d"),
.default = col_character()
)
).default = col_character() is a deliberate choice: any column you did not name arrives as text, unchanged, and you convert it consciously. That is much safer than letting the guesser decide.
SITEID values like "007" read as numeric become 7, and the join to the site reference table then finds nothing. Subject IDs, site IDs, visit codes and country codes should almost always be col_character().
The guessing trap
readr guesses types from the first 1000 rows by default:
# Row 1500 contains "N/A" in a numeric column
x <- read_csv("labs.csv")
#> Warning: One or more parsing issues...
problems(x)
#> # A tibble: 3 x 5
#> row col expected actual file
#> <int> <int> <chr> <chr> <chr>
#> 1 1500 4 a double N/A labs.csvAlways check problems() after a guessed read. Options:
read_csv("labs.csv", guess_max = Inf) # slow but thorough
read_csv("labs.csv", na = c("", "NA", "N/A", ".")) # declare the sentinels
read_csv("labs.csv", col_types = cols(RESULT = col_character())) # handle laterDelimiters and locales
read_csv2("data.csv") # ; separator, decimal comma (much of Europe)
read_tsv("data.tsv")
read_delim("data.txt", delim = "|")
read_delim(
"data_de.csv",
delim = ";",
locale = locale(decimal_mark = ",", grouping_mark = ".",
date_format = "%d.%m.%Y", encoding = "latin1")
)A file where 3,5 means three-and-a-half and 1.234 means one thousand two hundred thirty-four will parse to complete nonsense without the right locale, and it will do it quietly.
Writing
write_csv(dm, "data/derived/dm.csv")
write_csv(dm, "dm.csv", na = "") # blank instead of "NA"
write_excel_csv(dm, "dm.csv") # BOM so Excel gets UTF-8 right
# For R-to-R handoff, prefer a binary format — types are preserved exactly
saveRDS(dm, "data/derived/dm.rds")
dm <- readRDS("data/derived/dm.rds")
arrow::write_parquet(dm, "dm.parquet") # fast, typed, cross-languageReading Excel
library(readxl)
excel_sheets("data/raw/lab_ranges.xlsx")
#> [1] "Chemistry" "Haematology" "Notes"
chem <- read_excel("data/raw/lab_ranges.xlsx", sheet = "Chemistry")
chem <- read_excel("lab_ranges.xlsx", sheet = 1, range = "A3:F120")
chem <- read_excel("lab_ranges.xlsx", skip = 2, n_max = 100)Excel-specific problems and their fixes:
# 1. Types vary within a column -> force character, convert later
read_excel("f.xlsx", col_types = "text")
# 2. Explicit types per column
read_excel("f.xlsx",
col_types = c("text", "text", "numeric", "date", "skip"))
# 3. Excel dates arrive as serial numbers when the cell is formatted oddly
as.Date(45000, origin = "1899-12-30")
#> [1] "2023-03-15"
# 4. Merged header cells produce NA column names
janitor::clean_names(chem)Read every sheet into one tibble:
library(purrr)
path <- "data/raw/lab_ranges.xlsx"
all_sheets <- excel_sheets(path) |>
set_names() |>
map(~ read_excel(path, sheet = .x, col_types = "text")) |>
list_rbind(names_to = "sheet")It is a presentation format with data in it. Merged cells, colour-coded meaning, footnotes in the last row, and numbers stored as text are normal. Read everything as text, inspect, then convert. Never trust an Excel file’s types.
A defensive import function
Put all of this together into one function per dataset, in R/import.R:
library(readr)
library(dplyr)
library(cli)
import_dm <- function(path) {
if (!file.exists(path)) {
cli_abort("Demographics file not found: {.path {path}}")
}
dm <- read_csv(
path,
col_types = cols(
STUDYID = col_character(),
USUBJID = col_character(),
SITEID = col_character(),
AGE = col_integer(),
SEX = col_character(),
ARM = col_character(),
RFSTDTC = col_character(), # keep ISO text, parse deliberately
.default = col_character()
)
)
# Contract checks — fail here, not three programs later
required <- c("STUDYID", "USUBJID", "SITEID", "AGE", "SEX", "ARM")
missing <- setdiff(required, names(dm))
if (length(missing) > 0) {
cli_abort("Missing required column{?s}: {.var {missing}}")
}
if (anyDuplicated(dm$USUBJID) > 0) {
dups <- dm$USUBJID[duplicated(dm$USUBJID)]
cli_abort("Duplicate USUBJID: {.val {unique(dups)}}")
}
bad_sex <- setdiff(unique(na.omit(dm$SEX)), c("M", "F", "U"))
if (length(bad_sex) > 0) {
cli_warn("Unexpected SEX value{?s}: {.val {bad_sex}}")
}
cli_alert_success("Imported {nrow(dm)} subject{?s} from {.path {basename(path)}}")
dm
}The value is not the reading — it is the six lines of validation after it. An import that fails on the day the data changes is worth a great deal more than one that quietly produces a wrong table.
Choosing a reader
| Source | Function | Notes |
|---|---|---|
.sas7bdat |
haven::read_sas() |
Keeps labels and formats |
.xpt |
haven::read_xpt() |
V5 and V8 transport |
.sav (SPSS) |
haven::read_sav() |
|
.dta (Stata) |
haven::read_dta() |
|
.csv |
readr::read_csv() |
Specify col_types |
.csv, huge |
data.table::fread() |
Very fast |
.xlsx / .xls |
readxl::read_excel() |
Read as text first |
.parquet |
arrow::read_parquet() |
Typed, compressed, fast |
.rds |
readRDS() |
R-only, exact round trip |
| Database | DBI + dbplyr |
Push work to the server |
| JSON / API | jsonlite, httr2 |
Then tidyr::unnest() |
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Relying on type guessing | Types differ between runs | Explicit col_types |
| Reading IDs as numeric | Leading zeros lost, joins fail | col_character() |
Ignoring problems() |
Silent NAs |
Check it after every guessed read |
| Wrong locale | Decimal comma → NA |
locale(decimal_mark = ",") |
| Trusting Excel types | Mixed columns, serial dates | col_types = "text" |
| No validation after import | Errors surface far downstream | Contract checks in the import function |
read.csv() (base) |
Slower, older defaults | readr::read_csv() |
Exercise 4.1 — Write a strict CSV spec
A vitals CSV has columns STUDYID, USUBJID, SITEID, VISITNUM, VSTESTCD, VSORRES, VSORRESU, VSDTC. VSORRES occasionally contains "NOT DONE". SITEID values look like "007". VSDTC is ISO 8601 but sometimes only "2026-03". Write the read_csv() call.
Show solution
vs <- read_csv(
"data/raw/vs.csv",
col_types = cols(
STUDYID = col_character(),
USUBJID = col_character(),
SITEID = col_character(), # preserve "007"
VISITNUM = col_double(),
VSTESTCD = col_character(),
VSORRES = col_character(), # "NOT DONE" means this cannot be numeric
VSORRESU = col_character(),
VSDTC = col_character() # partial dates cannot be col_date()
),
na = c("", "NA") # do NOT treat "NOT DONE" as NA — it is information
)Two judgement calls worth stating explicitly:
VSORRESstays character. Creating a numericVSSTRESNis a derivation, done in a later program where you can record how"NOT DONE"was handled.VSDTCstays character becausecol_date()cannot represent"2026-03". Partial dates are handled in Dates, strings and factors.
Exercise 4.2 — Extract a data dictionary from a SAS file
Write sas_dictionary(path) returning a tibble with variable, label, format, type, n_missing and n_distinct for every column of a .sas7bdat file.
Show solution
library(haven)
library(dplyr)
library(purrr)
sas_dictionary <- function(path) {
dat <- read_sas(path)
tibble::tibble(
variable = names(dat),
label = map_chr(dat, ~ attr(.x, "label") %||% NA_character_),
format = map_chr(dat, ~ attr(.x, "format.sas") %||% NA_character_),
type = map_chr(dat, ~ class(.x)[1]),
n_missing = map_int(dat, ~ sum(is.na(.x))),
n_distinct = map_int(dat, ~ dplyr::n_distinct(.x, na.rm = TRUE))
)
}
sas_dictionary("data/raw/dm.sas7bdat")
#> # A tibble: 24 x 6
#> variable label format type n_missing n_distinct
#> <chr> <chr> <chr> <chr> <int> <int>
#> 1 STUDYID Study Identifier NA character 0 1
#> 2 USUBJID Unique Subject Identifier NA character 0 306
#> 3 AGE Age BEST numeric 2 46%||% is from rlang (and base R since 4.4): a %||% b returns b when a is NULL. It is the idiomatic way to supply a default for a missing attribute.
read_sas(path, n_max = 0) gives you the columns and attributes without the data — though n_missing and n_distinct then require the full read.
Exercise 4.3 — Diagnose a broken import
A colleague’s script produces a lab dataset where LBSTRESN is character on their machine and numeric on yours, from the same file. Nothing in the code differs. Explain what is happening and give two fixes.
Show solution
Cause. The read is relying on type guessing. readr guesses from the first guess_max rows (1000 by default). If the file has been re-extracted and now contains a non-numeric sentinel — "N/A", "<LLOQ", "." — within the first 1000 rows on one machine’s copy but not the other’s, the guessed type differs. Row ordering differences between extracts are enough to cause this.
Fix 1 — specify the type.
lb <- read_csv("lb.csv", col_types = cols(LBSTRESN = col_double()))Anything unparseable becomes NA and appears in problems(lb), so it is visible rather than silent.
Fix 2 — read as character and convert deliberately.
lb <- read_csv("lb.csv", col_types = cols(.default = col_character()))
lb <- lb |>
mutate(
lbstresn_num = suppressWarnings(as.numeric(LBSTRESN)),
lbstresn_flag = case_when(
is.na(LBSTRESN) ~ "MISSING",
!is.na(lbstresn_num) ~ "NUMERIC",
grepl("^<", LBSTRESN) ~ "BELOW_LLOQ",
grepl("^>", LBSTRESN) ~ "ABOVE_ULOQ",
.default ~ "NON_NUMERIC"
)
)Fix 2 is what you want in clinical work: the reason a value is not numeric is itself analysable information, and discarding it loses signal about assay limits.
A third, non-technical fix: addstopifnot(is.numeric(lb$LBSTRESN)) to the import function so the failure is loud and immediate.
Recap
haven::read_sas()/read_xpt()preserve labels and formats as attributeshaven_labelledcolumns need a study-wide decision: keep,as_factor(), orzap_labels()- Always give
readrexplicitcol_types; checkproblems()after any guessed read - IDs and site codes are character, never numeric
- Read Excel as text, then convert; expect merged headers and serial dates
- Validate immediately after import — the import function is the right place to fail
Next: Data manipulation with dplyr.