A walk through the package README, with more examples for every verb family
R
packages
tutorial
Author
Imad El Badisy
Published
September 5, 2026
I get more questions about how to actually use basetable day to day than about anything in the benchmarks, so this post takes the README and adds worked examples for the handful of functions that carry most of the day-to-day work and the performance story: subset(), transform(), aggregate(), merge() / semimerge(), and describe(). The README itself is unchanged below - the new material is the “Tutorial” section in the middle, and the figures and tables in “Performance” are exactly as published, with a link to the script that generated them.
basetable
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
people teaching or learning base R who want speed without the cognitive load of tidy evaluation or [i, j, by], and
codebases already built on subset() / merge() / aggregate() that want a faster engine without a rewrite.
A native C++ engine. Projection, filtering, ordering, distinct, grouping, all join kinds, row-bind and subset() predicate evaluation run in compiled .Call kernels. There is no third-party compute backend.
Explicit, standard-evaluation interfaces. Column names are strings, not captured symbols (with the marked exceptions subset() and transform() inherit from base R).
Zero hard dependencies.data.table and dplyr appear only in Suggests, and only as competitors in the benchmark vignette.
# nestedbasetable::describe(basetable::transform(basetable::subset(mtcars, cyl==6, select =c("mpg", "hp", "wt", "cyl")), power =hp/wt))
# basetable: 5 x 14
column class n missing missing_prop distinct mean sd
1 mpg numeric 7 0 0 6 19.742857 1.4535670
2 hp numeric 7 0 0 4 122.285714 24.2604911
3 wt numeric 7 0 0 6 3.117143 0.3563455
4 cyl numeric 7 0 0 1 6.000000 0.0000000
5 power numeric 7 0 0 6 39.927938 10.8533912
min q25 median q75 max top
1 17.80000 18.65000 19.70000 21.0000 21.4000
2 105.00000 110.00000 110.00000 123.0000 175.0000
3 2.62000 2.82250 3.21500 3.4400 3.4600
4 6.00000 6.00000 6.00000 6.0000 6.0000
5 30.34682 34.98522 35.75581 40.1228 63.1769
# pipemtcars|>basetable::pick(c("mpg", "hp", "wt", "cyl"))|>basetable::transform(power =hp/wt)|>basetable::aggregate(by ="cyl", value =c("mpg", "power"), fun =mean)
# basetable: 3 x 3
cyl mpg power
1 4 26.66364 37.92533
2 6 19.74286 39.92794
3 8 15.10000 53.85964
# table-1 style summarybasetable::summarytab(basetable::transform(mtcars, am =factor(am, labels =c("Automatic", "Manual"))), vars =c("mpg", "hp"), by ="am", p_value =TRUE)
# basetable: 2 x 6
variable level Automatic Manual Overall p_value
1 mpg Mean (SD) 17.1 (3.8) 24.4 (6.2) 20.1 (6.0) 0.00137
2 hp Mean (SD) 160.3 (53.9) 126.8 (84.1) 146.7 (68.6) 0.221
Every call above is qualified with basetable::. That’s not just belt and braces for the README: basetable intentionally reuses the base-R names subset(), transform(), merge() and split(), and attaching it with a plain library(basetable) puts those ahead of base on the search path for the rest of the session - which can surprise other code, including tooling, that calls the base versions unqualified. Qualifying the calls, as in “Using basetable alongside dplyr and data.table” below, sidesteps that entirely.
Tutorial
The examples above are the pitch. The rest of this section is the short list of functions that carry most of the day-to-day work, one at a time.
Filtering: subset()
Same syntax as base::subset() - a condition on the unquoted column names, plus an optional select - running on the C++ engine instead of R:
This is the one with the biggest gap in the benchmarks below - grouped reductions accumulate in compiled code without materialising intermediate columns, so a sd or a count by group allocates close to nothing:
basetable::aggregate(airquality, by ="Month", value =c("Ozone", "Temp"), fun =mean, na.rm =TRUE)
merge() covers inner, left, right, and full joins through the same all / all.x / all.y arguments as base::merge(), rows come back in input order rather than sorted by key. semimerge() filters one table by matches in another without adding columns - the join-as-filter case that a plain merge() would otherwise need a follow-up subset() for:
left<-data.frame(id =1:4, x =letters[1:4])right<-data.frame(id =c(2, 3, 5), y =LETTERS[2:4])basetable::merge(left, right, by ="id", all.x =TRUE)
# basetable: 4 x 3
id x y
1 1 a <NA>
2 2 b B
3 3 c C
4 4 d <NA>
describe() is the quick per-column numeric summary; summarytab() builds a table-1-style comparison across a grouping variable, shown already in the minimal examples above with a p-value column:
Timing and memory below come from the bench package at 1,000,000 rows on one Linux machine. The script that produced both figures and both tables is inst/benchmarks/make-readme-figures.R; 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:
base-R syntax and semantics, not [i, j, by], tidy evaluation, or a method-chained frame object;
no dependencies to install, pin, or reason about;
a package small enough to read end to end, teach from, and hand to a language model as a stable target;
competitive speed and best-in-class memory on the everyday operations (filter, group, join, distinct) without changing how you write code.
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:
call it explicitly: basetable::transform(...);
or conflicted::conflict_prefer("transform", "basetable") once per session.
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.