Prof. Marc Los Huertos • Pomona College
Overview CSV Files Many Files at Once Filename as Data Excel Files Field Data Encoding Datetime Nightmares Diagnosing Problems Checklist
What this module covers. Getting data into R is often the hardest part of an analysis — not because the code is complicated, but because real-world data files are messy, inconsistent, and full of hidden traps. This module covers CSV, Excel, and field-collected data files; reading a whole folder of files at once; using the file name itself as a data column; character encoding issues; and the special circle of hell that is importing dates and times. By the end you will have a systematic toolkit for handling virtually any file you receive.
Prerequisites: You should be comfortable creating objects and running code in R (Module R02) and understand the idea of a project working directory (Module R03). No prior experience with tidyverse import functions is assumed.
R ships with import functions like read.csv() in every
installation — no packages needed. These work, but they have quirks that
cause silent errors: older versions silently converted text columns to
factors, they produce data.frame objects with
sometimes-awkward behavior, and they give you no feedback when something
goes wrong.
The tidyverse import functions
(read_csv(), read_excel(), etc.) were built to
fix these problems. They are faster, more consistent, and — most
importantly — they tell you when something doesn’t parse
correctly instead of silently producing NA. We will use
these throughout the course.
🧭 The core import workflow is always the same
Regardless of file format, importing data follows four steps: (1) look
at the raw file before reading it; (2) read it into R with an
appropriate function; (3) inspect the result immediately with
glimpse() and problems(); (4) fix any type or
encoding issues before proceeding to analysis. This module builds each
step.
| Function | Package | File type | Returns | Notes |
|---|---|---|---|---|
read.csv() |
base R | CSV | data.frame |
Older; converts strings to factors by default (R < 4.0) |
read_csv() |
readr | CSV | tibble |
Faster; never converts strings; prints column spec |
read_csv2() |
readr | CSV (; sep, , decimal) |
tibble |
European locale CSVs |
read_tsv() |
readr | Tab-separated | tibble |
TSV files from instruments/GIS |
read_delim() |
readr | Any delimiter | tibble |
Specify delim = |
read_fwf() |
readr | Fixed-width | tibble |
Some legacy instrument exports |
read_excel() |
readxl | .xlsx / .xls |
tibble |
Specify sheet, range, col_types |
read_lines() |
readr | Raw text | character vector | Inspect structure before parsing |
💡 Install once in the Console, never in a chunk that
knits Run install.packages(c(“tidyverse”, “readxl”,
“janitor”)) once interactively in the Console. In your Rmd, only
library() calls belong in chunks — never
install.packages().
.xlsx and .xls. Use sheet=,
range=, col_types=, skip=.
delim = “|” for
pipe-delimited instrument exports.
read_csv(), call problems(df) to see
every row/column where parsing failed and why.
col_types = cols(…) to make it permanent.
snake_case. Fixes spaces,
special characters, and duplicate names.
The single most important import habit is inspecting your file
before you try to read it. Opening a CSV in a plain-text editor
(not Excel) or running read_lines() in R lets you answer
five questions that determine how your import call should be
written:
NA,
-9999, “N/A”, blank, something else?)
# Read the first 10 lines as raw text — no parsing, no interpretation
read_lines("data/air_quality.csv", n_max = 10)
Expected output — look for these things [1] “# Downloaded from EPA AQS 2024-03-15” [2] “# Site: Los Angeles, CA — Monitoring Station 060371103” [3] “” [4] “site_id,date,pm25_ug_m3,flag,notes” [5] “060371103,2024-01-01,8.2,,routine” [6] “060371103,2024-01-02,12.4,,routine” [7] “060371103,2024-01-03,,M,instrument maintenance” …
From these 7 lines you learn: (1) comma delimiter, (2) skip 3 header lines, (3) missing = blank field, (4) column names in row 4, (5) dates are ISO format.
read_csv()Once you know the file structure, you can write an informed import call. Start with the minimal version and build up:
library(readr)
# Step 1: minimal call — readr guesses types and prints the spec
df <- read_csv("data/air_quality.csv")
# Step 2: ALWAYS inspect immediately after reading
glimpse(df) # one line per column: type + first few values
head(df) # first 6 rows
problems(df) # any parsing failures — check this every time
⚠️ Read the column-type message — it is diagnostic
gold When read_csv() runs, it prints something
like:
Rows: 1095 Columns: 5
── Column
specification ──────────────────────
chr (2): site_id,
flag
dbl (1): pm25_ug_m3
date (1):
date
If a column you expect to be numeric shows up as
chr, something in that column — a “NA”,
“–”, “< LOD”, or similar — prevented
numeric parsing. Do not ignore this message.
file.choose()Typing a file path by hand is error-prone: a wrong slash, a typo, or
not knowing where a file actually lives on your machine all produce the
same unhelpful cannot open file error.
file.choose() opens your operating system’s file-picker
dialog and returns the full path as a string.
# Run this ONCE in the Console — not inside a chunk that knits
my_path <- file.choose()
# R prints the full path, e.g.:
# [1] "/Users/marc/Desktop/EA030/field_data.csv"
# On Windows:
# [1] "C:\\Users\\marc\\Desktop\\EA030\\field_data.csv"
my_path # type the object name to see the path — then copy it
my_path <- file.choose() in the
Console. Navigate to your file and click Open.
my_path in the Console and press Enter. R prints the
full path.
read_csv() call.
# In your Rmd chunk: hard-code the path you discovered in the Console
my_path <- "/Users/marc/EA030/data/field_data.csv"
df <- read_csv(my_path)
⚠️ file.choose() will freeze a knit — always hard-code after
discovery file.choose() waits for user input. If
it is inside a chunk that runs during knitting, the render will hang
indefinitely. Workflow: (1) discover path in Console → (2) copy path →
(3) paste as a hard-coded string in Rmd → (4) mark the discovery chunk
eval=FALSE so it is shown as documentation but never
executed.
💡 Windows path separators Windows uses backslashes
(C:.csv) but R uses forward slashes or escaped backslashes.
file.choose() on Windows returns double backslashes
(C:\Users\marc\data.csv) — these work in R. You can also
replace every \ with /; R accepts forward
slashes on all platforms.
✏️ Try It — discover and hard-code a path In the
Console, run my_path <- file.choose() and select any CSV
file you have on your computer. Then run my_path to see the
path printed. Finally, create a new chunk in your Rmd and paste the path
as a hard-coded string. Does read_csv(my_path) work?
After your first successful import, you should make the column types explicit so the import behaves identically no matter what future rows look like. The easiest way is to copy the spec that readr already printed:
# After reading once and confirming the types look correct,
# copy the spec and paste it into col_types = cols(...)
df <- read_csv(
"data/air_quality.csv",
col_types = cols(
site_id = col_character(),
date = col_date(format = "%Y-%m-%d"),
pm25_ug_m3 = col_double(),
flag = col_character(),
notes = col_character()
)
)
💡 How to get the spec to copy After df <-
read_csv(…) runs, type spec(df) in the Console. R
prints the full cols(…) call. Copy everything inside
cols() and paste it as the value for col_types
=.
Field data files use every imaginable placeholder for missing values.
If you do not list them, R will read them as character strings —
silently converting your numeric column to chr type:
df <- read_csv(
"data/field_data.csv",
na = c("", "NA", "N/A", "n/a", "n.d.", "ND", "–", "—",
"999", "-9999", ".", "NULL", "null", "missing")
)
📋 Where do these NA strings come from? Different
sources use different conventions. EPA/USGS databases often use
-9999 or -999 as sentinel values. Instrument
software may use NAN or 7999. Hand-entered
spreadsheets may have “N/A”, “n.d.”, or just a
blank. Check the data documentation or inspect the file with
read_lines() before assuming blank = missing.
Many instrument exports have metadata before the column names. Use
skip = to jump past them:
# Skip 4 metadata lines, then treat row 5 as the column-name header
df <- read_csv("data/licor_export.csv", skip = 4)
# If there are comment lines marked with "#" mixed in with the data
df <- read_csv("data/sensor_log.csv", skip = 3, comment = "#")
# If there is NO header row at all — you must supply names manually
df <- read_csv("data/raw_logger.csv",
col_names = c("datetime", "temp_C", "rh_pct", "lux"),
skip = 1)
✏️ Try It — practice reading a CSV step by step
read_lines(“your_file.csv”, n_max = 8) and answer
the five inspection questions from the start of this section.read_csv() call that uses skip =
if needed and declares the correct na = strings.glimpse(df) and problems(df). Do all
column types match what you expect?Field monitoring projects routinely generate one file per sensor, per
site, per deployment, or per day. A water-quality study might produce 50
CSV files — one per sampling location. An air-quality network might
produce one file per sensor per month. Loading these one at a time with
fifty copy-pasted read_csv() calls is tedious, fragile, and
error-prone.
The tidyverse approach is to treat the collection of file
paths as a vector and use purrr::map() to apply
read_csv() to each one. You then bind all the results into
a single data frame for analysis.
🗂️ The core idea: files as a vector, reading as a
function list.files() returns all matching file
paths as a character vector. map() applies a function to
every element of a vector. list_rbind() stacks the
resulting list of data frames into one. These three pieces together
replace fifty manual import calls.
Suppose you have a folder data/sensors/ containing one
CSV per sensor station, all with the same column structure:
data/sensors/
site_A_pm25.csv
site_B_pm25.csv
site_C_pm25.csv
...
Here is the workflow, piece by piece:
# ── Step 1: build a vector of file paths ─────────────────────────────────────
csv_files <- list.files(
path = "data/sensors/", # folder to search
pattern = "\\.csv$", # only files ending in .csv
full.names = TRUE # return the full path, not just the filename
)
csv_files # inspect: you should see a character vector of paths
Expected output [1] “data/sensors/site_A_pm25.csv” [2] “data/sensors/site_B_pm25.csv” [3] “data/sensors/site_C_pm25.csv”
# ── Step 2: read each file and stack into one data frame ─────────────────────
library(purrr)
all_data <- csv_files |>
map(\(f) read_csv(f, col_types = cols(.default = col_character()))) |>
list_rbind()
# col_types = cols(.default = col_character()) reads everything as text on
# the first pass. This prevents one file's format quirks (e.g., a flag string
# in a numeric column) from forcing that column to NA across all files.
# ── Step 3: re-infer types on the combined data frame ────────────────────────
all_data <- all_data |>
type_convert() # readr re-guesses types now that all rows are present
# ── Step 4: inspect ───────────────────────────────────────────────────────────
glimpse(all_data)
nrow(all_data) # should be the sum of rows across all files
problems(all_data) # any parsing failures?
⚠️ All files must have the same column names
list_rbind() matches columns by name, not position. If one
file has PM25_ug_m3 and another has
pm25_ug_m3, they will become two separate columns. Run
map(csv_files, (f) names(read_csv(f, n_max=1))) to check
that every file has the same names before stacking.
# Read only the first row of each file — fast way to check column names
column_names_per_file <- csv_files |>
map(\(f) names(read_csv(f, n_max = 1, show_col_types = FALSE)))
# Are all files identical?
all(sapply(column_names_per_file, identical, column_names_per_file[[1]]))
# TRUE = safe to stack; FALSE = find which files differ
# Find the differing files
column_names_per_file[!sapply(column_names_per_file,
identical, column_names_per_file[[1]])]
✏️ Try It — read a folder of files Create a small
test: save two or three copies of any CSV with the same column structure
into a single folder (rename them site_01.csv,
site_02.csv, etc.). Then use the step-by-step workflow
above to read them all into one data frame. Confirm that
nrow() on the combined frame equals the sum of rows in the
individual files.
After stacking dozens of files with map() and
list_rbind(), every row looks identical in structure —
there is nothing in the data itself that tells you which file a
row came from. But the file name often encodes critical metadata: the
site ID, the deployment date, the sensor serial number, the sampling
location.
Consider this folder:
data/sensors/
LakeElsinore_2023-06.csv
LakeElsinore_2023-07.csv
Riverside_2023-06.csv
Riverside_2023-07.csv
The site name and month are encoded in the file name. If you stack these files without extracting that information, you lose it.
📂 File names are metadata — extract them before they
disappear The moment you call list_rbind(), the
connection between each row and its source file is gone unless you
deliberately preserve it. The .id argument in
list_rbind() (or map_dfr()) creates a column
that records which element of the input list each row came from. Combine
this with set_names() to control exactly what that column
contains.
source_file column, parse it laterThe simplest approach: add the full file path (or just the file name)
as a column, then use stringr or tidyr to
extract the pieces you need.
library(purrr)
library(stringr)
csv_files <- list.files("data/sensors/", pattern = "\\.csv$", full.names = TRUE)
all_data <- csv_files |>
# Name the vector with just the bare file name (no path, no extension)
set_names(~ tools::file_path_sans_ext(basename(.x))) |>
map(\(f) read_csv(f, col_types = cols(.default = col_character()),
show_col_types = FALSE)) |>
list_rbind(names_to = "source_file")
# names_to = "source_file" creates a column using the names we set above
Expected result — new column at the left source_file site_id date pm25_ug_m3 LakeElsinore_2023-06 … 2023-06-01 12.4 LakeElsinore_2023-06 … 2023-06-02 9.8 … Riverside_2023-06 … 2023-06-01 18.1
When the file name follows a predictable pattern (e.g.,
SITE_YYYY-MM.csv), you can extract the components inside
the same pipeline using mutate() and
str_extract() or separate():
all_data <- csv_files |>
set_names(~ tools::file_path_sans_ext(basename(.x))) |>
map(\(f) read_csv(f, col_types = cols(.default = col_character()),
show_col_types = FALSE)) |>
list_rbind(names_to = "source_file") |>
# ── Parse the file name into site and year-month ────────────────────────
mutate(
site = str_extract(source_file, "^[^_]+"), # everything before first _
yearmonth = str_extract(source_file, "\\d{4}-\\d{2}$") # YYYY-MM at end
) |>
# ── Convert types now that we have clean columns ────────────────────────
type_convert()
Expected result — site and yearmonth extracted source_file site yearmonth date pm25_ug_m3 LakeElsinore_2023-06 LakeElsinore 2023-06 2023-06-01 12.4 Riverside_2023-06 Riverside 2023-06 2023-06-01 18.1
Sometimes the file name contains a code (e.g., S01,
S02) rather than a human-readable label. You can match
codes to a lookup table with left_join():
# A lookup table you create separately
site_lookup <- tibble::tribble(
~file_code, ~site_name, ~latitude, ~longitude,
"S01", "Lake Elsinore", 33.668, -117.332,
"S02", "Riverside", 33.953, -117.396,
"S03", "Mira Loma", 33.986, -117.519
)
csv_files <- list.files("data/sensors/", pattern = "\\.csv$", full.names = TRUE)
all_data <- csv_files |>
set_names(~ tools::file_path_sans_ext(basename(.x))) |>
map(\(f) read_csv(f, col_types = cols(.default = col_character()),
show_col_types = FALSE)) |>
list_rbind(names_to = "file_code") |>
# ── Bring in site metadata from lookup table ────────────────────────────
left_join(site_lookup, by = "file_code") |>
type_convert()
If the column you want is not in the file name but can be computed from the path, you can add it inside the reading function before the files are stacked:
# Example: add a column recording the file's modification date
read_with_meta <- function(filepath) {
read_csv(filepath,
col_types = cols(.default = col_character()),
show_col_types = FALSE) |>
mutate(
source_file = basename(filepath),
file_mod_date = file.mtime(filepath)
)
}
all_data <- csv_files |>
map(read_with_meta) |>
list_rbind() |>
type_convert()
Here is the full recommended workflow for a realistic environmental
dataset where files are named SITE_YYYY-MM.csv:
library(tidyverse) # includes purrr, dplyr, stringr, readr
# 1. Find files
csv_files <- list.files(
"data/sensors/",
pattern = "\\.csv$",
full.names = TRUE
)
# 2. Verify names are consistent across all files
stopifnot(
length(unique(
map(csv_files, \(f) names(read_csv(f, n_max = 1, show_col_types = FALSE)))
)) == 1
)
# 3. Read, stack, extract filename metadata, convert types
all_data <- csv_files |>
set_names(~ tools::file_path_sans_ext(basename(.x))) |>
map(\(f) read_csv(f, col_types = cols(.default = col_character()),
show_col_types = FALSE)) |>
list_rbind(names_to = "source_file") |>
mutate(
site = str_extract(source_file, "^[A-Za-z]+"),
year = as.integer(str_extract(source_file, "\\d{4}")),
month = as.integer(str_extract(source_file, "(?<=-)\\d{2}$"))
) |>
type_convert() |>
select(-source_file) # drop once parsed, or keep for provenance
# 4. Sanity checks
glimpse(all_data)
n_distinct(all_data$site) # how many sites?
range(all_data$date) # plausible date range?
sum(is.na(all_data$pm25_ug_m3)) # NAs in key column
💡 Decide whether to keep source_file
Keeping a source_file column in your final data frame gives
you a permanent record of data provenance — you can always trace any row
back to the raw file that produced it. This is especially valuable in
collaborative projects or when you will need to re-import after the
original files are updated. If the column is redundant (the site and
date already identify the source unambiguously), you can drop it with
select(-source_file).
⚠️ File naming conventions matter — establish them before
data collection The techniques above only work when file names
are machine-readable: consistent delimiters, no spaces, no special
characters, predictable field order. If your collaborators have been
naming files Site A data March final REVISED.csv, you will
spend more time wrangling names than reading data. Establish a naming
convention (SITE_YYYY-MM.csv) before you begin collecting
data.
✏️ Try It — extract site metadata from file names
Create three fake CSV files with the same columns but different names
following a SITE_YYYY-MM.csv pattern (e.g.,
Oak_2024-01.csv, Oak_2024-02.csv,
Pine_2024-01.csv). Each file can have just 3–5 rows of
made-up data. Then:
list.files() to find them.count(all_data, site, yearmonth) to verify that
each site × month combination appears the expected number of times.Excel is the most common format for shared field data, but it is also the most treacherous. Excel silently “helps” with dates, auto-formats numbers, stores values differently than they look, and hides information in cell formatting rather than cell content.
library(readxl)
# Step 1: list sheets before committing to one
excel_sheets("data/field_data.xlsx")
Expected output [1] “Site_A” “Site_B” “Methods” “README”
# Step 2: read a specific sheet by name (more robust than by position)
df <- read_excel("data/field_data.xlsx", sheet = "Site_A")
# Step 3: inspect immediately
glimpse(df)
problems(df)
When the data does not start at cell A1 — common with lab-formatted spreadsheets that have a title block at the top:
# Specify an Excel range directly (in Excel A1 notation)
df <- read_excel("data/lab_results.xlsx",
sheet = "Results",
range = "B4:H120")
# Or skip rows and limit the number of data rows
df <- read_excel("data/lab_results.xlsx",
skip = 3,
n_max = 200,
col_names = TRUE)
readxl guesses types from the first few rows — if row 5
has "< LOD" in a numeric column, the whole column
becomes chr:
# Provide one string per column in left-to-right order
df <- read_excel(
"data/water_quality.xlsx",
sheet = "Data",
col_types = c(
"text", # sample_id
"date", # collection_date ← readxl uses "date" not col_date()
"numeric", # nitrate_mg_l
"numeric", # phosphorus_ug_l
"text", # flag
"skip" # internal_notes — skip entirely
)
)
⚠️ “date” in readxl ≠ col_date() in readr
readxl’s col_types uses plain strings:
“date”, “numeric”, “text”,
“skip”, “logical”. It does not use
readr’s cols() syntax. These two packages have completely
different type-specification interfaces.
Excel stores dates as the number of days since 1900-01-00. When a
date column is read as numeric, you get values like 45290
instead of 2024-01-15:
# If you get mysterious 5-digit numbers in a date column:
df <- read_excel("data/old_data.xls") |>
mutate(date_fixed = as.Date(date_col, origin = "1899-12-30"))
# Excel's epoch is 1899-12-30 due to a deliberate Lotus 1-2-3 compatibility
# bug that Microsoft kept for 40 years.
# See the conversion in action
excel_serial <- c(45292, 45293, 45294)
as.Date(excel_serial, origin = "1899-12-30")
## [1] "2024-01-01" "2024-01-02" "2024-01-03"
🗓️ Excel Serial Date Nightmare You open a
spreadsheet and the date column looks fine — “Jan 15, 2024” — but in R
you get 45292. This happens when someone saved as
.xls or formatted cells as “Number” before entering dates.
Fix with as.Date(x, origin = “1899-12-30”). Always check
with class(df$date_col) right after import.
# Strategy 1: skip the merged-header rows, supply names manually
df <- read_excel(
"data/merged_headers.xlsx",
skip = 2,
col_names = c("site", "date", "temp_mean", "temp_sd",
"ph_mean", "ph_sd", "do_mean", "do_sd")
)
# Strategy 2: read raw, then rename with janitor::clean_names()
df_raw <- read_excel("data/merged_headers.xlsx", col_names = FALSE)
# inspect: print(df_raw[1:3, ])
# then supply names manually based on what you see
Sensors, dataloggers, and field instruments produce files with problems that CSVs from databases rarely have: non-standard delimiters, multi-line headers, mixed data types within columns, and files designed for proprietary software.
# Tab-separated (common from GIS software and some instruments)
df <- read_tsv("data/gps_tracks.txt")
# Pipe-delimited
df <- read_delim("data/licor_6800.txt", delim = "|")
# Semicolon-delimited with European decimal comma
df <- read_csv2("data/hobo_logger.csv") # ; sep, , decimal
# Space-separated with multiple spaces (fixed-width style)
df <- read_table("data/met_station.dat",
col_names = c("year","doy","time","temp","rh","ws","wd"))
Dataloggers often prepend metadata before the data rows. Use
read_lines() to inspect first, then read with the right
skip = value:
# Read the first 15 lines as raw text to understand the structure
read_lines("data/campbell_cr1000.dat", n_max = 15)
# Campbell Scientific CR1000: 4-line header (station name, field names,
# units, processing type); data starts on row 5
df <- read_csv(
"data/campbell_cr1000.dat",
skip = 4,
col_names = c("timestamp", "record_num", "temp_C", "rh_pct",
"precip_mm", "windspeed_ms", "winddir_deg",
"batt_v", "panel_temp"),
na = c("", "NA", "NAN", "7999", "-7999")
)
# HOBO Pendant / Onset loggers: 1-2 line header + units row
df <- read_csv("data/hobo_pendant.csv", skip = 1) |>
janitor::clean_names() # fixes "#", spaces, "()" in auto-generated names
A common field data problem: a column that should be numeric contains
occasional text flags ("< LOD", "offline",
"> range"):
# Simulate the problem
mixed <- tibble::tibble(
site = c("A", "A", "A", "A", "A"),
no3_mgl = c("1.2", "0.8", "< LOD", "1.5", "offline")
)
# Wrong approach: as.numeric() silently coerces to NA without preserving why
mixed |> dplyr::mutate(no3_num = as.numeric(no3_mgl))
## # A tibble: 5 × 3
## site no3_mgl no3_num
## <chr> <chr> <dbl>
## 1 A 1.2 1.2
## 2 A 0.8 0.8
## 3 A < LOD NA
## 4 A 1.5 1.5
## 5 A offline NA
# Better approach: extract the flag, then convert
mixed |>
dplyr::mutate(
flag = dplyr::case_when(
stringr::str_detect(no3_mgl, "^[<>]") ~ "below/above detection",
no3_mgl == "offline" ~ "instrument offline",
TRUE ~ NA_character_
),
no3_num = suppressWarnings(as.numeric(no3_mgl))
)
## # A tibble: 5 × 4
## site no3_mgl flag no3_num
## <chr> <chr> <chr> <dbl>
## 1 A 1.2 <NA> 1.2
## 2 A 0.8 <NA> 0.8
## 3 A < LOD below/above detection NA
## 4 A 1.5 <NA> 1.5
## 5 A offline instrument offline NA
🔬 Never silently drop flags When you coerce a
flagged string to NA, you lose the reason for the missing
value. Always create a companion flag or
quality_code column that preserves the original string
before converting. This matters enormously for environmental data with
detection limits — an analysis that treats “below detection” and
“instrument offline” as the same kind of missing data is scientifically
wrong.
Some instruments export a trailing comma on every row, creating a phantom extra column in R:
# Symptom: last column is named "...8" or similar and is all NA
df <- read_csv("data/sensor_with_trailing_comma.csv")
names(df) # look for "...N" column names
# Fix: drop any column whose name starts with "..."
df <- df |>
dplyr::select(!dplyr::starts_with("..."))
Character encoding determines how bytes map to text characters. In
practice you encounter this problem when a file contains accented
characters (é, ü, ñ), em-dashes (—), degree symbols (°), or any
non-ASCII character, and R displays garbled output like
<U+00E9> or é instead.
| Encoding | Where you find it | R argument |
|---|---|---|
| UTF-8 | Modern files, Mac, Linux, web, tidy data | locale = locale(encoding = "UTF-8") (default) |
| Latin-1 / ISO-8859-1 | Older Windows files, some European instruments | locale = locale(encoding = "latin1") |
| Windows-1252 / CP1252 | Excel exports on Windows, USGS/EPA downloads | locale = locale(encoding = "windows-1252") |
# Option 1: readr's built-in guesser (works most of the time)
readr::guess_encoding("data/field_notes.csv")
Expected output # A tibble: 3 × 2 encoding confidence chr dbl 1 UTF-8 0.80 2 windows-1252 0.51 3 ISO-8859-1 0.49
# Option 2: the stringi package (more thorough)
stringi::stri_enc_detect(readr::read_file_raw("data/field_notes.csv"))
# For a Latin-1 file (common from older Windows programs)
df <- read_csv(
"data/legacy_data.csv",
locale = locale(encoding = "latin1")
)
# For Windows-1252 (EPA AQS downloads, some USGS exports)
df <- read_csv(
"data/epa_aqs_download.csv",
locale = locale(encoding = "windows-1252")
)
Some Windows applications prepend a Byte Order Mark (BOM) — three
invisible bytes — to UTF-8 files. R may read the first column name as
"site_id" or "\xef\xbb\xbfsite_id":
# readr handles this automatically with UTF-8-BOM:
df <- read_csv("data/bom_file.csv",
locale = locale(encoding = "UTF-8-BOM"))
# Or strip the BOM after the fact:
names(df)[1] <- stringr::str_remove(names(df)[1], "^\xef\xbb\xbf")
💀 The BOM Nightmare in Practice A collaborator
sends you a CSV from their Windows machine. The first column is called
Site ID. You import it and can’t figure out why
df$“Site ID” returns NULL — the column is
actually named “ID”. Three invisible bytes at the start of
the file. Always run names(df) immediately after import and
look carefully at the first column name.
Dates and times are R’s most common source of import pain. The root cause is that there is no universal standard — every instrument, operating system, and country uses a different format.
😱 The Core Problem The string
“01/02/03” could mean January 2, 2003 (US), February 1,
2003 (European), February 3, 2001 (ISO year-last), or 1 February 2003 in
some logger formats. R cannot know which — and neither can you without
reading the instrument manual. There is no safe default.
lubridate provides functions named after the
order of the components:
library(lubridate)
ymd("2024-03-15") # ISO 8601 — always prefer this format
## [1] "2024-03-15"
mdy("03/15/2024") # US format
## [1] "2024-03-15"
dmy("15/03/2024") # European format
## [1] "2024-03-15"
mdy("March 15, 2024") # Long month name
## [1] "2024-03-15"
ymd_hms("2024-03-15 14:32:00") # datetime with seconds
## [1] "2024-03-15 14:32:00 UTC"
mdy_hm("03/15/2024 14:32") # datetime, no seconds
## [1] "2024-03-15 14:32:00 UTC"
# Works on vectors — safe to use inside mutate()
# Returns NA for unparseable rows (with a warning) — does NOT crash
dates <- c("2024-01-15", "2024-02-20", "2024-03-05", "not-a-date")
ymd(dates)
## [1] "2024-01-15" "2024-02-20" "2024-03-05" NA
💡 lubridate is forgiving; base R strptime() is not
ymd() tries multiple separators automatically.
as.Date(“15/03/2024”, format = “%d/%m/%Y”) requires an
exact format string and returns NA silently if you get it
wrong. Use lubridate in scripts.
When you must use format = inside
read_csv() col_date():
| Code | Meaning | Example |
|---|---|---|
%Y |
4-digit year | 2024 |
%y |
2-digit year | 24 (→ 2024; but 69 → 1969!) |
%m |
Month as number (01–12) | 03 |
%B |
Full month name (locale!) | March |
%b |
Abbreviated month name | Mar |
%d |
Day of month (01–31) | 15 |
%H |
Hour, 24-h (00–23) | 14 |
%I |
Hour, 12-h (01–12) | 02 |
%M |
Minute (00–59) | 32 |
%S |
Second (00–59) | 00 |
%p |
AM/PM | PM |
%z |
UTC offset | +0700, -0500 |
%j |
Day of year (001–366) | 075 |
as.Date("15/03/2024", format = "%d/%m/%Y")
## [1] "2024-03-15"
as.Date("Mar 15, 2024", format = "%b %d, %Y")
## [1] "2024-03-15"
as.Date("20240315", format = "%Y%m%d")
## [1] "2024-03-15"
lubridate::ymd_hms("2024-03-15 14:32:00", tz = "America/Los_Angeles")
## [1] "2024-03-15 14:32:00 PDT"
t_utc <- ymd_hms("2024-03-15 22:00:00", tz = "UTC")
t_pst <- with_tz(t_utc, "America/Los_Angeles")
t_est <- with_tz(t_utc, "America/New_York")
tibble::tibble(
label = c("UTC", "Los Angeles (PST)", "New York (EST)"),
datetime = c(format(t_utc, "%Y-%m-%d %H:%M:%S %Z"),
format(t_pst, "%Y-%m-%d %H:%M:%S %Z"),
format(t_est, "%Y-%m-%d %H:%M:%S %Z"))
)
## # A tibble: 3 × 2
## label datetime
## <chr> <chr>
## 1 UTC 2024-03-15 22:00:00 UTC
## 2 Los Angeles (PST) 2024-03-15 15:00:00 PDT
## 3 New York (EST) 2024-03-15 18:00:00 EDT
# CRITICAL DISTINCTION:
# force_tz() — "this timestamp WAS recorded in timezone X" (no time shift)
# with_tz() — "show me this UTC time in timezone X" (display shift only)
raw_logger_time <- ymd_hms("2024-03-15 14:32:00") # no tz → UTC assumed
correct <- force_tz(raw_logger_time, "America/Los_Angeles") # correct
wrong <- with_tz(raw_logger_time, "America/Los_Angeles") # WRONG: shifts time
tibble::tibble(
approach = c("raw (no tz declared)", "force_tz — correct", "with_tz — WRONG"),
time_shown = c(format(raw_logger_time, "%H:%M %Z"),
format(correct, "%H:%M %Z"),
format(wrong, "%H:%M %Z"))
)
## # A tibble: 3 × 2
## approach time_shown
## <chr> <chr>
## 1 raw (no tz declared) 14:32 UTC
## 2 force_tz — correct 14:32 PDT
## 3 with_tz — WRONG 07:32 PDT
⏰ The force_tz vs with_tz Nightmare Your sensor
logged data in Pacific time but didn’t record a timezone. You use
with_tz(datetime, “America/Los_Angeles”) thinking you’re
“setting” the timezone — but with_tz() converts the time,
not labels it. A timestamp that read 14:32 now reads 06:32. Your diurnal
analysis shows peak PM2.5 at 6 AM instead of 2 PM. Use
force_tz() when timestamps are already in local time and
you just need to declare that fact.
mixed_dates <- c(
"2024-01-15", # ISO
"01/20/2024", # US m/d/Y
"25-Jan-2024", # d-Mon-Y
"February 3 2024", # long month
"2024.02.10" # dot separator
)
parse_date_time(mixed_dates,
orders = c("Ymd", "mdY", "dBY", "BdY", "Y.m.d"),
quiet = TRUE)
## [1] "2024-01-15 UTC" "2024-01-20 UTC" "2024-01-25 UTC" "2024-02-03 UTC"
## [5] "2024-02-10 UTC"
📅 parse_date_time() is your multi-format safety net
When you receive hand-entered field data with inconsistent date
formatting, parse_date_time() with a vector of
orders tries each format in sequence. It is more forgiving
than any single-format parser.
No matter what went wrong, the same five-step diagnostic sequence will locate the problem:
# ── Step 1: Inspect the raw file before reading ───────────────────────────
read_lines("data/mystery_file.csv", n_max = 10)
readr::guess_encoding("data/mystery_file.csv")
# ── Step 2: Read with maximum verbosity ──────────────────────────────────
df <- read_csv("data/mystery_file.csv") # prints col-type message
spec(df) # full column spec
problems(df) # parsing failures by row/col
# ── Step 3: Check structure ───────────────────────────────────────────────
glimpse(df) # type + first values for every column
summary(df) # range, NAs, class
sapply(df, class) # type of every column in a named vector
# ── Step 4: Check column names ────────────────────────────────────────────
names(df) # look for BOM, spaces, special characters
nchar(names(df)) # unexpected length → invisible characters
# ── Step 5: Check key columns individually ───────────────────────────────
unique(df$date_col) # is it actually dates? or "01/02/2024"?
range(df$date_col, na.rm=T) # plausible range?
sum(is.na(df$pm25)) # how many NAs?
table(df$flag) # what flags exist?
# Run these after every import, before any analysis
stopifnot(nrow(df) > 0) # not empty
stopifnot(!all(is.na(df$pm25_ug_m3))) # key column not all NA
stopifnot(inherits(df$datetime, c("POSIXct","Date"))) # datetime parsed
range(df$date) # plausible date range?
nrow(df) # row count
n_distinct(df$sensor) # how many sensors?
sum(is.na(df$pm25_ug_m3)) # NAs in key column
Use this every time you receive a new data file.
✅ Before you import
Open the file in a plain-text editor (not Excel) and check: (1) What delimiter is actually used? (2) How many header rows are there? (3) What does the file use for missing values? (4) Are there any non-ASCII characters in the column names? (5) What is the date format in date columns? (6) Are there multiple files that should be stacked — and do their names encode metadata you need to preserve?
✅ Immediately after import
Run glimpse(df), problems(df),
names(df), summary(df). Check that every
column has the type you expect. Check that date columns parsed as dates,
not characters. Check that numeric columns aren’t character because of a
flag string. Count NAs in every key column.
⚠️ When reading multiple files
col_types = cols(.default = col_character()) on the
first pass, then type_convert() after binding.source_file column with names_to =
in list_rbind() before the source information is lost.⚠️ Datetime import checklist
force_tz()?range(df$datetime) — does the range make physical
sense?read_lines()
to answer the five structural questions (delimiter, header rows, NA
strings, column names, date format) before writing a single
read_csv() call.
read_csv() over
read.csv(). It never converts strings to factors,
prints a column-type spec, and reports parsing failures via
problems() instead of silently coercing to NA.
problems(df) right after
import. Parsing failures are easy to miss — they show up as NA
in the data but readr recorded exactly which row and column failed and
why.
set_names() +
list_rbind(names_to = “source_file”) so you can trace every
row back to its origin. Parse file name components into columns before
analysis.
SITE_YYYY-MM.csv convention so
str_extract() can recover site, year, and month without
ambiguity.
na = explicitly for field data.
Instruments use “–”, “-9999”,
“N/A”, “< LOD”. If you don’t list them,
they land as character strings.
readxl::read_excel() with explicit
col_types. Watch for the serial-date trap: 5-digit
numbers where dates should be.
force_tz() to label; with_tz() to
convert. If your sensor logged in local time without encoding
the timezone, use force_tz() to declare what timezone it
was — not with_tz(), which will shift all your times.
purrr::map() more deeply,
write your own functions, and automate repetitive import pipelinesdplyr and tidyrggplot2