Skip to content

Goals

First, I want to measure nanoparquet’s speed relative to good quality CSV readers and writers, and also look at the sizes of the Parquet and CSV files.

Second, I want to see how nanoparquet fares relative to other Parquet implementations available from R.

Data sets

I used use three data sets: small, medium and large. The small data set is the nycflights13::flights data set, as is. The medium data set contains 20 copies of the small data set. The large data set contains 200 copies of the small data set. See the gen_data() function in the benchmark-funcs.R file in the nanoparquet GitHub repository.

Some basic information about each data set:

Show the code
if (file.exists(file.path(me, "data-info.parquet"))) {
  info_tab <- nanoparquet::read_parquet(file.path(me, "data-info.parquet"))
} else {
  get_data_info <- function(x) {
    list(dim = dim(x), size = object.size(x))
  }
  info <- lapply(data_sizes, function(s) get_data_info(gen_data(s)))
  info_tab <- data.frame(
    check.names = FALSE,
    name = data_sizes,
    rows = sapply(info, "[[", "dim")[1,],
    columns = sapply(info, "[[", "dim")[2,],
    "size in memory" = sapply(info, "[[", "size")
  )
  nanoparquet::write_parquet(info_tab, file.path(me, "data-info.parquet"))
}
info_tab |>
  gt() |>
  tab_header(title = "Data sets") |>
  tab_options(table.align = "left") |>
  fmt_integer() |>
  fmt_bytes(columns = "size in memory")
Data sets
name rows columns size in memory
small 336,776 19 40.7 MB
medium 6,735,520 19 808.5 MB
large 67,355,200 19 8.1 GB

A quick look at the data:

Show the code
head(nycflights13::flights)
#> # A tibble: 6 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # ℹ 11 more variables: arr_delay <dbl>, carrier <chr>, flight <int>,
#> #   tailnum <chr>, origin <chr>, dest <chr>, air_time <dbl>, distance <dbl>,
#> #   hour <dbl>, minute <dbl>, time_hour <dttm>
Show the code
dplyr::glimpse(nycflights13::flights)
#> Rows: 336,776
#> Columns: 19
#> $ year           <int> 2013, 2013, 2013, 2013, 2013, 2013, 2013, 2013, 2013, 2…
#> $ month          <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
#> $ day            <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
#> $ dep_time       <int> 517, 533, 542, 544, 554, 554, 555, 557, 557, 558, 558, …
#> $ sched_dep_time <int> 515, 529, 540, 545, 600, 558, 600, 600, 600, 600, 600, …
#> $ dep_delay      <dbl> 2, 4, 2, -1, -6, -4, -5, -3, -3, -2, -2, -2, -2, -2, -1…
#> $ arr_time       <int> 830, 850, 923, 1004, 812, 740, 913, 709, 838, 753, 849,…
#> $ sched_arr_time <int> 819, 830, 850, 1022, 837, 728, 854, 723, 846, 745, 851,…
#> $ arr_delay      <dbl> 11, 20, 33, -18, -25, 12, 19, -14, -8, 8, -2, -3, 7, -1…
#> $ carrier        <chr> "UA", "UA", "AA", "B6", "DL", "UA", "B6", "EV", "B6", "…
#> $ flight         <int> 1545, 1714, 1141, 725, 461, 1696, 507, 5708, 79, 301, 4…
#> $ tailnum        <chr> "N14228", "N24211", "N619AA", "N804JB", "N668DN", "N394…
#> $ origin         <chr> "EWR", "LGA", "JFK", "JFK", "LGA", "EWR", "EWR", "LGA",…
#> $ dest           <chr> "IAH", "IAH", "MIA", "BQN", "ATL", "ORD", "FLL", "IAD",…
#> $ air_time       <dbl> 227, 227, 160, 183, 116, 150, 158, 53, 140, 138, 149, 1…
#> $ distance       <dbl> 1400, 1416, 1089, 1576, 762, 719, 1065, 229, 944, 733, …
#> $ hour           <dbl> 5, 5, 5, 5, 6, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 6, 6, 6…
#> $ minute         <dbl> 15, 29, 40, 45, 0, 58, 0, 0, 0, 0, 0, 0, 0, 0, 0, 59, 0…
#> $ time_hour      <dttm> 2013-01-01 05:00:00, 2013-01-01 05:00:00, 2013-01-01 0…

Parquet implementations

I ran nanoparquet, Arrow and DuckDB. I also ran data.table without and with compression and readr, to read/write CSV files. I used the running time of readr as the baseline.

I ran each benchmark three times and record the results of the third run. This is to make sure that the data and the software is not swapped out by the OS. (Except for readr on the large data set, because it would take too long.)

Show the code
if (file.exists(file.path(me, "results.parquet"))) {
  results <- nanoparquet::read_parquet(file.path(me, "results.parquet"))
} else {
  results <- NULL
  lapply(data_sizes[1:2], function(s) {
    lapply(variants, function(v) {
      r <- if (v == "readr" && s == "large") {
        measure(v, s)
      } else {
        measure(v, s)
        measure(v, s)
        measure(v, s)
      }
      results <<- rbind(results, r)
    })
  })
  nanoparquet::write_parquet(results, file.path(me, "results.parquet"))
}

I include the complete raw results at the end of this article.

Parquet vs CSV

The results, focusing on the CSV readers and nanoparquet:

Show the code
csv_tab_read <- results |>
  filter(software %in% c("nanoparquet", "data.table", "data.table.gz", "readr")) |>
  filter(direction == "read") |>
  mutate(software = case_when(
    software ==  "data.table.gz" ~ "data.table (compressed)",
    .default = software
  )) |>
  rename(`data size` = data_size, time = time_elapsed) |>
  mutate(memory = mem_max_after - mem_before) |>
  mutate(base = tail(time, 1), .by = `data size`) |>
  mutate(speedup = base / time, x = round(speedup, digits = 1)) |>
  select(`data size`, software, time, x, speedup, memory) |>
  mutate(rawtime = time, time = prettyunits::pretty_sec(time)) |>
  rename(`speedup from CSV` = speedup)

csv_tab_read |>
  gt() |>
  tab_header(title = "Parquet vs CSV, reading") |>
  tab_options(table.align = "left") |>
  tab_row_group(md("**small data**"), rows = `data size` == "small", "s") |>
  tab_row_group(md("**medium data**"), rows = `data size` == "medium", "m") |>
  tab_row_group(md("**large data**"), rows = `data size` == "large", "l") |>
  row_group_order(c("s", "m", "l")) |>
  cols_hide(columns = c(`data size`, rawtime)) |>
  cols_align(columns = time, align = "right") |>
  fmt_bytes(columns = memory) |>
  gt_plt_bar(column = `speedup from CSV`)
Parquet vs CSV, reading
software time x speedup from CSV memory
small data
nanoparquet 30ms 14.3 79.9 MB
data.table 59ms 7.0 67 MB
data.table (compressed) 151ms 2.8 113.8 MB
readr 415ms 1.0 188.5 MB
medium data
nanoparquet 980ms 5.2 1.5 GB
data.table 891ms 5.7 1.3 GB
data.table (compressed) 2s 2.6 1.4 GB
readr 5.1s 1.0 3.7 GB
large data
nanoparquet 10.8s 21.5 8 GB
data.table 12.8s 18.1 8.6 GB
data.table (compressed) 26.7s 8.7 7.9 GB
readr 3m 51.3s 1.0 8.4 GB

Notes:

  • The single-threaded nanoparquet Parquet-reader is competitive. It can read a compressed Parquet file just as fast as the state of the art uncompressed CSV reader that uses at least 2 threads.

The nanoparquet vs CSV results when writing Parquet or CSV files:

Show the code
csv_tab_write <- results |>
  filter(software %in% c("nanoparquet", "data.table", "data.table.gz", "readr")) |>
  filter(direction == "write") |>
  mutate(software = case_when(
    software ==  "data.table.gz" ~ "data.table (compressed)",
    .default = software
  )) |>
  rename(`data size` = data_size, time = time_elapsed, `file size` = file_size) |>
  mutate(memory = mem_max_after - mem_before) |>
  mutate(base = tail(time, 1), .by = `data size`) |>
  mutate(speedup = base / time, x = round(speedup, digits = 1)) |>
  select(`data size`, software, time, x, speedup, memory, `file size`) |>
  mutate(rawtime = time, time = prettyunits::pretty_sec(time)) |>
  rename(`speedup from CSV` = speedup)

csv_tab_write |>
  gt() |>
  tab_header(title = "Parquet vs CSV, writing") |>
  tab_options(table.align = "left") |>
  tab_row_group(md("**small data**"), rows = `data size` == "small", "s") |>
  tab_row_group(md("**medium data**"), rows = `data size` == "medium", "m") |>
  tab_row_group(md("**large data**"), rows = `data size` == "large", "l") |>
  row_group_order(c("s", "m", "l")) |>
  cols_hide(columns = c(`data size`, rawtime)) |>
  cols_align(columns = time, align = "right") |>
  fmt_bytes(columns = c(memory, `file size`)) |>
  gt_plt_bar(column = `speedup from CSV`)
Parquet vs CSV, writing
software time x speedup from CSV memory file size
small data
nanoparquet 117ms 6.7 81.4 MB 5.7 MB
data.table 62ms 12.6 917.5 kB 31 MB
data.table (compressed) 387ms 2.0 999.4 kB 8.3 MB
readr 781ms 1.0 44.4 MB 31.1 MB
medium data
nanoparquet 2.2s 6.4 683.1 MB 111.4 MB
data.table 744ms 18.8 2.6 MB 619.2 MB
data.table (compressed) 7s 2.0 2.7 MB 165.2 MB
readr 14s 1.0 842 MB 621.1 MB
large data
nanoparquet 24.8s 5.8 292.2 MB 1.1 GB
data.table 8.1s 17.7 269.1 MB 6.2 GB
data.table (compressed) 1m 11.6s 2.0 269.3 MB 1.7 GB
readr 2m 23.1s 1.0 920 MB 6.2 GB

Notes:

  • The data.table CSV writer is about 3 times as fast as the nanoparquet Parquet writer, if the CSV file is uncompressed. The CSV writer uses at least 4 threads, the Parquet write is single-threaded.
  • The nanoparquet Parquet writer is 2-5 times faster than the data.table CSV writer if the CSV file is compressed.
  • The Parquet files are about 5-6 times smaller than the uncompressed CSV files and about 30-35% smaller than the compressed CSV files.

Parquet implementations

This is the summary of the Parquet readers, for the same three files.

Show the code
pq_tab_read <- results |>
  filter(software %in% c("nanoparquet", "arrow", "duckdb", "readr")) |>
  filter(direction == "read") |>
  rename(`data size` = data_size, time = time_elapsed) |>
  mutate(memory = mem_max_after - mem_before) |>
  mutate(base = tail(time, 1), .by = `data size`) |>
  mutate(speedup = base / time, x = round(speedup, digits = 1)) |>
  select(`data size`, software, time, x, speedup, memory) |>
  mutate(rawtime = time, time = prettyunits::pretty_sec(time)) |>
  filter(software %in% c("nanoparquet", "arrow", "duckdb")) |>
  mutate(software = case_when(
    software ==  "arrow" ~ "Arrow",
    software == "duckdb" ~ "DuckDB",
    .default = software
  )) |>
  rename(`speedup from CSV` = speedup)

pq_tab_read |>
  gt() |>
  tab_header(title = "Parquet implementations, reading") |>
  tab_options(table.align = "left") |>
  tab_row_group(md("**small data**"), rows = `data size` == "small", "s") |>
  tab_row_group(md("**medium data**"), rows = `data size` == "medium", "m") |>
  tab_row_group(md("**large data**"), rows = `data size` == "large", "l") |>
  row_group_order(c("s", "m", "l")) |>
  cols_hide(columns = c(`data size`, rawtime)) |>
  cols_align(columns = time, align = "right") |>
  fmt_bytes(columns = memory) |>
  gt_plt_bar(column = `speedup from CSV`)
Parquet implementations, reading
software time x speedup from CSV memory
small data
nanoparquet 30ms 14.3 79.9 MB
Arrow 41ms 10.4 106.9 MB
DuckDB 66ms 6.3 120.6 MB
medium data
nanoparquet 980ms 5.2 1.5 GB
Arrow 983ms 5.2 2.1 GB
DuckDB 1.1s 4.5 2.1 GB
large data
nanoparquet 10.8s 21.5 8 GB
Arrow 10.8s 21.4 9.8 GB
DuckDB 14.6s 15.8 8.1 GB

Notes:

  • In general, all three implementations perform similarly. nanoparquet is very competitive for these three data sets in terms of speed and also tends to use the least amount of memory.
  • I turned off ALTREP in arrow, so that it reads the data into memory.

The summary for the Parquet writers:

Show the code
pq_tab_write <- results |>
  filter(software %in% c("nanoparquet", "arrow", "duckdb", "readr")) |>
  filter(direction == "write") |>
  rename(`data size` = data_size, time = time_elapsed, `file size` = file_size) |>
  mutate(memory = mem_max_after - mem_before) |>
  mutate(base = tail(time, 1), .by = `data size`) |>
  mutate(speedup = base / time, x = round(speedup, digits = 1)) |>
  select(`data size`, software, time, x, speedup, memory, `file size`) |>
  mutate(rawtime = time, time = prettyunits::pretty_sec(time)) |>
  filter(software %in% c("nanoparquet", "arrow", "duckdb", "readr")) |>
  mutate(software = case_when(
    software ==  "arrow" ~ "Arrow",
    software == "duckdb" ~ "DuckDB",
    .default = software
  )) |>
  rename(`speedup from CSV` = speedup)

pq_tab_write |>
  gt() |>
  tab_header(title = "Parquet implementations, writing") |>
  tab_options(table.align = "left") |>
  tab_row_group(md("**small data**"), rows = `data size` == "small", "s") |>
  tab_row_group(md("**medium data**"), rows = `data size` == "medium", "m") |>
  tab_row_group(md("**large data**"), rows = `data size` == "large", "l") |>
  row_group_order(c("s", "m", "l")) |>
  cols_hide(columns = c(`data size`, rawtime)) |>
  cols_align(columns = time, align = "right") |>
  fmt_bytes(columns = c(memory, `file size`)) |>
  gt_plt_bar(column = `speedup from CSV`)
Parquet implementations, writing
software time x speedup from CSV memory file size
small data
nanoparquet 117ms 6.7 81.4 MB 5.7 MB
Arrow 152ms 5.2 34.4 MB 5.7 MB
DuckDB 207ms 3.8 156.1 MB 10.7 MB
readr 781ms 1.0 44.4 MB 31.1 MB
medium data
nanoparquet 2.2s 6.4 683.1 MB 111.4 MB
Arrow 2.6s 5.4 289.9 MB 112.2 MB
DuckDB 2.4s 5.9 2 GB 213.2 MB
readr 14s 1.0 842 MB 621.1 MB
large data
nanoparquet 24.8s 5.8 292.2 MB 1.1 GB
Arrow 29.9s 4.8 531.2 MB 1.1 GB
DuckDB 33.7s 4.2 1 GB 2.1 GB
readr 2m 23.1s 1.0 920 MB 6.2 GB

Notes:

  • nanoparquet is again very competitive in terms of speed, it is slightly faster than the other two implementations, for these data sets.
  • DuckDB seems to waste space when writing out Parquet files. This could be possibly fine tuned by forcing a different encoding. This behavior will improve with the forthcoming DuckDB 1.2.0 release, see also https://github.com/duckdb/duckdb/issues/3316.

Conclusions

These results will probably change for a different data sets, or on a different system. In particular, Arrow and DuckDB are probably faster on larger systems, where the data is stored on multiple physical disks.

Both Arrow and DuckDB let you run queries on the data without loading it all into memory first. This is especially important if the data does not fit into memory at all, not even the columns needed for the analysis. nanoparquet cannot do this.

However, in general, based on these benchmarks I have good reasons to trust that the nanoparquet Parquet reader and writer is competitive with the other implementations available from R, both in terms of speed and memory use.

If the limitations of nanoparquet are not prohibitive for your use case, it is a good choice for Parquet I/O.

Raw benchmark results

These are the raw results. You can scroll to the right if your screen is not wide enough for the whole table.

Show the code
print(results, n = Inf)
#> # A data frame: 36 × 10
#>    software      direction data_size time_user time_system time_elapsed mem_before mem_max_before mem_max_after  file_size
#>    <chr>         <chr>     <chr>         <dbl>       <dbl>        <dbl>      <dbl>          <dbl>         <dbl>      <dbl>
#>  1 nanoparquet   read      small        0.0220     0.007         0.0290  156909568      156909568     236765184         NA
#>  2 nanoparquet   write     small        0.103      0.015         0.117   305168384      305168384     386613248    5687737
#>  3 arrow         read      small        0.06       0.023         0.0400  160317440      160317440     267173888         NA
#>  4 arrow         write     small        0.149      0.00700       0.151   306151424      306151424     340574208    5693381
#>  5 duckdb        read      small        0.081      0.018         0.0660  166313984      166313984     286916608         NA
#>  6 duckdb        write     small        0.283      0.025         0.207   309510144      309510144     465649664   10684818
#>  7 data.table    read      small        0.137      0.016         0.0590  164282368      164282368     231325696         NA
#>  8 data.table    write     small        0.158      0.0100        0.0620  313851904      313851904     314769408   30960660
#>  9 data.table.gz read      small        0.215      0.026         0.15    164986880      164986880     278757376         NA
#> 10 data.table.gz write     small        1.45       0.014         0.386   310034432      310034432     311033856    8263176
#> 11 readr         read      small        1.08       0.27          0.415   162152448      162152448     350666752         NA
#> 12 readr         write     small        1.78       1.85          0.781   314736640      314736640     359104512   31053850
#> 13 nanoparquet   read      medium       0.84       0.139         0.979   158351360      158351360    1640300544         NA
#> 14 nanoparquet   write     medium       1.97       0.227         2.20   1079656448     1079656448    1762787328  111363363
#> 15 arrow         read      medium       1.40       0.265         0.982   168099840      168099840    2229256192         NA
#> 16 arrow         write     medium       2.65       0.065         2.60   1090486272     1090486272    1380417536  112167843
#> 17 duckdb        read      medium       1.82       0.331         1.12    160743424      160743424    2224111616         NA
#> 18 duckdb        write     medium       7.07       0.353         2.38   1099300864     1099300864    3086221312  213168966
#> 19 data.table    read      medium       2.36       0.135         0.891   159596544      159596544    1453031424         NA
#> 20 data.table    write     medium       2.60       0.098         0.744  1086357504     1086357504    1088962560  619210198
#> 21 data.table.gz read      medium       3.26       0.305         1.98    155844608      155844608    1516044288         NA
#> 22 data.table.gz write     medium      27.6        0.084         7.01   1092681728     1092681728    1095352320  165249944
#> 23 readr         read      medium      19.1        5.35          5.10    158367744      158367744    3874635776         NA
#> 24 readr         write     medium      34.4       39.4          14.0    1090158592     1090158592    1932197888  621073998
#> 25 nanoparquet   read      large        7.25       2.44         10.8      73023488       73023488    8098021376         NA
#> 26 nanoparquet   write     large       19.2        4.46         24.8    8158134272     8450293760    8450293760 1113819142
#> 27 arrow         read      large       12.0        7.32         10.8      72941568       72941568    9892495360         NA
#> 28 arrow         write     large       27.9        2.31         29.9    8304607232     8573747200    8835842048 1121513329
#> 29 duckdb        read      large       16.2        5.18         14.6      75251712       75251712    8127512576         NA
#> 30 duckdb        write     large       54.7       14.2          33.7    8305164288     8574451712    9348841472 2131769619
#> 31 data.table    read      large       21.6        3.87         12.8      78872576       78872576    8691007488         NA
#> 32 data.table    write     large       26.3        1.69          8.09   8304033792     8573157376    8573157376 6192100558
#> 33 data.table.gz read      large       30.6        7.16         26.7      72876032       72876032    8018870272         NA
#> 34 data.table.gz write     large      279.         1.93         71.6    8303362048     8572665856    8572665856 1652494401
#> 35 readr         read      large      144.       177.          231.       73564160       73564160    8500789248         NA
#> 36 readr         write     large      333.       345.          143.     8304148480     8573452288    9224192000 6210738558

Notes:

  • User time (time_user) plus system time (time_system) can be larger than the elapsed time (time_elapsed) for multithreaded implementations and it indeed is for all tools, except for nanoparquet, which is single-threaded.
  • All memory columns are in bytes. mem_before is the RSS size before reading/writing. mem_max_before is the maximum RSS size of the process until then. mem_max_after is the maximum RSS size of the process after the read/write operation.
  • So I can calculate (estimate) the memory usage of the tool by subtracting mem_before from mem_max_after. This could overestimate the memory usage if mem_max_after were the same as mem_max_before, but this never happens in practice.
  • When reading the file, mem_max_after includes the memory needed to store the data set itself. (See data sizes above.)
  • For arrow, I turned off ALTREP using options(arrow.use_altrep = FALSE), see the benchmarks-funcs.R file. Otherwise arrow does not actually read all the data into memory.

About

See the benchmark-funcs.R file in the nanoparquet repository for the code of the benchmarks.

I ran each measurement in its own subprocess, to make it easier to measure memory usage.

I did not include the package loading time in the benchmarks. nanoparquet has no dependencies and loads very quickly. Both the arrow and duckdb packages might take up to 200ms to load on the test system, because they need to load their dependencies and they are also bigger.

Show the code
sessioninfo::session_info()
#> ─ Session info ───────────────────────────────────────────────────────────────
#>  setting  value
#>  version  R version 4.4.2 (2024-10-31)
#>  os       Ubuntu 24.04.1 LTS
#>  system   x86_64, linux-gnu
#>  ui       X11
#>  language en-US
#>  collate  C.UTF-8
#>  ctype    C.UTF-8
#>  tz       UTC
#>  date     2025-01-29
#>  pandoc   3.1.11 @ /opt/hostedtoolcache/pandoc/3.1.11/x64/ (via rmarkdown)
#>
#> ─ Packages ───────────────────────────────────────────────────────────────────
#>  package      * version    date (UTC) lib source
#>  arrow          18.1.0.1   2025-01-08 [1] RSPM
#>  assertthat     0.2.1      2019-03-21 [1] RSPM
#>  base64enc      0.1-3      2015-07-28 [1] RSPM
#>  bit            4.5.0.1    2024-12-03 [1] RSPM
#>  bit64          4.6.0-1    2025-01-16 [1] RSPM
#>  cli            3.6.3      2024-06-21 [1] RSPM
#>  colorspace     2.1-1      2024-07-26 [1] RSPM
#>  commonmark     1.9.2      2024-10-04 [1] RSPM
#>  DBI            1.2.3      2024-06-02 [1] RSPM
#>  digest         0.6.37     2024-08-19 [1] RSPM
#>  dplyr        * 1.1.4      2023-11-17 [1] RSPM
#>  duckdb         1.1.3-2    2025-01-24 [1] RSPM
#>  evaluate       1.0.3      2025-01-10 [1] RSPM
#>  farver         2.1.2      2024-05-13 [1] RSPM
#>  fastmap        1.2.0      2024-05-15 [1] RSPM
#>  fontawesome    0.5.3      2024-11-16 [1] RSPM
#>  generics       0.1.3      2022-07-05 [1] RSPM
#>  ggplot2        3.5.1      2024-04-23 [1] RSPM
#>  glue           1.8.0      2024-09-30 [1] RSPM
#>  gt           * 0.11.1     2024-10-04 [1] RSPM
#>  gtable         0.3.6      2024-10-25 [1] RSPM
#>  gtExtras     * 0.5.0      2023-09-15 [1] RSPM
#>  htmltools      0.5.8.1    2024-04-04 [1] RSPM
#>  jsonlite       1.8.9      2024-09-20 [1] RSPM
#>  knitr          1.49       2024-11-08 [1] RSPM
#>  labeling       0.4.3      2023-08-29 [1] RSPM
#>  lifecycle      1.0.4      2023-11-07 [1] RSPM
#>  magrittr       2.0.3      2022-03-30 [1] RSPM
#>  markdown       1.13       2024-06-04 [1] RSPM
#>  munsell        0.5.1      2024-04-01 [1] RSPM
#>  nanoparquet    0.4.0.9000 2025-01-29 [1] local
#>  nycflights13   1.0.2      2021-04-12 [1] RSPM
#>  paletteer      1.6.0      2024-01-21 [1] RSPM
#>  pillar         1.10.1     2025-01-07 [1] RSPM
#>  pkgconfig      2.0.3      2019-09-22 [1] RSPM
#>  prettyunits    1.2.0      2023-09-24 [1] RSPM
#>  purrr          1.0.2      2023-08-10 [1] RSPM
#>  R6             2.5.1      2021-08-19 [1] RSPM
#>  ragg           1.3.3      2024-09-11 [1] RSPM
#>  rematch2       2.1.2      2020-05-01 [1] RSPM
#>  rlang          1.1.5      2025-01-17 [1] RSPM
#>  rmarkdown      2.29       2024-11-04 [1] RSPM
#>  sass           0.4.9      2024-03-15 [1] RSPM
#>  scales         1.3.0      2023-11-28 [1] RSPM
#>  sessioninfo    1.2.2      2021-12-06 [1] any (@1.2.2)
#>  svglite        2.1.3      2023-12-08 [1] RSPM
#>  systemfonts    1.2.1      2025-01-20 [1] RSPM
#>  textshaping    1.0.0      2025-01-20 [1] RSPM
#>  tibble         3.2.1      2023-03-20 [1] RSPM
#>  tidyselect     1.2.1      2024-03-11 [1] RSPM
#>  utf8           1.2.4      2023-10-22 [1] RSPM
#>  vctrs          0.6.5      2023-12-01 [1] RSPM
#>  withr          3.0.2      2024-10-28 [1] RSPM
#>  xfun           0.50       2025-01-07 [1] RSPM
#>  xml2           1.3.6      2023-12-04 [1] RSPM
#>  yaml           2.3.10     2024-07-26 [1] RSPM
#>
#>  [1] /home/runner/work/_temp/Library
#>  [2] /opt/R/4.4.2/lib/R/site-library
#>  [3] /opt/R/4.4.2/lib/R/library
#>
#> ──────────────────────────────────────────────────────────────────────────────