data.table

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. This page is about the add-on package data.table, checked at version 1.18.6.1 (dated 22 August 2026 in its NEWS file). data.table extends the data frame of Data Frames with one query form, DT[i, j, by], that reads “take DT, choose the rows i, compute j, grouped by by”; with columns that are added, changed and removed in place; with keyed, rolling and non-equi joins; and with a multi-threaded file reader and writer, fread() and fwrite(). Apart from base R, the scripts on the page run only data.table, and one script that compares the three styles also runs dplyr, checked at version 1.2.1, always written dplyr:: so that the two packages are never attached together. The image used for the examples also had bit64 4.8.6, which supplies the integer64 class that fread() uses for 13-digit numbers such as ISBNs. dtplyr (1.3.3 in the image) is named in one sentence and is not run. data.table depends on nothing but R itself and the methods package that ships with it, and the vignette on importing it presents that as a reason to depend on it (data.table package documentation: Importing data.table).

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines starting with #> are what it printed, messages included, in order. The scripts also pass the default lintr 3.4.0 linters, which includes the line-length limit of 80 characters. lintr’s object_usage_linter cannot see that a name inside DT[...] is a column, so it complains when such a query sits inside a function body; the small functions on this page that do that carry a # nolint: object_usage_linter. comment (or a nolint start and nolint end pair), which is the only exception, and queries at the top level of a script need nothing. The examples use the library-shelf scenario of the section: books, loans, shelf_today and loan_days come from the scenario.R file described on the section index, so a script that needs them starts with source("scenario.R") and then converts the data frames in place with setDT(). books has 16 rows and the columns title, author, isbn, year, genre and pages; loans has 60 rows and the columns loan_id, isbn, member, loan_date and return_date. The scripts that read the files directly use fread("books.csv"). Files that a script writes go to tempfile() and are deleted before the script ends. Each script is complete, so any block can be pasted and run on its own; the setup lines are repeated where a later block needs them.

The package website at https://rdatatable.gitlab.io/data.table/ documents the development version of data.table (its page header said 1.18.99 when it was read for this page), so a few details there are ahead of the installed 1.18.6.1. Where the two disagree, the page reports what the installed package did: the website’s options page, for example, lists datatable.print.class and datatable.print.keys with the default FALSE, and the installed package returns TRUE for both (see Printing and threads). Terms from other pages are used without repeating them: copy-on-modify is in Memory and Copy Semantics and Assignment, Names and Values, the evaluation of column names inside a call is the data-mask idea of Evaluation and Non-Standard Evaluation, and the same shelf analyses done with dplyr are in The tidyverse and dplyr.

What a data.table is

A data.table is a data frame: its class is c("data.table", "data.frame"), it is a list of columns of equal length, and every function that accepts a data frame accepts it. What differs is the [ method. DT[i, j, by] is a query language in the brackets (the next section), DT[2] is the second ROW (a data frame treats a single number as a column), and a data.table never has or uses row names. Printing differs too: a data.table shows row numbers followed by a colon, a line of column classes under the names (<char>, <int>), and for a long table only the first and last rows. data.table() builds a table from vectors as data.frame() does, with stringsAsFactors = FALSE and check.names = FALSE as defaults and an optional key argument (data.table package documentation: data.table).

The first script attaches the package with a plain library(data.table) call, which is the only place on the page where its start-up message is shown. data.table exports its own %notin% (added in data.table 1.15.0), and R 4.6.0 added %notin% to base R (it is listed in the R NEWS for 4.6.0), so attaching the package prints a conflict message and the data.table version is found first on the search path; both are the negation of %in%. In an interactive session library(data.table) also prints a line with the version and the number of threads in use; Rscript does not, because the .onAttach() hook of the package returns at once when interactive() is FALSE.

library(data.table)
#>
#> Attaching package: ‘data.table’
#>
#> The following object is masked from ‘package:base’:
#>
#>     %notin%
#>

# The first print of a data.table shows its class, row numbers and a colon.
shelf <- data.table(
  isbn = c("9780141439518", "9780306406157", "9780002000291"),
  title = c("Pride and Prejudice", "The Dispossessed", "Loud Roofs"),
  copies = c(2L, 1L, 3L)
)
shelf
#>             isbn               title copies
#>           <char>              <char>  <int>
#> 1: 9780141439518 Pride and Prejudice      2
#> 2: 9780306406157    The Dispossessed      1
#> 3: 9780002000291          Loud Roofs      3
class(shelf)
#> [1] "data.table" "data.frame"

# Strings stay character: no stringsAsFactors argument is needed.
vapply(shelf, \(x) class(x)[[1L]], character(1))
#>        isbn       title      copies
#> "character" "character"   "integer"

# The same table as a data.frame: [ with one number selects a COLUMN there.
shelf_df <- as.data.frame(shelf)
shelf_df[2]
#>                 title
#> 1 Pride and Prejudice
#> 2    The Dispossessed
#> 3          Loud Roofs
shelf[2]
#>             isbn            title copies
#>           <char>           <char>  <int>
#> 1: 9780306406157 The Dispossessed      1

# The version, and which %notin% is found first on the search path.
packageVersion("data.table")
#> [1] ‘1.18.6.1’
environmentName(environment(`%notin%`))
#> [1] "data.table"

Real tables are usually already data frames, and data.table converts them in two ways. as.data.table() makes a copy and leaves the original alone. setDT() changes the object in place: it adds the class and the over-allocation described below, copies nothing, and returns the table invisibly, so it is called without an assignment. setDF() goes back in place. The next script converts the shelf tables, and shows keep.rownames for a data frame whose row names carry information.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")

# books and loans from scenario.R are data frames.
class(books)
#> [1] "data.frame"

# as.data.table() copies; the original is untouched.
copy_dt <- as.data.table(books)
class(books)
#> [1] "data.frame"
class(copy_dt)
#> [1] "data.table" "data.frame"

# setDT() converts IN PLACE: no copy, no assignment, no result printed.
setDT(books)
setDT(loans)
class(books)
#> [1] "data.table" "data.frame"
class(loans)
#> [1] "data.table" "data.frame"

# Row names are dropped; keep.rownames = "isbn" keeps them in a column.
copies <- data.frame(copies = 2:3,
                     row.names = c("9780306406157", "9780141439518"))
as.data.table(copies)
#>    copies
#>     <int>
#> 1:      2
#> 2:      3
as.data.table(copies, keep.rownames = "isbn")
#>             isbn copies
#>           <char>  <int>
#> 1: 9780306406157      2
#> 2: 9780141439518      3

# setDF() goes back, also in place.
tmp <- copy(books[1:2, .(title, year)])
setDF(tmp)
class(tmp)
#> [1] "data.frame"
is.data.table(books)
#> [1] TRUE

The general form: DT[i, j, by]

The package documentation reads the form out loud: “take DT, subset rows by i, then compute j grouped by by”. The table lines the three arguments up with the clauses of an SQL query, and lists the arguments that refine them. Every argument of a query is optional, and the commas keep the positions: DT[, j] has no i, DT[i] has only i.

Argument SQL clause What it does

i

WHERE, ORDER BY, JOIN

chooses and orders the rows: row numbers, a logical condition, order(...), or another table to join to

j

SELECT, UPDATE

what to compute: a column, an expression, a list written .(...), or an assignment with :=

by, keyby

GROUP BY

runs j for each group of the columns or expressions named; keyby also sorts the groups and sets a key

on, nomatch, mult, roll

JOIN

how a table in i is matched: the columns, rows without a match, several matches, the nearest match

.SDcols, with, env

which columns .SD holds, whether j is a name lookup, and values to substitute into the query

Queries chain by writing another pair of brackets straight after the first: DT[...][...] runs the second query on the result of the first. That is how a result is sorted after grouping (DT[, .N, by = g][order(-N)]), and it replaces the temporary variables of base R.

i: choosing and ordering rows

i is evaluated inside the table, so the columns are variables and books[year > 2020] needs no books$. It can be a vector of row numbers (negative numbers drop rows), a logical expression, or order(...) for sorting, where a minus sign means descending. A logical NA in i is treated as FALSE, as the help page says, which differs from a data frame, where a logical NA produces a row of NA values. A logical i must be as long as the table: recycling a shorter one is an error. The operators %between%, %chin% and %like% (a regular expression, as grepl()) are shortcuts for common conditions. order() inside i is handled by data.table’s own fast sort, which sorts text in C-locale, not in the locale of the session (see the setters), and puts NA last, as base order() does.

When a variable of the calling environment has the name of a column, the column wins: after year <- 1900, books[year > 2022] still compares the column. The last lines of the script show it; the way to reach the variable is env = or .. (see Programming with data.table).

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)

# i is evaluated inside the table: columns are variables, no books$ needed.
books[year > 2020, .(title, year)]
#>             title  year
#>            <char> <int>
#> 1:     Loud Roofs  2021
#> 2: THE LONG TABLE  2023
#> 3:    Ten Winters  2022

# Row numbers, negative numbers and logical vectors work as in a data frame.
books[-(3:16), .(title)]
#>                  title
#>                 <char>
#> 1:    The Dispossessed
#> 2: Pride and Prejudice

# NA in a logical i drops the row (year is NA in row 7).
books[year > 2020 | is.na(year), .(title, year)]
#>                    title  year
#>                   <char> <int>
#> 1:            Loud Roofs  2021
#> 2: Garden of Small Fires    NA
#> 3:        THE LONG TABLE  2023
#> 4:           Ten Winters  2022

# order() inside i sorts; - means descending. NA goes last for ascending.
books[order(-pages), .(title, pages)][1:3]
#>                  title pages
#>                 <char> <int>
#> 1: Pride and Prejudice   432
#> 2:    Notes on Gravity   410
#> 3:    The Dispossessed   387

# %between%, %chin% and %like% are shortcuts for common conditions.
books[year %between% c(2015, 2020), .(title, year)]
#>                   title  year
#>                  <char> <int>
#> 1:   the quiet streets   2019
#> 2:      Midnight Ledger  2016
#> 3:     Notes on Gravity  2017
#> 4:        Orchard Logic  2020
#> 5:      Salt and Static  2018
books[genre %chin% c("Poetry", "Mystery"), .(title, genre)]
#>                    title   genre
#>                   <char>  <char>
#> 1:       Midnight Ledger Mystery
#> 2: Garden of Small Fires  Poetry
#> 3:           Paper Moons  Poetry
#> 4:       Salt and Static Mystery
books[title %like% "^The", .(title)]
#>               title
#>              <char>
#> 1: The Dispossessed
#> 2: The Lamp at Noon
#> 3:   The Slow Comet

# A column named like an outside variable: the column wins.
year <- 1900
books[year > 2022, .(title)]
#>             title
#>            <char>
#> 1: THE LONG TABLE

j: columns, expressions and lists

j is evaluated in the scope of the table too. A bare column name returns the column as a vector, .(a, b) (an alias of list(a, b)) returns a data.table, and any other expression is evaluated on whole columns. When j returns a list, every element becomes a column, named after the argument, or V1, V2 and so on when it is unnamed. A number or a character vector of names in j selects columns and always gives a data.table back, which is the difference to df[, 2] on a data frame, where one column becomes a vector. A character vector held in a variable is looked up in the calling scope with the prefix .. (one level up, like .. in a file path), or passed to .SDcols. j can be any code, so it can also run for its side effect (a plot, a cat()), and then its value is what the code returns.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)

# A bare column in j comes back as a vector; .() or list() as a data.table.
books[, pages]
#>  [1] 387 432 214 198 301  NA  96 280 352 256  88 410 175 322  NA 244
books[1:3, .(pages)]
#>    pages
#>    <int>
#> 1:   387
#> 2:   432
#> 3:   214

# An expression is evaluated on the whole column. na.rm is needed for NA.
books[, mean(pages, na.rm = TRUE)]
#> [1] 268.2143
books[, .(mean_pages = mean(pages, na.rm = TRUE), n = .N)]
#>    mean_pages     n
#>         <num> <int>
#> 1:   268.2143    16

# Several results become several columns; unnamed ones are called V1, V2...
books[genre == "Poetry", .(title, nchar(title), pages * 2L)]
#>                    title    V2    V3
#>                   <char> <int> <int>
#> 1: Garden of Small Fires    21   192
#> 2:           Paper Moons    11   176

# A number in j selects a column and ALWAYS gives a data.table back.
books[1:2, 1]
#>                  title
#>                 <char>
#> 1:    The Dispossessed
#> 2: Pride and Prejudice
books[1:2, c("title", "year")]
#>                  title  year
#>                 <char> <int>
#> 1:    The Dispossessed  1974
#> 2: Pride and Prejudice  1813

# A character vector held in a variable is looked up one level up with
# `..`, or with .SD and .SDcols.
cols <- c("title", "year")
books[1:2, ..cols]
#>                  title  year
#>                 <char> <int>
#> 1:    The Dispossessed  1974
#> 2: Pride and Prejudice  1813
books[1:2, .SD, .SDcols = cols]
#>                  title  year
#>                 <char> <int>
#> 1:    The Dispossessed  1974
#> 2: Pride and Prejudice  1813

by and keyby: groups

by runs j once for each group of rows that share the values of the columns (or expressions) named, and stacks the results. The groups come out in the order of their first appearance in the table; keyby sorts them and sets them as the key of the result, which is the usual choice when the result is printed. by accepts a column name, a character vector of names, a range a:c of columns, or a list of expressions in .(...), and a computed grouping column needs a name there, as decade below. i is applied before the grouping, which is how a filtered summary is written. A single unnamed value in j is called V1 in the result; .N (the size of the group) becomes N.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)
books[, genre := tolower(trimws(genre))]

# by groups; the groups come out in the order of first appearance.
books[, .(n = .N, pages = sum(pages, na.rm = TRUE)), by = genre]
#>              genre     n pages
#>             <char> <int> <int>
#> 1: science fiction     3  1119
#> 2:         classic     2   712
#> 3:         fiction     4   668
#> 4:         mystery     2   545
#> 5:     non-fiction     3   527
#> 6:          poetry     2   184

# keyby sorts the groups and sets a key on the result.
books[, .N, keyby = genre]
#> Key: <genre>
#>              genre     N
#>             <char> <int>
#> 1:         classic     2
#> 2:         fiction     4
#> 3:         mystery     2
#> 4:     non-fiction     3
#> 5:          poetry     2
#> 6: science fiction     3

# An unnamed single expression is called V1; .() and names fix that.
books[, mean(pages, na.rm = TRUE), by = genre]
#>              genre       V1
#>             <char>    <num>
#> 1: science fiction 373.0000
#> 2:         classic 356.0000
#> 3:         fiction 222.6667
#> 4:         mystery 272.5000
#> 5:     non-fiction 263.5000
#> 6:          poetry  92.0000

# by takes expressions: a computed grouping column needs a name in .().
books[, .(n = .N), by = .(decade = year %/% 10L * 10L)][order(decade)]
#>    decade     n
#>     <int> <int>
#> 1:   1810     1
#> 2:   1970     1
#> 3:   1990     1
#> 4:   2000     2
#> 5:   2010     6
#> 6:   2020     4
#> 7:     NA     1

# Several columns: a character vector or .() work the same.
books[, .N, by = c("genre", "author")][1:3]
#>              genre            author     N
#>             <char>            <char> <int>
#> 1: science fiction Ursula K. Le Guin     1
#> 2:         classic       Jane Austen     1
#> 3:         fiction        Mara Quill     2

# Rows are subset first (i), then grouped.
books[year > 2000, .(first_title = title[[1L]], n = .N), by = genre]
#>              genre          first_title     n
#>             <char>               <char> <int>
#> 1:         fiction   the quiet streets      4
#> 2:         mystery      Midnight Ledger     2
#> 3:     non-fiction        A Map of Salt     3
#> 4:          poetry          Paper Moons     1
#> 5: science fiction     Notes on Gravity     2

A group of NA is a group like any other: the book without a year forms its own decade in the output above.

Adding, changing and removing columns by reference

The := operator

:= is data.table’s assignment inside j. It adds a column, replaces a column or a part of one, or (with NULL) removes a column, in the table itself: no copy of the table is made and nothing is assigned back, so books[, age := 2026L - year] is a complete statement. There are two spellings. The LHS := RHS form takes column names on the left (a single name, or a character vector in c() or in parentheses) and a value or a list of values on the right. The functional form, := called like a function, as in `:=`(a = ..., b = ...), takes named arguments, which lets each column carry its own comment; let() is an alias for it in the help page of the package, and it is present in 1.18.6.1. With i, only the rows chosen are changed, and a new column is NA in the other rows. With by, the value is computed for each group and recycled within it. The call returns the table invisibly, so nothing is printed at the prompt (Printing after the := operator has the details), and .Last.updated holds the number of rows the last := or set() changed.

A name in parentheses, (cols) :=, means “the columns whose names are held in cols”, where a bare cols := would create a column called cols. This is also how one function is applied to many columns, (cols) := lapply(.SD, f). Since data.table 1.16.0 names(.SD) := lapply(.SD, f) updates every column of .SD (NEWS for 1.16.0). The help page states the rule for types: the right-hand side is coerced to the type of the existing column, not the other way round (unlike <- on a data frame), and a whole column given as the right-hand side replaces the column, with its new type ("plonk" syntax, as in (num_cols) := lapply(.SD, as.numeric) below). Coercing a part of a column warns, and a right-hand side whose length is neither 1 nor the number of rows is an error (both messages are in Common errors and warnings).

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)

# := adds a column by reference: no assignment, and nothing is printed.
books[, age := 2026L - year]
books[1:3, .(title, year, age)]
#>                   title  year   age
#>                  <char> <int> <int>
#> 1:     The Dispossessed  1974    52
#> 2:  Pride and Prejudice  1813   213
#> 3:   the quiet streets   2019     7

# With i, only those rows change; a new column is NA in the others.
books[pages > 400L, size := "long"]
books[, .N, by = size]
#>      size     N
#>    <char> <int>
#> 1:   <NA>    14
#> 2:   long     2

# Several columns: names on the left, a list on the right.
books[, c("title", "genre") := .(trimws(title), tolower(trimws(genre)))]
books[3:4, .(title, genre)]
#>                title   genre
#>               <char>  <char>
#> 1: the quiet streets fiction
#> 2:        Loud Roofs fiction

# The functional form allows a comment next to each column.
books[, `:=`(
  author = gsub("\\s+", " ", trimws(author)), # collapse repeated blanks
  isbn = gsub("[^0-9X]", "", isbn) # keep digits and X only
)]
books[9:10, .(title, author, isbn)]
#>              title       author          isbn
#>             <char>       <char>        <char>
#> 1: Rivers of Glass  Tomas Arden
#> 2:  THE LONG TABLE Iris Fenwick 9780008000851

# One function over several columns: (cols) := lapply(.SD, f).
num_cols <- c("year", "pages")
books[, (num_cols) := lapply(.SD, as.numeric), .SDcols = num_cols]
vapply(books[, ..num_cols], class, character(1))
#>      year     pages
#> "numeric" "numeric"

# NULL deletes columns.
books[, c("age", "size") := NULL]
names(books)
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"

# The result is invisible; a trailing [] prints it.
small <- books[1:3, .(title, year)]
small[, decade := year %/% 10 * 10][]
#>                  title  year decade
#>                 <char> <num>  <num>
#> 1:    The Dispossessed  1974   1970
#> 2: Pride and Prejudice  1813   1810
#> 3:   the quiet streets  2019   2010

Reference semantics and copy()

Because := and the set* functions change the table where it is, two names can refer to one table, and a change made through one name is seen through the other. b <- a gives a data.table a second name, as it does for every R object, and a := on b then changes the table both names refer to; a data frame, by contrast, is copied the moment it is modified. The same holds when a table is passed to a function: the function receives the table itself, so a := inside it changes the caller’s data. copy() makes a real, independent copy and is the way to protect the original. The vignette data.table package documentation: Reference semantics gives the reason: since R 3.1.0 adding a column to a data frame copies only the vector of column pointers (a shallow copy), but changing some rows of a column with df$c[i] <- value still copies the whole column, and := does neither.

suppressPackageStartupMessages(library(data.table))

# b <- a does not copy a data.table, and := changes the one object.
a <- data.table(x = 1:3)
b <- a
b[, y := x * 2L]
names(a)
#> [1] "x" "y"
address(a) == address(b)
#> [1] TRUE

# copy() makes an independent table.
d <- copy(a)
d[, z := 0L]
names(a)
#> [1] "x" "y"
names(d)
#> [1] "x" "y" "z"
address(a) == address(d)
#> [1] FALSE

# A function that uses := on its argument changes the caller's table.
add_flag <- function(tbl) {
  tbl[, flag := TRUE] # nolint: object_usage_linter.
  invisible(NULL)
}
add_flag(a)
names(a)
#> [1] "x"    "y"    "flag"

# copy() at the top of the function keeps the caller's table untouched.
with_stamp <- function(tbl) {
  tbl <- copy(tbl)
  tbl[, stamp := "2026-06-30"] # nolint: object_usage_linter.
  tbl[]
}
res <- with_stamp(a)
names(res)
#> [1] "x"     "y"     "flag"  "stamp"
names(a)
#> [1] "x"    "y"    "flag"

# The base R way, a$col <- value, makes a (shallow) copy as usual.
e <- a
e$w <- 1
names(a)
#> [1] "x"    "y"    "flag"
names(e)
#> [1] "x"    "y"    "flag" "w"

address(a) == address(b) is TRUE when two names refer to the same table; the addresses themselves are memory locations that change from run to run and are not worth printing. The last lines of the script show the base R way, e$w <- 1: the replacement function $<- works on a (shallow) copy, so a is unchanged. Base R replacement functions such as attr<- are the usual way a data.table is copied by accident, which is the subject of the self-reference warning below. The rule of thumb is to use := and the set* functions to change a data.table, copy() to keep the original, and not to rely on the copy-on-modify behaviour of a data frame. The sharing of one table by several names is the same idea as the shared environments of Scoping, Environments and Closures; Memory and Copy Semantics covers when R copies and when it does not.

set(), setnames(), setorder() and setcolorder()

Every function whose name starts with set changes its argument by reference and returns it invisibly. set(DT, i, j, value) is described in the help page as a low-overhead, loop-able version of := that cannot group: the cost of a DT[...] call is small but is paid on each iteration, and set() avoids it, which makes it the tool for updating columns in a for loop (i must be row numbers, j a column name or number). setnames() renames columns by name or position, setorder() sorts the table itself (a minus sign for descending; NA first), setcolorder() moves columns to the front (it gained before and after arguments in 1.15.0), and setattr() sets an attribute without copying. setkey(), setindex() and setDT() are covered elsewhere on the page.

Sorting in data.table, whether by setorder(), setkey(), or order() in i, always uses the C-locale: capitals sort before lower case whatever language the session is set to, and the order is the same on every machine. The help page of setorder() explains the choice and says that DT[base::order(...)] sorts by the locale of the session instead.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)

# set() is := for loops: one call per column, with no [.data.table overhead.
for (col in c("title", "author", "genre")) {
  set(books, j = col, value = trimws(books[[col]]))
}
# With i it changes only some rows (the rows must be numbers).
set(books, i = which(books$genre == "Science fiction"), j = "genre",
    value = "science fiction")
books[genre == "science fiction", .N]
#> [1] 3

# setnames() renames in place, by name or by position.
setnames(books, "pages", "n_pages")
setnames(books, c("title", "author"), c("book_title", "writer"))
names(books)
#> [1] "book_title" "writer"     "isbn"       "year"       "genre"
#> [6] "n_pages"

# setorder() sorts the table itself; - means descending, NA comes first.
setorder(books, year)
books[, .(book_title, year)][1:3]
#>               book_title  year
#>                   <char> <int>
#> 1: Garden of Small Fires    NA
#> 2:   Pride and Prejudice  1813
#> 3:      The Dispossessed  1974
setorder(books, genre, -year)
books[, .(genre, year)][1:4]
#>      genre  year
#>     <char> <int>
#> 1: Classic  1999
#> 2: Classic  1813
#> 3: Fiction  2023
#> 4: Fiction  2022

# Strings sort in C-locale (capitals first), whatever the session locale.
words <- data.table(w = c("brazil", "America", "america", "Brazil"))
setorder(words, w)
words$w
#> [1] "America" "Brazil"  "america" "brazil"

# setcolorder() moves columns to the front; the others keep their order.
setcolorder(books, c("isbn", "book_title"))
names(books)
#> [1] "isbn"       "book_title" "writer"     "year"       "genre"
#> [6] "n_pages"

Over-allocation

The help page of truelength() explains why a data.table has room for more columns than it uses. Adding a column by creating a new list of column pointers (a shallow copy) "resulted in inconsistent behaviour in some circumstances", so data.table over-allocates the list of column pointers when data.table() or setDT() creates a table, so that := can add a column fully by reference. truelength() is the number of slots allocated and length() the number in use. The default is 1024 spare slots, set by options(datatable.alloccol = ). When the slots are used up, the next := has to reallocate, which takes a shallow copy; the page suggests copy() first, setalloccol() (also called alloc.col()) to reallocate in advance, or a larger option value to avoid it, and options(datatable.verbose = TRUE) reports when a shallow copy is taken. The functions are described as experimental and somewhat advanced, so the names might change.

A table that has been through saveRDS() and readRDS(), or save() and load(), comes back without its slots and without a valid self-reference. The next := or set() allocates them again, quietly, as the script shows with truelength() and the helper .selfref.ok(). The help page of .selfref.ok() says that it is for technical use; it is shown only to make the mechanism visible. Loading a table back is therefore not what triggers the warning in the next section.

suppressPackageStartupMessages(library(data.table))

# A data.table has room for more columns than it uses (over-allocation),
# which is why := can add a column without copying anything.
dt <- data.table(a = 1:3)
length(dt)
#> [1] 1
truelength(dt)
#> [1] 1025
getOption("datatable.alloccol")
#> [1] 1024

# Adding columns fills the spare slots, truelength() stays put.
dt[, c("b", "c") := 1L]
length(dt)
#> [1] 3
truelength(dt)
#> [1] 1025

# A table read back from disk has no spare room and no valid self-reference;
# := and set() allocate the room again, quietly.
path <- tempfile(fileext = ".rds")
saveRDS(dt, path)
back <- readRDS(path)
truelength(back)
#> [1] 0
.selfref.ok(back)
#> [1] FALSE
back[, d := 4L]
truelength(back)
#> [1] 1028
.selfref.ok(back)
#> [1] TRUE
other <- readRDS(path)
set(other, j = "e", value = 5L)
truelength(other)
#> [1] 1027
file.remove(path)
#> [1] TRUE

The self-reference warning

To know that a table is the one it allocated, data.table stores a pointer to the table itself in a hidden attribute, .internal.selfref. A table that R copied, or that was assembled by hand, does not pass the check. This happens when a table is built with structure() or by class<- on a data frame, when attr<- copies it, and when a package that knows nothing of data.table returns a modified copy. := still works on such a table, but it takes a shallow copy so that it can add the column, and warns that it did. In data.table 1.18.6.1 the warning begins "A shallow copy of this data.table was taken so that := can add or remove 1 columns by reference" (older releases, and many answers written for them, begin "Invalid .internal.selfref detected and fixed by taking a (shallow) copy of the data.table"). The cure is the one the message gives: build tables with setDT() or data.table(), copy with copy(), and use the set* functions in place of the base replacement functions.

suppressPackageStartupMessages(library(data.table))

# A data.table copied by R itself (attr<-, class<- on a data frame,
# structure()) loses its self-reference. := still works, with a warning.
df <- data.frame(x = 1:2)
class(df) <- c("data.table", "data.frame")
.selfref.ok(df)
#> [1] FALSE
res <- tryCatch(df[, y := 2L], warning = conditionMessage)
cat(strwrap(res, width = 70), sep = "\n")
#> A shallow copy of this data.table was taken so that := can add or
#> remove 1 columns by reference. At an earlier point, this data.table
#> was copied by R (or was created manually using structure() or
#> similar). Avoid names<- and attr<- which in R currently (and oddly)
#> may copy the whole data.table. Use set* syntax instead to avoid
#> copying: ?set, ?setnames and ?setattr. It's also not unusual for
#> data.table-agnostic packages to produce tables affected by this
#> issue. If this message doesn't help, please report your use case to
#> the data.table issue tracker so the root cause can be fixed or this
#> message improved.

# Taking the copy with copy() or setDT() avoids the problem.
df <- data.frame(x = 1:2)
setDT(df)
.selfref.ok(df)
#> [1] TRUE
df[, y := 2L]
df
#>        x     y
#>    <int> <int>
#> 1:     1     2
#> 2:     2     2

Printing after :=

At the prompt, books[, x := 1] prints nothing: inside := data.table sets a global flag, which the print method uses to know whether to print. The flag has a visible side effect: a function whose last expression is a := returns the table, and the print of that result can be swallowed. FAQ 2.23 of the package explains it and recommends a trailing [] after the last := in a function (data.table package documentation: Frequently asked questions). Since data.table 1.17.0 an explicit print(DT[, y := 2]) prints, because the detection of auto-printing was improved; the NEWS of 1.17.0 says this makes the older advice to append [] unnecessary for that case. The script shows both in 1.18.6.1.

suppressPackageStartupMessages(library(data.table))
a <- data.table(x = 1:2)

# At the prompt := returns the table invisibly: nothing is printed.
a[, y := 1L]

# An explicit print() prints it (since data.table 1.17.0, also around :=).
print(a[, y := 2L])
#>        x     y
#>    <int> <int>
#> 1:     1     2
#> 2:     2     2

# A function that ends with := returns the table, but the print of that
# result is swallowed by the flag := leaves behind. A trailing [] prints.
add_z <- function(tbl) {
  tbl <- copy(tbl)
  tbl[, z := 3L] # nolint: object_usage_linter.
}
add_z(a)
add_w <- function(tbl) {
  tbl <- copy(tbl)
  tbl[, w := 4L][] # nolint: object_usage_linter.
}
add_w(a)
#>        x     y     w
#>    <int> <int> <int>
#> 1:     1     2     4
#> 2:     2     2     4

Special symbols: .SD, .SDcols, .N, .I, .GRP, .BY and .EACHI

Inside j, data.table defines a few read-only symbols, listed on the help page special-symbols.

Symbol Meaning

.N

the number of rows: in the whole table, or in the current group; alone in i, as in DT[.N], it is the last row

.SD

the Subset of Data: a data.table with the columns of the current group, except the columns used in by

.SDcols

not a symbol but an argument: which columns .SD holds, as names, positions, a function such as is.numeric, patterns("regex"), or the negation of any of these with !

.I

the row numbers: seq_len(nrow(DT)) ungrouped, and the positions in DT of the rows of the group when grouping

.GRP

a counter of groups, 1 for the first group and so on; .NGRP is the number of groups

.BY

a list holding the value of the grouping columns for the current group

.EACHI

a value for by, not for j: by = .EACHI runs j for each row of the table in i of a join (see Joins)

.ROW

only with := NULL, to delete rows by reference (see Version notes)

.SD is the symbol behind most "do this to every column" jobs: DT[, lapply(.SD, mean), by = g] computes a mean for every column that is not in g, and .SDcols restricts the columns so that a text column is not averaged. To change .SD before returning it, take copy(.SD) first; the symbols are locked and assigning to them is an error. .I is the tool for "the row where something is maximal in each group": DT[, .I[which.max(x)], by = g] returns the row numbers, which are then used to subset the table, as the script does. The vignette data.table package documentation: Using .SD for data analysis collects the more advanced uses of .SD.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)
books[, genre := tolower(trimws(genre))]

# .N is the number of rows (in the group); alone in i it is the last row.
books[, .N]
#> [1] 16
books[.N, .(title)]
#>              title
#>             <char>
#> 1: Salt and Static
books[, .N, by = genre][order(-N)][1:2]
#>              genre     N
#>             <char> <int>
#> 1:         fiction     4
#> 2: science fiction     3

# .SD is the Subset of Data for each group; .SDcols picks its columns.
books[, lapply(.SD, mean, na.rm = TRUE), by = genre,
      .SDcols = c("year", "pages")]
#>              genre     year    pages
#>             <char>    <num>    <num>
#> 1: science fiction 2001.667 373.0000
#> 2:         classic 1906.000 356.0000
#> 3:         fiction 2021.250 222.6667
#> 4:         mystery 2017.000 272.5000
#> 5:     non-fiction 2013.333 263.5000
#> 6:          poetry 2005.000  92.0000

# .SDcols also takes a function or a pattern, and ! inverts it.
books[, lapply(.SD, max, na.rm = TRUE), .SDcols = is.numeric]
#>     year pages
#>    <int> <int>
#> 1:  2023   432
names(books[, .SD, .SDcols = patterns("^(t|a)")])
#> [1] "title"  "author"
names(books[, .SD, .SDcols = !c("title", "author", "isbn")])
#> [1] "year"  "genre" "pages"

# .I is the row number in the whole table: here the longest book per genre.
longest <- books[, .I[which.max(pages)], by = genre]
books[longest$V1, .(genre, title, pages)]
#>              genre                 title pages
#>             <char>                <char> <int>
#> 1: science fiction      Notes on Gravity   410
#> 2:         classic   Pride and Prejudice   432
#> 3:         fiction        THE LONG TABLE   256
#> 4:         mystery       Midnight Ledger   301
#> 5:     non-fiction       Rivers of Glass   352
#> 6:          poetry Garden of Small Fires    96

# .GRP numbers the groups, .BY holds the current value of the by columns.
books[, .(group = .GRP, name = .BY$genre, n = .N), by = genre]
#>              genre group            name     n
#>             <char> <int>          <char> <int>
#> 1: science fiction     1 science fiction     3
#> 2:         classic     2         classic     2
#> 3:         fiction     3         fiction     4
#> 4:         mystery     4         mystery     2
#> 5:     non-fiction     5     non-fiction     3
#> 6:          poetry     6          poetry     2

# head(.SD, n) by group is optimised: the oldest title of each genre.
books[order(year), head(.SD, 1L), by = genre, .SDcols = c("title", "year")]
#>              genre                title  year
#>             <char>               <char> <int>
#> 1:         classic  Pride and Prejudice  1813
#> 2: science fiction     The Dispossessed  1974
#> 3:          poetry          Paper Moons  2005
#> 4:     non-fiction        A Map of Salt  2008
#> 5:         mystery      Midnight Ledger  2016
#> 6:         fiction   the quiet streets   2019

Keys, indices and joins

Keys and binary search

A key is a sort order that the table remembers. setkey(DT, col1, col2) sorts the table by those columns, in place and ascending (NA first), and marks them as the key (the "sorted" attribute). key() returns the key, haskey() tests for one, and setkeyv() is the version that takes a character vector. A table has at most one key, because the key is the physical order. Its purpose is speed and brevity: a subset by key value is a binary search, not a scan of the whole column, DT["x"] finds the group "x" of the first key column, and grouping by a leading part of the key is faster because the groups are already adjacent in memory (the reasons are in the help page of setkey()). setkey() always sorts in C-locale. nomatch = NULL drops the values of i that are not in the table, where the default nomatch = NA returns a row of NA for each, and mult chooses "first", "last" or (the default) "all" of several matches.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)
books[, isbn := gsub("[^0-9X]", "", isbn)]

# setkey() sorts the table by the column(s) and marks them as the key.
key(books)
#> NULL
setkey(books, isbn)
key(books)
#> [1] "isbn"
haskey(books)
#> [1] TRUE
books[, .(isbn, title)][1:3]
#> Key: <isbn>
#>             isbn                title
#>           <char>               <char>
#> 1:                    Rivers of Glass
#> 2: 9780001000100   the quiet streets
#> 3: 9780002000291           Loud Roofs

# A key subset is a binary search, not a scan of the column.
books["9780306406157", .(title, year)]
#>               title  year
#>              <char> <int>
#> 1: The Dispossessed  1974
books[.("9780306406157"), .(title, year)]
#>               title  year
#>              <char> <int>
#> 1: The Dispossessed  1974

# An unknown key gives a row of NA (nomatch = NA); nomatch = NULL drops it.
books[c("9780306406157", "0000000000000"), .(isbn, title)]
#>             isbn            title
#>           <char>           <char>
#> 1: 9780306406157 The Dispossessed
#> 2: 0000000000000             <NA>
books[c("9780306406157", "0000000000000"), .(isbn, title), nomatch = NULL]
#> Key: <isbn>
#>             isbn            title
#>           <char>           <char>
#> 1: 9780306406157 The Dispossessed

# mult picks one of several matches; here several books share an author.
setkey(books, author)
books["Mara Quill", .(title, year)]
#>                   title  year
#>                  <char> <int>
#> 1:   the quiet streets   2019
#> 2:           Loud Roofs  2021
#> 3:      Salt and Static  2018
books["Mara Quill", .(title, year), mult = "first"]
#>                   title  year
#>                  <char> <int>
#> 1:   the quiet streets   2019
books["Mara Quill", .(title, year), mult = "last"]
#>              title  year
#>             <char> <int>
#> 1: Salt and Static  2018

Joins with on=

x[i, on = ...] looks up the rows of i in x, and the result has one row for each row of i (for each match): in SQL terms x[i] is a right join, and i can be a data.table, a data frame, a list, or a vector. on names the columns to match: an unnamed character vector when both tables have the same names (on = "isbn", or on = .(isbn)), a named one when they differ (on = c(isbn = "code"), or on = "isbn==code"), and an inequality for a non-equi join (next section). It overrides the keys of the tables, and the help page recommends it even when the tables are keyed, because it makes the code readable. In j, a column of i that has the same name as one of x is reached with the prefix i., and one of x with x..

The same tool makes the other joins of the table below. nomatch = NULL turns a join into an inner join, the ! prefix gives the rows of x that have no match in i (an anti join), x[i, col := i.col, on = ...] is an update join that adds columns to x by reference, and by = .EACHI evaluates j once for each row of i, with .N the number of matches, which is how counts and sums over matches are written without first building the large joined table. merge() also works on data.tables, with all, all.x, all.y and by as for data frames, sort = TRUE by default and a default by taken from the shared key (or the common column names). A join that would produce more rows than nrow(x) + nrow(i) stops with an error unless allow.cartesian = TRUE, a guard against joining on a key that is not unique in both tables.

SQL join In merge() With on =

inner join

merge(x, y, by = "k")

x[y, on = "k", nomatch = NULL]

right join, keeping every row of y

merge(x, y, by = "k", all.y = TRUE)

x[y, on = "k"]

left join, keeping every row of x

merge(x, y, by = "k", all.x = TRUE)

y[x, on = "k"]

full outer join

merge(x, y, by = "k", all = TRUE)

no single form; use merge()

anti join

not available

x[!y, on = "k"]

update join

not available

x[y, col := i.col, on = "k"]

cross join

not available (by must name columns)

CJ() for the combinations of vectors

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)
setDT(loans)
books[, `:=`(isbn = gsub("[^0-9X]", "", isbn), genre = tolower(trimws(genre)))]

# x[i, on = ] looks every row of i up in x, so the result has the rows of i
# (a right join in SQL terms). Here i = books: one row per book.
per_book <- loans[books, on = "isbn",
                  .(title = i.title, n_loans = .N), by = .EACHI]
per_book[order(-n_loans)][1:3]
#>             isbn                title n_loans
#>           <char>               <char>   <int>
#> 1: 9780001000100   the quiet streets        8
#> 2: 9780306406157     The Dispossessed       7
#> 3: 9780013001331          Ten Winters       7

# Put loans in i to keep every loan, with NA where no book matches.
joined <- books[loans, on = "isbn"]
nrow(joined)
#> [1] 60
joined[is.na(title), .(loan_id, isbn)]
#>    loan_id         isbn
#>     <char>       <char>
#> 1:    L005 978030640615

# nomatch = NULL turns it into an inner join.
nrow(books[loans, on = "isbn", nomatch = NULL])
#> [1] 59

# on = c(left = "right") joins columns that have different names.
copies <- data.table(code = c("9780306406157", "9780141439518"), copies = 2:1)
books[copies, on = c(isbn = "code"), .(title, copies)]
#>                  title copies
#>                 <char>  <int>
#> 1:    The Dispossessed      2
#> 2: Pride and Prejudice      1

# An update join adds a column to the left table by reference.
loans[books, on = "isbn", genre := i.genre]
loans[, .N, by = genre][order(-N)][1:3]
#>              genre     N
#>             <char> <int>
#> 1:         fiction    23
#> 2: science fiction    15
#> 3:         classic     6

# An anti join keeps the rows without a match (the ! in i).
loans[!books, on = "isbn", .(loan_id, isbn)]
#>    loan_id         isbn
#>     <char>       <char>
#> 1:    L005 978030640615

# merge() works as for data frames; the result is sorted by the key.
nrow(merge(loans, books[, .(isbn, title)], by = "isbn"))
#> [1] 59
nrow(merge(loans, books[, .(isbn, title)], by = "isbn", all.x = TRUE))
#> [1] 60

The first query in the script counts the loans of every book: loans[books, ..., by = .EACHI] runs j for each row of books, so the result has one row per book, and .N counts its loans. The default of merge() and the nomatch = NULL of on = give the same 59 matched loans, the count behind the overdue analysis below, because the loan L005 has an ISBN with one digit missing.

Rolling and non-equi joins

Two joins have no counterpart in merge(). A rolling join, roll = TRUE, matches each row of i to the last row of x whose value is less than or equal to it, in the last column of on; it is the tool for "the price, the fee or the status in force on this date". The other values of roll are -Inf (the next row, the opposite direction), "nearest", and a finite number that limits how far a row may be carried. rollends decides what happens before the first row and after the last: by default a forward roll continues past the last row, and a date before the first row gets NA. A non-equi join matches on >=, >, <= and < in on, which is how a value is placed in a range or a band; the operators are written in on = .(lo <= pages, hi > pages) (a column of x on the left of each operator and a column of i on the right) or as strings, on = c("lo<=pages", "hi>pages"). Joins on overlapping intervals have their own function, foverlaps() (data.table package documentation: foverlaps). Non-equi joins match a row of i to every row of x that satisfies the condition, so the result can be larger than either table.

The script gives each loan the late fee in force on its loan date from a small schedule (fees changed on 2025-07-01 and 2026-01-01), then places books in page-size bands.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(loans)
loans <- loans[!is.na(loan_date)]

# A fee schedule: each row is valid from its date until the next row.
fees <- data.table(
  from = as.Date(c("2025-01-01", "2025-07-01", "2026-01-01")),
  late_fee = c(0.10, 0.15, 0.20)
)

# Rolling join: for each loan, the last fee that started on or before the
# loan date (roll = TRUE carries the prevailing row forward).
fees[loans, on = .(from = loan_date), roll = TRUE,
     .(loan_id, loan_date = i.loan_date, late_fee)][c(1, 30, 58)]
#>    loan_id  loan_date late_fee
#>     <char>     <Date>    <num>
#> 1:    L001 2025-01-14     0.10
#> 2:    L031 2025-10-02     0.15
#> 3:    L059 2026-05-17     0.20

# roll = "nearest" takes the closest row, before or after.
fees[.(as.Date("2025-06-20")), on = "from", roll = "nearest"]
#>          from late_fee
#>        <Date>    <num>
#> 1: 2025-06-20     0.15

# A date before the first row gets NA, unless rollends[1] is TRUE.
early <- as.Date("2024-12-01")
fees[.(early), on = "from", roll = TRUE]
#>          from late_fee
#>        <Date>    <num>
#> 1: 2024-12-01       NA
fees[.(early), on = "from", roll = TRUE, rollends = c(TRUE, TRUE)]
#>          from late_fee
#>        <Date>    <num>
#> 1: 2024-12-01      0.1

# A finite roll limits how far a row is carried: 30 days here.
late <- as.Date(c("2025-07-20", "2025-09-20"))
fees[.(late), on = "from", roll = 30]
#>          from late_fee
#>        <Date>    <num>
#> 1: 2025-07-20     0.15
#> 2: 2025-09-20       NA

# Non-equi join: put each book in a page-size band.
setDT(books)
bands <- data.table(
  label = c("short", "medium", "long"),
  lo = c(0L, 200L, 300L),
  hi = c(200L, 300L, 1000L)
)
banded <- bands[books, on = .(lo <= pages, hi > pages), nomatch = NULL,
                .(title = i.title, pages = i.pages, label)]
banded[, .N, by = label]
#>     label     N
#>    <char> <int>
#> 1:   long     6
#> 2: medium     4
#> 3:  short     4
banded[label == "long", .(title, pages)]
#>                  title pages
#>                 <char> <int>
#> 1:    The Dispossessed   387
#> 2: Pride and Prejudice   432
#> 3:     Midnight Ledger   301
#> 4:     Rivers of Glass   352
#> 5:    Notes on Gravity   410
#> 6:      The Slow Comet   322

Secondary indices and auto-indexing

A key reorders the table, and a table has one. A secondary index stores the sorted order of a column (a vector of row numbers) in an attribute without moving any row, and a table can have many. setindex(DT, col) creates one, indices(DT) lists them, and setindex(DT, NULL) removes them all. A subset on an indexed column, DT[col == value], DT[col %in% values] or DT[.(value), on = "col"], uses binary search through the index. Auto-indexing creates the index the first time a column is used in == or %in% in i, so that the following subsets on that column are fast. The vignette says that auto-indexing is implemented for == and %in% (and for several of them combined with &), not yet for < and >=, and that on = computes the index on the fly each time without saving it. It is on by default; options(datatable.auto.index = FALSE) turns it off, and options(datatable.use.index = FALSE) stops the use of existing indices. Changing the indexed column by reference drops the index, as the script shows, because it would no longer be correct. One consequence is that a == subset can change the table it reads by adding an attribute, which is harmless for the data but visible in attributes().

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)
books[, genre := tolower(trimws(genre))]

# A secondary index records the sorted order of a column WITHOUT reordering
# the table, and a table can have several.
indices(books)
#> NULL
setindex(books, genre)
indices(books)
#> [1] "genre"
key(books)
#> NULL

# == and %in% on an indexed column use binary search through the index.
books[.("poetry"), on = "genre", .(title)]
#>                    title
#>                   <char>
#> 1: Garden of Small Fires
#> 2:           Paper Moons
books[genre == "poetry", .(title)]
#>                    title
#>                   <char>
#> 1: Garden of Small Fires
#> 2:           Paper Moons

# Auto-indexing: the first == or %in% on a column creates the index.
setindex(books, NULL)
indices(books)
#> NULL
invisible(books[year == 2019])
indices(books)
#> [1] "year"

# Changing the indexed column by reference drops the index.
books[1, year := 1975L]
indices(books)
#> NULL

# The behaviour is a global option; turn it off to keep tables unchanged.
options(datatable.auto.index = FALSE)
invisible(books[pages == 96L])
indices(books)
#> NULL

Reading and writing files

fread() reads delimited text and returns a data.table. It detects the separator, the header, the quoting and the type of every column, from a sample of 10,000 rows taken as 100 blocks spread over the file, and it re-reads a column from the beginning when a later row has a higher type than the sample suggested. It is multi-threaded, and its help page describes it as similar to read.csv() but faster and more convenient (compare Reading and Writing Text Data). The arguments that matter most:

Argument Use

input, file, text, cmd

a path or URL, a path only, a string of text, or a shell command whose output is read

select, drop

columns to keep or drop, by name or number; reading fewer columns is faster

colClasses

as in read.csv(), and also a named list of class to column names, list(character = c("a", "b"))

na.strings

strings read as NA; a quoted "" is an empty string unless na.strings = '""'

nrows, skip

the number of data rows to read and the lines to skip

sep, dec, quote

sep and dec are detected by default (dec since 1.16.0); quote is the double quote

strip.white

TRUE by default: leading and trailing blanks of unquoted fields are removed

integer64

how integers larger than 2^31 are read: "integer64" (package bit64) by default, or "double" (as read.csv() does, possibly losing precision silently) or "character"

fill, blank.lines.skip, comment.char

handling of ragged files

data.table

FALSE returns a data.frame

nThread, showProgress

threads for this call (default getDTthreads()), and a progress bar (default only when interactive())

key, index

set a key or an index on the result

suppressPackageStartupMessages(library(data.table))

# fread() guesses the separator, the header and the column types.
books <- fread("books.csv")
class(books)
#> [1] "data.table" "data.frame"
vapply(books, \(x) class(x)[[1L]], character(1))
#>       title      author        isbn        year       genre       pages
#> "character" "character" "character"   "integer" "character"   "integer"
books[c(6, 7), .(title, year, pages)]
#>                    title  year pages
#>                   <char> <int> <int>
#> 1:         A Map of Salt  2008    NA
#> 2: Garden of Small Fires    NA    96

# A quoted empty string stays "". na.strings = '""' reads it as NA.
books[9, .(title, isbn)]
#>              title   isbn
#>             <char> <char>
#> 1: Rivers of Glass
fread("books.csv", na.strings = '""')[9, .(title, isbn)]
#>              title   isbn
#>             <char> <char>
#> 1: Rivers of Glass   <NA>

# Thirteen digits are a number to fread(): the isbn column is integer64
# (package bit64) unless colClasses says "character".
vapply(fread("loans.csv"), \(x) class(x)[[1L]], character(1))
#>     loan_id        isbn      member   loan_date return_date
#> "character" "integer64" "character" "character"     "IDate"

# colClasses fixes the types; Date columns come back as Date or IDate.
loans <- fread("loans.csv", colClasses = list(
  character = c("loan_id", "isbn", "member"),
  Date = c("loan_date", "return_date")
))
loans[c(1, 20), .(loan_id, isbn, loan_date, return_date)]
#>    loan_id          isbn  loan_date return_date
#>     <char>        <char>     <Date>      <IDat>
#> 1:    L001 9780141439518 2025-01-14  2025-02-04
#> 2:    L020 9780006000624       <NA>  2025-07-15

# select = keeps columns, drop = drops them, nrows reads the first rows.
names(fread("books.csv", select = c("isbn", "year")))
#> [1] "isbn" "year"
names(fread("books.csv", drop = c("author", "genre")))
#> [1] "title" "isbn"  "year"  "pages"
fread("loans.csv", nrows = 2L, select = c("loan_id", "member"))
#>    loan_id     member
#>     <char>     <char>
#> 1:    L001 Hugo Silva
#> 2:    L002 Ben Okafor

# text = reads a string instead of a file; nThread limits the threads.
fread(text = "a,b\n1,x\n2,y")
#>        a      b
#>    <int> <char>
#> 1:     1      x
#> 2:     2      y
fread("loans.csv", nThread = 1L, nrows = 1L)[, .(loan_id)]
#>    loan_id
#>     <char>
#> 1:    L001

Two results in the script need a remark. The ISBNs of the loans are 13-digit numbers, so fread() reads them as numbers (integer64, from package bit64), and an ISBN with a leading zero would lose it. The colClasses list is the fix, and it is the same discipline as read.csv("loans.csv", colClasses = "character") in the base scenario. The date columns also differ: return_date is read as an IDate (data.table’s integer-backed subclass of Date), and loan_date as a plain Date, because that column holds the impossible date 2025-02-30 and cannot be parsed in one pass; with the bad row removed, both columns are IDate. The compressed files .gz and .bz2 (and .zip archives holding one file) are read by fread() directly, according to its help page.

fwrite() writes a data.table or data frame, in parallel. It quotes a field only when it needs to (with quote = "auto": when it contains the separator, a quote or a line ending), writes dates as ISO 8601 and NA as an empty field, and has arguments for sep, quote, na, col.names, append, compress, nThread and encoding (the last since 1.15.0). The default separator can be changed for a session with options(datatable.fwrite.sep = ). A round trip is faithful with one trap that follows from the two defaults above: a value with leading or trailing blanks is written unquoted, and fread() strips unquoted blanks by default, so strip.white = FALSE is needed to read back what was written.

suppressPackageStartupMessages(library(data.table))
loans <- fread("loans.csv", colClasses = list(
  character = c("loan_id", "isbn", "member"),
  Date = c("loan_date", "return_date")
))
path <- tempfile(fileext = ".csv")

# fwrite() quotes only where it must, writes dates as ISO 8601 and NA as an
# empty field.
fwrite(loans[18:21], path)
readLines(path)
#> [1] "loan_id,isbn,member,loan_date,return_date"
#> [2] "L018,9780001000100,Dev Patel,2025-07-01,2025-07-26"
#> [3] "L019,9780006000624,Ana Ruiz,2025-07-02,2025-07-25"
#> [4] "L020,9780006000624,Ana Ruiz,,2025-07-15"
#> [5] "L021,9780011001129,Ben Okafor,2025-07-05,2025-07-21"

# na = sets the text for NA, sep = the separator, quote = TRUE quotes strings.
fwrite(loans[18:19], path, na = "NA", sep = ";", quote = TRUE)
readLines(path)
#> [1] "\"loan_id\";\"isbn\";\"member\";\"loan_date\";\"return_date\""
#> [2] "\"L018\";\"9780001000100\";\"Dev Patel\";2025-07-01;2025-07-26"
#> [3] "\"L019\";\"9780006000624\";\"Ana Ruiz\";2025-07-02;2025-07-25"

# A round trip: write the books, read them back, compare. The title
# "  the quiet streets " is written unquoted, and fread() strips unquoted
# blanks by default (strip.white = TRUE).
books <- fread("books.csv")
fwrite(books, path)
all.equal(books, fread(path))
#> [1] "Column 'title': 1 string mismatch"
all.equal(books, fread(path, strip.white = FALSE))
#> [1] TRUE
identical(books, fread(path, strip.white = FALSE))
#> [1] TRUE

# append = TRUE adds rows to a file; a .gz name or compress = "gzip" zips it.
fwrite(loans[1:2, .(loan_id, member)], path)
fwrite(loans[3:4, .(loan_id, member)], path, append = TRUE)
fread(path)
#>    loan_id        member
#>     <char>        <char>
#> 1:    L001    Hugo Silva
#> 2:    L002    Ben Okafor
#> 3:    L003     Dev Patel
#> 4:    L004 Eva Lindqvist
zipped <- tempfile(fileext = ".csv.gz")
fwrite(loans, zipped)
fread(zipped, select = "loan_id")[, .N]
#> [1] 60
unlink(c(path, zipped))
file.exists(c(path, zipped))
#> [1] FALSE FALSE

The base-R side of the same job (read.csv(), write.csv(), readRDS() for R’s own binary format) is in Reading and Writing Text Data and Serialization: RDS, RData and JSON.

Reshaping and stacking

dcast() and melt() change the shape of a table, and the formula that dcast() takes is the one described in Formulas: rows ~ columns. dcast(DT, member ~ quarter, value.var = "N") makes one row per member and one column per quarter; when several rows fall into one cell, fun.aggregate (a function, or a list of functions) summarises them, and without it dcast() warns and counts the rows (the warning text is in Common errors and warnings). fill sets the value of empty cells, and an aggregate over no rows gives NaN for mean(), as the script shows for a member without loans in a year. Since data.table 1.18.0, dcast() is an error when fun.aggregate returns more than one value. melt() turns columns into rows: id.vars are kept, measure.vars are stacked, and variable.name and value.name name the two new columns. The helper measure() (exported since 1.16.0) melts several groups of columns at once, using the pieces of the column names, as in measure(value.name, year = as.integer, sep = "_"), which splits loans_2025 into the output column loans and the value 2025 of the column year.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(loans)
loans[, member := tools::toTitleCase(trimws(member))]
loans <- loans[nzchar(member) & !is.na(loan_date)]
loans[, quarter := paste0(year(loan_date), "-Q", quarter(loan_date))]

# Long: one row per member and quarter that has loans.
counts <- loans[, .N, by = .(member, quarter)]
counts[order(member, quarter)][1:4]
#>      member quarter     N
#>      <char>  <char> <int>
#> 1: Ana Ruiz 2025-Q1     1
#> 2: Ana Ruiz 2025-Q2     2
#> 3: Ana Ruiz 2025-Q3     2
#> 4: Ana Ruiz 2025-Q4     4

# dcast() spreads a column over new columns; fill gives the empty cells a value.
wide <- dcast(counts, member ~ quarter, value.var = "N", fill = 0L)
wide
#> Key: <member>
#>           member 2025-Q1 2025-Q2 2025-Q3 2025-Q4 2026-Q1 2026-Q2
#>           <char>   <int>   <int>   <int>   <int>   <int>   <int>
#> 1:      Ana Ruiz       1       2       2       4       2       2
#> 2:    Ben Okafor       2       1       1       1       0       0
#> 3:  Chloe Martin       0       2       2       2       2       1
#> 4:     Dev Patel       1       0       1       1       1       2
#> 5: Eva Lindqvist       1       0       3       1       0       1
#> 6:  Farid Karimi       0       0       0       1       1       0
#> 7:     Grace Liu       1       0       2       1       3       0
#> 8:    Hugo Silva       3       3       1       1       1       1

# melt() is the way back; variable.name and value.name name the new columns.
long <- melt(wide, id.vars = "member", variable.name = "quarter",
             value.name = "n")
long[n > 0][order(member, quarter)][1:4]
#>      member quarter     n
#>      <char>  <fctr> <int>
#> 1: Ana Ruiz 2025-Q1     1
#> 2: Ana Ruiz 2025-Q2     2
#> 3: Ana Ruiz 2025-Q3     2
#> 4: Ana Ruiz 2025-Q4     4
identical(nrow(long), nrow(wide) * (ncol(wide) - 1L))
#> [1] TRUE

# fun.aggregate summarises cells that hold several rows (here: days held).
loans[, held := as.integer(fifelse(is.na(return_date), shelf_today,
                                   return_date) - loan_date)]
loans[, loan_year := year(loan_date)]
dcast(loans, member ~ loan_year, value.var = "held",
      fun.aggregate = \(x) round(mean(x), 1))[1:3]
#> Key: <member>
#>          member  2025  2026
#>          <char> <num> <num>
#> 1:     Ana Ruiz  19.9  37.5
#> 2:   Ben Okafor  23.2   NaN
#> 3: Chloe Martin  19.2  43.7

# Several value columns give one block of columns for each; measure() melts
# such blocks back into one column per block (value.name) and one for the
# part of the name that varies.
per_year <- loans[, .(loans = .N, days = sum(held)), by = .(member, loan_year)]
two <- dcast(per_year, member ~ loan_year, value.var = c("loans", "days"))
names(two)
#> [1] "member"     "loans_2025" "loans_2026" "days_2025"  "days_2026"
melt(two, id.vars = "member",
     measure.vars = measure(value.name, year = as.integer, sep = "_"))[1:4]
#>          member  year loans  days
#>          <char> <int> <int> <int>
#> 1:     Ana Ruiz  2025     9   179
#> 2:   Ben Okafor  2025     5   116
#> 3: Chloe Martin  2025     6   115
#> 4:    Dev Patel  2025     3    50

rbindlist() stacks a list of tables in one pass, which its help page recommends for an unknown number of tables, such as the result of lapply(files, fread). idcol adds a column with the name of each list element, and fill = TRUE pads missing columns with NA. Its default use.names = "check" is a trap: if the column names are the same set but in another order, rbindlist() sends a message and then binds by position, as the script shows, with wrong results. use.names = TRUE matches by name, and rbind() on data.tables has use.names = TRUE by default. Since data.table 1.16.0, rbindlist() also binds columns of different but compatible classes (Date with IDate, for instance) and has ignore.attr to switch that safety net off. split() has a data.table method with by = that returns a list of tables, and rbindlist() puts them back.

suppressPackageStartupMessages(library(data.table))
loans <- fread("loans.csv", colClasses = "character")

# Split the loans into one CSV file per year, then read them all back.
dir <- tempfile("loans-")
dir.create(dir)
for (y in c("2025", "2026")) {
  fwrite(loans[startsWith(loan_date, y)], file.path(dir, paste0(y, ".csv")))
}
files <- list.files(dir, full.names = TRUE)
names(files) <- basename(files)

# rbindlist() stacks a list of tables in one pass; idcol records the name.
all_loans <- rbindlist(lapply(files, fread, colClasses = "character"),
                       idcol = "file")
all_loans[, .N, by = file]
#>        file     N
#>      <char> <int>
#> 1: 2025.csv    43
#> 2: 2026.csv    17

# The default use.names = "check" does NOT match by name when the order
# differs: it sends a message and binds by position (here: wrongly).
a <- data.table(id = 1:2, x = c("p", "q"))
b <- data.table(x = "r", id = 3L)
rbindlist(list(a, b))
#> Column 2 ['id'] of item 2 appears in position 1 in item 1. Set use.names=TRUE to match by column name, or use.names=FALSE to ignore column names. use.names='check' (default from v1.12.2) emits this message and proceeds as if use.names=FALSE for  backwards compatibility. See news item 5 in v1.12.2 for options to control this message.
#>        id      x
#>    <char> <char>
#> 1:      1      p
#> 2:      2      q
#> 3:      r      3

# use.names = TRUE matches by name; rbind() on data.tables does that already.
rbindlist(list(a, b), use.names = TRUE)
#>       id      x
#>    <int> <char>
#> 1:     1      p
#> 2:     2      q
#> 3:     3      r
rbind(a, b)
#>       id      x
#>    <int> <char>
#> 1:     1      p
#> 2:     2      q
#> 3:     3      r

# fill = TRUE pads a missing column with NA instead of failing.
c2 <- data.table(id = 4L, y = "extra")
rbindlist(list(a, c2), fill = TRUE)
#>       id      x      y
#>    <int> <char> <char>
#> 1:     1      p   <NA>
#> 2:     2      q   <NA>
#> 3:     4   <NA>  extra

# rbind() on data.tables is rbindlist() underneath; split() has a method
# with by = that returns a list of tables, and rbindlist() puts them back.
parts <- split(all_loans, by = "file")
names(parts)
#> [1] "2025.csv" "2026.csv"
nrow(rbindlist(parts))
#> [1] 60
unlink(dir, recursive = TRUE)
dir.exists(dir)
#> [1] FALSE

Helper functions

data.table ships a set of vectorised helpers that are used inside j, but work on any vector. The first script covers the conditional and membership ones: fifelse() is the help page’s “faster and more robust replacement of ifelse()” (it keeps the attributes of the first non-missing branch, so a Date stays a Date, which ifelse() does not do, and it requires yes and no to be of length 1 or of the length of test; see Common errors and warnings), fcase() is the case_when() of data.table (the first condition that is TRUE wins, and default = catches the rest), %between% and %inrange% test ranges (inrange() against several ranges at once), %chin% is a faster %in% for character vectors, %like% matches a regular expression (see Strings and Regular Expressions), and %notin% is the negation of %in%. The fcase() below splits the 32 overdue loans of the base-R analysis in Data Frames into 26 late loans and 6 very late ones (held for more than twice loan_days), and the two loans that cannot be classified, the one returned before it was made and the one with no date, fall to the default.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(loans)
setDT(books)
loans[, loan_date := as.Date(loan_date)]
loans[, held := as.integer(fifelse(is.na(return_date), shelf_today,
                                   return_date) - loan_date)]

# fifelse() is a strict, fast ifelse(): both branches must have one type.
loans[, status := fifelse(is.na(return_date), "out", "returned")]
loans[, .N, by = status]
#>      status     N
#>      <char> <int>
#> 1: returned    53
#> 2:      out     7

# fcase() is case_when() for data.table: the first TRUE condition wins.
loans[, band := fcase(
  held > 2L * loan_days, "very late",
  held > loan_days, "late",
  held >= 0L, "on time",
  default = "check"
)]
loans[, .N, by = band][order(-N)]
#>         band     N
#>       <char> <int>
#> 1:   on time    26
#> 2:      late    26
#> 3: very late     6
#> 4:     check     2

# %between%, %inrange% and %chin% are shortcuts for common conditions.
summer <- as.Date(c("2025-07-01", "2025-09-30"))
loans[loan_date %between% summer, .N]
#> [1] 12
loans[member %chin% c("Hugo Silva", "Ben Okafor"), .N]
#> [1] 15
books[, .(title, pages)][pages %inrange% list(200L, 300L)][1:3]
#>                   title pages
#>                  <char> <int>
#> 1:   the quiet streets    214
#> 2:     The Lamp at Noon   280
#> 3:       THE LONG TABLE   256
# inrange() tests each value against several ranges and returns one logical.
inrange(c(150L, 250L, 500L), c(100L, 200L), c(160L, 300L))
#> [1]  TRUE  TRUE FALSE

# %like% matches a regular expression; %notin% is the negation of %in%.
books[title %like% "^The|Map", .N]
#> [1] 4
books[genre %notin% c("Poetry", "Mystery"), .N]
#> [1] 12

The second script collects the helpers that look across rows. shift() lags or leads a vector, and with by it restarts for every group (type = "cyclic" wraps around, since 1.15.0); frank() is described as similar to rank() but much faster, with the extra ties.method = "dense" (it sorts in C-locale); uniqueN() counts the distinct values of a vector or of the rows of a table, also per group; rowid() numbers the rows within each value and rleid() numbers the runs of equal values; nafill() fills NA forward ("locf"), backward ("nocb") or with a constant, and fcoalesce() takes the first non-missing value of several vectors; tstrsplit() splits a character column into several columns at once. year(), month(), quarter() and the other date extractors of data.table (help page IDateTime) work on Date and IDate and are used in the reshaping script above. nafill() and shift() raise an error for a matrix since 1.15.0.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(loans)
loans <- loans[!is.na(loan_date) & nzchar(trimws(member))]
loans[, member := tools::toTitleCase(trimws(member))]

# shift() lags (or leads) a column; by = restarts it for each member.
setorder(loans, member, loan_date)
loans[, gap := as.integer(loan_date - shift(loan_date)), by = member]
loans[member == "Ana Ruiz", .(loan_id, loan_date, gap)][1:4]
#>    loan_id  loan_date   gap
#>     <char>     <Date> <int>
#> 1:    L008 2025-03-27    NA
#> 2:    L014 2025-05-07    41
#> 3:    L015 2025-06-04    28
#> 4:    L019 2025-07-02    28

# frank() ranks like rank() but faster; ties.method = "min" shares a rank.
loans[, held := as.integer(fifelse(is.na(return_date), shelf_today,
                                   return_date) - loan_date)]
loans[, rank := frank(-held, ties.method = "min")]
loans[rank <= 3, .(loan_id, member, held, rank)][order(rank)]
#>    loan_id        member  held  rank
#>     <char>        <char> <int> <int>
#> 1:    L054  Chloe Martin    84     1
#> 2:    L055 Eva Lindqvist    81     2
#> 3:    L056     Dev Patel    71     3

# uniqueN() counts distinct values, also per group.
uniqueN(loans$member)
#> [1] 8
loans[, .(members = uniqueN(member), loans = .N), by = year(loan_date)]
#>     year members loans
#>    <int>   <int> <int>
#> 1:  2025       8    41
#> 2:  2026       7    17

# rowid() numbers the rows within each value, rleid() the runs of values.
loans[, nth := rowid(member)]
loans[nth == 1L, .N]
#> [1] 8
rleid(c("a", "a", "b", "a"))
#> [1] 1 1 2 3

# nafill() fills NA in a numeric column; fcoalesce() takes the first non-NA.
pages <- c(214L, NA, 301L, NA, NA, 96L)
nafill(pages, type = "locf")
#> [1] 214 214 301 301 301  96
nafill(pages, fill = 0L)
#> [1] 214   0 301   0   0  96
fcoalesce(pages, 0L)
#> [1] 214   0 301   0   0  96

# tstrsplit() splits a character column into several columns at once.
loans[, c("first_name", "last_name") := tstrsplit(member, " ", fixed = TRUE)]
unique(loans[, .(member, first_name, last_name)])[1:3]
#>          member first_name last_name
#>          <char>     <char>    <char>
#> 1:     Ana Ruiz        Ana      Ruiz
#> 2:   Ben Okafor        Ben    Okafor
#> 3: Chloe Martin      Chloe    Martin

Programming with data.table

A query that names its columns in the code cannot be reused for another column, and building the text of a query for eval(parse(text = ...)) is fragile. data.table offers four routes, from the vignette data.table package documentation: Programming on data.table.

  1. ..var in j (or .SDcols = var) looks up a character vector of column names in the calling scope. It selects columns; it does not substitute names into an expression.

  2. get(name) and mget(names) inside j evaluate a name or names as columns. They work, but the column name is then hidden inside a string, where tools and readers cannot see it.

  3. env = list(a = value, ...) is the safe route: the names in the list are substituted into i, j and by BEFORE the query is evaluated, as substitute2() does, so a function can take column names as arguments and build the query from them. By default a character element of length 1 becomes a name (a longer one is an error), and I() keeps an element as it is, which is how a character vector is inserted as a vector.

  4. substitute2(expr, env) shows the expression env = builds, which is how a generated query is checked. Its help page calls it an experimental, more robust and friendlier substitute().

The next script uses them in order. The function mean_by() carries the nolint comments mentioned in the introduction, because lintr cannot see that value and group are substituted.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)
books[, genre := tolower(trimws(genre))]

# 1. A column name held in a variable: ..col looks it up one level up.
col <- "pages"
books[1:3, ..col]
#>    pages
#>    <int>
#> 1:   387
#> 2:   432
#> 3:   214
# get() evaluates a name inside j; it works but hides the column from checks.
books[, mean(get(col), na.rm = TRUE)]
#> [1] 268.2143

# 2. env = substitutes names, calls and values into the query BEFORE it is
# evaluated, so the expression stays readable and safe.
mean_by <- function(tbl, value, group) {
  # nolint start: object_usage_linter.
  tbl[, .(avg = mean(value, na.rm = TRUE), n = .N), by = group,
      env = list(value = value, group = group)]
  # nolint end
}
mean_by(books, "pages", "genre")[1:2]
#>              genre   avg     n
#>             <char> <num> <int>
#> 1: science fiction   373     3
#> 2:         classic   356     2

# substitute2() shows what env = builds.
substitute2(mean(value, na.rm = TRUE), list(value = as.name("pages")))
#> mean(pages, na.rm = TRUE)
substitute2(f(x), list(f = "sum", x = "pages"))
#> sum(pages)

# 3. Many columns at once: .SDcols takes a character vector, and env with
# I() inserts a character vector as a vector (not as a name).
num_cols <- c("year", "pages")
books[, lapply(.SD, max, na.rm = TRUE), .SDcols = num_cols]
#>     year pages
#>    <int> <int>
#> 1:  2023   432
books[, cols, env = list(cols = I(num_cols))][1:2]
#>     year pages
#>    <int> <int>
#> 1:  1974   387
#> 2:  1813   432

# 4. Assigning to a column whose name is in a variable: (var) := value.
new_col <- "pages_k"
books[, (new_col) := pages / 1000]
books[1:2, .(title, pages_k)]
#>                  title pages_k
#>                 <char>   <num>
#> 1:    The Dispossessed   0.387
#> 2: Pride and Prejudice   0.432
books[, c(new_col) := NULL]
names(books)
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"

The last lines show (var) :=, the form for assigning to a column whose name is in a variable. The env argument was added in data.table 1.15.0; older answers do the same job with bquote(), get() or eval(parse(text = )). See Evaluation and Non-Standard Evaluation for the general mechanism that substitute2() follows.

Printing and threads

print.data.table() has arguments for the number of rows shown (nrows, default 100, and topn, default 5: a longer table shows the first and last topn rows with a --- line between), class (the line of column classes), row.names, col.names ("auto", "top" or "none"), print.keys (the key and indices above the data), trunc.cols (only the columns that fit the width) and quote. Every default is read from an option, datatable.print.nrows, datatable.print.topn, datatable.print.class, datatable.print.keys, datatable.print.rownames, datatable.print.colnames and datatable.print.trunc.cols, so a session or a profile can change it for every table (options are set in a script or in a startup file; see Sessions, Workspaces and Startup). Character cells longer than datatable.prettyprint.char characters are truncated with .... When a table of more than 20 rows is printed in full, since 1.18.0 the column classes are repeated at the bottom as the names already were (a truncated table, as in the first script below, shows its --- line instead).

Threads are controlled separately. fread(), fwrite(), forder() (the sort behind setkey() and order() in i), grouping with GForce, fifelse(), nafill() and a few other functions use OpenMP threads. The number of threads is decided when the package is loaded: 50% of the logical CPUs by default (R_DATATABLE_NUM_PROCS_PERCENT changes the percentage and R_DATATABLE_NUM_THREADS fixes the number, both read at load time), and never more than OMP_THREAD_LIMIT allows. getDTthreads() reports it, and setDTthreads(n) changes it for the session (setDTthreads(0) is all logical CPUs) and returns the previous setting. The number depends on the machine, so the script prints only a comparison with 1 and a restored value. A throttle (default 1024 rows) keeps small tasks on one thread, and data.table switches to a single thread inside a forked process (the mechanism of parallel::mclapply()), so it does not oversubscribe the CPUs of code that is already parallel. CRAN’s own checks set OMP_THREAD_LIMIT to 2, and a package must not raise it above 2. See Parallel and Asynchronous Computing for the other parallel tools.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(loans)

# The defaults of the printing options in this version.
str(options("datatable.print.nrows", "datatable.print.topn",
            "datatable.print.class", "datatable.print.keys"))
#> List of 4
#>  $ datatable.print.nrows: int 100
#>  $ datatable.print.topn : int 5
#>  $ datatable.print.class: logi TRUE
#>  $ datatable.print.keys : logi TRUE

# A table longer than nrows (100) prints its first and last topn (5) rows.
data.table(n = 1:150, square = (1:150)^2)
#>          n square
#>      <int>  <num>
#>   1:     1      1
#>   2:     2      4
#>   3:     3      9
#>   4:     4     16
#>   5:     5     25
#>  ---
#> 146:   146  21316
#> 147:   147  21609
#> 148:   148  21904
#> 149:   149  22201
#> 150:   150  22500

# nrows and topn can be given to print() for a single call.
print(loans[, .(loan_id, member)], nrows = 8, topn = 2)
#>     loan_id     member
#>      <char>     <char>
#>  1:    L001 Hugo Silva
#>  2:    L002 Ben Okafor
#> ---
#> 59:    L059   Ana Ruiz
#> 60:    L060 Hugo Silva

# Other arguments: no row numbers, no class line, no column names.
print(loans[1:3, .(loan_id, member)], row.names = FALSE)
#>  loan_id     member
#>   <char>     <char>
#>     L001 Hugo Silva
#>     L002 Ben Okafor
#>     L003  Dev Patel
print(loans[1:3, .(loan_id, member)], class = FALSE)
#>    loan_id     member
#> 1:    L001 Hugo Silva
#> 2:    L002 Ben Okafor
#> 3:    L003  Dev Patel
print(loans[1:3, .(loan_id, member)], class = FALSE, col.names = "none")
#> 1: L001 Hugo Silva
#> 2: L002 Ben Okafor
#> 3: L003  Dev Patel

# The same settings as options apply to every table of the session.
options(datatable.print.nrows = 5, datatable.print.topn = 1,
        datatable.print.class = FALSE)
loans[, .(loan_id, member)]
#>     loan_id     member
#>  1:    L001 Hugo Silva
#> ---
#> 60:    L060 Hugo Silva
options(datatable.print.nrows = 100, datatable.print.topn = 5,
        datatable.print.class = TRUE)

# Threads: data.table uses OpenMP threads for fread(), fwrite(), sorting and
# grouping. The number depends on the machine, so only a check is shown.
getDTthreads() >= 1L
#> [1] TRUE
old <- setDTthreads(1L)
getDTthreads()
#> [1] 1
invisible(setDTthreads(old))
getDTthreads() == old
#> [1] TRUE

The shelf analyses with data.table

The analyses of Data Frames are rewritten here, and the numbers are the same: nine spellings of six genres, 32 overdue loans, 27 that are not and one that cannot be told, and an overdue rate by genre that runs from 0.67 for poetry to 0.47 for science fiction. Each script repeats its own setup and can be run alone.

Cleaning the books and the loans

One := call cleans four columns of books: it trims the blanks of title, author and genre, collapses the repeated blanks of the author (gsub("\\s+", " ", ...)), lower-cases the genre, and keeps only the digits and X of the ISBN. The two spellings of Ursula K. Le Guin remain, as in the base-R page: no rule fixes them. For the loans, tools::toTitleCase() repairs the member names and the empty name becomes NA; the dates are converted from the character columns of the scenario, and the rows that need a decision are listed.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)
setDT(loans)

# books: nine spellings of six genres, padded titles, two spellings of Le Guin.
books[, .N, by = genre][order(genre)]
#>              genre     N
#>             <char> <int>
#> 1:         Classic     2
#> 2:         Fiction     2
#> 3:        Fiction      1
#> 4:         Mystery     2
#> 5:     Non-fiction     3
#> 6:          Poetry     2
#> 7: Science fiction     2
#> 8:         fiction     1
#> 9: science fiction     1

# Clean in place with one := call; the isbn keeps only digits and X.
books[, `:=`(
  title = trimws(title),
  author = gsub("\\s+", " ", trimws(author)),
  genre = tolower(trimws(genre)),
  isbn = gsub("[^0-9X]", "", toupper(isbn))
)]
books[, .N, by = genre][order(genre)]
#>              genre     N
#>             <char> <int>
#> 1:         classic     2
#> 2:         fiction     4
#> 3:         mystery     2
#> 4:     non-fiction     3
#> 5:          poetry     2
#> 6: science fiction     3
books[c(3, 10), .(title, author)]
#>                title       author
#>               <char>       <char>
#> 1: the quiet streets   Mara Quill
#> 2:    THE LONG TABLE Iris Fenwick

# loans: member names differ in case and padding, and one is empty.
loans[, member := tools::toTitleCase(trimws(member))]
loans[!nzchar(member), member := NA]
sort(unique(loans$member))
#> [1] "Ana Ruiz"      "Ben Okafor"    "Chloe Martin"  "Dev Patel"
#> [5] "Eva Lindqvist" "Farid Karimi"  "Grace Liu"     "Hugo Silva"

# Rows that need a look: a loan returned before it was made, an impossible
# date (2025-02-30 is NA after the conversion).
loans[, `:=`(
  loan_date = as.Date(loan_date),
  return_date = as.Date(return_date)
)]
loans[return_date < loan_date | is.na(loan_date),
      .(loan_id, loan_date, return_date)]
#>    loan_id  loan_date return_date
#>     <char>     <Date>      <Date>
#> 1:    L012 2025-04-23  2025-04-20
#> 2:    L020       <NA>  2025-07-15

Overdue loans

A loan is overdue when it was held for more than loan_days (21) days: until the return date when there is one, and until shelf_today (2026-06-30) when the book is still out. fifelse() picks the end date and keeps its class, so the subtraction is between dates. The counts agree with the base-R page (27, 32 and 1), and the remaining overdue loans are the books to chase.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(loans)
loans[, `:=`(
  loan_date = as.Date(loan_date),
  return_date = as.Date(return_date)
)]

# Days held: until the return date, or until shelf_today when still out.
loans[, held := as.integer(fifelse(is.na(return_date), shelf_today,
                                   return_date) - loan_date)]
loans[, overdue := held > loan_days]
loans[1:4, .(loan_id, held, overdue)]
#>    loan_id  held overdue
#>     <char> <int>  <lgcl>
#> 1:    L001    21   FALSE
#> 2:    L002    10   FALSE
#> 3:    L003     1   FALSE
#> 4:    L004    26    TRUE
loans[, .N, by = overdue][order(overdue)]
#>    overdue     N
#>     <lgcl> <int>
#> 1:   FALSE    27
#> 2:    TRUE    32
#> 3:      NA     1

# Overdue loans that are still out: the books to chase.
still_out <- loans[is.na(return_date) & overdue,
                   .(loan_id, member, loan_date, held)]
still_out[order(-held)][1:4]
#>    loan_id        member  loan_date  held
#>     <char>        <char>     <Date> <int>
#> 1:    L054  Chloe Martin 2026-04-07    84
#> 2:    L055 Eva Lindqvist 2026-04-10    81
#> 3:    L056     Dev Patel 2026-04-20    71
#> 4:    L057     Dev Patel 2026-04-26    65

# Overdue returned loans per member, most first (the loan without a member
# is left out).
per_member <- loans[!is.na(member) & overdue, .(overdue = .N), by = member]
per_member[order(-overdue, member)][1:3]
#>          member overdue
#>          <char>   <int>
#> 1: Chloe Martin       6
#> 2:     Ana Ruiz       5
#> 3:   Hugo Silva       5

Loans per month and the overdue rate by genre

keyby sorts the months and sets the key; the busiest months are July and October 2025 with seven loans each, as in the chart of Base Graphics and Devices. The overdue rate joins the loans to the books with books[loans, on = "isbn", nomatch = NULL], which keeps the 59 loans that match a book; dropping the one whose overdue flag is NA leaves the 58 loans of the base-R page, and the table is the same as its merge() and aggregate() result, from poetry (4 of 6) to science fiction (7 of 15).

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)
setDT(loans)
books[, `:=`(
  title = trimws(title),
  genre = tolower(trimws(genre)),
  isbn = gsub("[^0-9X]", "", toupper(isbn))
)]
loans[, `:=`(
  loan_date = as.Date(loan_date),
  return_date = as.Date(return_date)
)]
loans[, held := as.integer(fifelse(is.na(return_date), shelf_today,
                                   return_date) - loan_date)]
loans[, overdue := held > loan_days]

# Loans per month; keyby sorts the months and the NA date is left out.
per_month <- loans[!is.na(loan_date), .N,
                   keyby = .(month = format(loan_date, "%Y-%m"))]
per_month[N == max(N)]
#> Key: <month>
#>      month     N
#>     <char> <int>
#> 1: 2025-07     7
#> 2: 2025-10     7
per_month[1:3]
#> Key: <month>
#>      month     N
#>     <char> <int>
#> 1: 2025-01     4
#> 2: 2025-02     2
#> 3: 2025-03     3

# Join the loans to the books, then the overdue rate by genre.
joined <- books[loans, on = "isbn", nomatch = NULL]
by_genre <- joined[!is.na(overdue), .(loans = .N, overdue = sum(overdue)),
                   by = genre]
by_genre[, share := round(overdue / loans, 2)]
by_genre[order(-share, genre)]
#>              genre loans overdue share
#>             <char> <int>   <int> <num>
#> 1:          poetry     6       4  0.67
#> 2:         classic     5       3  0.60
#> 3:     non-fiction     5       3  0.60
#> 4:         fiction    23      12  0.52
#> 5:         mystery     4       2  0.50
#> 6: science fiction    15       7  0.47

# The three titles loaned most often.
joined[, .N, by = title][order(-N, title)][1:3]
#>                title     N
#>               <char> <int>
#> 1: the quiet streets     8
#> 2:       Ten Winters     7
#> 3:  The Dispossessed     7

data.table or dplyr?

The two packages answer the same question, how to write a table transformation as a short, readable pipeline, and both are valid choices. They differ in the vocabulary, in the treatment of the table, and in the cost of a wrong guess. The table lines up the same operations in base R (the functions of Data Frames), dplyr (the verbs of The tidyverse and dplyr, following the official dplyr vignette on base R equivalents) and data.table.

Task base R dplyr data.table

read a CSV

read.csv("books.csv")

readr::read_csv("books.csv")

fread("books.csv")

filter rows

subset(books, year > 2020)

filter(books, year > 2020)

books[year > 2020]

choose columns

books[c("title", "year")]

select(books, title, year)

books[, .(title, year)]

sort

books[order(-books$pages), ]

arrange(books, desc(pages))

books[order(-pages)], or setorder(books, -pages) in place

add a column

books$age <- 2026L - books$year

mutate(books, age = 2026L - year)

books[, age := 2026L - year] (in place)

change some rows

books$pages[books$pages > 400] <- 400L

mutate(books, pages = if_else(pages > 400, 400L, pages))

books[pages > 400, pages := 400L] (in place)

summarise

mean(books$pages, na.rm = TRUE)

summarise(books, avg = mean(pages, na.rm = TRUE))

books[, .(avg = mean(pages, na.rm = TRUE))]

count by group

table(books$genre)

count(books, genre)

books[, .N, by = genre]

summarise by group

aggregate(pages ~ genre, books, mean)

summarise(books, avg = mean(pages, na.rm = TRUE), .by = genre)

books[, .(avg = mean(pages, na.rm = TRUE)), by = genre]

inner join

merge(loans, books, by = "isbn")

inner_join(loans, books, by = "isbn")

books[loans, on = "isbn", nomatch = NULL]

left join

merge(loans, books, by = "isbn", all.x = TRUE)

left_join(loans, books, by = "isbn")

books[loans, on = "isbn"]

rolling or non-equi join

no direct form

join_by(closest(x >= y)), join_by(x >= y)

roll = TRUE, on = .(x >= y)

wide to long

reshape(direction = "long"), stack()

tidyr::pivot_longer()

melt()

long to wide

reshape(direction = "wide"), xtabs()

tidyr::pivot_wider()

dcast()

stack tables

do.call(rbind, parts)

bind_rows(parts)

rbindlist(parts)

cases

ifelse(), cut()

case_when()

fcase()

previous value in a group

ave(x, g, FUN = \(v) c(NA, head(v, -1)))

lag(x) with .by

shift(x) with by

distinct count

length(unique(x))

n_distinct(x)

uniqueN(x)

modifies in place

no

no

yes: := and the set* functions

The script below does not rest on the table: it runs the base-R, dplyr and data.table versions of five of its rows (filter, choose and sort, summarise by group, count by group, inner join) and compares the results by value. dplyr is always called as dplyr::, because attaching it after data.table prints a conflict message for between(), first() and last() (and one for filter() and lag() of the stats package), and the later package hides the earlier one’s functions.

suppressPackageStartupMessages(library(data.table))
source("scenario.R")

# books_df stays a data frame; books_dt is a data.table copy of it.
books$genre <- tolower(trimws(books$genre))
books$isbn <- normalise_isbn(books$isbn)
loans$loan_date <- as.Date(loans$loan_date)
books_df <- books
books_dt <- as.data.table(books)
loans_dt <- as.data.table(loans)

# same() compares two results by value, whatever their class.
same <- \(a, b) {
  isTRUE(all.equal(as.data.frame(a), as.data.frame(b),
                   check.attributes = FALSE))
}

# Filter rows.
r_base <- subset(books_df, year > 2020)
r_dplyr <- dplyr::filter(books_df, year > 2020)
r_dt <- books_dt[year > 2020]
c(same(r_base, r_dplyr), same(r_base, r_dt))
#> [1] TRUE TRUE

# Select columns, then sort (NA last in all three).
r_base <- books_df[order(-books_df$pages), c("title", "pages")]
r_dplyr <- dplyr::arrange(dplyr::select(books_df, title, pages),
                          dplyr::desc(pages))
r_dt <- books_dt[order(-pages), .(title, pages)]
c(same(r_base, r_dplyr), same(r_base, r_dt))
#> [1] TRUE TRUE

# Count and summarise by group.
r_base <- aggregate(pages ~ genre, books_df, \(x) round(mean(x), 1))
r_dplyr <- books_df |>
  dplyr::summarise(pages = round(mean(pages, na.rm = TRUE), 1), .by = genre) |>
  dplyr::arrange(genre)
r_dt <- books_dt[, .(pages = round(mean(pages, na.rm = TRUE), 1)),
                 keyby = genre]
c(same(r_base, r_dplyr), same(r_base, r_dt))
#> [1] TRUE TRUE

n_base <- as.data.frame(table(genre = books_df$genre),
                        responseName = "n", stringsAsFactors = FALSE)
n_dplyr <- dplyr::count(books_df, genre)
n_dt <- books_dt[, .(n = .N), keyby = genre]
c(same(n_base, n_dplyr), same(n_base, n_dt))
#> [1] TRUE TRUE

# Join: the loans that match a book, with the title.
j_base <- merge(loans, books_df[c("isbn", "title")], by = "isbn")
j_dplyr <- dplyr::inner_join(loans, books_df[c("isbn", "title")], by = "isbn")
j_dt <- books_dt[, .(isbn, title)][loans_dt, on = "isbn", nomatch = NULL]
sort_ids <- \(d) as.data.frame(d)[order(d$loan_id), c("loan_id", "title")]
c(same(sort_ids(j_base), sort_ids(j_dplyr)),
  same(sort_ids(j_base), sort_ids(j_dt)))
#> [1] TRUE TRUE
nrow(j_base)
#> [1] 59

Speed depends on the operation, the size of the data and the machine, so this page prints no timings. The data.table website links to the db-benchmark project (db-benchmark, whose page lists the timings with the syntax timed and the machine type, from 16 cores and 32 GB to 128 cores and 256 GB), and Performance, Profiling and Benchmarking shows how to measure a real case with bench. The differences that do not depend on timings are these:

  • Syntax. dplyr is a set of verbs, each doing one thing, joined with the pipe; data.table is one query form with three slots, in which the work of several verbs fits into one call. The SQL reading of DT[i, j, by] is a way to learn it.

  • Copying. dplyr verbs return a new table and leave the input alone, so a pipeline can be reasoned about step by step; := and the set* functions change the table in place, which saves a copy of a large table and makes it easy to change something the caller did not expect (see Pitfalls).

  • Joins. dplyr has join_by() for equality, inequality, rolling and overlap joins since version 1.1.0 (dplyr package documentation: join_by), so the two cover the same ground; data.table’s on = is terser, and by = .EACHI evaluates j for each row of i, which the error message for an accidental many-to-many join suggests as a way "to avoid the large allocation".

  • Files. fread(data.table = FALSE) returns a plain data frame and fwrite() writes data frames, so the two functions can be used with either package.

  • Dependencies. data.table depends on R 3.4.0 and imports only methods, which ships with R (packageDescription("data.table") in the image used here). dplyr 1.2.1 depends on R 4.1.0 and imports thirteen packages (cli, generics, glue, lifecycle, magrittr, methods, pillar, R6, rlang, tibble, tidyselect, utils and vctrs).

  • Mixing. A data.table is a data frame, so the dplyr verbs accept it, but code that mixes the two vocabularies is read by people who must know both, and a := among pipes can surprise a reader who expects a copy. Teams usually choose one for the main pipeline.

  • dtplyr. The package dtplyr (dtplyr package documentation) is a data.table back end for dplyr: its README states the goal as translating dplyr code into the equivalent data.table code. It is installed in the image used for the examples and is not run on this page.

Common errors and warnings

The messages below are what data.table 1.18.6.1 printed. A helper function runs an expression and prints its first condition as conditionMessage() with error: or warning: in front, wrapped to 76 columns. Many of the messages say what to do next.

suppressPackageStartupMessages(library(data.table))

# what() runs an expression and prints the first condition it signals,
# wrapped to 76 columns (the text is otherwise exactly conditionMessage()).
what <- function(expr) {
  msg <- "no condition"
  withCallingHandlers(
    tryCatch(expr, error = \(e) msg <<- paste("error:", conditionMessage(e))),
    warning = \(w) {
      if (msg == "no condition") {
        msg <<- paste("warning:", conditionMessage(w))
      }
      invokeRestart("muffleWarning")
    }
  )
  cat(strwrap(msg, width = 76, exdent = 2), sep = "\n")
}
new_dt <- \() data.table(a = 1:3, b = c("x", "y", "z"))

# A column that does not exist, in j, in i, and as a selection.
what(new_dt()[, zzz])
#> error: j (the 2nd argument inside [...]) is a single symbol but column name
#>   'zzz' is not found. If you intended to select columns using a variable in
#>   calling scope, please try DT[, ..zzz]. The .. prefix conveys one-level-up
#>   similar to a file system path.
what(new_dt()[, sum(zzz)])
#> error: object 'zzz' not found
what(new_dt()[zzz > 1])
#> error: Object 'zzz' not found amongst [a, b]
what(new_dt()[, c("a", "zzz")])
#> error: column not found: [zzz]
what(new_dt()[, ..zzz])
#> error: Variable 'zzz' is not found in calling scope. Looking in calling
#>   scope because you used the .. prefix.

# Assigning the wrong number of values, or a value of the wrong type.
what(new_dt()[, a := c(1, 2)])
#> error: Supplied 2 items to be assigned to 3 items of column 'a'. If you
#>   wish to 'recycle' the RHS please use rep() to make this intent clear to
#>   readers of your code.
what(new_dt()[1:2, a := "x"])
#> warning: Coercing 'character' RHS to 'integer' to match the type of column
#>   1 named 'a'.
what(new_dt()[, `:=`(p = 1, 2)])
#> error: In `:=`(col1=val1, col2=val2, ...) form, all arguments must be
#>   named, but the last argument has no name. Did you forget a trailing
#>   comma?
what(new_dt()[, zzz := NULL])
#> warning: Tried to assign NULL to column 'zzz', but this column does not
#>   exist to remove

# j that returns different types in different groups.
what(new_dt()[, if (.GRP == 1L) 1 else "a", by = b])
#> error: Column 1 of result for group 2 is type 'character' but expecting
#>   type 'double'. Column types must be consistent for each group.

# A logical i of the wrong length (no recycling).
what(new_dt()[c(TRUE, FALSE)])
#> error: i evaluates to a logical vector length 2 but there are 3 rows.
#>   Recycling of logical i is no longer allowed as it hides more bugs than is
#>   worth the rare convenience. Explicitly use rep(...,length=.N) if you
#>   really need to recycle.

# Names and positions.
what(setnames(new_dt(), "zzz", "q"))
#> error: Items of 'old' not found in column names: [zzz]. Consider
#>   skip_absent=TRUE.
what(setnames(new_dt(), c("p", "q", "r")))
#> error: Can't assign 3 names to a 2-column data.table
what(new_dt()[, 5])
#> error: Item 1 of j is 5 which is outside the column number range [1,ncol=2]
what(setkey(new_dt(), zzz))
#> error: some columns are not in the data.table: [zzz]
suppressPackageStartupMessages(library(data.table))
what <- function(expr) {
  msg <- "no condition"
  withCallingHandlers(
    tryCatch(expr, error = \(e) msg <<- paste("error:", conditionMessage(e))),
    warning = \(w) {
      if (msg == "no condition") {
        msg <<- paste("warning:", conditionMessage(w))
      }
      invokeRestart("muffleWarning")
    }
  )
  cat(strwrap(msg, width = 76, exdent = 2), sep = "\n")
}
x <- data.table(a = 1:3, b = c("x", "y", "z"))

# Joins: wrong column, wrong type, accidental many-to-many.
what(x[x, on = "zzz"])
#> error: argument specifying columns received non-existing column(s):
#>   cols[1]='zzz'
what(x[.("x"), on = "a"])
#> error: Incompatible join types: x.a (integer) and i.V1 (character)
what(merge(x, x, by = "zzz"))
#> error: The following columns listed in `by` are missing from x: [zzz]
dup <- data.table(k = c(1, 1, 1), v = 1:3)
what(dup[dup, on = "k"])
#> error: Join results in 9 rows; more than 6 = nrow(x)+nrow(i). Check for
#>   duplicate key values in i each of which join to the same group in x over
#>   and over again. If that's ok, try by=.EACHI to run j for each group to
#>   avoid the large allocation. If you are sure you wish to proceed, rerun
#>   with allow.cartesian=TRUE. Otherwise, please search for this error
#>   message in the FAQ, Wiki, Stack Overflow and data.table issue tracker for
#>   advice.
dup[dup, on = "k", allow.cartesian = TRUE][, .N]
#> [1] 9

# Reshaping.
what(melt(x, id.vars = "zzz"))
#> error: One or more values in 'id.vars' is invalid.
what(dcast(data.table(g = c(1, 1), k = c("u", "u"), v = 1:2), g ~ k,
           value.var = "v"))
#> warning: 'fun.aggregate' is NULL, but found duplicate row/column
#>   combinations, so defaulting to length(). That is, the variables [g, k]
#>   used in 'formula' do not uniquely identify rows in the input 'data'. In
#>   such cases, 'fun.aggregate' is used to derive a single representative
#>   value for each combination in the output data.table, for example by
#>   summing or averaging (fun.aggregate=sum or fun.aggregate=mean,
#>   respectively). Check the resulting table for values larger than 1 to see
#>   which combinations were not unique. See ?dcast.data.table for more
#>   details.

# fifelse() is strict about lengths.
what(fifelse(c(TRUE, FALSE), 1:3, 0L))
#> error: Length of 'yes' is 3 but must be 1 or length of 'test' (2).

# Reading text: a ragged file and a header that is too short.
what(fread(text = "a,b\n1,2\n3,4,5\n6,7"))
#> warning: Stopped early on line 3. Expected 2 fields but found 3. Consider
#>   fill=TRUE. First discarded non-empty line: <<3,4,5>>
what(fread(text = "a,b\n1,2,3\n4,5,6"))
#> warning: Detected 2 column names but the data has 3 columns (i.e. invalid
#>   file). Added an extra default column name for the first column which is
#>   guessed to be row names or an index. Use setnames() afterwards if this
#>   guess is not correct, or fix the file write command that created the file
#>   to create a valid file.

# A data.table made by R itself, not by data.table.
df <- data.frame(x = 1:2)
class(df) <- c("data.table", "data.frame")
what(df[, y := 2L])
#> warning: A shallow copy of this data.table was taken so that := can add or
#>   remove 1 columns by reference. At an earlier point, this data.table was
#>   copied by R (or was created manually using structure() or similar). Avoid
#>   names<- and attr<- which in R currently (and oddly) may copy the whole
#>   data.table. Use set* syntax instead to avoid copying: ?set, ?setnames and
#>   ?setattr. It's also not unusual for data.table-agnostic packages to
#>   produce tables affected by this issue. If this message doesn't help,
#>   please report your use case to the data.table issue tracker so the root
#>   cause can be fixed or this message improved.
Message (start) Raised by Cause and fix

j (the 2nd argument inside [...]) is a single symbol but column name 'zzz' is not found

DT[, zzz]

A column that is not in the table. If a variable holding names was meant, write DT[, ..zzz].

Object 'zzz' not found amongst [a, b]

DT[zzz > 1]

A column that is not in the table, used in i; the message lists the columns.

column not found: [zzz]

DT[, c("a", "zzz")]

A character vector in j names a missing column.

Variable 'zzz' is not found in calling scope

DT[, ..zzz]

The .. prefix looks one level up and finds nothing.

Supplied 2 items to be assigned to 3 items of column 'a'

:=

The value is not of length 1 or the number of rows. Use rep() if recycling is meant.

Coercing 'character' RHS to 'integer' to match the type of column 1 named 'a' (warning)

DT[i, a := "x"]

A sub-assignment of the wrong type. Replace the whole column to change its type.

all arguments must be named, but the last argument has no name

the functional form of :=

An unnamed argument, often a trailing comma.

Tried to assign NULL to column 'zzz', but this column does not exist to remove (warning)

:= NULL

Deleting a column that is not there.

Column 1 of result for group 2 is type 'character' but expecting type 'double'

j with by

j returns different types in different groups. Make the types equal.

i evaluates to a logical vector length 2 but there are 3 rows

DT[c(TRUE, FALSE)]

A logical i is never recycled. Use rep(..., length.out = .N) if recycling is meant.

Items of 'old' not found in column names: [zzz]

setnames()

An old name that is not there; skip_absent = TRUE ignores it.

Can't assign 3 names to a 2-column data.table

setnames()

The new names do not match the number of columns.

Item 1 of j is 5 which is outside the column number range [1,ncol=2]

DT[, 5]

A column position past the end.

some columns are not in the data.table: [zzz]

setkey()

A key column that is not in the table.

argument specifying columns received non-existing column(s)

on = "zzz"

A join column that is missing from one table.

Incompatible join types: x.a (integer) and i.V1 (character)

on =

The join columns have different types. Convert one of them.

The following columns listed in followed by by in backquotes

merge()

The by column is missing from a table.

Join results in 9 rows; more than 6 = nrow(x)+nrow(i)

on =

The key is not unique in both tables. Check for duplicates, use by = .EACHI, or pass allow.cartesian = TRUE.

One or more values in 'id.vars' is invalid

melt()

A name in id.vars that is not a column.

'fun.aggregate' is NULL, but found duplicate row/column combinations (warning)

dcast()

Several rows per cell: give fun.aggregate.

Length of 'yes' is 3 but must be 1 or length of 'test' (2)

fifelse()

yes or no of the wrong length.

Stopped early on line 3. Expected 2 fields but found 3 (warning)

fread()

A ragged file: the rows from the problem on are not read. Use fill = TRUE or fix the file.

Detected 2 column names but the data has 3 columns (warning)

fread()

The header has fewer names than the data has columns; the first column is taken to be row names.

A shallow copy of this data.table was taken so that := can add or remove 1 columns by reference (warning)

:=

The table was copied by R or built by hand. Use setDT(), copy() and the set* functions.

Column 2 ['id'] of item 2 appears in position 1 in item 1 (message)

rbindlist()

Names in a different order are bound by position. Use use.names = TRUE.

A missing file is reported by fread() as an error that starts File 'no-such-file.csv' does not exist or is non-readable and ends with getwd()== and the working directory, which depends on the machine and so is not run here.

Pitfalls

  • DT[, 2] is a data.table, not a vector. A data frame drops a single column to a vector; data.table never does. Use DT[[2]], DT[, col] or DT$col for the vector.

  • b <- a is not a copy. A := or set* call on b changes a too, and so does the same call inside a function that was given a. Use copy() when the original must stay as it is. This is the opposite of the copy-on-modify rule that the rest of R follows (Memory and Copy Semantics).

  • A column wins over a variable. In i, j and by a name is looked up among the columns first, so after year <- 1900 the query DT[year > 2022] still compares the column. Use ..var, env = or a differently named variable.

  • x[y] is a join, and the order matters. x[i, on = ] starts from the rows of i. books[loans, on = "isbn"] has 60 rows, one for each loan, and loans[books, on = "isbn"] starts from the books: a row for each loan of each book, and one row for a book that was never loaned.

  • Check how a join matched. Keys that are not unique give a many-to-many join (an error unless allow.cartesian = TRUE), types that differ give an error, and values that look alike but are not equal (trailing blanks, an ISBN with one digit missing) give NA.

  • rbindlist() binds by position unless told otherwise. use.names = "check" (the default) reports a different order with a message and binds by position. Pass use.names = TRUE.

  • fread() guesses. A column of 13-digit codes is integer64; an empty quoted string stays ""; unquoted blanks are stripped; a date column with one impossible date is a different class from one without. Fix the types with colClasses and check them with vapply(DT, \(x) class(x)[[1L]], character(1)).

  • Sorting is in C-locale. setkey(), setorder() and order() in i sort capitals before lower case in every locale. Sort by the locale of the session with DT[base::order(...)] when that is what is wanted.

  • A function that ends with := may print nothing. Add a trailing [] (see Printing after the := operator).

  • Base replacement functions copy. attr<-, class<- on a data frame and structure() produce tables that := has to repair with a warning (the self-reference warning). Use setattr(), setDT() and setnames().

  • Loops need set(). A for loop of DT[...] calls pays the cost of the bracket on every iteration; set() does not.

  • Packages that use data.table. In a package, import the symbols (import(data.table) or importFrom(data.table, :=, .N)), and declare the columns used in DT[...] to avoid the R CMD check NOTE about "no visible binding for global variable" (assign them NULL or use character names), as the importing vignette describes; see Packages, Libraries and Namespaces and Creating Packages.

Version notes

The table lists the changes that matter for the code on this page, from the NEWS file of the installed data.table 1.18.6.1 (also at https://rdatatable.gitlab.io/data.table/news/index.html). The first rows are the R versions that matter to the page.

Release Change

R 4.0.0

stringsAsFactors = FALSE becomes the default of data.frame(); the default of data.table() is FALSE.

R 4.4.0

base R gets sort_by(), which data.table 1.18.0 extends with a method for data.tables.

R 4.6.0

base R gets %notin%; data.table has had its own since 1.15.0, and attaching the package prints a masking message.

data.table 1.15.0 (Jan 2024)

the env argument and substitute2() for programming; %notin%; nafill() fills the ends for "locf" and "nocb"; setcolorder(before =, after =); shift(type = "cyclic"); melt() with several output columns and NA in measure.vars; fwrite(encoding =) and datatable.fwrite.sep; keyby = TRUE; DT[i, nomatch = NULL] with row numbers; shift() and nafill() error for matrices.

data.table 1.16.0 (Aug 2024)

names(.SD) := lapply(.SD, f); fread(dec = "auto"); fcase(default =) takes a vector and is lazy; showProgress for by; rbindlist() and rbind() bind columns of different but compatible classes, with ignore.attr; setdroplevels() replaces droplevels(in.place = TRUE); measure() and patterns() are exported; R 3.3.0 is the minimum.

data.table 1.17.0 (Feb 2025)

rowwiseDT() builds a small table row by row; fread(logicalYN =); fwrite(compressLevel =); print(DT[, y := 2]) prints; fread() reads Date columns faster, as IDate.

data.table 1.18.0 (Dec 2025)

dcast() is an error when fun.aggregate returns more than one value; melt() returns an integer variable for a list of length one in measure.vars; the hasNA argument of frollmean() and frollsum() becomes has.nf; sort_by() for data.tables; fctr(); cbindlist() and mergelist(); the .selfref.ok() helper; column classes at the bottom of long tables; fread(autostart =), fwrite(logicalAsInt =) and droplevels(in.place =) are removed; R 3.4.0 is the minimum.

data.table 1.18.2.1 (Jan 2026)

set() allocates new column slots itself when it needs them, as := already did.

data.table 1.18.6.1 (Aug 2026)

rows can be deleted by reference with DT[i, .ROW := NULL], and the experimental helper setallocrow() prepares columns for it.

The 1.18 additions that this page ran are in the next script. The row deletion works only with i (not by), clears the keys and the indices of the table, and is the newest item in the table; the mergelist() call joins a list of tables from left to right with how set to "inner" or "left" (the other modes are "right", "full", "semi", "anti" and "cross").

suppressPackageStartupMessages(library(data.table))
source("scenario.R")
setDT(books)

# data.table 1.18.6.1: rows can be deleted by reference with .ROW := NULL.
# Only i selects the rows (no by), and keys and indices are dropped.
n_before <- nrow(books)
books[is.na(year) | is.na(pages), .ROW := NULL]
c(before = n_before, after = nrow(books))
#> before  after
#>     16     13
books[, .(title, year, pages)][1:3]
#>                   title  year pages
#>                  <char> <int> <int>
#> 1:     The Dispossessed  1974   387
#> 2:  Pride and Prejudice  1813   432
#> 3:   the quiet streets   2019   214

# data.table 1.18.0: a sort_by() method for data.tables (see base R 4.4.0).
sort_by(books[, .(title, year)], ~year)[1:3]
#>                  title  year
#>                 <char> <int>
#> 1: Pride and Prejudice  1813
#> 2:    The Dispossessed  1974
#> 3:    The Lamp at Noon  1999

# data.table 1.18.0: mergelist() joins a list of tables, left to right.
parts <- list(
  data.table(id = 1:3, x = c("a", "b", "c")),
  data.table(id = c(1L, 2L, 4L), y = c("d", "e", "f")),
  data.table(id = c(1L, 3L, 4L), z = c("g", "h", "i"))
)
mergelist(parts, on = "id", how = "inner")
#>       id      x      y      z
#>    <int> <char> <char> <char>
#> 1:     1      a      d      g
mergelist(parts, on = "id", how = "left")
#>       id      x      y      z
#>    <int> <char> <char> <char>
#> 1:     1      a      d      g
#> 2:     2      b      e   <NA>
#> 3:     3      c   <NA>      h

Where older tutorials differ

Much data.table material on the web predates the 1.15 to 1.18 releases. Most of it still runs; the differences below are the ones a reader will meet.

Older tutorial Current practice Why Version

data.table(..., stringsAsFactors = FALSE) on every call

leave the argument out

its default is FALSE, and since R 4.0.0 so is that of data.frame()

R 4.0.0

a join needs setkey(x, k) first, and x[y] joins the keys

x[y, on = "k"], with keys optional

the help page recommends on = even for keyed tables; on = also allows non-equi and rolling joins

inequalities in on = since 1.9.8

dcast.data.table() and melt.data.table() called by their full names

dcast() and melt()

melt() became a standard generic in 1.15.0 so that other packages can add methods; dcast() is data.table’s own function and accepts a data.table (the full names still work)

1.15.0

append [] to every := to see the result

print(DT[, y := 2]) prints; [] is needed only at the end of a function that ends with :=

the detection of auto-printing was improved in 1.17.0 for the explicit print() case

1.17.0

some older posts attribute the "Invalid .internal.selfref detected" warning to readRDS() or load()

:= and set() work silently on a table read back; the warning marks a table that R copied or that was built by hand

the message of 1.18.6.1 starts "A shallow copy of this data.table was taken"

1.18.6.1

rbind.fill() from plyr, or rbind() that fails on different columns

rbindlist(l, fill = TRUE) or rbind(a, b, fill = TRUE)

both pad the missing columns with NA; use.names = TRUE matches by name

current

eval(parse(text = ...)), get() or bquote() to program on columns

env = list(...) and ..var

env substitutes names safely before evaluation

1.15.0

reshape2::melt() and reshape2::dcast() on a data.table

data.table::melt() and data.table::dcast()

data.table has its own, which take several value columns (measure(), several value.var); the message of data.table’s dcast() calls reshape2 superseded

current

fread(autostart = ), fwrite(logicalAsInt = ) and droplevels(in.place = TRUE)

skip =, fwrite() without the argument, and setdroplevels()

the arguments were removed in 1.18.0 (in.place was deprecated in 1.16.0)

1.18.0

options(datatable.nomatch = )

nomatch = in the query

setting the option has been an error since 1.18.0, after warning from 1.15.0

1.18.0

For the changes of R itself see What’s New and Migrating Older Code.

Where to go next