R (4.6) Module 7 – Importing and exporting data
Reading and writing CSV and text data
Read CSV and text files in R with read.csv(), readr and fread(): set column types, encodings, decimal commas and missing codes, list problems, write data out.
What you will learn
- Read delimited files with read.csv(), readr::read_csv() and data.table::fread(), and set their column types
- Handle encodings, byte-order marks, decimal commas and custom missing-value codes
- Diagnose parsing problems with readr's problems() and spec()
- Write data and R objects back out with write.csv(), write_csv(), saveRDS() and dput()
Before you start
What you’ll learn · Before you start
What you will learn
- Read delimited files with read.csv(), readr::read_csv() and data.table::fread(), and set their column types
- Handle encodings, byte-order marks, decimal commas and custom missing-value codes
- Diagnose parsing problems with readr's problems() and spec()
- Write data and R objects back out with write.csv(), write_csv(), saveRDS() and dput()
Before you start
On this page
A clinic’s billing system exports six visits as visits.csv, and R reads all five of its columns as text, the fees,
the weights and the dates, whichever of R’s three common readers you use. Nothing stops with an error: three invisible
bytes at the start, a fee written Rs. 3,450.50, a weight written - and a date that does not exist decide what you
get back.
On runs of R 4.6.1 with readr 2.2.0 and data.table 1.18.6.1, you will read that file with read.csv(),
readr::read_csv() and data.table::fread(), fix its encoding, decimal marks and missing-value codes, name the type
of every column and read the list of values that failed, then write the clean table out in a form that keeps what you
need.
Three readers return three kinds of table
visits.csv holds one visit per line: a patient id, the visit date, the ward, the fee and the weight, separated by
commas. A file like this is a delimited file: A text file with one record per line whose fields are separated by one character: a comma in a CSV, a semicolon in many European exports, a tab in a TSV., and CSV, comma-separated values, is the
commonest kind; RFC 4180 describes the format. Here is the file as a text editor shows it:
id,visit_date,ward,fee,weight_kg
P-101,03/04/2026,General,"1,250",61.5
P-102,05/04/2026,ICU,"12,400",-
P-103,07/04/2026,,450,NA
P-104,31/04/2026,General,"1,25,000",72
P-105,09/04/2026,Day care,"Rs. 3,450.50",58
P-106,12/04/2026,ICU,"2,300", You will meet three readers for it in other people’s code: read.csv() from base R, read_csv() from the readr
package and fread() from data.table. The run reads the file with each one and lists the type each gave each column:
from session import run, version
version()
# R reads three_readers.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f three_readers.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f three_readers.R
> # One clinic export, read by the three common readers.
> base_df <- read.csv("data/visits.csv")
> tidy_df <- readr::read_csv("data/visits.csv")
Rows: 6 Columns: 5
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (5): id, visit_date, ward, fee, weight_kg
ℹ 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.
> fast_df <- data.table::fread("data/visits.csv")
> class(base_df)
[1] "data.frame"
> class(tidy_df)
[1] "spec_tbl_df" "tbl_df" "tbl" "data.frame"
> class(fast_df)
[1] "data.table" "data.frame"
> # The type each reader gave each column:
> rbind(read.csv = sapply(base_df, class),
+ read_csv = sapply(tidy_df, class),
+ fread = sapply(fast_df, class))
id visit_date ward fee weight_kg
read.csv "character" "character" "character" "character" "character"
read_csv "character" "character" "character" "character" "character"
fread "character" "character" "character" "character" "character"
> # P-103 has no ward. What did each reader make of the blank?
> base_df$ward[3]
[1] ""
> tidy_df$ward[3]
[1] NA
> fast_df$ward[3]
[1] ""
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 three_readers_session.py
Each reader returned its own kind of table: a plain data frame: R's table: a list of columns of equal length, each with its own type, with the class data.frame and row names. read.csv() and most modelling functions work with data frames. More in “Data frames in base R”, a
tibble: A data frame from the tibble package, used by the tidyverse, with stricter rules: no partial matching with $, [ ] always returns a tibble, only values of length 1 are recycled, and there are no row names. More in “Data frames in base R” that also carries the specification readr printed, and a data.table. All three work
wherever R expects a data frame. They agree on the types too, and that is the problem: every column is text, fees,
weights and dates included. They disagree about the blank ward of P-103, which read.csv() and fread() keep as an
empty string, "", and read_csv() makes NA.
| Reader | Returns | Chooses types from | An empty text field |
|---|---|---|---|
read.csv() |
a data frame | every value, all read as text first | "" |
readr::read_csv() |
a tibble | 1,000 values, spread from first row to last (guess_max) |
NA |
data.table::fread() |
a data.table | 100 rows at each of 100 points | "" |
The help pages and readr’s guide to column types give the third column: read.table() reads every column as text
and then converts it, readr inspects guess_max values spread evenly from the first row to the last, and fread()
samples rows throughout the file and rereads any column that a row outside its sample shows to need a wider type. A
reader does the same five things to every line, and you can make each of those decisions yourself instead of leaving
it to the reader. Step through the line of P-104:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The bytes. The file starts with the three bytes ef bb bf, the UTF-8 byte-order mark, then the header id,visit_date,ward,fee,weight_kg. Every reader starts from these bytes.
- Decoding. The reader turns the bytes into text using an encoding, UTF-8 unless you name another one. In a UTF-8 locale read.csv() and readr drop the mark, so the first column is called id.
- Fields. The line of P-104 splits at its commas into five fields. The quotes keep 1,25,000 in one field, although it holds two commas.
- Missing values. A field that matches a missing-value code becomes NA. By default only "NA" counts, and for readr an empty field too, so the "-" that P-102 has for its weight stays text.
- Types. The reader picks one type for each column from the values it sees. "Rs. 3,450.50" is no plain number, and for read.csv() and fread() neither is any fee with a grouping comma, so the whole fee column stays text, 1,25,000 included.
- The table. All five columns of this file arrive as text from every reader. Name the types with col_types, and 1,25,000 becomes 125000 and every real visit date becomes a Date.
- What failed. With named types, readr converts what it can and lists the rest in problems(). For P-104 that is its visit date, day 31 of month 4, a day April does not have, which becomes NA.
The sections that follow take the stages in order: the encoding, the separator and decimal mark, the codes for missing values and the type of each column, then the way back out.
Checkpoint
Check: Three readers, three tables
The first bytes decide how the text is decoded
A file is bytes, and a character encoding: The rule that turns a file's bytes into characters. UTF-8 is the usual one; a file saved as UTF-16LE or Latin-1 reads as garbled text or fails unless its encoding is named when it is read. is the rule that turns them into characters. The run prints the first eight bytes of two exports of the same visits, then reads each:
from session import run, version
version()
# R reads bytes.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f bytes.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f bytes.R
> # The first bytes of two exports of the same visits.
> readBin("data/visits.csv", "raw", 8)
[1] ef bb bf 69 64 2c 76 69
> readBin("data/visits-unicode.txt", "raw", 8)
[1] ff fe 69 00 64 00 09 00
> # In a UTF-8 locale, read.csv() drops the UTF-8 mark ef bb bf.
> names(read.csv("data/visits.csv"))
[1] "id" "visit_date" "ward" "fee" "weight_kg"
> # The UTF-16 file, read as if it were UTF-8:
> try(read.delim("data/visits-unicode.txt"))
Error in make.names(col.names, unique = TRUE) :
invalid multibyte string at '<ff><fe>i'
In addition: Warning messages:
1: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 1 appears to contain embedded nulls
2: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 2 appears to contain embedded nulls
3: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 3 appears to contain embedded nulls
4: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 4 appears to contain embedded nulls
5: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 5 appears to contain embedded nulls
> # Read with the encoding it was written in:
> u <- read.delim("data/visits-unicode.txt", fileEncoding = "UTF-16LE")
> dim(u)
[1] 6 5
> names(u)
[1] "id" "visit_date" "ward" "fee" "weight_kg"
> # readr names the encoding in its locale; fread() refuses UTF-16:
> dim(readr::read_tsv("data/visits-unicode.txt", show_col_types = FALSE,
+ locale = readr::locale(encoding = "UTF-16LE")))
[1] 6 5
> try(data.table::fread("data/visits-unicode.txt"))
Error in data.table::fread("data/visits-unicode.txt") :
File is encoded in UTF-16, this encoding is not supported by fread(). Please recode the file to UTF-8.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bytes_session.py
visits.csv starts with ef bb bf, then 69 64, the letters i and d. The three bytes are a
byte-order mark: Bytes at the very start of a text file that announce its encoding: ef bb bf for UTF-8, ff fe for UTF-16 little-endian. R drops the UTF-8 one in a UTF-8 locale; other tools may keep it as part of the first name.: they announce UTF-8 and print as nothing. R discards them in a
UTF-8 locale, the usual setting on macOS and Linux and, since R 4.2.0, on recent Windows, so the first column is called
id. A tool that does not discard them makes them part of the first name, and the connection encoding "UTF-8-BOM",
which its help page says removes a byte order mark if present, is how older scripts guard against that.
visits-unicode.txt is UTF-16 little-endian, the encoding that R’s help on connections names for Windows “Unicode”
text files: two bytes for each ordinary character (69 00 is i) after the mark ff fe. Read as UTF-8 it
stops with “invalid multibyte string”, and the five warnings about “embedded nulls” give it away, because every second
byte is zero. With fileEncoding = "UTF-16LE" it reads into 6 rows and 5 columns. readr takes the same name as
locale = locale(encoding = "UTF-16LE"), while fread() refuses UTF-16 and asks you to recode the file to UTF-8, as
the last two lines of the run show.
Older tools also save one byte per character, in Latin-1:
from session import run, version
version()
# R reads latin1.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f latin1.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f latin1.R
> # Names saved in Latin-1, one byte per character.
> p <- read.csv("data/names-latin1.csv")
> p$name
[1] "M\xfcller" "Jos\xe9"
> validUTF8(p$name)
[1] FALSE FALSE
> try(nchar(p$name))
Error in nchar(p$name) : invalid multibyte string, element 1
> # fileEncoding = converts the file to UTF-8 as it is read.
> read.csv("data/names-latin1.csv", fileEncoding = "latin1")$name
[1] "Müller" "José"
> readr::read_csv("data/names-latin1.csv", show_col_types = FALSE,
+ locale = readr::locale(encoding = "latin1"))$name
[1] "Müller" "José"
> # encoding = only labels strings; it cannot turn UTF-16 into text.
> try(read.delim("data/visits-unicode.txt", encoding = "UTF-16LE"),
+ silent = TRUE)[1]
[1] "Error in make.names(col.names, unique = TRUE) : \n invalid multibyte string at '<ff><fe>i'\n"
Warning messages:
1: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 1 appears to contain embedded nulls
2: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 2 appears to contain embedded nulls
3: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 3 appears to contain embedded nulls
4: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 4 appears to contain embedded nulls
5: In read.table(file = file, header = header, sep = sep, quote = quote, :
line 5 appears to contain embedded nulls
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 latin1_session.py
Read as UTF-8, Müller comes back as "M\xfcller": the byte fc is not valid UTF-8, validUTF8() says so, and
nchar() stops with an error. fileEncoding = "latin1" converts the text as it is read, and so does readr’s locale.
The argument called encoding is another thing: its help page says it marks strings as Latin-1 or UTF-8 and is not
used to re-encode the input, so it cannot rescue a UTF-16 file, as the last line of the run shows.
encoding = where fileEncoding = is needed
read.delim(f, encoding = "UTF-16LE") fails exactly as if you had passed nothing. Give read.csv() and
read.delim() the argument fileEncoding = "UTF-16LE", give readr locale = locale(encoding = "UTF-16LE"), and
recode the file to UTF-8 before fread().
Some of these defaults are younger than the scripts you will inherit, which is why old code passes arguments it no longer needs:
- R 3.0.0
The UTF-8 mark is dropped reading
readLines() and scan(), and so read.table() and read.csv(), discard a UTF-8 byte-order mark in a UTF-8 locale. The encoding name "UTF-8-BOM" drops it in any locale.
- R 4.0.0
Text stays text new default
read.table() and data.frame() default to stringsAsFactors = FALSE, so read.csv() returns character columns instead of factors. Older scripts pass the argument by hand.
- R 4.2.0
UTF-8 on Windows Windows
R uses UTF-8 as the native encoding on recent Windows systems, so a UTF-8 file, with or without the mark, reads there as it does on macOS and Linux.
- readr 2.0.0
A new reading engine speed
readr's second edition reads and writes through the vroom package, which uses several threads. It read lazily at first, column values only when they were used.
- readr 2.1.0
Eager reading again new default
All readr functions read the whole file at once again by default, because lazy reading kept files locked on Windows. options(readr.read_lazy = TRUE) brings lazy reading back.
The timeline has 5 events, from R 3.0.0 to readr 2.1.0.
Checkpoint
Check: Encodings and byte-order marks
Semicolon files need a reader that expects decimal commas
Where a comma is the decimal mark, a CSV cannot also use commas between fields, so many European exports separate them
with semicolons; Microsoft’s guide to exporting text from Excel says its separator can be changed in the Windows
region settings. fees-eu.csv writes P-301’s weight as 61,5 and its fee as 1.250,00:
from session import run, version
version()
# R reads decimal_comma.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f decimal_comma.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f decimal_comma.R
> # Fees exported on a computer set to write decimal commas.
> readLines("data/fees-eu.csv")
[1] "id;weight_kg;fee" "P-301;61,5;1.250,00" "P-302;70,25;980,50"
[4] "P-303;58;12.400,00"
> try(read.csv("data/fees-eu.csv"))
Error in read.table(file = file, header = header, sep = sep, quote = quote, :
more columns than column names
> str(read.csv2("data/fees-eu.csv"))
'data.frame': 3 obs. of 3 variables:
$ id : chr "P-301" "P-302" "P-303"
$ weight_kg: num 61.5 70.2 58
$ fee : chr "1.250,00" "980,50" "12.400,00"
> readr::read_csv2("data/fees-eu.csv")
ℹ Using "','" as decimal and "'.'" as grouping mark. Use `read_delim()` for more control.
Rows: 3 Columns: 3
── Column specification ────────────────────────────────────────────────────────
Delimiter: ";"
chr (1): id
dbl (1): weight_kg
num (1): fee
ℹ 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.
# A tibble: 3 × 3
id weight_kg fee
<chr> <dbl> <dbl>
1 P-301 61.5 1250
2 P-302 70.2 980.
3 P-303 58 12400
> f <- data.table::fread("data/fees-eu.csv")
> sapply(f, class)
id weight_kg fee
"character" "numeric" "character"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 decimal_comma_session.py
read.csv() split the lines at their commas, found more fields than column names and stopped. read.csv2() expects
semicolons and decimal commas, the variant the R Data Import/Export manual calls appropriate where the comma is the
decimal point, so the weights are numbers, but 1.250,00 stays text: its dot is a grouping mark, and read.csv2() does not
remove one. readr’s read_csv2() reads with a locale: The regional settings a program reads and writes text with: the character encoding, the decimal mark and the grouping mark of numbers, and the names of months. readr's locale() sets them for one read. whose decimal mark is , and grouping
mark ., as its first message says, and returns both columns as numbers. fread() found the semicolon and the decimal
comma by itself and, like read.csv2(), kept the grouped fee as text.
| Reader | Separator | Decimal mark | Grouping mark . in 1.250,00 |
|---|---|---|---|
read.csv() |
, |
. |
the read stops |
read.csv2() |
; |
, |
kept, so the column is text |
readr::read_csv2() |
; |
, |
removed: 1250 |
data.table::fread() |
found by itself | found by itself | kept, so the column is text |
For any other mix, name the marks: readr::read_delim(f, delim = "\t", locale = locale(decimal_mark = ",")) reads
tabs with decimal commas, and a locale with the decimal comma takes . as its grouping mark unless you set
grouping_mark to something else.
Indian digit grouping
Indian exports group digits in lakhs and crores, so one lakh twenty-five thousand rupees is written 1,25,000.
readr’s number parser drops the grouping mark wherever it stands, so parse_number("1,25,000") is 125000, the
same as for 125,000, while base R’s as.numeric() gives NA for both. The fees section below reads them with
col_number().
Checkpoint
Check: Separators and decimal marks
Only the codes you list become NA
A missing value has many spellings in exported files: NA, an empty field, a dash, n/a, or a code such as -999,
a sentinel value: A stand-in that a file or a device writes for a missing measurement, such as -999. It must be turned into NA before any summary, or it is counted as a real value. More in “Indexing and subsetting vectors”. A reader turns into NA only the spellings it is told about. The clinic’s
export writes a missing weight in three ways, and the run reads it with the defaults, then with every code listed:
from session import run, version
version()
# R reads missing_codes.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f missing_codes.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f missing_codes.R
> # The export writes a missing weight as "NA", as "-" or as nothing.
> v <- read.csv("data/visits.csv")
> v$ward
[1] "General" "ICU" "" "General" "Day care" "ICU"
> v$weight_kg
[1] "61.5" "-" NA "72" "58" ""
> # List every code the file uses for "missing":
> v <- read.csv("data/visits.csv", na.strings = c("NA", "-", ""))
> v$ward
[1] "General" "ICU" NA "General" "Day care" "ICU"
> v$weight_kg
[1] 61.5 NA NA 72.0 58.0 NA
> r <- readr::read_csv("data/visits.csv", na = c("", "NA", "-"),
+ show_col_types = FALSE)
> r$weight_kg
[1] 61.5 NA NA 72.0 58.0 NA
> f <- data.table::fread("data/visits.csv", na.strings = c("", "NA", "-"))
> f$weight_kg
[1] 61.5 NA NA 72.0 58.0 NA
> # A blank in a column of numbers is NA for every reader:
> kg <- "id,kg\nP-1,61.5\nP-2,\nP-3,70"
> read.csv(text = kg)$kg
[1] 61.5 NA 70.0
> readr::read_csv(I(kg), show_col_types = FALSE)$kg
[1] 61.5 NA 70.0
> data.table::fread(text = kg)$kg
[1] 61.5 NA 70.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 missing_codes_session.py
With its defaults, read.csv() turned only the text NA into NA. The - stayed, and because one value of
weight_kg is not a number, the whole column stayed text, with P-106’s blank weight as "". Listing the three
spellings in na.strings made weight_kg numeric and the blank ward NA; readr’s na and fread’s na.strings gave
the same weights. The last three lines show the one case where the readers agree without help: a blank field in a
column of numbers is NA, which the help page of read.table() states for logical, integer, numeric and complex
fields.
List the codes in the read
na = c("", "NA", "-") in the call records which codes the file uses, where a reader of the script can see them.
Converting afterwards with as.numeric() also turns "-" into NA, but it turns every other unexpected value into
NA too, with one warning for all of them.
| The field holds | read.csv() |
read_csv() |
fread() |
|---|---|---|---|
NA |
NA |
NA |
NA |
| nothing, in a text column | "" |
NA |
"" |
| nothing, in a column of numbers | NA |
NA |
NA |
- |
"-", and the column is text |
"-" |
"-" |
Key idea
A reader knows only the missing-value codes you give it: any other code keeps its column as text, or, if it is a
number such as -999, is counted as a value.
Checkpoint
Check: Missing-value codes
Name the column types, then read problems()
A guess is fine for a first look and wrong for a script that runs again next month on a new export. With the dash
listed as missing, readr guessed weight_kg as numbers and left the other four columns as text. spec() prints that
guess as code, and the run then names every type itself:
from session import run, version
version()
# R reads types_problems.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f types_problems.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f types_problems.R
> library(readr)
> v <- read_csv("data/visits.csv", na = c("", "NA", "-"))
Rows: 6 Columns: 5
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (4): id, visit_date, ward, fee
dbl (1): weight_kg
ℹ 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.
> spec(v)
cols(
id = col_character(),
visit_date = col_character(),
ward = col_character(),
fee = col_character(),
weight_kg = col_double()
)
> # Say what each column is, then ask what failed.
> v <- read_csv("data/visits.csv", na = c("", "NA", "-"),
+ col_types = cols(
+ id = col_character(),
+ visit_date = col_date(format = "%d/%m/%Y"),
+ ward = col_factor(levels = c("General", "ICU", "Day care")),
+ fee = col_number(),
+ weight_kg = col_double()
+ ))
Warning message:
One or more parsing issues, call `problems()` on your data frame for details,
e.g.:
dat <- vroom(...)
problems(dat)
> problems(v)[, 1:4]
# A tibble: 2 × 4
row col expected actual
<int> <int> <chr> <chr>
1 5 2 date like %d/%m/%Y 31/04/2026
2 6 4 a number Rs. 3,450.50
> v
# A tibble: 6 × 5
id visit_date ward fee weight_kg
<chr> <date> <fct> <dbl> <dbl>
1 P-101 2026-04-03 General 1250 61.5
2 P-102 2026-04-05 ICU 12400 NA
3 P-103 2026-04-07 <NA> 450 NA
4 P-104 NA General 125000 72
5 P-105 2026-04-09 Day care NA 58
6 P-106 2026-04-12 ICU 2300 NA
> # A script that must not go on with failed values stops here:
> try(stop_for_problems(v))
Error : 2 parsing failures
> # read.csv() takes types too, but stops at the first value that fails:
> try(read.csv("data/visits.csv", colClasses = c(fee = "numeric")))
Error in scan(file = file, what = what, sep = sep, quote = quote, dec = dec, :
scan() expected 'a real', got '"1'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 types_problems_session.py
The column specification: The type a reader gives each column of a file. readr prints the one it guessed and spec() returns it; col_types sets it yourself, so the read no longer depends on a guess. that spec() printed is the code col_types takes,
so you can paste it and correct what the guess got wrong: col_date(format = "%d/%m/%Y") for the visit dates,
col_number() for the fees, which drops grouping marks and text around the number, and col_factor() with the three
wards in a fixed order. readr converted every field it could and warned about the rest. problems(v) lists each
parsing failure: A value that cannot be converted to its column's type, such as a day the month does not have in a date column. readr turns it into NA and records its row, column, expected type and text in problems().: its row, counted from the top of the file with the header as row 1,
its column, what readr expected and the text it found. Row 5 is P-104, whose date 31/04/2026 does not exist, and row
6 is P-105, whose fee Rs. 3,450.50 is not a plain number. Both became NA; every other NA in the table is a
missing-value code that the read listed.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Grouping marks. col_number() drops grouping marks wherever they stand, so the Indian grouping 1,25,000 becomes 125000, the same as 125,000 would.
- A word with a dot. "Rs. 3,450.50" fails: the dot after Rs stops col_number() from reading a number. The value becomes NA, and its row and column go into problems().
- A date that does not exist. col_date(format = "%d/%m/%Y") reads day, month and year, so P-101's date is the third of April. P-104's date names day 31 of month 4, a day April does not have, so it becomes NA and is listed too.
- A missing-value code. Because "-" is in the na argument, P-102's weight becomes NA before any type is chosen. It is not a parsing failure, so problems() does not list it.
- A category with fixed levels. col_factor() with levels General, ICU and Day care keeps the categories in that order, and a ward outside the list would become NA and a problem.
- Two failures, both listed. The read converted every other field. problems() names exactly the two values that failed, so no value fails without a record; the only other NA values are the missing codes you listed.
stop_for_problems(v) turns the list into an error, the line a script should run when it must not go on with holes in
its data. read.csv() also takes types, through colClasses, but it stops at the first fee it cannot read, without
saying which rows would have failed.
Rs. before a number
parse_number() reads the first number it finds in a text. Written Rs. 3,450.50, the fee fails and becomes NA
with a warning; written Rs.3,450.50, with no space, the dot after Rs starts a decimal and the result is a silent
0.345. Take the currency word off before you parse, as the next run does.
from session import run, version
version()
# R reads currency.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f currency.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f currency.R
> library(readr)
> fees <- c("1,25,000", "Rs. 3,450.50", "Rs.3,450.50", "Rs 3,450.50")
> parse_number(fees)
Warning: 1 parsing failure.
row col expected actual
2 -- a number Rs. 3,450.50
[1] 125000.000 NA 0.345 3450.500
attr(,"problems")
# A tibble: 1 × 4
row col expected actual
<int> <int> <chr> <chr>
1 2 NA a number Rs. 3,450.50
> # Take the currency word off first:
> parse_number(sub("^Rs\\.?\\s*", "", fees))
[1] 125000.0 3450.5 3450.5 3450.5
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 currency_session.py
Key idea
Name every column’s type in the read, then check problems(): a value that failed is NA, and the list says which
row, which column and what text.
Checkpoint
Check: Column types and problems()
CSV keeps the text; RDS keeps the object
Writing is reading in reverse, and it loses what text cannot hold. The run writes three visits, with a factor and a Date, to CSV and reads them back, then does the same with an RDS file:
from session import run, version
version()
# R reads roundtrip.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f roundtrip.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f roundtrip.R
> visits <- data.frame(
+ id = c("P-101", "P-102", "P-103"),
+ ward = factor(c("ICU", "General", "ICU"),
+ levels = c("General", "ICU", "Day care")),
+ visit_date = as.Date(c("2026-04-03", "2026-04-05", "2026-04-07")),
+ fee = c(1250, 12400, 450)
+ )
> dir.create("out")
> write.csv(visits, "out/visits.csv")
> readLines("out/visits.csv")
[1] "\"\",\"id\",\"ward\",\"visit_date\",\"fee\""
[2] "\"1\",\"P-101\",\"ICU\",2026-04-03,1250"
[3] "\"2\",\"P-102\",\"General\",2026-04-05,12400"
[4] "\"3\",\"P-103\",\"ICU\",2026-04-07,450"
> sapply(read.csv("out/visits.csv"), class)
X id ward visit_date fee
"integer" "character" "character" "character" "integer"
> write.csv(visits, "out/visits.csv", row.names = FALSE)
> readr::write_csv(visits, "out/visits-readr.csv")
> sapply(readr::read_csv("out/visits-readr.csv", show_col_types = FALSE), class)
id ward visit_date fee
"character" "character" "Date" "numeric"
> # RDS keeps the object itself:
> saveRDS(visits, "out/visits.rds")
> same <- readRDS("out/visits.rds")
> identical(same, visits)
[1] TRUE
> levels(same$ward)
[1] "General" "ICU" "Day care"
> # dput() writes the data as R code that rebuilds it:
> dput(visits[1:2, c("id", "ward")])
structure(list(id = c("P-101", "P-102"), ward = structure(2:1, levels = c("General",
"ICU", "Day care"), class = "factor")), row.names = 1:2, class = "data.frame")
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 roundtrip_session.py
write.csv() wrote the row names as a first column with an empty header, which came back as a column called X, so
pass row.names = FALSE. Back from text, the factor ward is character, the Date is character, and fee became an
integer because 1250 was written without decimals. readr’s write_csv() writes no row names, and read_csv()
recognised the dates, but the factor is text and its unused level, Day care, is gone. saveRDS() writes one R object
to an RDS file: A file that holds one R object exactly as it was, written by saveRDS() and read back by readRDS(): types, factor levels and attributes all survive., and readRDS() returned an identical() copy, levels and all. dput()
prints an object as R code, structure(list(...)), the way to paste a few rows into a question or a test.
save() keeps several objects in one .RData file, and load() brings them back under their saved names:
from session import run, version
version()
# R reads save_load.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f save_load.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f save_load.R
> fee <- 1250
> save(fee, file = "work.RData")
> fee <- 999 # a newer value, worked out later
> load("work.RData")
> fee # load() put the old value back, without a word
[1] 1250
> e <- new.env()
> load("work.RData", envir = e)
> ls(e)
[1] "fee"
> e$fee
[1] 1250
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 save_load_session.py
load() into your workspace
load("work.RData") put the old fee, 1250, over the newer 999 and printed nothing; its help page warns that it
replaces all existing objects with the same names. Load into an environment of its own,
load(f, envir = e <- new.env()), or write single objects with saveRDS() and read them into a name you choose.
Excel needs one thing more for text beyond English letters. write_excel_csv() starts the file with a UTF-8
byte-order mark, which, its help page says, tells Excel the file is UTF-8 encoded, and by default it puts every field
in quotes, where write_csv() quotes only the fields that need it:
from session import run, version
version()
# R reads excel_csv.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f excel_csv.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f excel_csv.R
> # Two Hindi names, written for R and for Excel.
> staff <- data.frame(name = c("अनीता", "रवि"), ward = c("ICU", "General"))
> readr::write_csv(staff, "staff.csv")
> readr::write_excel_csv(staff, "staff-excel.csv")
> readBin("staff.csv", "raw", 6)
[1] 6e 61 6d 65 2c 77
> readBin("staff-excel.csv", "raw", 6)
[1] ef bb bf 22 6e 61
> readr::read_csv("staff.csv", show_col_types = FALSE)$name
[1] "अनीता" "रवि"
> readr::read_csv("staff-excel.csv", show_col_types = FALSE)$name
[1] "अनीता" "रवि"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 excel_csv_session.py
The first six bytes show both differences: the mark ef bb bf, then 22, the quote before name. The Hindi names
read back the same in R from either file; the mark changes only how Excel decodes them. Which way out fits depends on
who reads the file next. Answer three questions about yours:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Function | Types come back | Opens outside R | Non-English text in Excel | Safe to read back |
|---|---|---|---|---|
| write.csv() Writes: a CSV; pass row.names = FALSE | Poor ward and visit_date came back as text, fee as integer | Good any program that reads CSV opens it | Mixed no mark tells Excel the file is UTF-8 | Good read.csv() builds a new data frame |
| readr::write_csv() Writes: a UTF-8 CSV with no row names | Mixed the Date came back; the factor came back as text | Good any program that reads CSV opens it | Mixed no mark tells Excel the file is UTF-8 | Good read_csv() builds a new tibble |
| readr::write_excel_csv() Writes: a UTF-8 mark, then every field in quotes | Mixed as write_csv(): dates come back, factors do not | Good a CSV, after three marker bytes | Good ef bb bf tells Excel the text is UTF-8 | Good read_csv() drops the mark again |
| saveRDS() Writes: one R object in a compressed file | Good identical() was TRUE, Day care level and all | Poor only R reads it | Poor Excel cannot open it | Good readRDS() returns it; you choose the name |
| save() Writes: several named objects in one file | Good every object comes back exactly | Poor only R reads it | Poor Excel cannot open it | Poor load() overwrote fee 999 with 1250, silently |
| dput() Writes: R code that rebuilds the object | Good levels and classes are written out as code | Poor R code, not a data file | Poor Excel cannot use it | Mixed it is code: read it before you run it |
How the answers decide
- “Will people or programs outside R open the file?” Yes, someone else opens it: rules out saveRDS(), save() and dput(). Only R reads RDS and RData files, and dput() writes R code. Other programs need a text file such as a CSV.
- “Will people or programs outside R open the file?” No, only R reads it back and “Must factors, their levels and dates come back exactly as they were?” Yes, exactly: rules out write.csv(), readr::write_csv() and readr::write_excel_csv(). A CSV stores text. In the run the factor came back as text and its unused level, Day care, was lost.
- “Will people or programs outside R open the file?” Yes, someone else opens it and “Must factors, their levels and dates come back exactly as they were?” Yes, exactly: adds a caveat to write.csv(), readr::write_csv() and readr::write_excel_csv(). A CSV cannot carry types, so send a short data dictionary with it that lists each column's type and levels.
- “Will someone open it in Excel with text beyond English letters, such as Hindi names?” Yes: rules out write.csv() and readr::write_csv(). Only write_excel_csv() starts the file with the UTF-8 mark that tells Excel how to read it.
- “Will people or programs outside R open the file?” No, only R reads it back: adds a caveat to save(). load() puts objects back under their saved names and replaces any of the same name. saveRDS() and readRDS() let you choose the name.
In short: a CSV for people and other programs, with a list of every column’s type and levels beside it;
write_excel_csv() when the file will be opened in Excel; RDS between R sessions, to keep the object exactly; dput()
for a few rows in a question; and load() only into an environment of its own.
Checkpoint
Check: Writing CSV, RDS and code
Practise: predict, then check
Ten lines poke at what this lesson showed. Note what you expect from each, and only then look at the run.
Before you look
Predict what read.csv() and read_csv() make of an empty ward, what read.csv() makes of a weight column holding
-, with and without na.strings = "-", what read.csv2() does with the fee 1.250,00, what
parse_number("Rs 1,25,000") returns, what write.csv() writes for a one-row data frame, and which levels a factor
has after saveRDS() and readRDS(). Then type the lines into your own R.
from session import run, version
version()
# R reads practice.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f practice.R
""") Output
# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f practice.R
> # Predict each result before you look.
> read.csv(text = "id,ward\n1,\n2,ICU")$ward
[1] "" "ICU"
> readr::read_csv(I("id,ward\n1,\n2,ICU"), show_col_types = FALSE)$ward
[1] NA "ICU"
> read.csv(text = "kg\n61.5\n-\n70")$kg
[1] "61.5" "-" "70"
> read.csv(text = "kg\n61.5\n-\n70", na.strings = "-")$kg
[1] 61.5 NA 70.0
> read.csv2(text = "kg;fee\n61,5;1.250,00")$fee
[1] "1.250,00"
> readr::parse_number("Rs 1,25,000")
[1] 125000
> write.csv(data.frame(kg = 61.5), stdout())
"","kg"
"1",61.5
> f <- tempfile(fileext = ".rds")
> saveRDS(factor("ICU", levels = c("General", "ICU")), f)
> levels(readRDS(f))
[1] "General" "ICU"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
read.csv() kept the empty ward as "" where read_csv() gave NA; the dash kept the weights as text until it was
listed; read.csv2() kept the grouped fee as text; parse_number() dropped Rs and both grouping commas; the
row-name column came first in the written CSV; and the RDS file kept both levels of the factor, the unused one
included.
Then work through the questions below. They start with the idea you have shown least so far, and each one explains its answer the moment you check it:
Practice that adapts to you
Practise this lesson
15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: a column of fees comes back from read.csv() as text. What usually causes it, and how do you find the values responsible?
One value that is not a plain number keeps the whole column as text, because read.csv() reads every column as text
and converts it only if every value converts. The usual culprits are a currency word (Rs. 3,450.50), grouping commas
(1,250) and a missing-value code the reader was not told about (-, n/a). To find them, convert a copy and look
at what failed: x[is.na(suppressWarnings(as.numeric(x))) & !is.na(x)] lists the values that are
present but not numbers. With readr, read with col_types = cols(fee = col_number()) and problems() gives each
failed value with its row. Then fix the cause in the read: list the missing codes in na.strings, and remove currency
words and grouping marks before converting, rather than letting them turn into silent NA values.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A nightly R script reads a supplier's CSV with read_csv() and sums the fee column. It has run for months; one morning the total is 40 % lower, and nothing failed. How do you find what changed, and how do you make the script stop next time instead of reporting a wrong total?
Key takeaways
read.csv(),readr::read_csv()anddata.table::fread()return a data frame, a tibble and a data.table, choose column types in different ways, and disagree about empty text fields.- The encoding decides how bytes become text: R drops a UTF-8 byte-order mark in a UTF-8 locale, a UTF-16LE file needs
fileEncoding = "UTF-16LE", andencoding =only labels strings. - Semicolon files with decimal commas need
read.csv2(),read_csv2()or alocale(), and only readr removes the grouping mark in a value such as1.250,00. - A reader makes
NAonly of the codes you list, so pass every code the file uses, such as"-"or"-999", inna.stringsorna. - Name each column’s type with
col_types, readproblems()for the values that failed, and stop a script withstop_for_problems()when it must not continue. - A CSV keeps text only: factors, levels and dates come back changed, so use
saveRDS()between R sessions,write_excel_csv()for Excel, andload()only into an environment of its own.
Terms in this lesson
- byte-order mark
- Bytes at the very start of a text file that announce its encoding: ef bb bf for UTF-8, ff fe for UTF-16 little-endian. R drops the UTF-8 one in a UTF-8 locale; other tools may keep it as part of the first name.
- character encoding
- The rule that turns a file's bytes into characters. UTF-8 is the usual one; a file saved as UTF-16LE or Latin-1 reads as garbled text or fails unless its encoding is named when it is read.
- column specification
- The type a reader gives each column of a file. readr prints the one it guessed and spec() returns it; col_types sets it yourself, so the read no longer depends on a guess.
- delimited file
- A text file with one record per line whose fields are separated by one character: a comma in a CSV, a semicolon in many European exports, a tab in a TSV.
- locale
- The regional settings a program reads and writes text with: the character encoding, the decimal mark and the grouping mark of numbers, and the names of months. readr's locale() sets them for one read.
- parsing failure
- A value that cannot be converted to its column's type, such as a day the month does not have in a date column. readr turns it into NA and records its row, column, expected type and text in problems().
- RDS file
- A file that holds one R object exactly as it was, written by saveRDS() and read back by readRDS(): types, factor levels and attributes all survive.
References
- Data Input (read.table, read.csv, read.csv2) (The R Core Team)
- Data Output (write.table, write.csv) (The R Core Team)
- Functions to Manipulate Connections: Encoding (The R Core Team)
- Serialization Interface for Single Objects (saveRDS, readRDS) (The R Core Team)
- Reload Saved Datasets (load) (The R Core Team)
- Write an Object to a File or Recreate It (dput) (The R Core Team)
- R Data Import/Export (The R Core Team)
- NEWS: changes in R 4.x (The R Core Team)
- NEWS: changes in R 3.x (The R Core Team)
- readr: Read a delimited file into a tibble (read_delim, read_csv, read_csv2) (readr authors (Posit))
- readr: Retrieve parsing problems (problems, stop_for_problems) (readr authors (Posit))
- readr: Column type guessing (column types guide) (readr authors (Posit))
- readr: Examine the column specifications (spec) (readr authors (Posit))
- readr: Create column specification (cols) (readr authors (Posit))
- readr: Parse numbers, flexibly (parse_number) (readr authors (Posit))
- readr: Create locales (locale) (readr authors (Posit))
- readr: Write a data frame to a delimited file (write_csv, write_excel_csv) (readr authors (Posit))
- readr changelog (readr authors (Posit))
- data.table: Fast and friendly file finagler (fread) (The data.table project)
- RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files (IETF)
- Import or export text (.txt or .csv) files (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress