An R script that reads a 4 GB CSV, groups by three columns and writes a summary should not take longer than your coffee break — but the same job can run in seconds or in minutes depending on which data library you load. That difference is not academic. It decides whether an analyst iterates five times an hour or twice a day, and it decides whether a laptop with 16 GB of RAM can hold the working set at all.

Three packages cover almost all of modern R data manipulation: data.table, dplyr and tidytable. They share a language, they do not share a philosophy, and picking the wrong one for a codebase costs months of refactoring later. Here is how they compare in 2026 — with syntax you can paste, memory behaviour you can measure, and the migration traps that actually bite.

TL;DR — Quick Verdict

Pick data.table when the data is large, the job is repeated, or memory is tight. Its DT[i, j, by] syntax is terse, it modifies data in place, and it is the only one of the three with built-in multi-threading.

Pick dplyr when the codebase is shared and readability wins. It is the lingua franca of R, it has the largest ecosystem of compatible packages, and it talks to databases through dbplyr — something the other two cannot do at all.

Pick tidytable when you want dplyr’s verbs but data.table’s engine and no dependency on tibble internals. Just know it returns its own class, so “drop-in” is approximately true rather than literally true.

The 2026 Status Check

Live GitHub figures, pulled 2026-10-15:

data.tabledplyrtidytable
RepoRdatatable/data.tabletidyverse/dplyrmarkfairbanks/tidytable
Stars3,9275,073475
Last commit2026-10-142026-06-022026-07-23
Installinstall.packages("data.table")install.packages("dplyr")install.packages("tidytable")
Core syntaxDT[i, j, by]verbs + pipeverbs + pipe
EngineC, in-place, multi-threadedC++ via vctrs, copy-on-modifydata.table underneath
Returnsdata.table (a data.frame)tibbletidytable
Third-party depsnonetibble, vctrs, tidyselect, genericsdata.table, vctrs, tidyselect, rlang
Multi-threaded groupingYesNoYes (inherited)
Modifies input by referenceYes (:=)NoNo
Database backendNoYes (dbplyr)No

Two rows matter more than the star counts. Reference semantics means data.table can update a column without copying the whole table — fast, memory-efficient, and a genuine footgun in functions. Database backends are dplyr-only: if your data lives in Postgres or a warehouse, dplyr pushes the computation down, while data.table must pull everything into memory first.

Decision Matrix: Pick in 10 Seconds

Your situationUseWhy
10M+ rows, grouped aggregation, repeated runsdata.tableIn-place ops plus multi-threading avoid copies
Shared analysis code read by many peopledplyrVerb-based syntax is self-documenting
Data sits in a SQL database or warehousedplyrdbplyr translates verbs into SQL
Tidy syntax but data.table performancetidytableSame verbs, data.table engine, no tibble
Memory-constrained machine, wide tablesdata.table:= avoids duplicating columns
Strict data.frame contract with librariesdplyr or data.tabletidytable returns its own class by default
Time-series with rolling windowsdata.tableroll, frollmean, non-equi joins built in
You are learning R in 2026dplyr firstTransferable vocabulary, gentler errors

data.table — Maximum Speed, Minimum Abstraction

data.table compresses query logic into a single bracket. The mnemonic is FROM[WHERE, SELECT, GROUP BY], and the README states it exactly that way:

1
2
3
4
5
6
library(data.table)
DT = as.data.table(iris)

# FROM[WHERE, SELECT, GROUP BY]
# DT  [i,     j,      by]
DT[Petal.Width > 1.0, mean(Petal.Length), by = Species]

Reading data is where the package earns its reputation. fread is a parallel CSV parser that guesses types and separators:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
library(data.table)

# multi-threaded read: 4 GB of CSV, memory mapped where possible
DT <- fread("events.csv", na.strings = c("", "NA"), showProgress = FALSE)

setDTthreads(0)              # 0 = use all available cores
setkey(DT, user_id)          # sorts and indexes in place

# update a column without copying the table
DT[, revenue := price * qty]
DT[revenue < 0, revenue := NA_real_]

# grouped aggregation, results keyed by group
summary_dt <- DT[, .(orders = .N,
                     revenue = sum(revenue, na.rm = TRUE),
                     p95 = quantile(revenue, 0.95, na.rm = TRUE)),
                 by = .(region, product)]

:= is the pivot: it adds or replaces columns by reference, so DT itself changes. That is why data.table handles wide tables on modest hardware — you are not allocating a second copy on every transformation.

The data.table logo, the package built around DT[i, j, by] syntax

dplyr — The Grammar Everyone Reads

dplyr’s design bet was that a small vocabulary of verbs beats a compact but cryptic operator. The code reads like a sentence, and that is precisely why it dominates collaborative R:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
library(dplyr)

result <- events |>
  filter(status == "complete", revenue > 0) |>
  mutate(margin = revenue - cost) |>
  summarise(orders = n(),
            revenue = sum(revenue),
            margin = sum(margin),
            .by = c(region, product)) |>
  arrange(desc(revenue))

Two details are easy to miss. .by replaces group_by() for one-shot aggregations and avoids leaving grouped state behind on the object. And the native pipe |> (R 4.1+) means you no longer need magrittr for new code — the tidyverse still exports %>%, but mixing both in one file is a readability tax.

The real differentiator is untouched by any benchmark table: dbplyr. The same verbs can target a remote database, and dplyr compiles them to SQL instead of downloading rows:

1
2
3
4
5
6
library(dplyr)
con <- DBI::dbConnect(RPostgres::Postgres(), dbname = "warehouse")
tbl(con, "orders") |>
  filter(order_date >= "2026-01-01") |>
  summarise(revenue = sum(amount), .by = channel) |>
  collect()

The dplyr logo, the verb-based data manipulation package

dplyr’s cost is memory and speed. Copy-on-modify means each mutate on a large frame can duplicate data, and grouping is single-threaded. On multi-gigabyte frames that shows up as the classic “cannot allocate vector of size” error.

tidytable — Tidy Syntax on the data.table Engine

tidytable exists because plenty of teams want both halves: dplyr’s verbs and data.table’s speed. It replicates tidyverse syntax and dispatches to data.table underneath:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
library(tidytable)

dt <- tidytable::tidytable(x = 1:3, y = 4:6)

result <- dt |>
  mutate(double_x = x * 2, x_plus_y = x + y)

# grouped operations mirror dplyr
summary_tt <- sales |>
  summarise(total = sum(amount, na.rm = TRUE),
            orders = n(),
            .by = c(region, month))

Because the engine is data.table, grouped aggregation gets threading and in-place execution — but with copy semantics on the surface, so you do not inherit :=’s spooky action at a distance. Objects created with tidytable() or returned from verbs are of class tidytable, which behaves like a data.frame in most contexts but is not one. Code that calls is.data.frame() or hands results to packages with strict data.frame expectations needs as.data.frame().

That class boundary is the honest caveat: tidytable is a syntax-compatible reimplementation, not a linker-level drop-in. Not every dplyr or tidyr function is implemented, so check the package’s function reference before migrating a large codebase, and expect the odd behaviour difference rather than assuming parity.

The tidytable logo, a dplyr-compatible interface backed by data.table

Performance and Memory: What Actually Shows Up

Rather than repeat second-hand numbers, measure on your own data. This benchmark is short enough to run in any session:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
library(data.table)
library(dplyr)
library(bench)

set.seed(1)
n <- 5e6
df <- data.frame(
  id     = sample(1e5, n, TRUE),
  region = sample(c("NA", "EU", "APAC"), n, TRUE),
  amount = rnorm(n, 500, 120)
)
dt <- as.data.table(df)

bench::mark(
  data.table = dt[, .(total = sum(amount)), by = id],
  dplyr      = df |> summarise(total = sum(amount), .by = id),
  tidytable  = df |> tidytable::summarise(total = sum(amount), .by = id),
  iterations = 5,
  check      = FALSE
)

Three patterns hold across hardware and package versions:

  • Grouped aggregation: data.table and tidytable typically finish in a fraction of dplyr’s time on multi-million-row frames, because grouping is multi-threaded and nothing is copied.
  • Column updates: DT[, new := f(old)] reuses existing memory, while a dplyr mutate on a 50-column frame can duplicate the object.
  • CSV import: fread is the fastest of the three for large flat files; base read.csv is not close, and dplyr’s readr sits between them.

Two fairness notes. First, dplyr’s optimisations (vctrs-backed mutate, summarise(.by)) have improved materially, so old benchmark blog posts understate its current speed. Second, reading a Parquet or Arrow file into memory is often a bigger win than any package swap — if your bottleneck is I/O, fix that first. Our Python dataframe comparison of Polars, Vaex and data.table-style libraries covers that side of the problem.

Pitfalls and Migration Notes

Reference semantics will surprise someone. A function that does DT[, x := x * 2] modifies the caller’s object. Inside package code or a function, either document it loudly or start with DT <- copy(DT). Silent mutation of shared data is the most common bug reported by newcomers to data.table.

Programmatic column names need the parentheses. In a loop or function, DT[, col := 1] creates a literal column named col. Use DT[, (colname) := 1] and DT[colname == ..target]. This differs from dplyr, where {{ }} and .data[[ ]] handle indirection — a rewrite that is mechanical but must be done deliberately.

Grouped state lingers in dplyr. group_by() persists on the object, so a later summarise() silently regroups. Prefer .by for single aggregations and call ungroup() when you do use group_by().

setkey() sorts your data in place. That is useful for non-equi and rolling joins, but it is not free and not silent — if row order matters downstream, sort explicitly at the end.

Third-party package compatibility is a real migration cost. Many R packages accept data.frame or tibble. data.table inherits from data.frame and tidytable mostly behaves like one, but strict consumers can still surprise you. If your pipeline feeds a modelling or reporting layer, keep the conversion at the boundary.

For teams running R in production, the deployment layer matters as much as the library choice — our Shiny Server vs ShinyProxy vs Posit Connect deployment guide covers the hosting tradeoffs. And if your workload is trial data with regulatory expectations, the open-source clinical biostatistics stack around gtsummary, finalfit and arsenal shows why auditable output sometimes outranks raw speed.

FAQ

Is data.table faster than dplyr in 2026? For grouped aggregation, column updates and large CSV imports, generally yes — data.table is multi-threaded, avoids copies and parses CSV in parallel. The honest answer is that dplyr narrowed the gap with vctrs-backed operations, so run the bundled benchmark on your own data before rewriting a working pipeline.

Can I use dplyr syntax on a data.table? Yes, via the dtplyr package: it accepts dplyr verbs and translates them into data.table operations, so you get familiar syntax with much of the speed. It will not match hand-written data.table exactly, and some verbs are unsupported or slower than the native equivalent.

Is tidytable a drop-in replacement for dplyr? Almost. The verb names and pipeline shape match, and the engine is data.table, but objects are class tidytable rather than tibble or data.frame, and not every dplyr or tidyr function is implemented. Treat it as a compatible reimplementation and check the function reference before migrating.

Does data.table support multi-threading, and can I control it? Yes. Many operations — fread, grouping, joins, sorting — are multi-threaded by default. Control it with setDTthreads(4) or setDTthreads(0) to use all cores, and remember that some environments restrict thread counts, so pin the value explicitly in production scripts.

Which should a beginner learn first? Learn dplyr first: the vocabulary transfers to other languages, the errors are more legible, and the same verbs work against databases. Add data.table when you hit a size or runtime wall — at that point its syntax has a concrete motivation rather than being trivia.

Can data.table query a database directly? No. data.table works on in-memory data, so a database workflow means reading the table in full (or pre-aggregating in SQL yourself). If your data lives in Postgres, a warehouse or a lakehouse, dplyr plus dbplyr is the practical choice.


💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com