Data Import

Marc Los Huertos

EA030 · Compiled 2026-04-27

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.


1. The Import Landscape

Why we have two generations of import tools

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.

The import toolkit at a glance

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().

Quick-reference: the tidyverse import toolkit

read_csv()
readr
The workhorse. Reads UTF-8 comma-separated files. Prints a column-type spec you can copy-paste to lock in types.
read_excel()
readxl
Reads .xlsx and .xls. Use sheet=, range=, col_types=, skip=.
read_delim()
readr
Any single-character delimiter. Pass delim = “|” for pipe-delimited instrument exports.
problems()
readr
After read_csv(), call problems(df) to see every row/column where parsing failed and why.
spec()
readr
Shows the column-type specification readr guessed. Copy it into col_types = cols(…) to make it permanent.
clean_names()
janitor
Converts all column names to snake_case. Fixes spaces, special characters, and duplicate names.

2. Reading CSV Files

Before you read: look at the raw file

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:

  1. What delimiter is used? (comma, semicolon, tab, pipe?)
  2. How many rows of metadata or comments appear before the column names?
  3. What string does the file use for missing values? (NA, -9999, “N/A”, blank, something else?)
  4. Are the column names in the first row, or is there a units row below them?
  5. What format are any date or time columns in?
# 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.

The basics of 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.

Finding your file path with 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
  1. Run my_path <- file.choose() in the Console. Navigate to your file and click Open.
  2. Type my_path in the Console and press Enter. R prints the full path.
  3. Copy that path. In your Rmd chunk, paste it as a hard-coded string.
  4. Use the object name (or the string directly) in every subsequent 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?

Locking in column types

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 =.

Declaring NA strings

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.

Skipping junk rows

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

  1. Find any CSV file on your computer (or download a dataset from the EPA AQS or USGS NWIS website).
  2. Run read_lines(“your_file.csv”, n_max = 8) and answer the five inspection questions from the start of this section.
  3. Write a read_csv() call that uses skip = if needed and declares the correct na = strings.
  4. Run glimpse(df) and problems(df). Do all column types match what you expect?

3. Reading Many CSV Files at Once

Why this matters in environmental work

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.

Step-by-step: reading a folder of CSV files

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.

Checking for column-name consistency

# 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.


4. Using the File Name as a Data Column

The problem: where did each row come from?

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.

Method 1: add a source_file column, parse it later

The 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

Method 2: parse file name components directly into columns

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

Method 3: inject metadata from a lookup table

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()

Method 4: inject metadata inside the reading function

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()

Putting it all together: a complete pattern

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:

  1. Use list.files() to find them.
  2. Use Method 2 above to read them all and extract the site name and year-month as columns.
  3. Run count(all_data, site, yearmonth) to verify that each site × month combination appears the expected number of times.

5. Reading Excel Files

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.

Basic usage

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)

Reading a specific range

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)

Forcing column types

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.

The Excel serial-date trap

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.

Merged cells and multiline headers

# 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

6. Field and Instrument Data

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.

Non-standard delimiters

# 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"))

Instrument files with metadata headers

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

Mixed data in one column

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.

Trailing commas and blank columns

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("..."))

7. Encoding Issues

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.

The three encodings you will encounter

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")

Detecting encoding

# 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"))

Specifying encoding on import

# 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")
)

The UTF-8 BOM problem

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.


8. Datetime Importing Nightmares

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.

Why datetimes are hard

😱 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.

The lubridate parsing approach

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.

The strptime / format-string reference

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"

Timezone nightmares

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.

Multiple datetime formats in one column

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.


9. Diagnosing Import Problems

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?

Post-import sanity checks as a standard habit

# 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

10. Import Checklist

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

  1. Verify column names are identical across all files before stacking.
  2. Use col_types = cols(.default = col_character()) on the first pass, then type_convert() after binding.
  3. Add a source_file column with names_to = in list_rbind() before the source information is lost.
  4. Extract any metadata encoded in file names immediately — before you start analysis.

⚠️ Datetime import checklist

  1. What timezone did the sensor record in?
  2. Is the timezone encoded in the file, or do you need force_tz()?
  3. Is the year 2- or 4-digit?
  4. Are dates US format or European format?
  5. Did the data span a DST transition?
  6. If Excel: are dates actually serial numbers? Run range(df$datetime) — does the range make physical sense?

🔑 Data Import — key takeaways


What’s next?