basetable

R-CMD-check Lifecycle: stable License: MIT

basetable is a fast in-memory data-manipulation package for R with a base-R interface and no dependencies. You write subset(), transform(), aggregate(), merge(), split(), and the work runs on the package’s own C++ engine. There is no data.table, no dplyr, no Arrow underneath, and nothing in Imports beyond the base and recommended packages (parallel, stats, utils).

Every verb returns a basetable: an ordinary data.frame with one extra class so it prints compactly and [ keeps the class. as.data.frame() strips it back to a plain frame.

This is a deliberately focused tool. It is aimed at

Design

Installation

# install.packages("pak")
pak::pak("ielbadisy/basetable")

Minimal examples

library(basetable)

# nested
describe(
  transform(
    subset(mtcars, cyl == 6, select = c("mpg", "hp", "wt", "cyl")),
    power = hp / wt
  )
)

# pipe
mtcars |>
  pick(c("mpg", "hp", "wt", "cyl")) |>
  transform(power = hp / wt) |>
  aggregate(by = "cyl", value = c("mpg", "power"), fun = mean)

# table-1 style summary
summarytab(
  transform(mtcars, am = factor(am, labels = c("Automatic", "Manual"))),
  vars = c("mpg", "hp"), by = "am", p_value = TRUE
)

Performance

Timing and memory below come from the bench package at 1,000,000 rows on one Linux machine (inst/benchmarks/make-readme-figures.R regenerates the figures; the Benchmarks vignette has the full reproducible report). basetable is compared with data.table and dplyr.

Speed

Median runtime by engine at 1e6 rows

Memory

Memory allocated by engine at 1e6 rows
Operation basetable data.table dplyr basetable mem data.table mem dplyr mem
filter 7 ms 10 ms 10 ms 15 MB 21 MB 28 MB
sort (string key) 58 ms 44 ms 102 ms 34 MB 47 MB 69 MB
distinct 5 ms 8 ms 13 ms 0.03 MB 20 MB 12 MB
count by group 23 ms 40 ms 738 ms 1 MB 30 MB 30 MB
sd by group 10 ms 16 ms 42 ms 0.05 MB 27 MB 36 MB
equi join 16 ms 15 ms 66 ms 8 MB 8 MB 101 MB
semi join 13 ms 67 ms 52 ms 4 MB 58 MB 82 MB

(equi join pins data.table to sort = FALSE, matching basetable::merge(), which returns rows in input order.)

basetable is faster than data.table on filter, distinct, grouped count, sd by group and semi join, and is level with it on equi join. Against dplyr it is faster on every operation here, by more than 30x on high-cardinality count. The one operation it loses is string sort.

Memory, ranked by advantage

basetable allocates the least (or tied least) on every operation measured. The size of the edge splits in two: overwhelming on grouped reductions, where the result is tiny and nothing intermediate is materialised in R; modest on operations that return a full table, where the output frame itself sets a floor.

Operation basetable data.table dplyr basetable vs data.table
distinct 0.03 MB 20 MB 12 MB ~700x less
sd by group 0.05 MB 27 MB 36 MB ~500x less
count by group 1 MB 30 MB 30 MB ~30x less
semi join 4 MB 58 MB 82 MB ~15x less
filter 15 MB 21 MB 28 MB ~1.4x less
sort (string key) 34 MB 47 MB 69 MB ~1.4x less
equi join 8 MB 8 MB 101 MB ~parity

These are R-level allocations as reported by bench. The C++ engine also uses malloc’d scratch buffers (radix keys, per-thread row-position vectors) that bench does not count, so peak process memory during a sort or filter is higher than the figure above; data.table does the same.

The one gap is sorting: orderrows() is a stable parallel radix, ~20x faster than base order(), but still ~1.3x of data.table, whose hand-tuned parallel radix is the one operation basetable does not match.

Positioning

data.table is faster on some workloads (notably sorting) and has a far larger ecosystem; dplyr is the tidyverse standard. basetable is a good fit when you want:

Grouping is a by argument on the verb that needs it (aggregate(), count(), summaries(), transform(), subset(), samplerows(), firstby(), …), not a stateful group_by(). The group is named at the call and never persists, so there is no ungroup() to forget.

Using basetable alongside dplyr and data.table

basetable reuses base-R verb names (subset(), merge(), transform(), split(), aggregate()) on purpose. It does not ship the dplyr-coined verbs (filter(), select(), mutate(), arrange(), summarise(), distinct(), glimpse(), …), so it can be attached next to dplyr without shadowing its grammar. The two names it shares with dplyr are count() and pick(), kept because they read as base-style verbs; with both packages attached, whichever was attached last wins for those (and for the base-R names data.table also defines). Two fixes:

Operation dictionary

Family Exported functions Base reference
Row subsetting subset() base::subset()
Column keep / drop / rename pick(), drop(), renamecols() [, names<-()
Transformation transform(), within() base equivalents
Ordering orderrows() order()
Distinct / duplicates uniquerows(), duplicaterows(), removeduplicates() unique(), duplicated()
Aggregation aggregate(), count(), summaries() aggregate(), table()
Recoding recode(), collapsevalues(), casewhen(), replacewhere() ifelse(), switch()
Joins merge(), semimerge(), antimerge(), updatemerge(), crossmerge(), nonequimerge(), overlapmerge(), rangemerge(), rollingmerge() merge()
Row / column bind rbindfill() rbind()
Split / apply split(), applyby() split()
Reshaping tolong(), towide(), reshape(), stack(), unstack() base equivalents
Completion completegrid() expand.grid() + join
File I/O btread(), btwrite(); aggregate() / count() / uniquerows() / freq() also take a file path read.delim(), fused file to result
Inspection preview(), dims(), types(), headtail() str(), dim(), head()
EDA describe(), missingness(), profile(), freq(), summarytab(), compare() base summaries

btread() memory-maps the file and, with lazy = TRUE, returns columns as ALTREP vectors parsed on first access. aggregate(), count(), uniquerows() and freq() accept a single file path as their first argument and fuse the parse with the grouping, so unused columns are never materialised.

Status

Every exported function has direct test coverage. Vignettes cover getting started, data manipulation, exploration, a complete function reference, and benchmarks. CI checks release R on Linux, macOS and Windows plus oldrel and devel.