Serialization: RDS, RData and JSON

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. The first half of this page is base R (the base, utils and methods functions that ship with R). The second half uses two add-on packages that were installed from CRAN and run: jsonlite 2.0.0 (JSON) and yaml 2.3.12 (YAML); the lintr 3.4.0 default linters were used only to check the scripts. Two more packages are named and not run: xml2 1.6.0 in one sentence with a link, and arrow, which was not installed for this page, as a pointer for Parquet and Feather. A running R session keeps its objects in memory and loses them when it ends. Serialization is the act of turning an object into a stream of bytes (or text) and back, so that it can be stored in a file, sent to another process or compared with a later version. R has several ways of doing it, and they differ in exactly the things that matter: whether the file holds one object or many, whether it keeps classes, attributes and environments, whether people can read it, whether other programs can read it, and how much code you are trusting when you read it back. This page walks through all of them, from saveRDS() to JSON, in the order a data analyst usually meets them.

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint: comment marks a line where the idiom on show is what a default linter dislikes, so the linter does not hide the lesson. The names books, loans, shelf_today, loan_days and the book() constructor come from the running library-shelf scenario described on the section index: a script that needs them starts with source("scenario.R"). Files that an example writes go to tempfile() and are never printed, because a temporary path changes on every run. The byte counts that appear below were measured with file.size() and length() on the same data every time; they are real, but they depend on the compression library built into R, so read them as indicative and not as promises. Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale.

This page belongs to the data import and export group. Reading and writing delimited text (read.csv(), write.csv()) is in Reading and Writing Text Data, the connection objects that files and compression are built on are in Connections and the File System, databases are in Databases with DBI, and downloading over HTTP is in HTTP and Web APIs. The data frames and dates that the examples save are the subject of Data Frames and Dates and Times; the .RData file that R writes when a session ends is covered from the session side in Sessions, Workspaces and Startup.

Three families of files

R Data Import/Export observes that the easiest data to import is a simple text file, and that when data arrive in a proprietary binary format the simplest course is often to export them as text from the program that made them (R Data Import/Export: Imports). Serialization is the other direction: R talking to R, and sometimes R talking to other programs. The formats fall into three families.

  • R’s native binary format. saveRDS(), save() and serialize() all write the same serialization format; they differ in what they write it to (a file, a connection, a raw vector) and in whether they save one object or a collection of named objects (the R Internals chapter on serialization formats says exactly this). It keeps everything: classes, attributes, NA, factor levels, dates, even functions and environments. Only R reads it.

  • R’s own text format. dput() writes R code that rebuilds an object, and dget() evaluates it. It is readable by people and pastable into a question, but it can fail on unusual objects and it loses environments.

  • Interchange formats. JSON, YAML, XML, CSV, Parquet. Other programs read them, so they are the right choice at a boundary, and the price is that R classes have to be mapped onto a smaller set of types and mapped back.

Function Writes Reads back Typical extension

saveRDS()

one object, binary, gzip by default

readRDS() returns it; you pick the name

.rds

save()

several named objects, binary, gzip by default

load() recreates them under the saved names, in an environment

.RData, .rda

save.image()

every object of the global environment

load(), or R itself at startup when the file is .RData in the working directory

.RData

serialize()

one object, to a raw vector or an open connection

unserialize()

none

dput()

R code as text

dget() (which evaluates the file)

.R, .txt

toJSON(), write_json() (jsonlite)

JSON text, a subset of R types

fromJSON(), read_json()

.json, .ndjson

as.yaml(), write_yaml() (yaml)

YAML text

yaml.load(), read_yaml()

.yml, .yaml

The rest of the page takes the families in turn. The rule of thumb for what to use is short: saveRDS() for one R object that only R will read, JSON or CSV for anything that leaves R, and save() and load() only when several named objects must travel together and you control the file.

saveRDS and readRDS

saveRDS() writes a single R object to a file and readRDS() returns it. The help page says that this differs from save() and load(), which save and restore one or more named objects into an environment, and that the single object can be restored under a different name: because readRDS() is a function that returns a value, the caller chooses the name, and nothing in the calling environment is overwritten. The help page’s own note on save() goes further and says that for saving single R objects, saveRDS() is mostly preferable to save(), notably because of the functional nature of readRDS() as opposed to load(). R itself uses .rds files for package metadata and for the help.search() databases, and .rds is the usual extension.

The signature is saveRDS(object, file = "", ascii = FALSE, version = NULL, compress = TRUE, refhook = NULL) and readRDS(file, refhook = NULL). The file argument is a path or a connection; ascii = TRUE writes text instead of bytes; version selects the format version (see the versions section below); compress takes TRUE (gzip), FALSE or one of "gzip", "bzip2", "xz" and "zstd"; and refhook lets a function handle environments and other reference objects (see the section on serialize()). saveRDS() returns NULL invisibly.

source("scenario.R")

# saveRDS() writes exactly one object; readRDS() returns it, so the caller
# chooses the name.
rds <- tempfile(fileext = ".rds")
saveRDS(books, rds)
file.exists(rds)
#> [1] TRUE
shelf_books <- readRDS(rds)
identical(shelf_books, books)
#> [1] TRUE

# saveRDS() returns NULL invisibly; readRDS() returns visibly.
is.null(saveRDS(loans, rds))
#> [1] TRUE
shelf_loans <- readRDS(rds)
identical(shelf_loans, loans)
#> [1] TRUE

# Attributes, classes and NA values all come back: the book object keeps its
# class, so print() dispatches to print.book() again.
dune <- book("Dune", "Frank Herbert", year = 1965, pages = 412)
saveRDS(dune, rds)
dune_again <- readRDS(rds)
dune_again
#> <book> Dune (Frank Herbert, 1965)
identical(dune_again, dune)
#> [1] TRUE
class(dune_again)
#> [1] "book"
str(unclass(dune_again))
#> List of 6
#>  $ title : chr "Dune"
#>  $ author: chr "Frank Herbert"
#>  $ isbn  : chr NA
#>  $ year  : int 1965
#>  $ genre : chr NA
#>  $ pages : int 412

# Several objects: put them in one list (or any other container) first.
shelf <- list(books = books, loans = loans, shelf_today = shelf_today)
saveRDS(shelf, rds)
names(readRDS(rds))
#> [1] "books"       "loans"       "shelf_today"

# Errors: the file must exist and must be an RDS file.
tryCatch(readRDS("missing.rds"), error = \(e) conditionMessage(e),
         warning = \(w) conditionMessage(w))
#> [1] "cannot open compressed file 'missing.rds', probable reason 'No such file or directory'"
rdata <- tempfile(fileext = ".RData")
save(books, file = rdata)
tryCatch(readRDS(rdata), error = \(e) conditionMessage(e))
#> [1] "unknown input format"

Four things in that output are worth reading twice. First, identical(shelf_books, books) is TRUE: the round trip is exact, including the integer type of year, the NA values, the row names and every string. A CSV round trip cannot promise that (see the fidelity section below). Second, the book object came back with its class, so autoprinting called print.book() again; the method itself is not in the file, it is found by the normal S3 dispatch in the session that reads it (see S3 Classes and Generics). Third, storing several objects with saveRDS() means putting them in a list, which is a good habit anyway because the list names document the content. Fourth, the two error messages are plain: a missing file is a warning from the connection followed by an error, and reading a save() file with readRDS() fails with unknown input format, because the two functions use different headers.

The help page also documents infoRDS(), which reads the header of a file written by saveRDS() or serialize() and returns the format version, the version of R that wrote it, the minimum version that can read it, the data representation and the native encoding of the writer. It cannot tell whether a file is a serialization at all, nor whether it is valid. The versions section shows it at work.

save and load

save() writes the objects whose names you give and load() recreates them with the same names. The names to save can be given as symbols or strings in the dots, or as a character vector in list =; they are looked up in envir, which defaults to the calling environment. The arguments ascii, version, compress and compression_level mean what they mean in saveRDS(), and eval.promises and precheck are specific to save(): by default promises are forced before saving, and the existence of all the objects is checked before the file is opened, so a typo does not leave a half-written file.

source("scenario.R")

# save() takes object NAMES (symbols, strings or list =) and a file;
# load() recreates the objects under the same names.
rdata <- tempfile(fileext = ".RData")
save(books, loans, file = rdata)
rm(books, loans)
exists("books")
#> [1] FALSE
loaded <- load(rdata)
loaded
#> [1] "books" "loans"
exists("books")
#> [1] TRUE

# The names to save can be computed, which is what list = is for.
save(list = c("books", "shelf_today"), file = rdata)

# Environment pollution: load() overwrites objects of the same name in the
# target environment without a word.
books <- "my own variable"
load(rdata)
class(books)
#> [1] "data.frame"

# load(verbose = TRUE) prints each name as it is restored.
invisible(load(rdata, verbose = TRUE))
#> Loading objects:
#>   books
#>   shelf_today

# Load into a fresh environment instead, and pick what you need.
e <- new.env()
loaded <- load(rdata, envir = e)
loaded
#> [1] "books"       "shelf_today"
ls(e)
#> [1] "books"       "shelf_today"
nrow(e$books)
#> [1] 16
books_copy <- get("books", envir = e)
identical(books_copy, books)
#> [1] TRUE

# Or never touch the caller's environment at all.
shelf <- local({
  load(rdata)
  list(books = books, today = shelf_today)
})
names(shelf)
#> [1] "books" "today"

# save() finds objects by name in envir (the calling environment by default).
tryCatch(save(no_such_object, file = rdata), error = \(e) conditionMessage(e))
#> [1] "object ‘no_such_object’ not found"

# A file made by saveRDS() is not an RData file.
rds <- file.path(tempdir(), "shelf.rds")
saveRDS(books, rds)
res <- withCallingHandlers(
  tryCatch(load(rds), error = \(e) conditionMessage(e)),
  warning = \(w) {
    cat("warning:", conditionMessage(w), "\n")
    invokeRestart("muffleWarning")
  }
)
#> warning: truncating string with embedded nuls
#> warning: file ‘shelf.rds’ has magic number 'X'
#>   Use of save versions prior to 2 is deprecated
res
#> [1] "bad restore file magic number (file may be corrupted) -- no data loaded"

# save.image() saves the whole workspace, helper variables included.
workspace <- tempfile(fileext = ".RData")
save.image(file = workspace)
restored <- load(workspace, envir = new.env())
c("books", "e", "rdata", "format.book") %in% restored
#> [1] TRUE TRUE TRUE TRUE

load() returns the character vector of the names it created, invisibly, which is the only reliable way to find out what a file contained without looking inside. The warning in the help page for load() is the reason for the third and fourth blocks of that script: load(file) replaces all existing objects with the same names in the current environment, typically the workspace, and so can overwrite important data; the help page calls it considerably safer to use envir = to load into a different environment, or to attach(file), which loads it into a new entry in the search path. On this site the first of the two is the recommendation. attach() is mentioned here because old code and the help page use it; it changes the search path for every function that runs afterwards, which is the problem described in Scoping, Environments and Closures, and it only warns about masked objects instead of preventing them. The pattern in the script, e <- new.env(); load(file, envir = e) followed by e$name or get("name", envir = e), turns load() into the function-like call that readRDS() already is. The local() version in the script keeps the names out of the calling environment altogether.

load(verbose = TRUE) prints each name just before it is restored; the help page says it is mainly intended for debugging, and that a value greater than one also prints names for attributes and deeper parts. When a file’s objects hold references to a namespace that cannot be loaded (for example a function or formula created inside a package that is not installed on the reader’s machine), load() replaces the reference with the global environment and warns; the warning names the first such object, but there may be more. This behaviour was added in R 3.0.1.

Workspace images and the .RData file

save.image() is the shortcut for saving the whole workspace: save(list = ls(all.names = TRUE), file = ".RData", envir = .GlobalEnv). It is also what happens when a session ends with q("yes"), and it is why a file called .RData appears in the working directory of someone who answered "yes" to the prompt (R FAQ 7.2, How can I save my workspace?; An Introduction to R describes the same behaviour in Data permanency and removing objects). The next session started in that directory loads .RData again before it runs anything, unless --no-restore-data or --no-restore is given; the startup sequence and the reasons for turning the restore off in scripts are the subject of Sessions, Workspaces and Startup. The last block of the script shows why a workspace image is a poor archive: it contains every helper variable and every function that happened to be defined, here including the methods of the book class, and nothing says which of them the analysis needs.

save.image() takes file = ".RData", version, ascii, compress and safe; with safe = TRUE (the default) the image is first written to a temporary file that replaces file only if the save succeeds, so a full disk does not destroy the previous image. Defaults for ascii, compress, safe and version can be set with the "save.defaults" option, and a "save.image.defaults" option, if set, takes precedence for save.image(); compression_level can be part of "save.defaults" since R 3.1.0. The most common failure is lack of write permission in the current directory, which the help page shows as Error in gzfile(file, "wb") : unable to open connection together with a warning that the compressed file .RDataTmp cannot be opened.

Reading from a URL

load() can read from a connection, including one made by url(), and a connection that is not already a gzfile or gzcon connection is wrapped in gzcon for it, which leaves the connection in an altered, binary-only state that must be closed explicitly. readRDS() leaves compression to the connection, so a url() connection has to be wrapped in gzcon() by hand. Both fragments below need a network and a real host, so they were not run; api.example.com is a placeholder, and the downloading side (https, redirects, timeouts) belongs to HTTP and Web APIs. readRDS(url(...)) itself has worked since R 3.4.1.

# Fragment, not run: the host is a placeholder.
con <- gzcon(url("https://api.example.com/data/books.rds"))
remote_books <- readRDS(con)
close(con)

con <- url("https://api.example.com/data/books.RData")
loaded <- load(con, envir = new.env())
close(con)

Treat a file fetched from the network as untrusted input, which is the subject of its own section below.

Choosing between RDS and RData

The two functions are equally capable; the difference is in how they are used.

saveRDS() and readRDS() save() and load()

Objects per file

exactly one

any number, each with its name

Who chooses the name on reading

the caller: x <- readRDS(file)

the file: load() recreates the saved names

Overwrites existing objects

never

yes, unless envir points to a fresh environment

Header

none: the stream starts with the format marker

one line first: RDX3 for a binary version-3 save, RDA3 for an ASCII one (version 2: RDX2 and RDA2)

Detected by mistake

readRDS() of an RData file fails with unknown input format

load() of an RDS file fails with bad restore file magic number

Conventional extension

.rds

.RData or .rda

The help page for readRDS() has a warning that is easy to miss: files produced by saveRDS() (or by serialize() to a file connection) are not suitable as an interchange format between machines, for example to download from a website, while the files produced by save() carry a header that identifies the file type and so are better protected against erroneous use. In practice this advice is about robustness, not about portability of the bytes: the binary format is the same on every platform. And the conventional extension is only a convention: R does not look at it. .RData is also the name of the workspace file that R loads at startup, which is a good reason not to use that name for a data file you save by hand.

dput, dget and deparse

dput() writes an ASCII text representation of an object, as R code, to the console, a file or a connection. The object name is not written (unlike dump()); if the object is a function its environment is stripped, so scoping information can be lost. dget() evaluates the text and returns the object. The help page states plainly that this is not a good way to transfer objects between R sessions, that dump() is better, and that save() and saveRDS() are designed for transporting R data, work with objects that dput() does not handle, and are much faster. Its real use is to show someone an object: a data frame in a question on a forum or an issue tracker, in a form that can be pasted straight back into R.

source("scenario.R")

# dput() writes R code that rebuilds the object: pasteable into a bug report.
dput(books[1:3, c("title", "year", "pages")])
#> structure(list(title = c("The Dispossessed", "Pride and Prejudice",
#> "  the quiet streets "), year = c(1974L, 1813L, 2019L), pages = c(387L,
#> 432L, 214L)), row.names = c(NA, 3L), class = "data.frame")

# The pieces that make a factor, a Date and a book.
dput(factor(c("b", "a", "b")))
#> structure(c(2L, 1L, 2L), levels = c("a", "b"), class = "factor")
dput(shelf_today)
#> structure(20634, class = "Date")
dput(loans$loan_date[1:3])
#> structure(c(20102, 20104, 20109), class = "Date")
dune <- book("Dune", "Frank Herbert", year = 1965, pages = 412)
dput(dune)
#> structure(list(title = "Dune", author = "Frank Herbert", isbn = NA_character_,
#>     year = 1965L, genre = NA_character_, pages = 412L), class = "book")

# The default control = c("keepNA", "keepInteger", "niceNames",
# "showAttributes"); NULL is the bare minimum and drops the attributes.
dput(c(a = 1L, b = NA))
#> c(a = 1L, b = NA)
dput(c(a = 1L, b = NA), control = NULL)
#> c(1, NA)
dput(dune, control = NULL)
#> list("Dune", "Frank Herbert", NA, 1965, NA, 412)

# Numbers: the default prints 15 significant digits, so it may not be exact.
x <- 1 / 3
dput(x)
#> 0.333333333333333
dput(x, control = "digits17")
#> 0.33333333333333331
dput(x, control = "hexNumeric")
#> 0x1.5555555555555p-2
dput(x, control = "exact")
#> 0x1.5555555555555p-2
identical(eval(parse(text = deparse(x))), x)
#> [1] FALSE
identical(eval(parse(text = deparse(x, control = "digits17"))), x)
#> [1] TRUE

# dget() reads the file back and evaluates it.
rebuilt_file <- tempfile(fileext = ".R")
dput(books, rebuilt_file)
rebuilt <- dget(rebuilt_file)
identical(rebuilt, books)
#> [1] TRUE
length(readLines(rebuilt_file))
#> [1] 21

# deparse() returns the text instead of printing it.
deparse(books$year[1:3])
#> [1] "c(1974L, 1813L, 2019L)"
deparse1(quote(x + y))
#> [1] "x + y"

# Functions lose their environment, and with Rscript no source is kept.
make_adder <- function(n) {
  function(x) x + n
}
add_two <- make_adder(2)
add_two(1)
#> [1] 3
dput(add_two)
#> function (x)
#> x + n
copy <- dget(textConnection(deparse(add_two)))
tryCatch(copy(1), error = \(e) conditionMessage(e))
#> [1] "object 'n' not found"

The output shows how the classes are built from their parts: a data frame is a structure() of a list with row.names and class attributes (the compact row.names = c(NA, 3L) means "three automatic row names"), a factor is an integer vector with levels and class, and a Date is the number of days since 1970-01-01 with the class "Date", which is exactly the representation described in Dates and Times. That is also why dput() is the best way to report a bug involving a data frame: dput(head(books, 3)) is everything someone else needs to reproduce it, with no file to attach.

The control argument is a character vector of deparsing options. The default is c("keepNA", "keepInteger", "niceNames", "showAttributes"); control = "all" is the most thorough deparsing, control = NULL the simplest, dropping even the attributes (the book object came back as a plain list of six numbers and strings), and "useSource" shows the saved source of a function instead of deparsing it. For numbers, "digits17" writes 17 significant digits, which makes the round trip exact for doubles on most platforms, and "hexNumeric" writes the exact binary value as a hexadecimal fraction; R 4.0.0 added "digits17" to control = "all" and a new control = "exact" that guarantees numeric exactness through "hexNumeric". The default writes 15 digits, so a number like one third does not survive: the script shows identical() returning FALSE for the default and TRUE for "digits17". deparse() returns the same text as a character vector (and deparse1() as one string) and takes the same control; it breaks long output into elements of about 60 characters by default, so a data frame needs more than one.

Functions need a word of their own. dput() removes the environment, and with Rscript or options(keep.source = FALSE) the original formatting and comments are not kept either, so the function prints in R’s own layout. The last lines of the script show the damage: the closure add_two carries the value n = 2 in its environment, the deparsed copy does not, and calling it fails with object 'n' not found. This is the reason to use saveRDS() for functions, as the section on closures shows.

serialize and unserialize

serialize() is the lower-level interface to the same machinery that saveRDS() and save() use: serialize(object, connection, ascii, xdr = TRUE, version = NULL, refhook = NULL). With connection = NULL it returns a raw vector, which is how an object is put in a database column, sent through a socket, hashed or compared; unserialize() accepts either a connection or a raw vector. Objects serialized to a connection can be read by readRDS() and the other way round.

source("scenario.R")

# serialize(x, NULL) returns a raw vector; unserialize() reverses it.
bytes <- serialize(books, NULL)
class(bytes)
#> [1] "raw"
length(bytes)
#> [1] 1733
head(bytes, 14)
#>  [1] 58 0a 00 00 00 03 00 04 06 01 00 03 05 00
identical(unserialize(bytes), books)
#> [1] TRUE

# The first two bytes are "X\n" for the XDR (big-endian) binary format, then
# the format version as a four-byte integer: 0 0 0 3 for version 3.
rawToChar(bytes[1:2])
#> [1] "X\n"
as.integer(bytes[3:6])
#> [1] 0 0 0 3

# xdr = FALSE writes the machine's own byte order ("B\n"), a little faster
# between two little-endian machines but not portable.
native <- serialize(books, NULL, xdr = FALSE)
rawToChar(native[1:2])
#> [1] "B\n"
length(native)
#> [1] 1733

# ascii = TRUE writes plain text: "A\n" and one value per line.
text <- serialize(c(a = 1.5), NULL, ascii = TRUE)
cat(rawToChar(text))
#> A
#> 3
#> 263681
#> 197888
#> 5
#> UTF-8
#> 526
#> 1
#> 1.5
#> 1026
#> 1
#> 262153
#> 5
#> names
#> 16
#> 1
#> 262153
#> 1
#> a
#> 254
identical(unserialize(text), c(a = 1.5))
#> [1] TRUE

# Version 3 is the default; version = 2 is what R < 3.5.0 reads.
v2 <- serialize(books, NULL, version = 2)
c(version3 = length(bytes), version2 = length(v2))
#> version3 version2
#>     1733     1724
as.integer(v2[3:6])
#> [1] 0 0 0 2

# ALTREP: version 3 can store a compact sequence as its bounds (the last line
# is the size of the expanded vector in memory, for comparison).
c(version3 = length(serialize(1:1e6, NULL)),
  version2 = length(serialize(1:1e6, NULL, version = 2)))
#> version3 version2
#>      133  4000022
as.numeric(object.size(1:1e6))
#> [1] 4000048

# serialize() to a raw vector, then compress the bytes yourself.
packed <- memCompress(bytes, type = "gzip")
c(raw = length(bytes), gzip = length(packed))
#>  raw gzip
#> 1733  757
identical(unserialize(memDecompress(packed, type = "gzip")), books)
#> [1] TRUE

# Reference objects (environments) can be swapped through a refhook.
e <- new.env()
e$count <- 3L
placeholder <- serialize(list(note = "hello", env = e), NULL,
                         refhook = \(x) "the-environment")
restored <- unserialize(placeholder, refhook = \(name) {
  env <- new.env()
  env$count <- 0L
  env
})
restored$note
#> [1] "hello"
restored$env$count
#> [1] 0

# Pitfall: a binary serialization is not a string.
tryCatch(rawToChar(bytes), error = \(e) substr(conditionMessage(e), 1, 34))
#> [1] "embedded nul in string: 'X\\n\\0\\0\\0"
# An ASCII serialization is plain text, so it survives a trip through a string.
via_string <- rawToChar(serialize(1:3, NULL, ascii = TRUE))
identical(unserialize(charToRaw(via_string)), 1:3)
#> [1] TRUE

The R Internals chapter on serialization formats describes what those bytes are. A stream starts with a format marker: X for XDR, A for ASCII and B for native word order binary, each followed by a newline; then three integers giving the format version, the version of R that wrote the stream and the minimum version of R needed to read it (and, in version 3, the name of the writer’s native encoding). After that the object is written recursively. In the XDR format integers and doubles are written big-endian, so a file written on one platform reads on any other; in the ASCII format integers are written with %d, doubles with %.16g and bytes as two hexadecimal digits. Environments that are not the global environment, a package environment or a namespace are reference objects: they are written once, in a hash table, and every later reference is written as an index, which is how sharing inside one object survives a round trip.

Choosing between the variants is a matter of what the reader is. xdr = FALSE writes the machine’s own byte order; the help page notes that, because almost all systems in current use are little-endian, it avoids byte-shuffling at both ends when data go from one little-endian machine to another or between processes on the same machine, and can speed serialization and unserialization up by a factor of up to three. It is for process-to-process transfer, not for files that others read. ascii = TRUE or NA writes text; ascii = NA (from R 3.2.0) writes doubles as hexadecimal fractions so that no value is lost in a decimal conversion, and for the same reason the save() help page calls ASCII saves mainly of historical interest: they are almost always slower to read and write, compress worse and, when written in decimal, may not restore doubles exactly. For a text-mode connection the default of ascii is TRUE, and ascii = FALSE there is an error.

The warning in the help page for serialize() is worth repeating: the interface has been stable since R 2.4.0, but the format may change in future versions of R, so serialization should not be used for long-term storage of R objects. The dependable archive for a table is a text format such as CSV; the dependable archive for an R object is an RDS file together with the note of which R version wrote it, and infoRDS() records that for you.

Version 3 and ALTREP

R 3.5.0 introduced serialization format version 3, and R 3.6.0 made it the default for save(), serialize(), saveRDS() and compiler::cmpfile() (R 3.6.0 NEWS, under significant user-visible changes). Version 3 supports custom serialization of ALTREP objects, the alternative representation framework that came in with R 3.5.0, whose compact sequences (1:n, seq_len(), seq_along()) and lazily converted vectors are stored without being expanded. The script shows the effect: serializing 1:1e6 takes 133 bytes in version 3 and 4,000,022 bytes in version 2. Version 3 also records the native encoding of the session that wrote it, so unflagged strings can be converted if the stream is read under another encoding (Encodings, Locales and Internationalization). Data in format 3 cannot be read by R versions before 3.5.0; version 2 is still supported and selected with version = 2.

Reference objects and refhook

refhook customizes the handling of the non-system reference objects of the help page: external pointers, weak references and every environment other than namespace and package environments and the global environment. The hook given to serialize() returns a character vector for each reference it wants to handle (and NULL for the others), and the hook given to unserialize() is called with those character vectors and returns an object to put in their place. The script uses it to replace an environment by a name on the way out and to build a fresh one on the way in. This is the supported way to save an object that holds a database connection, a compiled pattern or a pointer owned by C code: the pointer itself cannot be meaningfully restored in another process, so the hook stores whatever is needed to recreate it. Sharing of reference objects is preserved within one call to serialize() but not across separate calls.

The rawToChar pitfall

A raw vector looks like a string you could keep in a text field, and rawToChar(serialize(x, NULL)) is a common attempt to get one. It fails for the default binary format because the stream contains zero bytes: the script shows the message embedded nul in string: followed by the first bytes escaped. It works only for ascii = TRUE, whose output is text, and then it is easy to get wrong in the other direction (charToRaw() of the string gives the original bytes back, as the last line shows). If a text field is the requirement, either use the ASCII form deliberately, or encode the bytes (base64 is the usual choice, and jsonlite’s serializeJSON() below does it for built-in functions), or use JSON.

Compression

saveRDS() and save() compress by default. compress = TRUE means gzip; the character strings "gzip", "bzip2", "xz" and "zstd" choose the algorithm, and compress = FALSE writes the plain stream. compression_level (in save()) defaults to 6 for gzip and to 9 for bzip2 and xz. The compression is handled by the connection that R opens for a file name, which is why compress is ignored when file is a connection: the connection decides. R Internals states that compression is applied to the whole file stream, including the headers, so the files can be uncompressed or re-compressed by external programs, and that load() and readRDS() read gzip, bzip2 and xz when reading from a file and gzip when reading from a connection. The save() help page adds that it is possible to compress later, with gzip, bzip2 or xz, a file saved with compress = FALSE, and that a saved file can be uncompressed and re-compressed under a different scheme (tools::resaveRdaFiles() does it from within R).

"zstd" is newer than the others: it was first supported in R 4.5.0, when R gained a zstdfile connection type, and it needs optional operating-system support, so the help pages advise against it as an interchange format. The same pages give a measurement of a 46 MB object, saved as 35 MB without compression in 2 seconds, 22 MB with gzip in 8 seconds, 19 MB with bzip2 in 13 seconds and 9.4 MB with xz in 40 seconds, and say that the figures are indicative and that xz compressed unusually well there. The script below measures four objects of our own: the two shelf data frames, a six-thousand-row copy of loans made by repeating its rows 100 times (a highly repetitive table), and a table of 5,000 random readings (data that compress poorly).

source("scenario.R")

# Four objects of growing size: the shelf data, and two bigger tables.
big_loans <- loans[rep(seq_len(nrow(loans)), 100L), ]
set.seed(282)
readings <- data.frame(
  id = 1:5000,
  x = rnorm(5000),
  g = sample(letters, 5000, replace = TRUE)
)
objects <- list(
  books = books, loans = loans, big_loans = big_loans, readings = readings
)

# Bytes on disk for one object and one set of saveRDS() arguments.
bytes_on_disk <- function(x, ...) {
  path <- tempfile()
  on.exit(unlink(path))
  saveRDS(x, path, ...)
  file.size(path)
}

sizes <- data.frame(
  none = vapply(objects, bytes_on_disk, numeric(1), compress = FALSE),
  gzip = vapply(objects, bytes_on_disk, numeric(1)),
  bzip2 = vapply(objects, bytes_on_disk, numeric(1), compress = "bzip2"),
  xz = vapply(objects, bytes_on_disk, numeric(1), compress = "xz"),
  zstd = vapply(objects, bytes_on_disk, numeric(1), compress = "zstd")
)
sizes
#>             none  gzip bzip2    xz  zstd
#> books       1733   770   806   772   818
#> loans       4345  1029  1009   876  1004
#> big_loans 477905 15432  6529  3284  5461
#> readings   85306 44641 42666 42920 44461

# ASCII is bigger and compresses worse than the binary format.
ascii <- data.frame(
  ascii = vapply(objects, bytes_on_disk, numeric(1),
                 ascii = TRUE, compress = FALSE),
  ascii_gzip = vapply(objects, bytes_on_disk, numeric(1), ascii = TRUE)
)
ascii
#>            ascii ascii_gzip
#> books       2092        781
#> loans       4722        980
#> big_loans 527619      14707
#> readings  151135      52890

# Version 3 stores the id sequence compactly (ALTREP); version 2 does not.
c(
  version3 = bytes_on_disk(readings),
  version2 = bytes_on_disk(readings, version = 2L)
)
#> version3 version2
#>    44641    57330

# The compression level of save() trades time for size.
bytes_saved <- function(x, ...) {
  path <- tempfile()
  on.exit(unlink(path))
  save(x, file = path, ...)
  file.size(path)
}
vapply(c(1L, 6L, 9L), \(level) {
  bytes_saved(readings, compression_level = level)
}, numeric(1))
#> [1] 46440 44654 44529
vapply(c(1L, 6L, 9L), \(level) {
  bytes_saved(readings, compress = "xz", compression_level = level)
}, numeric(1))
#> [1] 43312 42932 42932

# readRDS() and load() detect gzip, bzip2, xz and zstd by themselves.
path <- tempfile()
saveRDS(books, path, compress = "xz")
identical(readRDS(path), books)
#> [1] TRUE
magic <- function(path, n) {
  con <- file(path, "rb")
  on.exit(close(con))
  readBin(con, "raw", n)
}
saveRDS(books, path, compress = "gzip")
magic(path, 3L)
#> [1] 1f 8b 08
saveRDS(books, path, compress = "bzip2")
magic(path, 3L)
#> [1] 42 5a 68
saveRDS(books, path, compress = "xz")
magic(path, 6L)
#> [1] fd 37 7a 58 5a 00
saveRDS(books, path, compress = "zstd")
magic(path, 4L)
#> [1] 28 b5 2f fd
saveRDS(books, path, compress = FALSE)
magic(path, 2L)
#> [1] 58 0a
unlink(path)

The table tells three stories. For the tiny books table the algorithms are within a few bytes of each other, and compression turns the 1,733 bytes of the plain stream into about 770; at this size the choice is irrelevant. For the repetitive big_loans table the picture is the textbook one: gzip makes 477,905 bytes into 15,432, bzip2 into 6,529, xz into 3,284 and zstd into 5,461. For random doubles (readings) nothing helps much, because the digits are noise: the best result is a halving. The ASCII table confirms the help page: the plain ASCII stream is larger than the binary one, and after gzip it is the same size for a small table and noticeably worse for numbers (52,890 bytes against 44,641 for readings). The magic-number block shows how the reader tells the algorithms apart: gzip starts with 1f 8b, bzip2 with BZh (42 5a 68), xz with fd 37 7a 58 5a 00, zstd with 28 b5 2f fd, and the uncompressed stream starts directly with the X marker. Note that readings also shows version 3 at work: the id column is an ALTREP sequence, which version 3 stores compactly (with version = 2 the gzip file is 57,330 bytes instead of 44,641).

For speed on large objects the help page suggests, instead of a bigger algorithm, a parallel compressor reached through a pipe() connection (pigz, pbzip2, xz -T), because a connection passed as file is exactly what makes the external tool usable; it warns that parallel compression is effective only for large objects.

What travels with an object

An R object is more than its values. Closures, formulas and environments carry references to other environments, and serialization follows those references. The rules are the ones in R Internals: package, namespace and global environments are written by name, as pseudo-types, and every other environment is written out in full with its enclosure, its frame and its attributes.

source("scenario.R")
rds <- tempfile(fileext = ".rds")

# A closure carries the environment it was created in, and saveRDS() saves it.
make_counter <- function() {
  n <- 0L
  function() {
    n <<- n + 1L # nolint: assignment_linter.
    n
  }
}
counter <- make_counter()
counter()
#> [1] 1
counter()
#> [1] 2
saveRDS(counter, rds)
counter_again <- readRDS(rds)
counter_again()
#> [1] 3
counter()
#> [1] 3
identical(environment(counter), environment(counter_again))
#> [1] FALSE
get("n", envir = environment(counter_again))
#> [1] 3

# The global environment is not copied: it is saved as a reference and the
# function finds the reader's own variables of that name.
days_left <- function(loaned) {
  loan_days - as.numeric(shelf_today - loaned) # nolint: object_usage_linter.
}
saveRDS(days_left, rds)
days_left_again <- readRDS(rds)
identical(environment(days_left_again), globalenv())
#> [1] TRUE
days_left_again(as.Date("2026-06-20"))
#> [1] 11

# A package function is saved as a reference to its namespace.
saveRDS(sd, rds)
environment(readRDS(rds))
#> <environment: namespace:stats>
file.size(rds) < 2000
#> [1] TRUE

# Environments are shared inside one object, and not between two files.
shared <- new.env()
shared$value <- 1
pair <- list(first = shared, second = shared)
saveRDS(pair, rds)
back <- readRDS(rds)
identical(back$first, back$second)
#> [1] TRUE
back$first$value <- 99
back$second$value
#> [1] 99
saveRDS(shared, rds)
identical(readRDS(rds), readRDS(rds))
#> [1] FALSE

# A formula remembers the environment it was written in, data and all.
set.seed(282)
fit_formula <- function() {
  readings <- rnorm(1e5) # nolint: object_usage_linter.
  pages ~ genre
}
captured <- fit_formula()
plain <- pages ~ genre
saveRDS(captured, rds)
big <- file.size(rds)
saveRDS(plain, rds)
small <- file.size(rds)
c(captured = big, global = small)
#> captured   global
#>   768050      117
ls(environment(captured))
#> [1] "readings"
# Attach the global environment, or build the formula with env =, to avoid it.
environment(captured) <- globalenv()
saveRDS(captured, rds)
file.size(rds) == small
#> [1] TRUE
unlink(rds)

The consequences are all visible in that output.

  • A closure keeps its private state. The counter that had been called twice is restored with n = 2, and the copy and the original then count independently: the file is a snapshot, not a link. This is what makes saveRDS() the right tool for functions and the wrong one for dput().

  • The global environment is never copied. A function defined at the top level is saved with a reference to .GlobalEnv; when it is read in another session it uses whatever variables of those names exist there. days_left() computed its answer from the reader’s own loan_days and shelf_today.

  • A package function is a reference to a namespace. sd() was saved in under 2,000 bytes because its environment is written as the name namespace:stats. If the namespace is not installed on the reader’s machine, load() and readRDS() replace the reference with the global environment and warn.

  • Sharing is preserved inside one stream and lost across streams. The two elements of pair were the same environment before and after, so changing one changed the other. Two separate reads of the same file give two different environments.

  • A formula remembers its environment. The formula made inside fit_formula() captured the environment that held 100,000 random numbers, and saving it saved them: 768,050 bytes against 117 for the same formula written at the top level. This is the same behaviour that makes fitted models from a function enormous; the formula page describes the remedy, environment(f) <- globalenv() or building the formula with env = (Formulas).

Objects that hold a handle owned by something outside R do not survive. A database connection from DBI, an xml2 document, a socket or a httpuv server is an external pointer to memory or to a descriptor owned by C code in the writing process, which cannot exist in the reading process; the pointer is saved and the thing it pointed at is not. The documented way to handle it is refhook, shown above, or, better, to save the data and the recipe for reconnecting (Databases with DBI).

Versions across R releases

The serialization format has been compatible for a long time, and the changes are few. Version 2 was the format from R 1.4.0 (December 2001) to R 3.5.3 (March 2019), expanded in back-compatible ways. Version 3 was introduced in R 3.5.0 and became the default in R 3.6.0. The save() help page spells out the consequence: a version number that did not change does not mean that saved files are necessarily backwards compatible. A saved image can be loaded into an earlier version of R that supports its version unless use is made of later additions (for version 2, raw vectors, external pointers and some S4 objects; long vectors, introduced in R 3.0.0, can be loaded only on 64-bit platforms).

source("scenario.R")
rds <- tempfile(fileext = ".rds")

# infoRDS() (R 4.0.0) reads the header of a file written by saveRDS().
saveRDS(books, rds)
str(infoRDS(rds))
#> List of 5
#>  $ version           : int 3
#>  $ writer_version    : chr "4.6.1"
#>  $ min_reader_version: chr "3.5.0"
#>  $ format            : chr "xdr"
#>  $ native_encoding   : chr "UTF-8"
saveRDS(books, rds, version = 2)
str(infoRDS(rds))
#> List of 4
#>  $ version           : int 2
#>  $ writer_version    : chr "4.6.1"
#>  $ min_reader_version: chr "2.3.0"
#>  $ format            : chr "xdr"

# The version-3 file says which R can read it: 3.5.0 or newer.
saveRDS(books, rds, version = 3)
infoRDS(rds)$min_reader_version
#> [1] "3.5.0"

# save() writes a header line first: RDX3 (binary) or RDX2, RDA3 (ascii).
header <- function(path) {
  con <- gzfile(path, "rb")
  on.exit(close(con))
  rawToChar(readBin(con, "raw", 5L))
}
rdata <- tempfile(fileext = ".RData")
save(books, file = rdata)
header(rdata)
#> [1] "RDX3\n"
save(books, file = rdata, version = 2)
header(rdata)
#> [1] "RDX2\n"
save(books, file = rdata, ascii = TRUE)
header(rdata)
#> [1] "RDA3\n"

# An R that does not know the version stops at the header. R 4.6.1 does not
# know version 4, so patching the version field shows the message and how it
# names the writer and the minimum reader.
bytes <- serialize(books, NULL)
bytes[6L] <- as.raw(4L)
tryCatch(unserialize(bytes), error = \(e) conditionMessage(e))
#> [1] "cannot read workspace version 4 written by R 4.6.1; need R 3.5.0 or newer"

# Files made with version = 2 still load in R 4.6.1.
save(books, file = rdata, version = 2)
e <- new.env()
load(rdata, envir = e)
identical(e$books, books)
#> [1] TRUE

# The default version for a whole session comes from an environment variable
# that R reads once, so a new R process is needed to see it change.
default_version <- function(setting) {
  rscript <- file.path(R.home("bin"), "Rscript")
  code <- "cat(as.integer(serialize(1, NULL)[6L]))"
  system2(
    rscript, c("-e", shQuote(code)),
    stdout = TRUE, env = paste0("R_DEFAULT_SERIALIZE_VERSION=", setting)
  )
}
default_version(2)
#> [1] "2"
default_version(3)
#> [1] "3"
unlink(c(rds, rdata))

Read the first two blocks together: a default file reports version 3, the R version that wrote it, min_reader_version of "3.5.0", the xdr format and the native encoding (shown as "UTF-8" on the machine that produced this output), while a file written with version = 2 reports min_reader_version "2.3.0" and has no encoding field. The header block shows the magic numbers of save() files.

What happens when an old R meets a version-3 file is a hard stop with a message of a fixed shape. R’s unserializer reads the version number, the writer’s version and the minimum reader version from the header; a version that the running R does not know is rejected with the text cannot read workspace version V written by R W; need R M or newer. R before 3.5.0 has no branch for version 3, so an R 3.4 session that reads a version-3 file written by R 4.6.1 ends in that same error, with the wording cannot read workspace version 3 written by R 4.6.1; need R 3.5.0 or newer (the check is in src/main/serialize.c, source of the R-4-6 branch). An old R cannot be run for this page, so the script reproduces the message with the one thing R 4.6.1 can reject in the same way: a header whose version field is patched to 4, which an R that only knows versions 2 and 3 cannot read either. The remedy for sharing with an old reader is saveRDS(x, file, version = 2) or save(..., version = 2), at the cost of the ALTREP compactness; R_DEFAULT_SAVE_VERSION and R_DEFAULT_SERIALIZE_VERSION set to 2 change the default for a whole session, but R reads them once, when the first serialization takes place, so they must be set before R starts (the script starts a child Rscript process to show it). R NEWS for 3.6.0 notes that, for maximal backward compatibility, the vignette.rds and partial.rdb files that R CMD build generates stay in version 2, and that resave produces version 2 unless the original is already version 3.

Version notes for serialization

Version Change

R 3.0.1

load() gains verbose; a reference to a namespace that cannot be loaded is replaced by a reference to the global environment, with a warning.

R 3.1.0

The "save.defaults" option can include a compression_level.

R 3.2.0

save(), saveRDS() and serialize() support ascii = NA, which writes doubles and complex numbers as hexadecimal fractions.

R 3.4.1

readRDS(url(...)) works.

R 3.5.0

Serialization format version 3 is introduced, with custom serialization of ALTREP objects and a record of the native encoding; magic numbers RDA3, RDB3 and RDX3. Version 2 remains the default. Data in version 3 cannot be read by R before 3.5.0.

R 3.6.0

Version 3 becomes the default for save(), serialize(), saveRDS() and compiler::cmpfile(); R_DEFAULT_SAVE_VERSION and R_DEFAULT_SERIALIZE_VERSION can set the default back to 2.

R 4.0.0

New infoRDS(); deparse(), dump() and dput() gain the "digits17" and "exact" control options; the functions .readRDS() and .saveRDS() are defunct.

R 4.4.0

Serializations can be interrupted (for example with Ctrl-C), such as a long save.image().

R 4.5.0

compress = "zstd" for save() and saveRDS(), and a zstdfile connection, where R was built with zstd support.

The entries for R 3.0.1 to 3.6.0 are in the R 3.x NEWS (R NEWS: Changes in R 3.x). Neither the R 4.6.0 and 4.6.1 sections of the release NEWS nor the R-devel NEWS, checked on the date above, list a change to saveRDS(), readRDS(), save(), load() or serialize().

Never load untrusted files

Reading a serialized object is not like reading a CSV file. A CSV reader turns text into numbers and strings. A deserializer rebuilds R objects, and some R objects are code: functions, language objects, promises (delayed computations) and active bindings (functions that run each time a name is read). All of them can be saved, and all of them come back as they were. Nothing in readRDS() or load() limits what a file may contain.

source("scenario.R")

# Data files can carry code. An active binding is a function that R runs every
# time the name is read; saving the environment saves the function too.
hostile <- new.env()
makeActiveBinding("balance", \() {
  cat("code from the file ran\n")
  42L
}, hostile)
rds <- tempfile(fileext = ".rds")
saveRDS(hostile, rds)

# Loading it runs nothing, but it hands you an object that runs code on use.
from_file <- readRDS(rds)
bindingIsActive("balance", from_file)
#> [1] TRUE
ls(from_file)
#> [1] "balance"
from_file$balance
#> code from the file ran
#> [1] 42

# A promise (a delayed computation) is saved with its expression as well.
lazy <- new.env()
delayedAssign("later", {
  cat("a promise from the file ran\n")
  7L
}, assign.env = lazy)
rdata <- tempfile(fileext = ".RData")
save(list = "later", envir = lazy, file = rdata, eval.promises = FALSE)
restored <- new.env()
load(rdata, envir = restored)
ls(restored)
#> [1] "later"
restored$later
#> a promise from the file ran
#> [1] 7

# A function is code by definition: reading it is harmless, calling it is not.
saveRDS(\(x) x + 1, rds)
helper <- readRDS(rds)
class(helper)
#> [1] "function"

# Defensive habits: load into a throwaway environment, take only the names you
# expect, and check the shape of what you got before using it.
read_table_file <- function(path, name, columns) {
  env <- new.env()
  loaded <- load(path, envir = env)
  stopifnot(identical(loaded, name))
  result <- get(name, envir = env)
  stopifnot(is.data.frame(result), identical(names(result), columns))
  result
}
trusted <- tempfile(fileext = ".RData")
save(books, file = trusted)
nrow(read_table_file(trusted, "books", names(books)))
#> [1] 16
save(loans, file = trusted)
tryCatch(
  read_table_file(trusted, "books", names(books)),
  error = \(e) conditionMessage(e)
)
#> [1] "identical(loaded, name) is not TRUE"
unlink(c(rds, rdata, trusted))

The script is deliberately harmless, and it shows the two places where the code in a file runs. Reading the file ran nothing; the code ran the moment the name balance was read, and the promise ran the moment later was evaluated. A file that is read by a script and then used by it, which is what a file is for, gives the code its moment. Functions are the same: reading a function is harmless, calling it is not (the second and third blocks). None of this is a bug in R; it is what faithful serialization means. R itself has had defects in the deserializer, and the National Vulnerability Database records one: CVE-2024-27322 describes deserialization of untrusted data in R versions from 1.4.0 up to, and not including, 4.4.0, which let a crafted RDS file or R package run arbitrary code when it was interacted with (NVD record for CVE-2024-27322). Running a current R closes that particular hole and not the class of problem: the general rule stays that deserialising runs R internals on bytes chosen by whoever made the file.

The practical rules follow.

  • Never load(), readRDS(), unserialize(), attach() or dget() a file whose origin you do not control. That includes an attachment, a download from a site that is not yours, a file from a shared drive that others can write to, and a .RData file in a project directory you have just cloned (R will load a .RData in the working directory at startup, so only start R in directories you trust).

  • If a file must be read, read it into a throwaway environment and take only what you expect, then check its shape before using it, as read_table_file() does in the script: the names match, the object is a data frame and the columns are the ones you meant. This limits accidents; it does not make an untrusted file safe, because the deserializer has already run by then.

  • Prefer to send data, not objects. A data frame can travel as CSV, JSON, Parquet or Feather, which cannot carry a function or a promise; an RDS file of plain data can be inspected after the fact, but an interchange format is safer by construction.

  • Keep RDS and RData for files your own code writes and your own code reads.

  • YAML has the same shape of problem at a smaller scale: the yaml package refuses to evaluate !expr tags unless asked (below).

dget() deserves the same suspicion: it evaluates the file as R code, so a .R file from outside is a program.

JSON with jsonlite

JSON (JavaScript Object Notation, RFC 8259) is the usual format at the edge of a program: it is text, every language reads it, and it is what web APIs speak. It has six kinds of value: objects, arrays, strings, numbers, true/false and null. R has more: integers and doubles, factors, dates, NA, matrices, data frames, classes, functions and environments. A JSON library for R must therefore map a rich set of types onto a poor one and back. jsonlite (labelled package documentation) implements a class-based mapping that its author describes in a journal article (The jsonlite Package: A Practical and Consistent Mapping Between JSON Data and R Objects) and in the quickstart vignette (Getting started with JSON and jsonlite): its main strength is a bidirectional mapping between JSON and the most important R types, so that data frames round trip without manual restructuring. It has no pkgdown site; its help pages are on the CRAN reference pages, linked below. toJSON() and fromJSON() are documented as drop-in replacements for the functions of the same names in the older rjson and RJSONIO packages, with a somewhat more consistent mapping.

Writing JSON: toJSON

toJSON(x, dataframe = c("rows", "columns", "values"), matrix = c("rowmajor", "columnmajor"), Date = c("ISO8601", "epoch"), POSIXt = c("string", "ISO8601", "epoch", "mongo"), factor = c("string", "integer"), complex, raw, null = c("list", "null"), na = c("null", "string"), auto_unbox = FALSE, digits = 4, pretty = FALSE, force = FALSE) returns a character string of class json.

library(jsonlite)
source("scenario.R")

# Three books; the second has year = NA and the third an empty isbn.
three <- books[c(1, 7, 9), ]

# A data frame becomes an array of objects, one per row (dataframe = "rows").
# NA fields are left out of a row by default.
toJSON(three)
#> [{"title":"The Dispossessed","author":"Ursula K. Le Guin","isbn":"978-0-306-40615-7","year":1974,"genre":"Science fiction","pages":387},{"title":"Garden of Small Fires","author":"Noor Haddad","isbn":"978-0-00-500057-1","genre":"Poetry","pages":96},{"title":"Rivers of Glass","author":"Tomas Arden","isbn":"","year":2012,"genre":"Non-fiction","pages":352}]
toJSON(three, pretty = TRUE)
#> [
#>   {
#>     "title": "The Dispossessed",
#>     "author": "Ursula K. Le Guin",
#>     "isbn": "978-0-306-40615-7",
#>     "year": 1974,
#>     "genre": "Science fiction",
#>     "pages": 387
#>   },
#>   {
#>     "title": "Garden of Small Fires",
#>     "author": "Noor Haddad",
#>     "isbn": "978-0-00-500057-1",
#>     "genre": "Poetry",
#>     "pages": 96
#>   },
#>   {
#>     "title": "Rivers of Glass",
#>     "author": "Tomas Arden",
#>     "isbn": "",
#>     "year": 2012,
#>     "genre": "Non-fiction",
#>     "pages": 352
#>   }
#> ]

# "columns" gives an object of arrays, "values" an array of arrays.
toJSON(three[, c("title", "year")], dataframe = "columns")
#> {"title":["The Dispossessed","Garden of Small Fires","Rivers of Glass"],"year":[1974,"NA",2012]}
toJSON(three[, c("title", "year")], dataframe = "values")
#> [["The Dispossessed",1974],["Garden of Small Fires","NA"],["Rivers of Glass",2012]]

# na = "null" keeps the key; na = "string" writes the text "NA".
toJSON(three[, c("title", "year")], na = "null")
#> [{"title":"The Dispossessed","year":1974},{"title":"Garden of Small Fires","year":null},{"title":"Rivers of Glass","year":2012}]
toJSON(three[, c("title", "year")], na = "string")
#> [{"title":"The Dispossessed","year":1974},{"title":"Garden of Small Fires","year":"NA"},{"title":"Rivers of Glass","year":2012}]

# A length-one vector is still an array unless you unbox it.
toJSON("Dune")
#> ["Dune"]
toJSON("Dune", auto_unbox = TRUE)
#> "Dune"
toJSON(list(title = "Dune", pages = 412L), auto_unbox = TRUE)
#> {"title":"Dune","pages":412}
toJSON(list(title = "Dune", pages = 412L))
#> {"title":["Dune"],"pages":[412]}
toJSON(list(title = "Dune", pages = unbox(412L)))
#> {"title":["Dune"],"pages":412}

# NULL in a list: an empty object by default, null on request.
toJSON(list(a = NULL, b = 1), auto_unbox = TRUE)
#> {"a":{},"b":1}
toJSON(list(a = NULL, b = 1), auto_unbox = TRUE, null = "null")
#> {"a":null,"b":1}

# Numbers: digits = 4 by default; I() counts significant digits; NA is exact.
toJSON(pi)
#> [3.1416]
toJSON(pi, digits = 10)
#> [3.1415926536]
toJSON(pi, digits = I(3))
#> [3.14]
toJSON(pi, digits = NA)
#> [3.14159265358979]
toJSON(c(1, NA, NaN, Inf))
#> [1,"NA","NaN","Inf"]
toJSON(c(TRUE, NA))
#> [true,null]
toJSON(c("a", NA))
#> ["a",null]

# Dates, date-times and factors.
toJSON(shelf_today)
#> ["2026-06-30"]
toJSON(shelf_today, Date = "epoch")
#> [20634]
moment <- as.POSIXct("2026-06-30 12:30:00", tz = "UTC")
toJSON(moment)
#> ["2026-06-30 12:30:00"]
toJSON(moment, POSIXt = "ISO8601")
#> ["2026-06-30T12:30:00"]
toJSON(moment, POSIXt = "epoch")
#> [1782822600000]
toJSON(moment, POSIXt = "mongo")
#> [{"$date":1782822600000}]
toJSON(factor(c("b", "a", "b")))
#> ["b","a","b"]
toJSON(factor(c("b", "a", "b")), factor = "integer")
#> [2,1,2]

# Matrices are arrays of rows (rowmajor) or of columns.
toJSON(matrix(1:6, nrow = 2))
#> [[1,3,5],[2,4,6]]
toJSON(matrix(1:6, nrow = 2), matrix = "columnmajor")
#> [[1,2],[3,4],[5,6]]

# A classed object has no JSON mapping: unclass() it, or force = TRUE.
dune <- book("Dune", "Frank Herbert", year = 1965, pages = 412)
tryCatch(toJSON(dune), error = \(e) conditionMessage(e))
#> [1] "No method asJSON S3 class: book"
toJSON(unclass(dune), auto_unbox = TRUE, null = "null")
#> {"title":"Dune","author":"Frank Herbert","isbn":null,"year":1965,"genre":null,"pages":412}
toJSON(dune, force = TRUE, auto_unbox = TRUE, null = "null")
#> {"title":"Dune","author":"Frank Herbert","isbn":null,"year":1965,"genre":null,"pages":412}
tryCatch(toJSON(globalenv()), error = \(e) conditionMessage(e))
#> [1] "No method asJSON S3 class: environment"

# toJSON() returns a json object: a character string with a class.
js <- toJSON(three)
class(js)
#> [1] "json"
nchar(js)
#> [1] 355

The decisions that matter, in the order they cause surprises:

  • Everything is an array until it is unboxed. R has no scalars, only vectors of length one, so toJSON("Dune") is ["Dune"]. auto_unbox = TRUE writes length-one vectors as plain values; the help page warns that it is usually safer to avoid it and to use unbox() on individual elements, because a vector that happens to have length one in one call and three in another then changes its JSON type. An object wrapped in I() is not unboxed automatically, which is the way to keep a length-one array.

  • A data frame is an array of objects by default. dataframe = "rows" writes one object per row, and a field that is NA is left out of that row. The row-wise form is what web APIs expect and what fromJSON() turns back into a data frame; "columns" writes an object of arrays (shorter, and every column is explicit), and "values" an array of arrays without names. With na = "null" the key stays with a null value, and na = "string" writes the text "NA".

  • The default of na depends on the type. The help page says its defaults are class specific: a numeric NA is written as the string "NA" (so are NaN and Inf, which JSON cannot express), a logical or character NA as null. Mixed arrays like [1,"NA","NaN","Inf"] are legal JSON and a nuisance for the reader; use na = "null" for numbers if the consumer expects numbers or null.

  • Numbers have digits = 4 by default, which is why pi becomes 3.1416. digits = 10 gives ten decimal places, digits = I(3) means three significant digits, and NA prints the maximum precision of 15 significant digits. A precision lost here is lost for good.

  • Dates are strings. A Date becomes "2026-06-30" (ISO 8601) or the number of days with Date = "epoch"; a POSIXct becomes a string by default, an ISO 8601 string with POSIXt = "ISO8601", milliseconds since the epoch with "epoch", or the MongoDB form with "mongo". Time zones are the reader’s problem: the string form carries none.

  • Factors are their labels unless factor = "integer".

  • Matrices are arrays of rows ("rowmajor") or of columns.

  • A classed object without a mapping is an error, with the message No method asJSON S3 class: book. Either unclass() it (and decide how to handle the missing fields: here isbn and genre become null because null = "null" was asked for), or pass force = TRUE, which unclasses and skips objects of classes with no mapping. An environment cannot be written at all.

Reading JSON: fromJSON and simplification

fromJSON(txt, simplifyVector = TRUE, simplifyDataFrame = simplifyVector, simplifyMatrix = simplifyVector, flatten = FALSE) parses a JSON string and then simplifies the result according to three rules. Simplification, in the vignette’s words, is the process by which JSON arrays automatically get converted from a list into a more specific R class.

JSON structure Example Becomes Argument

array of primitives

["a", "b", null]

atomic vector (null becomes NA; mixed types are coerced to the most general)

simplifyVector

array of objects

[{"a": 1}, {"a": 2}]

data frame (missing keys become NA)

simplifyDataFrame

array of equal-length arrays

[[1, 2], [3, 4]]

matrix, or an array of higher dimension

simplifyMatrix

object

{"a": 1}

named list

none

nested object inside a record

{"who": {"name": "Ana"}}

a data frame column that is itself a data frame, or flat columns with flatten = TRUE

flatten

library(jsonlite)
source("scenario.R")

# Arrays of primitives become atomic vectors; a null becomes NA.
fromJSON("[1, 2, 3]")
#> [1] 1 2 3
fromJSON("[1, 2.5, null]")
#> [1] 1.0 2.5  NA
fromJSON('["a", null]')
#> [1] "a" NA
fromJSON("[true, false]")
#> [1]  TRUE FALSE
# A mixed array is coerced to the most general type.
fromJSON('[1, "a"]')
#> [1] "1" "a"
class(fromJSON("[1, 2]"))
#> [1] "integer"
class(fromJSON("[1.0, 2.0]"))
#> [1] "numeric"

# Objects become named lists, and simplifyVector = FALSE keeps arrays as lists.
book_txt <- '{"title": "Dune", "pages": [412, 5], "publisher": {"name": "Ace"}}'
str(fromJSON(book_txt))
#> List of 3
#>  $ title    : chr "Dune"
#>  $ pages    : int [1:2] 412 5
#>  $ publisher:List of 1
#>   ..$ name: chr "Ace"
str(fromJSON(book_txt, simplifyVector = FALSE))
#> List of 3
#>  $ title    : chr "Dune"
#>  $ pages    :List of 2
#>   ..$ : int 412
#>   ..$ : int 5
#>  $ publisher:List of 1
#>   ..$ name: chr "Ace"

# An array of equal-length arrays becomes a matrix, unless told not to.
str(fromJSON("[[1, 2], [3, 4]]"))
#>  int [1:2, 1:2] 1 3 2 4
str(fromJSON("[[1, 2], [3, 4]]", simplifyMatrix = FALSE))
#> List of 2
#>  $ : int [1:2] 1 2
#>  $ : int [1:2] 3 4

# An array of objects becomes a data frame; missing keys become NA.
str(fromJSON('[{"a": 1, "b": "x"}, {"a": 2}]'))
#> 'data.frame':	2 obs. of  2 variables:
#>  $ a: int  1 2
#>  $ b: chr  "x" NA
str(fromJSON('[{"a": 1, "b": "x"}, {"a": 2}]', simplifyDataFrame = FALSE))
#> List of 2
#>  $ :List of 2
#>   ..$ a: int 1
#>   ..$ b: chr "x"
#>  $ :List of 1
#>   ..$ a: int 2

# Nested objects become nested data frames, and flatten = TRUE unnests them.
nested <- paste0(
  '[{"id": 1, "who": {"name": "Ana", "city": "Porto"}, "tags": ["a", "b"]},',
  ' {"id": 2, "who": {"name": "Ben", "city": "Oslo"}, "tags": ["c"]}]'
)
loans_nested <- fromJSON(nested)
str(loans_nested)
#> 'data.frame':	2 obs. of  3 variables:
#>  $ id  : int  1 2
#>  $ who :'data.frame':	2 obs. of  2 variables:
#>   ..$ name: chr  "Ana" "Ben"
#>   ..$ city: chr  "Porto" "Oslo"
#>  $ tags:List of 2
#>   ..$ : chr  "a" "b"
#>   ..$ : chr "c"
names(loans_nested$who)
#> [1] "name" "city"
str(fromJSON(nested, flatten = TRUE))
#> 'data.frame':	2 obs. of  4 variables:
#>  $ id      : int  1 2
#>  $ tags    :List of 2
#>   ..$ : chr  "a" "b"
#>   ..$ : chr "c"
#>  $ who.name: chr  "Ana" "Ben"
#>  $ who.city: chr  "Porto" "Oslo"
identical(flatten(loans_nested), fromJSON(nested, flatten = TRUE))
#> [1] TRUE

# Round trip of the shelf data. Row names, Dates and NA need care.
three <- books[c(1, 7, 9), ]
back <- fromJSON(toJSON(three))
all.equal(back, three)
#> [1] "Attributes: < Component “row.names”: Mean relative difference: 2.2 >"
rownames(three) <- NULL
identical(back, three)
#> [1] TRUE
str(fromJSON(toJSON(loans[1:2, ])))
#> 'data.frame':	2 obs. of  5 variables:
#>  $ loan_id    : chr  "L001" "L002"
#>  $ isbn       : chr  "9780141439518" "9780306406157"
#>  $ member     : chr  "Hugo Silva" "Ben Okafor"
#>  $ loan_date  : chr  "2025-01-14" "2025-01-16"
#>  $ return_date: chr  "2025-02-04" "2025-01-26"
loans_back <- fromJSON(toJSON(loans))
loans_back$loan_date <- as.Date(loans_back$loan_date)
loans_back$return_date <- as.Date(loans_back$return_date)
all.equal(loans_back, loans)
#> [1] TRUE

# Empty values and scalars.
fromJSON("[]")
#> list()
fromJSON("{}")
#> named list()
fromJSON("null")
#> NULL
fromJSON("42")
#> [1] 42
fromJSON('"caf\\u00e9"')
#> [1] "café"

# Invalid JSON: the message has a pointer line, so keep the first line only.
first_line <- \(e) sub("\n.*", "", conditionMessage(e))
tryCatch(fromJSON('{"a": 1,}'), error = first_line)
#> [1] "parse error: invalid object key (must be a string)"
tryCatch(fromJSON("not json"), error = first_line)
#> [1] "lexical error: invalid string in json text."
tryCatch(fromJSON(""), error = first_line)
#> [1] "parse error: premature EOF"

# validate() answers TRUE or FALSE, with the position and the error attached.
validate('{"a": 1}')
#> [1] TRUE
bad <- validate('{"a": 1,}')
bad[[1L]]
#> [1] FALSE
attr(bad, "offset")
#> [1] 9
sub("\n.*", "", attr(bad, "err"))
#> [1] "parse error: invalid object key (must be a string)"

# prettify() and minify() change only whitespace.
compact <- '{"a":1,"b":[1,2]}'
cat(prettify(compact, indent = 2))
#> {
#>   "a": 1,
#>   "b": [
#>     1,
#>     2
#>   ]
#> }
minify(prettify(compact)) == compact
#> [1] TRUE

Four details in the output. The class of a number array depends on how the numbers are written: [1, 2] is integer, [1.0, 2.0] is double. A JSON object with a nested object produces a nested data frame (who is a column holding a data frame with name and city), and flatten = TRUE (or flatten() afterwards, which gives the same result) unnests it into who.name and who.city; a column of arrays such as tags stays a list column, because the arrays have different lengths. The data frame round trip is exact only when the row names are the default ones: the three-row subset kept the row names 1, 7 and 9, which JSON does not carry, so all.equal() reported a difference in row.names until they were reset. And dates do not survive: they come back as strings and have to be converted with as.Date(), because JSON has no date type. A package built on this can keep the conversions in one function, in the same way that Data Frames describes for CSV.

Errors from the parser have two lines, the message and a pointer into the text; the script shows only the first. validate() returns TRUE or a FALSE carrying the position of the error as the attribute offset and the parser message as err, which makes it the cheap way to check a payload before parsing it. One warning from the help page for read_json() belongs here: fromJSON(txt) accepts a JSON string, a URL or a file name, so a string that looks like a URL or a path is fetched or read, not parsed. read_json(path) takes only a path and parse_json(json) only literal JSON, which is why they are the better choice when the argument comes from outside, and they do not simplify unless asked.

prettify(txt, indent = 4) adds indentation and minify(txt) removes all whitespace; neither changes the meaning, and the pretty argument of toJSON() does the first of them directly.

Files, pages and NDJSON

write_json(x, path, ...) and read_json(path, simplifyVector = FALSE) are the file versions. stream_in() and stream_out() process JSON line by line over a connection, in the NDJSON format (also called JSON Lines): one minified JSON record per line, so the whole stream is not valid JSON, but every line is. This is how large data sets move between databases such as MongoDB and R, and it is what makes processing in pages possible: stream_in(con, handler = f, pagesize = n) calls f on each page of n records and keeps nothing, so memory use stays flat; without a handler it collects and binds all pages into one data frame. verbose = TRUE (the default) prints progress messages to the console, which is noisy in scripts and in reports; the examples switch it off. Because R destroys a connection when it is closed, a connection cannot be reused after stream_in() closes it; open it yourself first to use it for several calls.

library(jsonlite)
source("scenario.R")

# write_json() and read_json() take a path; read_json() does not simplify.
path <- tempfile(fileext = ".json")
write_json(books[1:2, ], path, pretty = TRUE, auto_unbox = TRUE)
readLines(path, 9L)
#> [1] "["
#> [2] "  {"
#> [3] "    \"title\": \"The Dispossessed\","
#> [4] "    \"author\": \"Ursula K. Le Guin\","
#> [5] "    \"isbn\": \"978-0-306-40615-7\","
#> [6] "    \"year\": 1974,"
#> [7] "    \"genre\": \"Science fiction\","
#> [8] "    \"pages\": 387"
#> [9] "  },"
shelf <- read_json(path)
class(shelf)
#> [1] "list"
length(shelf)
#> [1] 2
str(shelf[[1L]])
#> List of 6
#>  $ title : chr "The Dispossessed"
#>  $ author: chr "Ursula K. Le Guin"
#>  $ isbn  : chr "978-0-306-40615-7"
#>  $ year  : int 1974
#>  $ genre : chr "Science fiction"
#>  $ pages : int 387
str(read_json(path, simplifyVector = TRUE))
#> 'data.frame':	2 obs. of  6 variables:
#>  $ title : chr  "The Dispossessed" "Pride and Prejudice"
#>  $ author: chr  "Ursula K. Le Guin" "Jane Austen"
#>  $ isbn  : chr  "978-0-306-40615-7" "978-0-14-143951-8"
#>  $ year  : int  1974 1813
#>  $ genre : chr  "Science fiction" "Classic"
#>  $ pages : int  387 432

# parse_json() reads a literal string, with the same defaults as read_json().
str(parse_json('{"a": [1, 2]}'))
#> List of 1
#>  $ a:List of 2
#>   ..$ : int 1
#>   ..$ : int 2

# NDJSON: one minified object per line, so a file can be read in pages.
ndjson_path <- tempfile(fileext = ".ndjson")
sink_con <- file(ndjson_path, "w")
stream_out(loans[1:5, c("loan_id", "member")], sink_con, verbose = FALSE)
close(sink_con)
readLines(ndjson_path, 2L)
#> [1] "{\"loan_id\":\"L001\",\"member\":\"Hugo Silva\"}"
#> [2] "{\"loan_id\":\"L002\",\"member\":\"Ben Okafor\"}"
str(stream_in(file(ndjson_path), verbose = FALSE))
#> 'data.frame':	5 obs. of  2 variables:
#>  $ loan_id: chr  "L001" "L002" "L003" "L004" ...
#>  $ member : chr  "Hugo Silva" "Ben Okafor" "Dev Patel" "Eva Lindqvist" ...

# A handler sees each page and nothing is kept in memory.
seen <- 0L
stream_in(
  file(ndjson_path),
  handler = \(page) {
    seen <<- seen + nrow(page) # nolint: assignment_linter.
    cat("page of", nrow(page), "rows\n")
  },
  pagesize = 2L,
  verbose = FALSE
)
#> page of 2 rows
#> page of 2 rows
#> page of 1 rows
seen
#> [1] 5

# Lines already in R can be read through a text connection.
str(stream_in(textConnection(c('{"a":1}', '{"a":2,"b":"x"}')), verbose = FALSE))
#> 'data.frame':	2 obs. of  2 variables:
#>  $ a: int  1 2
#>  $ b: chr  NA "x"
unlink(c(path, ndjson_path))

Paginated web APIs, which return one page of records per request, are a related case: rbind_pages() binds a list of such pages into one data frame, filling missing columns with NA (jsonlite package documentation: rbind_pages). Fetching the pages is the subject of HTTP and Web APIs.

Round-tripping R types: serializeJSON

toJSON() maps classes; serializeJSON() maps storage types. It records the type and the attributes of every part, so unserializeJSON() can restore an object almost perfectly, classes included, at the cost of very verbose JSON that only R is going to read. Apart from environments, all standard storage types are supported.

library(jsonlite)
source("scenario.R")

# serializeJSON() records types and attributes, so the round trip is exact.
two <- books[1:2, ]
two_json <- serializeJSON(two)
substr(two_json, 1, 120)
#> {"type":"list","attributes":{"names":{"type":"character","attributes":{},"value":["title","author","isbn","year","genre"
identical(unserializeJSON(two_json), two)
#> [1] TRUE

# The book object keeps its class (and so its print method).
dune <- book("Dune", "Frank Herbert", year = 1965, pages = 412)
dune_json <- serializeJSON(dune)
substr(dune_json, 1, 150)
#> {"type":"list","attributes":{"names":{"type":"character","attributes":{},"value":["title","author","isbn","year","genre","pages"]},"class":{"type":"ch
dune_again <- unserializeJSON(dune_json)
dune_again
#> <book> Dune (Frank Herbert, 1965)
identical(dune_again, dune)
#> [1] TRUE

# The price is size. One small object grows several times over; a table is
# stored column by column, so it can even be shorter than rows of objects.
slim <- toJSON(unclass(dune), auto_unbox = TRUE, null = "null")
c(toJSON = nchar(slim), serializeJSON = nchar(dune_json))
#>        toJSON serializeJSON
#>            90           524
c(toJSON = nchar(toJSON(books)), serializeJSON = nchar(serializeJSON(books)))
#>        toJSON serializeJSON
#>          1899          1736
identical(unserializeJSON(serializeJSON(books)), books)
#> [1] TRUE

# JSON is text, so numbers are printed with a limited number of digits.
third <- 1 / 3
identical(unserializeJSON(serializeJSON(third)), third)
#> [1] FALSE
serializeJSON(third)
#> {"type":"double","attributes":{},"value":[0.33333333]}
serializeJSON(third, digits = 4)
#> {"type":"double","attributes":{},"value":[0.3333]}
unserializeJSON(serializeJSON(third, digits = 4)) == third
#> [1] FALSE

# Environments are not supported: only an empty shell is written.
serializeJSON(globalenv())
#> {"type":"environment","attributes":{},"value":{}}

# The compact alternatives for the same table, side by side.
three <- books[c(1, 7, 9), ]
c(rds = length(serialize(three, NULL)), json = nchar(toJSON(three)))
#>  rds json
#>  558  355

This is not a replacement for an RDS file: the format is text, so numbers are printed with limited digits (the default digits = 8; the help page notes that JSON loses precision when printing numbers, as the failed identical() on one third shows), environments are written as an empty shell, and the output is several times longer for small objects. Its niche is a text field, a log line or a message queue that must carry an arbitrary R object between R processes, and a debugging aid for seeing what an object is made of.

Which format keeps what

The same table through three formats, with the check that matters: is what comes back the same object?

library(jsonlite)
source("scenario.R")

# The same table, written and read back through three formats.
check <- function(back, original) {
  c(
    identical = identical(back, original),
    same_classes = identical(
      vapply(back, \(column) class(column)[[1L]], character(1)),
      vapply(original, \(column) class(column)[[1L]], character(1))
    ),
    all_equal = isTRUE(all.equal(back, original))
  )
}
vapply(loans, \(column) class(column)[[1L]], character(1))
#>     loan_id        isbn      member   loan_date return_date
#> "character" "character" "character"      "Date"      "Date"

# RDS: R's own format, exact.
rds <- tempfile(fileext = ".rds")
saveRDS(loans, rds)
check(readRDS(rds), loans)
#>    identical same_classes    all_equal
#>         TRUE         TRUE         TRUE

# CSV: text, so the Date columns come back as character unless converted.
csv <- tempfile(fileext = ".csv")
write.csv(loans, csv, row.names = FALSE)
readLines(csv, 3L)
#> [1] "\"loan_id\",\"isbn\",\"member\",\"loan_date\",\"return_date\""
#> [2] "\"L001\",\"9780141439518\",\"Hugo Silva\",2025-01-14,2025-02-04"
#> [3] "\"L002\",\"9780306406157\",\"Ben Okafor\",2025-01-16,2025-01-26"
from_csv <- read.csv(csv, colClasses = "character")
check(from_csv, loans)
#>    identical same_classes    all_equal
#>        FALSE        FALSE        FALSE
from_csv$loan_date <- as.Date(from_csv$loan_date)
from_csv$return_date <- as.Date(from_csv$return_date)
check(from_csv, loans)
#>    identical same_classes    all_equal
#>         TRUE         TRUE         TRUE

# JSON: the same story, and NA fields are dropped from each row on the way
# out and come back as NA.
from_json <- fromJSON(toJSON(loans))
check(from_json, loans)
#>    identical same_classes    all_equal
#>        FALSE        FALSE        FALSE
from_json$loan_date <- as.Date(from_json$loan_date)
from_json$return_date <- as.Date(from_json$return_date)
check(from_json, loans)
#>    identical same_classes    all_equal
#>         TRUE         TRUE         TRUE
unlink(c(rds, csv))

RDS returns exactly what was saved. CSV and JSON return strings for the two Date columns, so identical() is FALSE until they are converted, and then it is TRUE: the shelf data survive because they contain no information that the text formats lack, other than the type of the dates. A table with factors, list columns, attributes or time zones would not be so lucky. This is the trade at the boundary: interchange formats give up fidelity for readability by other programs, and the reader must restate the types.

YAML, XML and columnar formats

YAML is a text format for nested data with an emphasis on being easy for people to read and write, which is why it is used for configuration. R Data Import/Export names the yaml package as the R interface (R Data Import/Export: XML). yaml 2.3.12 (labelled package documentation) provides as.yaml() and write_yaml() to write, yaml.load() and read_yaml() to read.

library(yaml)
source("scenario.R")

# A configuration for the shelf, written as YAML text.
config <- list(
  shelf = list(
    name = "Main shelf",
    loan_days = loan_days,
    genres = c("Fiction", "Classic"),
    open = TRUE,
    closed_on = NULL
  )
)
text <- as.yaml(config)
cat(text)
#> shelf:
#>   name: Main shelf
#>   loan_days: 21
#>   genres:
#>   - Fiction
#>   - Classic
#>   open: yes
#>   closed_on: ~
str(yaml.load(text))
#> List of 1
#>  $ shelf:List of 5
#>   ..$ name     : chr "Main shelf"
#>   ..$ loan_days: int 21
#>   ..$ genres   : chr [1:2] "Fiction" "Classic"
#>   ..$ open     : logi TRUE
#>   ..$ closed_on: NULL

# Files: write_yaml() and read_yaml().
path <- tempfile(fileext = ".yml")
write_yaml(config, path)
identical(read_yaml(path), config)
#> [1] TRUE
unlink(path)

# YAML 1.1 reads yes and no as logicals; as.yaml() quotes the strings that
# would be misread, and a string that looks like a number stays a string.
str(yaml.load("a: yes\nb: no\nc: 1.0\nd: '1.0'\ne: ~"))
#> List of 5
#>  $ a: logi TRUE
#>  $ b: logi FALSE
#>  $ c: num 1
#>  $ d: chr "1.0"
#>  $ e: NULL
cat(as.yaml(list(a = "yes", b = "1.0", c = NA, d = 2.5)))
#> a: 'yes'
#> b: '1.0'
#> c: .na
#> d: 2.5

# !expr tags are not evaluated unless you ask, which matters for untrusted
# files.
tryCatch(yaml.load("a: !expr 1 + 1"), warning = \(w) conditionMessage(w))
#> [1] "Evaluating R expressions (!expr) requires explicit `eval.expr=TRUE` option (see yaml.load help)"
str(yaml.load("a: !expr 1 + 1", eval.expr = TRUE))
#> List of 1
#>  $ a: num 2

# A broken document gives a parser error.
tryCatch(yaml.load("a: [1, 2"), error = \(e) conditionMessage(e))
#> [1] "Parser error: while parsing a flow sequence at line 1, column 4 did not find expected ',' or ']' at line 2, column 1"

Two properties of YAML are worth knowing. The package reads yes and no as logicals (YAML 1.1 booleans), and as.yaml() therefore quotes the strings "yes" and "no" when it writes them; a country code such as no in a hand-written file turns into FALSE. And a tag !expr asks the parser to evaluate R code: yaml.load() leaves the text alone and warns that evaluating requires an explicit eval.expr = TRUE, which is the same rule as the one above for RData, applied by default. Do not pass eval.expr = TRUE to a file you did not write. The reference pages are yaml.load, read_yaml and write_yaml.

XML is the other structured text format, and R Data Import/Export notes that the XML package provides general facilities and that xml2 is another interface to the libxml2 C library; xml2 1.6.0 was installed for this page but not used, and read_xml() is where to start (xml2 package documentation: read_xml).

Parquet and Feather are columnar binary formats read by Python, Spark, DuckDB and many other systems; in R they come with the arrow package (arrow documentation for R), with write_parquet() and read_parquet() (arrow package documentation: read_parquet). arrow was not installed for this page, so nothing was run and the fragment below is only the shape of the calls. For a large table that other programs also read, a columnar file is the better interchange format than CSV or JSON (it keeps column types and is usually much smaller), and the only one here that does not require parsing text.

# Fragment, not run: arrow is not installed here.
arrow::write_parquet(books, "books.parquet")
parquet_books <- arrow::read_parquet("books.parquet")
arrow::write_feather(books, "books.feather")

R Data Import/Export lists HDF5 and netCDF packages (h5, rhdf5, RNetCDF, ncdf4) under binary data formats, for array-oriented scientific data (R Data Import/Export: Binary data formats), and spreadsheets and the formats of other statistical systems are in Reading and Writing Text Data.

Pitfalls

  • load() overwrites silently. An object with the same name in the target environment is replaced without a message. Use envir = (or local()), and check the returned names.

  • readRDS() of an RData file and load() of an RDS file both fail (unknown input format and bad restore file magic number, the latter after two warnings about the header). The extension does not decide; the writer does.

  • A workspace image is not an archive. save.image() saves everything, including helpers that depended on the state of that session. Save the data and the script that rebuilds the rest (R FAQ 7.2 describes the mechanism; see Sessions, Workspaces and Startup for turning the automatic restore off).

  • Binary serializations are not strings. rawToChar(serialize(x, NULL)) fails with an embedded nul; the ASCII form works, and so does base64.

  • dput() is not a transport. It drops the environment of a function, may not rebuild unusual objects, and writes 15 digits unless asked for 17.

  • A formula or closure drags its environment into the file. Check ls(environment(f)) before saving a model.

  • Handles do not survive. Database connections, sockets and other external pointers cannot be restored in another process; save the data and reconnect.

  • Files written by a newer R may not load in an older one. The default version has been 3 since R 3.6.0; use version = 2 for readers older than 3.5.0.

  • Compression numbers are indicative. Do not hard-code an expectation about file size; measure on your own data.

  • JSON loses types. Dates become strings, factors become labels, NA in numeric vectors becomes the text "NA" by default, numbers are cut to four decimal digits by default, and a length-one vector is an array unless unboxed.

  • fromJSON() accepts URLs and file names as well as text. Use parse_json() for literal strings and read_json() for paths.

  • Reading untrusted RData, RDS or YAML with expression evaluation is running someone else’s code.

Where older tutorials differ

Older tutorial writes Current practice Why Since

load("data.RData") at the top of a script, then use the names

e <- new.env(); load("data.RData", envir = e), or readRDS() for a single object

load() overwrites objects of the same name in the calling environment, and the names are not visible in the code

always

attach("data.RData") to avoid overwriting

envir = or readRDS()

attach() adds an entry to the search path that changes how later code finds objects; the help page offers it as safer than load(), but it only warns about masked objects

always

save.image() at the end of every session, and .RData restored at the start

scripts that rebuild the objects, saveRDS() for slow results, q("no")

the restored workspace hides what the analysis depends on; see the sessions page

always

readRDS() and saveRDS() under their old names .readRDS() and .saveRDS()

readRDS() and saveRDS()

the dotted names are defunct

R 4.0.0

save(..., ascii = TRUE) "so other platforms can read it"

the default binary format, which is XDR on every platform; JSON or CSV for other programs

the help page calls ASCII saves mainly of historical interest: they are bigger, slower and may not restore doubles exactly

R 3.2.0 added ascii = NA for exact text

dput() and dump() as a way of saving results

saveRDS()

dput() does not handle all objects and drops environments; the help page says it is not a good way to transfer objects

always

rjson::toJSON() or RJSONIO::toJSON()

jsonlite::toJSON()

jsonlite documents its toJSON() and fromJSON() as drop-in replacements with a more consistent mapping, and also provides write_json(), stream_in() and serializeJSON(); check the arguments, because the defaults differ

jsonlite 2.0.0 checked here

fromJSON(file_or_text) with whatever is at hand

read_json(path) for files, parse_json(text) for literal text

fromJSON() guesses between a URL, a file and a string

always

serialize(x, NULL) kept as the long-term format of a model

a versioned format of your own: CSV or Parquet for data, saveRDS() plus the R version for objects

the serialize() help page warns that the format may change in a future version

always

readRDS() on any .rds found online

only on files from sources you control

a deserializer can run code; CVE-2024-27322 affected R before 4.4.0

R 4.4.0 (fix)

Where to go next