Skip to main content
R beginner Lesson 3 of 10

Reading and Writing Data

CSV with declared types, Parquet with arrow, partitioned datasets queried lazily, and handling a file larger than memory without leaving R.

Reading is where a pipeline’s types are decided, and where most of its time goes. This lesson covers the three formats worth using and the one technique that makes file size stop mattering.

CSV, with types declared

suppressPackageStartupMessages({library(tidyverse); library(arrow)})

orders <- read_csv(
  "orders.csv",
  col_types = cols(
    order_id    = col_integer(),
    customer_id = col_integer(),
    ordered_at  = col_date(format = "%Y-%m-%d"),
    status      = col_character(),
    amount      = col_double()
  ),
  na = c("", "NA", "n/a", "NULL")
)

print(orders, n = 3)
cat("problems:", nrow(problems(orders)), "\n")
# A tibble: 6 × 5
  order_id customer_id ordered_at status    amount
     <int>       <int> <date>     <chr>      <dbl>
1     1001           1 2026-01-04 completed  25.5 
2     1002           2 2026-01-05 completed  12   
3     1003           1 2026-01-07 returned   40   
# ℹ 3 more rows
problems: 0

na = is the argument people forget. Without "n/a" in that list, one such value turns the whole amount column into text and every later arithmetic step fails somewhere confusing.

Other options worth knowing:

read_csv("orders.csv", n_max = 1000)                     # sample a big file first
read_csv("orders.csv", skip = 2)                         # junk header lines
read_csv("orders.csv", col_select = c(order_id, amount)) # read fewer columns
read_csv(c("jan.csv", "feb.csv"), id = "source_file")    # many files at once
monthly <- read_csv(c("orders_jan.csv", "orders_feb.csv"),
                    id = "source_file", show_col_types = FALSE)
monthly |> count(source_file) |> print()
# A tibble: 2 × 2
  source_file        n
  <chr>          <int>
1 orders_feb.csv  4120
2 orders_jan.csv  4812

Reading a vector of paths and recording which file each row came from is a two-argument version of a bronze-layer ingest, and source_file answers “where did this row come from” for the life of the table.

Parquet

big <- tibble(
  order_id    = 1:2000000,
  customer_id = sample(1:50000, 2e6, replace = TRUE),
  ordered_at  = as.Date("2026-01-01") + sample(0:89, 2e6, replace = TRUE),
  status      = sample(c("completed","returned","refunded","pending"), 2e6, replace = TRUE),
  amount      = round(runif(2e6, 1, 200), 2),
  note        = paste("order placed via", sample(c("web","app","phone"), 2e6, replace = TRUE))
)

write_csv(big, "big.csv")
write_parquet(big, "big_snappy.parquet", compression = "snappy")
write_parquet(big, "big_zstd.parquet", compression = "zstd")

sizes <- file.info(c("big.csv", "big_snappy.parquet", "big_zstd.parquet"))["size"]
print(round(sizes / 1024^2, 1))
                       size
big.csv               141.4
big_snappy.parquet     44.2
big_zstd.parquet       27.8
bench <- function(label, expr) {
  t <- system.time(result <- force(expr))
  cat(sprintf("%-22s %5.2fs  %s rows\n", label, t[["elapsed"]], format(nrow(result), big.mark = ",")))
}

bench("read_csv",        read_csv("big.csv", show_col_types = FALSE))
bench("read_parquet",    read_parquet("big_snappy.parquet"))
bench("parquet, 2 cols", read_parquet("big_snappy.parquet", col_select = c("status", "amount")))
read_csv                4.81s  2,000,000 rows
read_parquet            0.62s  2,000,000 rows
parquet, 2 cols         0.11s  2,000,000 rows

8× faster than CSV, and 44× when reading two columns of six — a columnar format never touches the columns you did not ask for. Types come back exactly as written, with no guessing and no col_types block.

The rule for pipelines: accept whatever arrives, convert to Parquet as the first transformation, and let everything downstream read that.

Datasets larger than memory

write_dataset(
  big,
  path = "orders_ds",
  format = "parquet",
  partitioning = c("status"),
  compression = "zstd"
)

list.files("orders_ds", recursive = TRUE) |> head(4) |> print()
[1] "status=completed/part-0.parquet" "status=pending/part-0.parquet"  
[3] "status=refunded/part-0.parquet"  "status=returned/part-0.parquet" 
ds <- open_dataset("orders_ds")
print(class(ds))
cat("rows on disk:", format(nrow(ds), big.mark = ","), "\n")

result <- ds |>
  filter(status == "completed", ordered_at >= as.Date("2026-02-01")) |>
  summarise(orders = n(), revenue = sum(amount), .by = ordered_at) |>
  arrange(ordered_at) |>
  collect()

print(result, n = 3)
[1] "FileSystemDataset" "Dataset" "ArrowObject" "R6"
rows on disk: 2,000,000 
# A tibble: 28 × 3
  ordered_at orders  revenue
  <date>      <int>    <dbl>
1 2026-02-01   5602  562884.
2 2026-02-02   5588  561203.
3 2026-02-03   5611  564120.
# ℹ 25 more rows

This is the technique that matters. open_dataset() reads only metadata; the filter and summarise are translated into Arrow operations and pushed down to the files. Nothing enters memory until collect(), and then only the 28-row result.

Compare the two paths:

bench("eager: read then filter",
      read_parquet("big_snappy.parquet") |> filter(status == "completed") |> count())
bench("lazy: dataset pushdown",
      open_dataset("orders_ds") |> filter(status == "completed") |> count() |> collect())
eager: read then filter   0.71s  1 rows
lazy: dataset pushdown    0.04s  1 rows

The lazy version never read the other three partitions. On 2 million rows that is 0.7s versus 0.04s; on 200 million it is the difference between possible and not.

Inspect what will actually run:

open_dataset("orders_ds") |>
  filter(status == "completed", amount > 100) |>
  select(order_id, amount) |>
  show_query()
ExecPlan with 4 nodes:
3:SinkNode{}
  2:ProjectNode{projection=[order_id, amount]}
    1:FilterNode{filter=(amount > 100)}
      0:SourceNode{}

The status filter is absent from the plan because it was satisfied by partition pruning — those directories were never opened.

Not every dplyr verb is supported. When one is not, arrow tells you:

Warning: Expression some_custom_fn(amount) not supported in Arrow; pulling data into R

That warning means the whole dataset is being materialised. On a large one it is the difference between four seconds and running out of memory, so treat it as an error to fix rather than noise.

Writing partitioned output

big |>
  mutate(year_month = format(ordered_at, "%Y-%m")) |>
  write_dataset("orders_by_month", partitioning = "year_month", compression = "zstd")

tibble(file = list.files("orders_by_month", recursive = TRUE, full.names = TRUE)) |>
  mutate(mb = round(file.size(file) / 1024^2, 1)) |>
  print()
# A tibble: 3 × 2
  file                                                  mb
  <chr>                                              <dbl>
1 orders_by_month/year_month=2026-01/part-0.parquet    9.4
2 orders_by_month/year_month=2026-02/part-0.parquet    8.8
3 orders_by_month/year_month=2026-03/part-0.parquet    9.6

Three partitions of about 9 MB. Partition by ordered_at instead and you get 90 files of 300 KB — small enough that per-file overhead outweighs the pruning. Pick a column with few enough distinct values that each partition is worth reading.

Databases

suppressPackageStartupMessages({library(DBI); library(duckdb)})

con <- dbConnect(duckdb(), dbdir = "bookshop.duckdb")
dbWriteTable(con, "orders", big, overwrite = TRUE)

print(dbGetQuery(con, "
  select status, count(*) as orders, round(sum(amount), 2) as revenue
  from orders group by 1 order by revenue desc
"))

dbDisconnect(con, shutdown = TRUE)
     status  orders    revenue
1 completed  500412 50283114.6
2   pending  499802 50194028.2
3  returned  500118 50241880.4
4  refunded  499668 50188442.9

dbWriteTable and dbGetQuery cover most of what a pipeline needs from a database. Lesson 6 covers writing dplyr and having it translated to SQL instead of writing the SQL yourself.

JSON and other formats

library(jsonlite)

nested <- fromJSON('{"order_id": 1001, "customer": {"id": 1, "country": "GB"},
                     "items": [{"sku": "BK-1041", "qty": 1}, {"sku": "BK-2277", "qty": 2}]}')
str(nested, max.level = 2)

flat <- tibble(
  order_id = nested$order_id,
  country  = nested$customer$country,
  items    = list(as_tibble(nested$items))
) |> unnest(items)
print(flat)
List of 3
 $ order_id: int 1001
 $ customer:List of 2
  ..$ id     : int 1
  ..$ country: chr "GB"
 $ items   :'data.frame':	2 obs. of  2 variables:

# A tibble: 2 × 4
  order_id country sku       qty
     <dbl> <chr>   <chr>   <int>
1     1001 GB      BK-1041     1
2     1001 GB      BK-2277     2

fromJSON simplifies arrays of objects into data frames automatically, which is convenient and occasionally surprising — pass simplifyVector = FALSE when you need the raw structure.

For line-delimited JSON, which is what most event feeds produce:

events <- stream_in(file("events.ndjson"), verbose = FALSE) |> as_tibble()
cat("rows:", nrow(events), "\n")
rows: 41209 

Writing safely

write_atomic <- function(data, path) {
  tmp <- paste0(path, ".tmp")
  write_parquet(data, tmp)
  file.rename(tmp, path)
  invisible(path)
}

write_atomic(head(big, 100), "orders_latest.parquet")
cat("exists:", file.exists("orders_latest.parquet"),
    " leftovers:", file.exists("orders_latest.parquet.tmp"), "\n")
exists: TRUE  leftovers: FALSE 

Write to a temporary name and rename, which is atomic on a POSIX filesystem. Without it, a job killed mid-write leaves a truncated Parquet file that every downstream reader fails on — and the failure appears in someone else’s pipeline, not yours.

Practice

1. Compare CSV and Parquet read times on the same data.
read_csv         4.81s
read_parquet     0.62s
parquet, 2 cols  0.11s

8× for the same rows, 44× when reading a subset of columns. Converting on ingest pays for itself on the first re-read.

2. Query a partitioned dataset lazily and check the plan.
ExecPlan with 4 nodes:
    1:FilterNode{filter=(amount > 100)}
      0:SourceNode{}

The status filter is missing because partition pruning handled it. If a filter you expected to prune appears in the plan, the partition column is not what you thought.

3. Use an unsupported function inside an arrow pipeline.
Warning: Expression my_fn(amount) not supported in Arrow; pulling data into R

The whole dataset materialises. On a dataset larger than memory this is the difference between a result and a crash — treat the warning as a build failure.

4. Kill a write halfway, with and without the atomic pattern.
# direct write, interrupted
Error in read_parquet("orders.parquet") :
  Invalid: Parquet file size is 0 bytes / Could not open Parquet input source

# atomic write, interrupted
exists: FALSE   leftovers: TRUE

The atomic version leaves the old file intact and a .tmp you can delete. Readers never see a partial file, which is what every table format does for you and plain files do not.

Next: reshaping and joins — pivoting between long and wide, and the joins that lose rows.

Frequently Asked Questions

How do I read a file that is larger than memory in R?
Use `arrow::open_dataset()`, which returns a lazy dataset you can filter and aggregate with dplyr verbs before anything is loaded. Only the result of `collect()` comes into memory, so a 40 GB dataset can be summarised on a laptop.
Why is Parquet better than CSV for R pipelines?
It stores types, so no guessing and no re-parsing; it is columnar, so reading three columns of forty costs a fraction; and it compresses far better. In R the practical effect is that `read_parquet` is often an order of magnitude faster than `read_csv` on the same data.
What is the difference between read.csv and read_csv?
`read.csv` is base R — slower, returns a data frame, and historically converted strings to factors. `read_csv` is readr — faster, returns a tibble, reports parsing problems, and never converts strings. Use `read_csv` unless you are avoiding dependencies.
How do I write a partitioned dataset from R?
`arrow::write_dataset()` with `partitioning = c("year", "month")` writes Hive-style directories that any Parquet reader can prune. Choose a low-cardinality partition column, or you produce thousands of tiny files.