Tuesday, June 9, 2026
Today: Tuesday June 9
Before class
Plan for today
read_csv()Helpful links
Every dataset we’ve used this semester was already in R: penguins, flights, diamonds. That won’t be true in real work.
Real data lives in files, including CSV spreadsheets, Excel workbooks, tab-delimited exports from survey software, files someone emails you.
Today’s goal: get a file off disk (or the web) and into a tidy data frame that we can easily work with.
Note
readr is part of the tidyverse, so library(tidyverse) is all you need for CSV and most text files.
The most common file type you’ll encounter is CSV (comma-separated values), which can be read in usingread_csv():
We can also read in CSV files directly from a URL, without having to download anything:
Rows: 6 Columns: 5
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (4): Full Name, favourite.food, mealPlan, AGE
dbl (1): Student ID
ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
After reading in the CSV, readr prints a column specification message that tells you how many rows and columns it found, and what type it guessed for each column.
Rows: 6 Columns: 5
── Column specification ─────────────────
Delimiter: ","
chr (4): Full Name, favourite.food, mealPlan, AGE
dbl (1): Student ID
chr — character stringdbl — double (numeric)lgl — logical (TRUE/FALSE)dttm / date — date-time / datereadr samples up to 1,000 rows and works through a set of rules to guess each column’s type. It’s usually right, but not always. The rest of today is largely about what to do when it’s not.
# A tibble: 6 × 5
`Student ID` `Full Name` favourite.food mealPlan AGE
<dbl> <chr> <chr> <chr> <chr>
1 1 Sunil Huffmann Strawberry yoghurt Lunch only 4
2 2 Barclay Lynn French fries Lunch only 5
3 3 Jayendra Lyne N/A Breakfast and lunch 7
4 4 Leon Rossini Anchovies Lunch only <NA>
5 5 Chidiegwu Dunkel Pizza Breakfast and lunch five
6 6 Güvenç Attila Ice cream Lunch only 6
A few things to fix:
favourite.food has "N/A" as a string, not a real NAStudent ID and Full Name have spaces, which are awkward to typeAGE is a character column even though it’s mostly numbers ("five" snuck in)mealPlan is categorical and should be a factorBy default, readr only treats empty cells as NA. Strings like "N/A", ".", "missing", or "-" are left as-is.
Use the na argument to tell readr what else counts as missing:
# A tibble: 6 × 5
`Student ID` `Full Name` favourite.food mealPlan AGE
<dbl> <chr> <chr> <chr> <chr>
1 1 Sunil Huffmann Strawberry yoghurt Lunch only 4
2 2 Barclay Lynn French fries Lunch only 5
3 3 Jayendra Lyne <NA> Breakfast and lunch 7
4 4 Leon Rossini Anchovies Lunch only <NA>
5 5 Chidiegwu Dunkel Pizza Breakfast and lunch five
6 6 Güvenç Attila Ice cream Lunch only 6
favourite.food row 3 is now NA instead of the string "N/A".
Columns with spaces are non-syntactic, and you can’t type them without backticks:
Two fixes. Rename manually:
Or use janitor::clean_names() to convert everything to snake_case at once:
# A tibble: 6 × 5
student_id full_name favourite_food meal_plan age
<dbl> <chr> <chr> <chr> <chr>
1 1 Sunil Huffmann Strawberry yoghurt Lunch only 4
2 2 Barclay Lynn French fries Lunch only 5
3 3 Jayendra Lyne <NA> Breakfast and lunch 7
4 4 Leon Rossini Anchovies Lunch only <NA>
5 5 Chidiegwu Dunkel Pizza Breakfast and lunch five
6 6 Güvenç Attila Ice cream Lunch only 6
janitor isn’t part of the tidyverse; run install.packages("janitor") once.
mealPlan is categorical, but we want to make it a factor. age is numeric, so we need adjust the (incorrect) character string "five" to be numeric first, then parse:
# A tibble: 6 × 5
student_id full_name favourite_food meal_plan age
<dbl> <chr> <chr> <fct> <dbl>
1 1 Sunil Huffmann Strawberry yoghurt Lunch only 4
2 2 Barclay Lynn French fries Lunch only 5
3 3 Jayendra Lyne <NA> Breakfast and lunch 7
4 4 Leon Rossini Anchovies Lunch only NA
5 5 Chidiegwu Dunkel Pizza Breakfast and lunch 5
6 6 Güvenç Attila Ice cream Lunch only 6
if_else(test, yes, no): where age == "five", substitute "5"; otherwise leave it alone. Then parse_number() converts the string to a number.
Tip
parse_number() is also great for values like "$1,234" or "42%" since it strips non-numeric characters automatically.
For each column, readr samples up to 1,000 values and asks, in order:
TRUE, FALSE, T, F? \(\rightarrow\) logical2026-06-09)? \(\rightarrow\) date# A tibble: 3 × 4
logical numeric date string
<lgl> <dbl> <date> <chr>
1 TRUE 1 2021-01-15 abc
2 FALSE 4.5 2021-02-15 def
3 TRUE Inf 2021-02-16 ghi
This works well with clean data, but fails when unexpected values like "N/A" or "five" are present.
We can tell readr what type to expect with the col_types argument:
Available column types:
| Function | Type |
|---|---|
col_double() / col_integer() |
numbers |
col_character() |
always a string (good for IDs, phone numbers) |
col_logical() |
TRUE/FALSE |
col_factor() |
categorical |
col_date() / col_datetime() |
dates |
col_skip() |
drop this column entirely |
If you force a type and readr finds values that don’t fit, it warns you and stores the details:
# A tibble: 1 × 5
row col expected actual file
<int> <int> <chr> <chr> <chr>
1 3 1 a double . /private/var/folders/_4/htlmsm_n50vcj9k6v2766k_40…
Row 3 has "." where readr expected a number. Now that we have identified the culprit, we can fix it:
Tip
The workflow: read with forced types \(\rightarrow\) problems() to locate oddities \(\rightarrow\) decide whether they’re true missing values or something else.
Once you know read_csv(), the others follow the same pattern, but have a different delimiter:
| Function | Use when |
|---|---|
read_csv() |
comma-separated (.csv) |
read_csv2() |
semicolon-separated (common in Europe where , is the decimal) |
read_tsv() |
tab-delimited (.tsv, .txt) |
read_delim() |
any delimiter; guesses if you don’t specify |
read_fwf() |
fixed-width fields |
All of them accept a file path or a URL as the first argument.
Any read_* function accepts a URL. This is useful for:
To save a data frame to CSV:
The catch: column types are lost. When you read the file back, readr guesses types fresh and meal_plan goes back to character, not factor.
CSV is a plain-text format, so it stores values, not type metadata.
For saving intermediate results within R, use RDS, which is R’s native binary format. In this format, types are preserved exactly.
| Format | Readable outside R | Preserves types | File size |
|---|---|---|---|
| CSV | Yes | No | Medium |
| RDS | No | Yes | Small |
Use CSV to share data with others or for archiving. Use RDS when you’re saving an intermediate result for your own pipeline and care about types.
Sometimes you need to key in a small table by hand. You have two options:
tibble(): column by column:
# A tibble: 3 × 3
x y z
<dbl> <chr> <dbl>
1 1 h 0.08
2 2 m 0.83
3 5 g 0.6
tribble(): row by row (easier to read for small tables):
# A tibble: 3 × 3
x y z
<dbl> <chr> <dbl>
1 1 h 0.08
2 2 m 0.83
3 5 g 0.6
Column headers start with ~. This is what we used to define students and departments for the join activity yesterday.
Real data often stores numbers as strings, such as formatted currency, percentages, European decimals.
as.numeric(): works on clean numeric strings:
parse_number(): ignores non-numeric characters:
parse_number() is more forgiving and usually the right choice for messy real-world values.
Warning
as.numeric("nine") returns NA with a warning. If you have words, fix them first with if_else() or case_when() before parsing.
Survey exports and government data often store categories as integers. They will typically provide a codebook that tells you what the numbers mean.
Strategy: build a lookup table, then left_join():
Dates stored as strings need to be converted before R can do date arithmetic or plot them in order.
lubridate (part of the tidyverse, but needs library(lubridate)) provides functions named after the order of the components:
[1] "2026-06-09"
[1] "2026-06-09"
[1] "2026-06-09"
[1] "2026-06-09 09:35:00 UTC"
Once a date is a proper <date> object, arithmetic works as you would expect:
Note
Always check what order the components are in the original string. mdy() vs dmy() on the same string give different (wrong) dates.
R has a special type for categorical data: factors. They store categories efficiently and let you control the order of levels.
readr::read_csv() reads text as character by default.read.csv() (base R) converts text to factors by default — this causes problems.When to convert to factor:
Watch out: if you accidentally have a factor when you expect a string, as.numeric() gives the level number, not the value. Use as.character() first.
Work through day16-activity.R in RStudio.
What we covered
read_csv() and its key arguments: na, col_types, col_namesjanitor::clean_names(), parse_number(), factor()read_tsv(), read_delim(), read_csv2()write_csv() vs write_rds()tribble() for hand-keyed tablesleft_join()lubridateUpcoming
Reach out
Stuck? Office hours, or email me (cgc5478@psu.edu) or Xinyue (xpw5228@psu.edu).
Reference
Syllabus · AI policy · Schedule · R4DS Ch. 7 · readr reference