Attributes and Names

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. Everything on this page is base R, so no add-on package version applies; the scripts also use the book class of the running shelf scenario, which is plain base R code, and the lintr 3.4.0 default linters were used only to check the scripts.

Almost everything in R that is not the plain data of a vector is stored in one place: its attributes. A matrix is a vector with a dim attribute, a factor is an integer vector with levels and class attributes, a data frame is a list with names, class and row.names attributes, a time series is a numeric vector with tsp and class, and the names that label the elements of a vector are the names attribute. Because attributes are so central, the rules for when they are kept, copied or dropped decide whether a calculation returns a labelled result or a bare vector. This page starts with the generic tools (attributes(), attr(), structure() and mostattributes<-), goes through the attributes that R treats specially one by one (names, dim, dimnames, class, levels, tsp and comment), then collects the rules for which attributes survive which operations, each with an example you can run, and ends with the attributes of a data frame, user-defined attributes as metadata, and the places where older tutorials give outdated advice.

Every example below 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. The scripts pass the default lintr linters. A long script is shown in consecutive parts, each part continuing the previous one with the same variables; the part boundaries are always at a blank line. Errors and warnings that a script shows on purpose are caught with tryCatch() and printed as their conditionMessage(), so the text is exactly what R says. The examples use the library scenario of the section: books, loans, shelf_today, loan_days and the book class come from the scenario.R file described on the section index, and the scripts that use them start with source("scenario.R"). The neighbouring pages go deeper on each attribute’s own topic: Atomic Vectors and Types, Factors, Matrices and Arrays, Lists, Data Frames and Dates and Times; S3 Classes and Generics and OOP Overview and Base Types cover what the class attribute does for method dispatch.

What an attribute is

The R Language Definition puts it in one paragraph. All objects except NULL can have one or more attributes attached to them. Attributes are stored as a pairlist where all elements are named, but they should be thought of as a set of name and value pairs. A listing is obtained with attributes() and set with attributes<-; individual components are accessed with attr() and attr<-. Some attributes have special accessor functions (levels<- for factors) and these should be used when they exist: besides hiding the implementation, they may perform additional operations, and R intercepts calls to attr<- and attributes<- that involve the special attributes to enforce consistency checks. Matrices and arrays are simply vectors with the attribute dim and optionally dimnames attached to the vector, and the class structure of R is implemented by the class attribute (The R Language Definition: Attributes). An Introduction to R adds that attributes() returns the non-intrinsic attributes, that attr(object, name) selects one, and that these functions are rarely needed except when some new attribute is being created for a particular purpose, for example to associate a creation date with an object, but that the concept is very important (An Introduction to R: Getting and setting attributes).

Two words in those descriptions matter. First, the attributes are a set: their order does not matter, the names must be unique, and identical() compares them as a set by default (attrib.as.set = TRUE, see Comparing objects). Second, the attributes are non-intrinsic: the mode (or type) and the length of an object are its intrinsic properties, so they never appear in attributes() (see Atomic Vectors and Types for typeof(), mode() and length()).

The next table lists the seven attributes that R treats specially. The help page of attributes() names them: class, comment, dim, dimnames, names, row.names and tsp are treated specially and have restrictions on the values that can be set; levels is not on that list, and is set for factors through its own replacement function (R documentation: attributes).

Attribute Meaning and checks Accessors

names

labels the elements of a vector or list; a character vector of at most the length of the object (padded with NA)

names(), names<-, setNames(), unname()

dim

the extents of an array; the product must equal the length; setting it drops names and dimnames

dim(), dim<-, nrow(), ncol()

dimnames

a list of character vectors (or NULL), one per dimension; each as long as its extent

dimnames(), dimnames<-, rownames(), colnames()

class

the classes an object inherits from; no check that the object has the parts the methods expect

class(), oldClass(), class<-, inherits(), unclass()

levels

the labels of a factor; any character vector, checked by the factor methods and not by attr()

levels(), levels<-, nlevels()

tsp

start, end and frequency of a time series; checked for consistency with the length

tsp(), tsp<-, start(), end(), frequency()

comment

a character vector that is never printed

comment(), comment<-

row.names

the row labels of a data frame, stored in a compact form when they are the automatic 1, 2, 3 and so on

row.names(), rownames(), .row_names_info()

Reading, setting and removing attributes

attributes(x) returns the list of all attributes, or NULL when there are none. attr(x, "name") returns one attribute, and attr(x, "name") <- value creates or replaces it; assigning NULL removes it. attributes(x) <- NULL removes them all, and structure(x, name = value, ...) is the same as setting each attribute in turn, written as an expression so that it can be used inside a call. The script uses the first four books' page counts.

source("scenario.R")

# A plain vector has no attributes: attributes() returns NULL
pages <- books$pages[1:4]
pages
#> [1] 387 432 214 198
attributes(pages)
#> NULL

# attr<- creates an attribute; attr() reads it back
attr(pages, "unit") <- "printed pages"
attributes(pages)
#> $unit
#> [1] "printed pages"
#>
attr(pages, "unit")
#> [1] "printed pages"

# print() shows non-special attributes under the values
pages
#> [1] 387 432 214 198
#> attr(,"unit")
#> [1] "printed pages"

The default print() method shows the attributes that it does not treat specially below the values, in the form attr(,"name"); the section How print shows attributes says which attributes are hidden. The script continues with structure() and with removal.

# Attributes survive a copy, and you can set several at once with structure()
shelf_pages <- structure(pages, source = "books.csv", checked = TRUE)
str(attributes(shelf_pages))
#> List of 3
#>  $ unit   : chr "printed pages"
#>  $ source : chr "books.csv"
#>  $ checked: logi TRUE

# attr<- with NULL removes one attribute, attributes<- with NULL removes all
attr(shelf_pages, "checked") <- NULL
names(attributes(shelf_pages))
#> [1] "unit"   "source"
attributes(shelf_pages) <- NULL
shelf_pages
#> [1] 387 432 214 198
attributes(shelf_pages)
#> NULL

structure() is the idiom of the book constructor in the shelf scenario (new_book() calls structure(list(...), class = "book")). Its help page says what to remember: the last of two equal tags wins, a tag set to NULL removes the attribute, and adding the class "factor" ensures that numeric codes are given integer storage mode. The next part shows that attributes() reports only non-intrinsic properties, so that the length and the type are asked for separately.

# attributes() returns a list of the non-intrinsic attributes: mode and length
# are not attributes
x <- c(a = 1, b = 2)
attributes(x)
#> $names
#> [1] "a" "b"
#>
length(x)
#> [1] 2
mode(x)
#> [1] "numeric"
typeof(x)
#> [1] "double"

Partial matching in attr()

The extractor attr(x, which, exact = FALSE) looks first for an exact match of which among the attributes of x and then, unless exact = TRUE, for a unique partial match; it returns NULL when there is no match or when more than one attribute matches. The replacement attr<- only uses exact matches, so assigning to a shortened name creates a new attribute instead of changing the one you meant. Setting options(warnPartialMatchAttr = TRUE) makes partial matches give warnings (R documentation: attr).

# attr() accepts a unique partial match unless exact = TRUE
x <- 1:3
attr(x, "shelf_location") <- "A3"
attr(x, "shelf_number") <- 3L

attr(x, "shelf_loc")
#> [1] "A3"
attr(x, "shelf_loc", exact = TRUE)
#> NULL
attr(x, "shelf_")
#> NULL
attr(x, "shelf_location", exact = TRUE)
#> [1] "A3"

# The replacement function matches exactly, so this creates a new attribute
attr(x, "shelf_loc") <- "B1"
names(attributes(x))
#> [1] "shelf_location" "shelf_number"   "shelf_loc"

# options(warnPartialMatchAttr = TRUE) makes partial matching warn
old <- options(warnPartialMatchAttr = TRUE)
msg <- tryCatch(attr(x, "shelf_n"), warning = \(w) conditionMessage(w))
msg
#> [1] "partial argument match of 'shelf_n' to 'shelf_number'"
options(old)

# Without the option, a shortened or mistyped name silently reads
# another attribute
attr(x, "shelf_nu")
#> [1] 3

The partial match is the reason why a typo in an attribute name can read back the wrong value without any message. Code that must be exact passes exact = TRUE, and a package that checks attr(x, "unit") for a classed object should do the same, because a user attribute named unit_of_measure would otherwise answer to it.

Errors and restrictions when setting attributes

The special attributes are checked at assignment, whether through attr<-, attributes<-, structure() or their own accessors. The messages below are what R 4.6.1 prints. The same script shows the NULL rule: NULL cannot have attributes, so attr<- on NULL is an error, attributes<- on NULL silently promotes it to an empty list, and since R 4.6.0 structure(NULL, ...) is an error as well (the argument was deprecated for years, and the 4.6.0 NEWS lists it as defunct). In R 4.6.0 setting attributes on a primitive function became an error too: it was deprecated in R 4.5.0 because it modified the function without copying it.

# structure(): the last of two equal tags wins, NULL removes a tag
structure(1:3, a = 1, a = 2) |> attributes()
#> $a
#> [1] 2
#>
structure(structure(1:3, a = 1), a = NULL) |> attributes()
#> NULL

# structure() with special tags is checked by the same code as attr<-
r <- tryCatch(structure(1:6, dim = c(4, 2)), error = \(e) conditionMessage(e))
r
#> [1] "dims [product 8] do not match the length of object [6]"
r2 <- tryCatch(
  structure(1:3, names = letters),
  error = \(e) conditionMessage(e)
)
r2
#> [1] "'names' attribute [26] must be the same length as the vector [3]"
r3 <- tryCatch(
  structure(1:3, dimnames = list("a")),
  error = \(e) conditionMessage(e)
)
r3
#> [1] "'dimnames' applied to non-array"
# NULL has no attributes: attr<- on NULL is an error, and since R 4.6.0
# so is structure(NULL, ...)
r4 <- tryCatch(
  {
    z <- NULL
    attr(z, "a") <- 1
  },
  error = \(e) conditionMessage(e)
)
r4
#> [1] "attempt to set an attribute on NULL"
r5 <- tryCatch(structure(NULL, a = 1), error = \(e) conditionMessage(e))
r5
#> [1] "attempt to set an attribute on NULL"

# attributes<- on NULL silently coerces to an empty list
z <- NULL
attributes(z) <- list(a = 1)
z
#> list()
#> attr(,"a")
#> [1] 1

# Attributes of primitive functions cannot be set (R >= 4.6.0)
f <- sum
r6 <- tryCatch(attr(f, "foo") <- NA, error = \(e) conditionMessage(e))
r6
#> [1] "cannot set an attribute on a 'builtin'"

attributes<- replaces all the attributes: it first removes every existing attribute, then sets any dim, and then the remaining attributes in the order given, which guarantees that dim always precedes dimnames. mostattributes<- is the forgiving variant: it assigns dim, names and dimnames only when they are valid, where attributes<- would give an error, and sets the other attributes as they are. Its help page says that it is principally intended for arrays, should be used with care on classed objects, and does not, for example, check that row.names are assigned correctly for data frames.

# attributes<- replaces all attributes; dim is set before dimnames
x <- 1:6
attributes(x) <- list(dimnames = list(c("a", "b"), c("x", "y", "z")), dim = 2:3)
x
#>   x y z
#> a 1 3 5
#> b 2 4 6

# mostattributes<- sets only the valid ones
y <- 1:6
mostattributes(y) <- list(unit = "n", dim = 3:2, names = letters[1:6],
                          dimnames = list(c("a", "b", "c"), c("x", "y")))
attributes(y)
#> $unit
#> [1] "n"
#>
#> $dim
#> [1] 3 2
#>
#> $dimnames
#> $dimnames[[1]]
#> [1] "a" "b" "c"
#>
#> $dimnames[[2]]
#> [1] "x" "y"
#>
#>
y2 <- 1:6
mostattributes(y2) <- list(dim = c(4, 2), unit = "n")
attributes(y2)
#> $unit
#> [1] "n"
#>

# attributes<- with an invalid dim is an error
r7 <- tryCatch(
  {
    y3 <- 1:6
    attributes(y3) <- list(dim = c(4, 2))
  },
  error = \(e) conditionMessage(e)
)
r7
#> [1] "dims [product 8] do not match the length of object [6]"

In the second call the invalid dim is skipped without a message, the user attribute is kept, and in the first call the names are not assigned because the object has become a matrix (a matrix has dimnames, not names).

How print shows attributes

print.default() shows an attribute that it has no special treatment for as attr(,"name") after the values, for vectors, matrices and lists alike. It does not show names and dim as attributes because it uses them to lay out the values, it shows dimnames as the row and column headings, it hides comment, and it shows class unless a print method for that class exists. The book objects of the shelf scenario have a print.book() method that prints one line, so a user attribute on a book is never shown by print(); unclass() or attributes() is the way to see it, and str() lists each attribute with the prefix - attr(*, "name")=.

source("scenario.R")

# Attach metadata to a book without changing its fields
b <- book(
  "Notes on Gravity", "Elias Moreau", "978-0-01-000104-4",
  year = 2017, genre = "Science fiction", pages = 410
)
attr(b, "shelf") <- "B2"
attr(b, "acquired") <- as.Date("2025-03-14")
attributes(b)
#> $names
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"
#>
#> $class
#> [1] "book"
#>
#> $shelf
#> [1] "B2"
#>
#> $acquired
#> [1] "2025-03-14"
#>
attr(b, "shelf")
#> [1] "B2"

# Printing a classed object uses its print method, which ignores the attribute
b
#> <book> Notes on Gravity (Elias Moreau, 2017)

# unclass() shows the attributes the default print method would
unclass(b)[c("title", "year")]
#> $title
#> [1] "Notes on Gravity"
#>
#> $year
#> [1] 2017
#>
str(unclass(b))
#> List of 6
#>  $ title : chr "Notes on Gravity"
#>  $ author: chr "Elias Moreau"
#>  $ isbn  : chr "9780010001044"
#>  $ year  : int 2017
#>  $ genre : chr "Science fiction"
#>  $ pages : int 410
#>  - attr(*, "shelf")= chr "B2"
#>  - attr(*, "acquired")= Date[1:1], format: "2025-03-14"

# The default print methods show non-special attributes as attr(,"name")
shelf_codes <- structure(c(a = "A1", b = "B2"), source = "inventory")
shelf_codes
#>    a    b
#> "A1" "B2"
#> attr(,"source")
#> [1] "inventory"
m <- structure(matrix(1:4, 2), check = TRUE)
m
#>      [,1] [,2]
#> [1,]    1    3
#> [2,]    2    4
#> attr(,"check")
#> [1] TRUE
f <- structure(list(a = 1, b = 2), owner = "library")
f
#> $a
#> [1] 1
#>
#> $b
#> [1] 2
#>
#> attr(,"owner")
#> [1] "library"

# An unknown class has no print method, so print.default shows the class too
structure(1:3, class = "no_print_method")
#> [1] 1 2 3
#> attr(,"class")
#> [1] "no_print_method"

The value of the attribute is not hidden by the method: it is the method that chooses what to show. That is the reason why print() is not a reliable way to check whether an attribute survived an operation; names(attributes(x)) is.

Names

A names attribute labels the individual elements of a vector or list: the names are used to print the object and also for indexing, for example quantile(x)["25%"]. names(x) <- value performs the consistency checks that the attribute has the proper type and length. Pairlists and one-dimensional arrays are special: for a pairlist the names are a virtual attribute constructed from the tags of the components, and for a one-dimensional array the names attribute really accesses dimnames[[1]] (The R Language Definition: Names).

names() and names<- are generic and primitive. The help page adds the rules to remember:

  • value is a character vector of up to the same length as x, or NULL; a shorter value is extended with character NA values.

  • The name "" is special: it means that there is no name for that element. Subscripting by "" matches nothing, not even the elements that have no name.

  • A name can be a character NA, but it will never be matched and is likely to lead to confusion.

  • A part of the attribute can be updated through the general replacement rules: names(z)[3] <- "c2" is evaluated as z <- "names<-"(z, "[<-"(names(z), 3, "c2")).

  • For an environment, names(env) gives the names of the corresponding list (ls(env, all.names = TRUE, sorted = FALSE)), in arbitrary order.

source("scenario.R")

# Names label the elements of a vector or list
pages <- setNames(books$pages, trimws(books$title))[1:4]
pages
#>    The Dispossessed Pride and Prejudice   the quiet streets          Loud Roofs
#>                 387                 432                 214                 198
names(pages)
#> [1] "The Dispossessed"    "Pride and Prejudice" "the quiet streets"
#> [4] "Loud Roofs"

# names<- replaces all names; a shorter value is padded with NA
names(pages) <- c("dispossessed", "pride")
names(pages)
#> [1] "dispossessed" "pride"        NA             NA
pages
#> dispossessed        pride         <NA>         <NA>
#>          387          432          214          198

# Partial replacement: names(x)[2] <- "..." works through names<- and [<-
names(pages) <- c("dispossessed", "pride", "streets", "roofs")
names(pages)[2] <- "pride_prejudice"
names(pages)
#> [1] "dispossessed"    "pride_prejudice" "streets"         "roofs"

# NULL removes the names
names(pages) <- NULL
pages
#> [1] 387 432 214 198
names(pages)
#> NULL

The padding with NA in the second step is the trap: a half-finished names(x) <- c("a", "b") on a longer vector leaves NA names that print as <NA>, and a lookup by name never finds them. The part below shows the empty name, the NA name, and how lists and c() build and combine names.

# The empty name "" means "no name for this element"; it never matches
v <- c(a = 1, 2, c = 3)
names(v)
#> [1] "a" ""  "c"
v[""]
#> <NA>
#>   NA
v["b"]
#> <NA>
#>   NA

# A character NA is a valid, but never matched, name
w <- c(1, 2)
names(w) <- c("x", NA)
names(w)
#> [1] "x" NA
w[NA_character_]
#> <NA>
#>   NA

# Lists have names too, and c() keeps and combines them
info <- list(title = "The Dispossessed", year = 1974L)
names(info)
#> [1] "title" "year"
combined <- c(info, list(pages = 387L))
names(combined)
#> [1] "title" "year"  "pages"
c(a = 1, b = 2, 3)
#> a b
#> 1 2 3
c(first = c(x = 1, y = 2), second = 3)
#> first.x first.y  second
#>       1       2       3
c(first = 1:2, second = 3)
#> first1 first2 second
#>      1      2      3
unlist(list(a = 1:2, b = list(c = 3, d = 4:5)))
#>   a1   a2  b.c b.d1 b.d2
#>    1    2    3    4    5

The last lines are the naming rules of c() and unlist(): an argument name is prefixed to the inner names with a dot when the argument has names (first.x), and followed by a running number when it has none and more than one element (first1, first2). That is how unlist(list(a = 1:2, b = list(c = 3, d = 4:5))) gets a1 a2 b.c b.d1 b.d2. See Lists for the [[ and $ side of named components.

setNames() and unname()

setNames(object, nm) is names(object) <- nm returning the object, which makes it usable inside a pipeline and as a one-liner for a lookup table. It lives in the stats package. unname(obj, force = FALSE) removes the names and the dimnames of an object; for a data frame it removes them only with force = TRUE. A common task is to find the name of the best element: which.max() and which.min() return a named integer index, so names(which.max(x)) is the label.

source("scenario.R")

# setNames() sets names and returns the object, so it works in a pipeline
years <- setNames(books$year, trimws(books$title))
head(years, 3)
#>    The Dispossessed Pride and Prejudice   the quiet streets
#>                1974                1813                2019

# The same with the native pipe: the value is the first argument
books$year |>
  setNames(trimws(books$title)) |>
  head(3)
#>    The Dispossessed Pride and Prejudice   the quiet streets
#>                1974                1813                2019

# unname() removes names and dimnames
m <- matrix(1:4, nrow = 2, dimnames = list(c("a", "b"), c("x", "y")))
m
#>   x y
#> a 1 3
#> b 2 4
unname(m)
#>      [,1] [,2]
#> [1,]    1    3
#> [2,]    2    4
unname(c(a = 1, b = 2))
#> [1] 1 2

# names() of the largest value: which.max() keeps the name of the element
by_title <- setNames(books$pages, trimws(books$title))
which.max(by_title)
#> Pride and Prejudice
#>                   2
names(which.max(by_title))
#> [1] "Pride and Prejudice"
by_title[which.max(by_title)]
#> Pride and Prejudice
#>                 432

# which.min() works the same way, and skips the NA values
which.min(by_title)
#> Paper Moons
#>          11

make.names() and make.unique()

make.names(names, unique = FALSE, allow_ = TRUE) turns arbitrary strings into syntactically valid names: invalid characters become dots, an X is prepended when the first character is not a letter or a dot followed by a non-digit, a reserved word gets a trailing dot, and NA becomes "NA.". With unique = TRUE it then calls make.unique(), which appends .1, .2 and so on (or another separator given by sep) to the duplicates. data.frame() and read.csv() apply make.names(unique = TRUE) to column names unless check.names = FALSE; that is the reason why a column called page count becomes page.count.

# make.names() turns strings into syntactically valid names
make.names(c("year", "page count", "1st", "_x", "if", "ok.name", "a-b"))
#> [1] "year"       "page.count" "X1st"       "X_x"        "if."
#> [6] "ok.name"    "a.b"
make.names(c("a", "a", "b"), unique = TRUE)
#> [1] "a"   "a.1" "b"

# make.unique() appends .1, .2 ... to duplicates
make.unique(c("a", "a", "b", "a"))
#> [1] "a"   "a.1" "b"   "a.2"
make.unique(c("a", "a", "b", "a"), sep = "_")
#> [1] "a"   "a_1" "b"   "a_2"

# Data frames use the same rules: check.names in read.csv() and data.frame()
df <- data.frame("page count" = 1:2, "page count" = 3:4)
names(df)
#> [1] "page.count"   "page.count.1"
df2 <- data.frame("page count" = 1:2, "page count" = 3:4, check.names = FALSE)
names(df2)
#> [1] "page count" "page count"

# NA and "" are replaced
make.names(c(NA, ""))
#> [1] "NA." "X"

R FAQ 7.14 explains why there are several kinds of "name" in the language: a syntactic name is a string the parser reads as a name (letters, numbers, dot and underscore, starting with a letter or with a dot not followed by a number, and not a reserved word); an object name is usually syntactic but can be any non-empty string if it is quoted; an argument name and an element name can be any string, but need quoting or backticks when used with f(...) or $ unless they are syntactic (R FAQ 7.14: What are valid names?). A names attribute may therefore contain anything, including blanks and non-ASCII characters; make.names() is only needed when the names will be used as variable names, for example in a formula or with $. See Syntax and Lexical Structure for the reserved words.

Dimensions: dim

The dim attribute implements arrays. The content of the array is stored in a vector in column-major order, and the dim attribute is an integer vector with the extents; R ensures that the length of the vector is the product of the extents, and the length of one or more dimensions may be zero. A vector is not the same as a one-dimensional array: the latter has a dim attribute of length one, whereas the former has no dim attribute (The R Language Definition: Dimensions).

dim(x) <- value accepts NULL or a numeric vector that is coerced to integer by truncation. The replacement checks that the new dimensions are compatible with the length, and it removes any dimnames and names attributes (R documentation: dim). dim() has a method for data frames that returns the number of rows (from the row.names attribute) and the number of columns.

source("scenario.R")

# dim<- turns a vector into a matrix (column-major order); nothing is moved
v <- 1:6
dim(v) <- c(2, 3)
v
#>      [,1] [,2] [,3]
#> [1,]    1    3    5
#> [2,]    2    4    6
attributes(v)
#> $dim
#> [1] 2 3
#>
class(v)
#> [1] "matrix" "array"
is.matrix(v)
#> [1] TRUE

# The same with structure(), and a one-dimensional array
structure(1:6, dim = c(3L, 2L))
#>      [,1] [,2]
#> [1,]    1    4
#> [2,]    2    5
#> [3,]    3    6
a <- array(1:3, dim = 3)
attributes(a)
#> $dim
#> [1] 3
#>
is.array(a)
#> [1] TRUE
is.matrix(a)
#> [1] FALSE

# dim<- coerces its value to integer (truncating) and checks the product
w <- 1:6
dim(w) <- c(2.9, 3)
dim(w)
#> [1] 2 3

# The error when the product does not match the length
r <- tryCatch(
  {
    dim(w) <- c(4, 2)
  },
  error = \(e) conditionMessage(e)
)
r
#> [1] "dims [product 8] do not match the length of object [6]"

The error text for a wrong product is dims [product 8] do not match the length of object [6]; the same message appears for an attributes<- or structure() call with a wrong dim, and for an arithmetic operation that would produce an array longer than the matrix operand (m + 1:12 in the arithmetic part of Arithmetic and element-by-element functions below). The NEWS of the 4.6.2 patch release, which is scheduled for 2026-10-29 and was read in the R-devel manual, adds a check in dim(a) <- vec for a prod(vec) that is too large (PR#19109) (R NEWS (R-devel): Changes in R 4.6.2); on R 4.6.1 the message above is the one you get for any mismatch. The rest of the script shows that dim<- with NULL undoes the dimensions and that it silently drops the names.

# dim<- NULL drops the dimensions again
dim(w) <- NULL
w
#> [1] 1 2 3 4 5 6

# dim<- removes names and dimnames
n <- c(a = 1, b = 2, c = 3, d = 4)
dim(n) <- c(2, 2)
attributes(n)
#> $dim
#> [1] 2 2
#>

# A data frame has a dim() method: rows and columns
dim(books)
#> [1] 16  6
nrow(books)
#> [1] 16
ncol(books)
#> [1] 6
length(books)
#> [1] 6
dim(books[, c("title", "year")])
#> [1] 16  2

R FAQ 7.5 is the other half of this: a subscript that selects a single row or column turns a matrix into a vector by default (drop = TRUE), which removes the dim and dimnames attributes; drop = FALSE keeps them, and should be used defensively when programming (R FAQ 7.5: Why do my matrices lose dimensions?). The complete matrix toolkit is on Matrices and Arrays.

Dimnames, rownames and colnames

dimnames names each dimension separately: it is a list of character vectors, one for each dimension (or NULL when a dimension has no names), and the list may itself have names, which are then used as the headings of the dimensions when the array is printed (The R Language Definition: Dimnames). rownames(x) and colnames(x) are the accessors of dimnames(x)[[1]] and dimnames(x)[[2]] for matrices, and for a data frame they are row.names(x) and names(x).

source("scenario.R")

# dimnames is a list of character vectors, one per dimension (or NULL)
m <- matrix(1:6, nrow = 2)
dimnames(m) <- list(c("r1", "r2"), c("a", "b", "c"))
m
#>    a b c
#> r1 1 3 5
#> r2 2 4 6
dimnames(m)
#> [[1]]
#> [1] "r1" "r2"
#>
#> [[2]]
#> [1] "a" "b" "c"
#>

# rownames() and colnames() are shortcuts for matrices and data frames
rownames(m) <- c("first", "second")
colnames(m)[2] <- "B"
m
#>        a B c
#> first  1 3 5
#> second 2 4 6

# The list may itself have names: they head the dimensions when printing
dimnames(m) <- list(row = c("first", "second"), col = c("a", "B", "c"))
m
#>         col
#> row      a B c
#>   first  1 3 5
#>   second 2 4 6
names(dimnames(m))
#> [1] "row" "col"

# A NULL component means no names for that dimension
dimnames(m) <- list(NULL, c("a", "b", "c"))
m
#>      a b c
#> [1,] 1 3 5
#> [2,] 2 4 6
rownames(m)
#> NULL

The two errors below are the checks that dimnames<- performs: each component must be as long as the extent of its dimension, and the value must be a list.

# Errors: the length of each component has to match the extent
r <- tryCatch(
  {
    dimnames(m) <- list(c("only_one"), c("a", "b", "c"))
  },
  error = \(e) conditionMessage(e)
)
r
#> [1] "length of 'dimnames' [1] not equal to array extent"
r2 <- tryCatch(
  {
    dimnames(m) <- c("a", "b")
  },
  error = \(e) conditionMessage(e)
)
r2
#> [1] "'dimnames' must be a list"

# Data frames: rownames() and colnames() are row.names() and names()
head(rownames(books), 3)
#> [1] "1" "2" "3"
colnames(books)
#> [1] "title"  "author" "isbn"   "year"   "genre"  "pages"

# For a one-dimensional array the names are dimnames[[1]]
t1 <- table(books$genre)
attributes(t1)
#> $dim
#> [1] 9
#>
#> $dimnames
#> $dimnames[[1]]
#> [1] "Classic"         "fiction"         "Fiction"         "Fiction "
#> [5] "Mystery"         "Non-fiction"     "Poetry"          "science fiction"
#> [9] "Science fiction"
#>
#>
#> $class
#> [1] "table"
#>
names(t1)
#> [1] "Classic"         "fiction"         "Fiction"         "Fiction "
#> [5] "Mystery"         "Non-fiction"     "Poetry"          "science fiction"
#> [9] "Science fiction"

The last example shows that a one-dimensional array, such as the result of table(), keeps its names in dimnames, and names() reads dimnames[[1]]; the class attribute is "table". The table also shows that the shelf’s genres, with their inconsistent case and stray blanks, give nine distinct labels, which is the kind of mess the Factors page cleans up.

Class

The class attribute is a character vector with the list of classes that an object inherits from; it is the basis of the generic functions of S3. It can be accessed and manipulated virtually without restriction by users: there is no checking that an object actually contains the components that its methods expect. The manual therefore recommends caution, and the use of the specific creation and coercion functions when they are available (The R Language Definition: Classes). All objects have a class, reported by class(): for simple vectors it is the implicit class (the mode, for example "numeric", "logical", "character" or "list", or "matrix", "array", "factor" or "data.frame"), and unclass() removes the effect of a class temporarily (An Introduction to R: The class of an object).

The functions, from the help page of class():

  • class(x) returns the class attribute when there is one, and the implicit class otherwise. oldClass(x) returns only the attribute, and so is NULL for an object without one (including a plain matrix, whose implicit class is computed from dim).

  • class<- and oldClass<- set the attribute; assigning NULL removes it. Assigning an empty character vector also removes the class, which is natural in class(x) <- setdiff(class(x), "ts"). Assigning an implicit class such as "character" coerces the object to that type (the script shows it), while assigning "numeric" to an integer vector leaves it as an integer.

  • inherits(x, what, which = FALSE) tests whether any class of x is in what, or, with which = TRUE, returns the position of each of what in the class vector (0 for no match). R 4.3.0 let what be something other than a character vector, resolved through the generic nameOfClass(), to support alternative object systems.

  • .class2(x) is the full implicit class chain that is used for S3 dispatch (new in R 4.0.0).

  • unclass(x) returns a copy of the object without the class attribute.

  • isa(x, what) is TRUE when all of the classes in what are in class(x) (inherits() needs any of them).

  • is.object(x) is TRUE if the object has a class attribute.

source("scenario.R")

# class() reports the class attribute, or the implicit class when there is none
x <- 1:3
attributes(x)
#> NULL
class(x)
#> [1] "integer"
oldClass(x)
#> NULL
class(matrix(1:4, 2))
#> [1] "matrix" "array"
oldClass(matrix(1:4, 2))
#> NULL
class(sum)
#> [1] "function"
class(quote(a + b))
#> [1] "call"
class(NULL)
#> [1] "NULL"

# Dispatch uses the implicit class: .class2() shows the full chain
.class2(matrix(1:4, 2))
#> [1] "matrix"  "array"   "integer" "numeric"
.class2(1L)
#> [1] "integer" "numeric"
.class2(1)
#> [1] "double"  "numeric"
.class2(sum)
#> [1] "function"

For the matrix, the implicit class is c("matrix", "array") since R 4.0.0, whose NEWS says that matrices now also inherit from class "array" and that code assuming class(matrix_obj) has length one is invalid. The rest of the script uses the book class: inherits() instead of class(x) == "book", unclass() to look inside the object, class<- to remove and put back the class, and the effect of setting a class on the wrong kind of object.

# Setting the class attribute
b <- book("Notes on Gravity", "Elias Moreau", year = 2017, pages = 410)
class(b)
#> [1] "book"
attributes(b)$class
#> [1] "book"
inherits(b, "book")
#> [1] TRUE
inherits(b, c("data.frame", "book"), which = TRUE)
#> [1] 0 1
is.list(b)
#> [1] TRUE

# unclass() removes the class and gives the underlying list
str(unclass(b))
#> List of 6
#>  $ title : chr "Notes on Gravity"
#>  $ author: chr "Elias Moreau"
#>  $ isbn  : chr NA
#>  $ year  : int 2017
#>  $ genre : chr NA
#>  $ pages : int 410
print(unclass(b)$title)
#> [1] "Notes on Gravity"

# class<- NULL removes the attribute; the object becomes an unclassed list
class(b) <- NULL
class(b)
#> [1] "list"
class(b) <- "book"
b
#> <book> Notes on Gravity (Elias Moreau, 2017)

# class<- can change the class of the underlying data without checking it
y <- 1:3
class(y) <- "book"
r <- tryCatch(format(y), error = \(e) conditionMessage(e))
r
#> [1] "$ operator is invalid for atomic vectors"

# Assigning an implicit class: class<- "numeric" coerces
z <- 1:3
class(z) <- "numeric"
typeof(z)
#> [1] "integer"
class(z) <- "character"
z
#> [1] "1" "2" "3"

The middle of that script is the warning about the "no checking" rule: class(y) <- "book" on an integer vector is accepted, and the error appears later, in format.book(), which expects a list ($ operator is invalid for atomic vectors). That is why the shelf scenario uses a constructor (new_book()), a validator (validate_book()) and a user-facing helper (book()) instead of calling structure() or class<- directly; see S3 Classes and Generics for the pattern, and OOP Overview and Base Types for the difference between the base type, the implicit class and the S3, S4 and reference classes.

Levels

A factor is an integer vector with two attributes, levels (the character labels) and class equal to "factor". levels(x) returns the attribute, levels<- replaces it, and nlevels(x) is its length. The help page of attributes() stresses that levels is not one of the specially treated attributes, so attr(x, "levels") <- value is not checked: for factors the replacement function levels<- should be used. When it is given a list, levels<- merges several old levels under one new name; renaming one level is the idiom levels(x)[levels(x) == "old"] <- "new".

source("scenario.R")

# levels is an ordinary attribute, but of factors it is mandatory
genre <- factor(trimws(tolower(books$genre)))
attributes(genre)
#> $levels
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"
#>
#> $class
#> [1] "factor"
#>
levels(genre)
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"
nlevels(genre)
#> [1] 6
class(genre)
#> [1] "factor"
typeof(genre)
#> [1] "integer"

# levels<- renames levels; a value in a list merges levels
levels(genre)[levels(genre) == "science fiction"] <- "sci-fi"
levels(genre)
#> [1] "classic"     "fiction"     "mystery"     "non-fiction" "poetry"
#> [6] "sci-fi"
levels(genre) <- list(
  fiction = c("fiction", "sci-fi"),
  other = c("classic", "mystery", "non-fiction", "poetry")
)
table(genre)
#> genre
#> fiction   other
#>       7       9

# On a non-factor, levels is just a user attribute, and is not checked
v <- c(1, 2, 3)
levels(v) <- c("lo", "hi")
attributes(v)
#> $levels
#> [1] "lo" "hi"
#>
v
#> [1] 1 2 3
#> attr(,"levels")
#> [1] "lo" "hi"

On a vector that is not a factor, levels<- just stores a user attribute, which print() shows as attr(,"levels") and which most operations drop. The last part of the script shows that structure() builds a factor directly from integer codes, and that no check is made at that point that every code has a level: the problem is reported by the first method that reads the factor.

# structure() with the class factor creates the integer codes
structure(c(2L, 1L, 2L), levels = c("a", "b"), class = "factor")
#> [1] b a b
#> Levels: a b

# A code without a level is not checked when the attribute is set: the
# error comes later, when a method such as print() reads the factor
bad <- structure(c(1L, 3L), levels = c("a", "b"), class = "factor")
r <- tryCatch(print(bad), error = \(e) conditionMessage(e))
r
#> [1] "malformed factor"

More about factors (factor(), droplevels(), ordered factors, the integer-codes trap of R FAQ 7.10) is on Factors.

Time series attributes: tsp

The tsp attribute holds the parameters of a time series: the start, the end and the frequency. It is used mainly for series with periodic substructure such as monthly or quarterly data (The R Language Definition: Time series attributes). ts() creates a series with tsp and the class "ts"; tsp(x) reads the attribute, tsp<- sets it (with NULL removing it, which also removes the "ts" class when the series had it), and start(), end(), frequency(), time() and window() work from it. tsp<- and attr<- check that the three numbers are consistent with the length of the object.

# ts() builds a numeric vector with a tsp attribute and class "ts"
monthly <- ts(c(12, 15, 9, 20, 18, 14), start = c(2026, 1), frequency = 12)
attributes(monthly)
#> $tsp
#> [1] 2026.000 2026.417   12.000
#>
#> $class
#> [1] "ts"
#>
tsp(monthly)
#> [1] 2026.000 2026.417   12.000
monthly
#>      Jan Feb Mar Apr May Jun
#> 2026  12  15   9  20  18  14

# tsp is c(start, end, frequency), in the time unit (here years)
start(monthly)
#> [1] 2026    1
end(monthly)
#> [1] 2026    6
frequency(monthly)
#> [1] 12
time(monthly)
#>           Jan      Feb      Mar      Apr      May      Jun
#> 2026 2026.000 2026.083 2026.167 2026.250 2026.333 2026.417

# tsp<- sets the attribute with consistency checks; NULL removes it
y <- c(1, 2, 3, 4)
tsp(y) <- c(1, 4, 1)
attributes(y)
#> $tsp
#> [1] 1 4 1
#>
tsp(y) <- NULL
attributes(y)
#> NULL

The tsp of monthly is c(2026.000, 2026.417, 12.000): the series starts at the beginning of 2026, ends at 2026 + 5/12, and has 12 observations a year. The consistency check and the effect of the operations are in the second part. Arithmetic keeps the tsp (the last line shows the attribute after monthly + 1), subsetting with [ drops it, and window() is the subsetting function that keeps the series structure.

# An inconsistent tsp is an error
r <- tryCatch(
  {
    tsp(y) <- c(1, 10, 1)
  },
  error = \(e) conditionMessage(e)
)
r
#> [1] "invalid time series parameters specified (1)"
r2 <- tryCatch(
  {
    attr(y, "tsp") <- c(1, 5, 1)
  },
  error = \(e) conditionMessage(e)
)
r2
#> [1] "invalid time series parameters specified (1)"

# Subsetting a ts with [ drops the time series attributes; window() keeps them
monthly[2:3]
#> [1] 15  9
window(monthly, start = c(2026, 2), end = c(2026, 3))
#>      Feb Mar
#> 2026  15   9
attributes(monthly + 1)$tsp
#> [1] 2026.000 2026.417   12.000

Dates and date-times are not stored as time series: they are numbers with a class (and, for POSIXct, a tzone attribute); see Dates and Times.

Comments

comment is an attribute for a note that must never clutter the output. The help page of comment() says that it is typically useful for data frames or model fits and that, contrary to the other attributes, it is not printed by print() or print.default(); assigning NULL or a zero-length character vector removes it (R documentation: comment). attributes() and attr(x, "comment") do show it, the value must be a character vector, and a non-character value is an error.

source("scenario.R")

# comment is a special attribute that print() never shows
pages <- books$pages
comment(pages) <- "pages as read from books.csv, two values missing"
comment(pages)
#> [1] "pages as read from books.csv, two values missing"
pages
#>  [1] 387 432 214 198 301  NA  96 280 352 256  88 410 175 322  NA 244
attributes(pages)
#> $comment
#> [1] "pages as read from books.csv, two values missing"
#>

# It must be a character vector
r <- tryCatch(
  {
    comment(pages) <- 42
  },
  error = \(e) conditionMessage(e)
)
r
#> [1] "attempt to set invalid 'comment' attribute"

# Subsetting drops it; arithmetic and unclass() keep it
comment(pages[1:3])
#> NULL
comment(pages + 1L)
#> [1] "pages as read from books.csv, two values missing"
comment(unclass(pages))
#> [1] "pages as read from books.csv, two values missing"

# attr(x, "comment") reads it too, and structure() can set it
attr(pages, "comment")
#> [1] "pages as read from books.csv, two values missing"

The comment of a column read from a CSV file is a way to remember where it came from, but it is lost by subsetting with [ (arithmetic and unclass() keep it, as the script shows), so keep metadata that must travel with the data in a structure that is not an attribute (a list or a data frame column) or put it back after every subset.

Which attributes survive which operations

The R Language Definition collects the rules for copying attributes in one section, and says that whether attributes should be copied when an object is altered is a complex area, with the general rules taken from The New S Language (Becker, Chambers and Wilks, 1988):

  • Scalar functions (those which operate element-by-element on a vector and whose output is similar to the input) should preserve attributes, except perhaps the class.

  • Binary operations normally copy most attributes from the longer argument and, if they are of the same length, from both, preferring the values on the first. Here "most" means all except names, dim and dimnames, which are set appropriately by the code for the operator.

  • Subsetting, other than by an empty index, generally drops all attributes except names, dim and dimnames, which are reset as appropriate. Subassignment, on the other hand, generally preserves attributes even if the length is changed. Coercion drops all attributes.

  • The default method for sorting drops all attributes except names, which are sorted along with the object.

(The R Language Definition: Copying of attributes). Those are the rules for the base types. They are summarised, together with the other functions this page checked, in the table below; every row is a call that is run in the scripts of this section.

Operation Attributes kept on the result Rule and where the example is

x + y, x * y, x > y, -x, !x

of the longer operand, or of both when the lengths are equal (the first wins on a clash); names, dim and dimnames are set by the operator

Arithmetic

sqrt(x), round(x), abs(x), trunc(x) and the other element-by-element math functions

all attributes

Arithmetic

x[i], x[[i]], head(x, n), x[-1]

names, and dim and dimnames where they still apply; the class only when the class has a [ method (factors, dates and data frames do)

Subsetting

x[] (empty index)

all attributes

Subsetting

x[i] <- value, x[[i]] <- value, x$name <- value

all attributes, also when the length or the type changes

Subassignment

c(x, y)

names only (and what the class’s c() method does, for factors and dates)

Combining and coercing

as.vector(x), as.numeric(x), as.integer(x), as.character(x), as.logical(x)

none (for as.vector() of a list, the names stay)

Combining and coercing

unlist(x)

the names it builds, and nothing else

Combining and coercing

sapply(), vapply()

names from the input or from USE.NAMES, and the dim and dimnames of a matrix result

Combining and coercing

ifelse(test, yes, no)

those of test, not of yes or no

Combining and coercing

sort(x), rev(x), rep(x, n), cumsum(x), diff(x), append(x, v), lapply(x, f), x[order(x)]

names only

Other functions

is.na(x), is.finite(x), which(x > 1), replace(x, i, v)

names; for replace() all of them (it is a subassignment on a copy)

Other functions

paste(x), unique(x), sum(x), range(x), order(x)

none

Other functions

Arithmetic and element-by-element functions

The script keeps a helper kept() that returns the names of the attributes of a result. The first group shows the rule for two operands of the same length and of different lengths: the attributes come from both operands when the lengths are equal, from the longer operand otherwise, and the first operand wins when both have the same attribute.

# Binary operations copy the attributes of the longer operand, or of both
# when the lengths are equal, with the first operand winning on a clash.
# Names, dim and dimnames are set appropriately by the operator itself.
kept <- \(v) names(attributes(v))
a <- structure(c(x = 1, y = 2, z = 3), unit = "pages")
b <- structure(c(10, 20, 30), note = "bonus")

# Same length: the attributes of both
kept(a + b)
#> [1] "note"  "unit"  "names"
kept(b + a)
#> [1] "unit"  "note"  "names"

# Different length: the attributes of the longer operand only
kept(a + 1)
#> [1] "unit"  "names"
kept(1 + a)
#> [1] "unit"  "names"
long <- structure(1:6, unit = "pages")
short <- structure(1:2, note = "short")
kept(long + short)
#> [1] "unit"
kept(short + long)
#> [1] "unit"

# On a clash for the same attribute, the first operand wins
c1 <- structure(1:2, tag = "first")
c2 <- structure(3:4, tag = "second")
attr(c1 + c2, "tag")
#> [1] "first"
attr(c2 + c1, "tag")
#> [1] "second"

The next part has the names, which follow their own rule: the names come from the first operand that has them when the lengths are equal, and from the longer operand when they are not, so c(p = 1) + c(r = 10, s = 20) has the names r and s. The arithmetic of matrices requires that the dim agree (non-conformable arrays), keeps the dim and dimnames, and recycles a vector along the columns with a warning when its length does not divide the matrix’s, or fails when the vector is longer than the matrix.

# Scalar (element-by-element) functions keep attributes as well
kept(sqrt(a))
#> [1] "names" "unit"
kept(round(a / 7, 2))
#> [1] "unit"  "names"
kept(-a)
#> [1] "names" "unit"
kept(a > 1)
#> [1] "names"
kept(!c(p = TRUE, q = FALSE))
#> [1] "names"

# Names: the first operand with names wins when the lengths are equal, and
# the longer operand wins otherwise
names(c(p = 1, q = 2) + c(1, 2))
#> [1] "p" "q"
names(c(1, 2) + c(p = 1, q = 2))
#> [1] "p" "q"
names(c(p = 1, q = 2) + c(r = 10, s = 20))
#> [1] "p" "q"
names(c(p = 1) + c(r = 10, s = 20))
#> [1] "r" "s"
names(c(p = 1, q = 2, r = 3, s = 4) + c(u = 1, v = 2))
#> [1] "p" "q" "r" "s"

# Matrices: the dimensions must agree
m <- matrix(1:6, nrow = 2, dimnames = list(c("a", "b"), NULL))
kept(m * 2)
#> [1] "dim"      "dimnames"
kept(m + m)
#> [1] "dim"      "dimnames"
r <- tryCatch(m + matrix(1:6, nrow = 3), error = \(e) conditionMessage(e))
r
#> [1] "non-conformable arrays"

# A vector against a matrix is recycled: a length that is not a divisor warns,
# and a vector longer than the matrix is an error
r2 <- tryCatch(m + 1:4, warning = \(w) conditionMessage(w))
r2
#> [1] "longer object length is not a multiple of shorter object length"
r3 <- tryCatch(m + 1:12, error = \(e) conditionMessage(e))
r3
#> [1] "dims [product 6] do not match the length of object [12]"

Subsetting

[ keeps names, dim and dimnames and drops everything else, including the class, unless a method for the class restores it. Factors, dates and data frames have their own [ methods, which is why they keep their class. An empty index (x[]) is the exception: it returns the object with all its attributes, which is why x[] <- value keeps the shape of a matrix. [[ extracts one element and drops all the attributes of the container; head() and tail() call [.

# Subsetting drops all attributes except names, dim and dimnames
x <- structure(c(a = 1, b = 2, c = 3), unit = "pages", class = "myclass")
attributes(unclass(x)[1:2])
#> $names
#> [1] "a" "b"
#>
names(attributes(x[1:2]))
#> [1] "names"

# For a plain vector with a user attribute
p <- structure(c(10, 20, 30), unit = "pages")
attributes(p[1:2])
#> NULL
attributes(p[])          # an empty index keeps everything
#> $unit
#> [1] "pages"
#>
attributes(p[TRUE])
#> NULL
attributes(head(p, 2))
#> NULL
attributes(p[[1]])
#> NULL

# Names are kept and subset along with the values
n <- c(a = 1, b = 2, c = 3)
n[c("c", "a")]
#> c a
#> 3 1
n[-1]
#> b c
#> 2 3

When a subscript selects one row or column of a matrix, the dim is dropped to a plain vector (R FAQ 7.5) and drop = FALSE keeps it. Factors and dates are restored by their [ methods; a plain S3 object is not.

# A matrix loses dim when a single row or column is extracted (drop = TRUE)
m <- matrix(1:6, nrow = 2, dimnames = list(c("r1", "r2"), c("a", "b", "c")))
attributes(m[1, ])
#> $names
#> [1] "a" "b" "c"
#>
attributes(m[, 2:3])
#> $dim
#> [1] 2 2
#>
#> $dimnames
#> $dimnames[[1]]
#> [1] "r1" "r2"
#>
#> $dimnames[[2]]
#> [1] "b" "c"
#>
#>
attributes(m[1, , drop = FALSE])
#> $dim
#> [1] 1 3
#>
#> $dimnames
#> $dimnames[[1]]
#> [1] "r1"
#>
#> $dimnames[[2]]
#> [1] "a" "b" "c"
#>
#>

# Factors and dates: [ methods restore the class
f <- factor(c("a", "b", "a"))
attributes(f[2:3])
#> $levels
#> [1] "a" "b"
#>
#> $class
#> [1] "factor"
#>
d <- as.Date("2026-06-30") + 0:2
attributes(d[1:2])
#> $class
#> [1] "Date"
#>

# Classed objects: the "[" method decides
cls <- structure(list(a = 1, b = 2), class = "plain_class")
class(cls[1])
#> [1] "list"
class(cls[[1]])
#> [1] "numeric"

# Lists subset with [ keep names, drop other attributes
l <- structure(list(a = 1, b = "x"), meta = "info")
attributes(l["a"])
#> $names
#> [1] "a"
#>
attributes(l[["a"]])
#> NULL

See Subsetting and Indexing for the operators themselves.

Subassignment

Subassignment preserves the attributes, even when the length or the type of the object changes. Growing a named vector pads the names with the empty string, and assigning with a new name appends an element with that name. The last part is about a one-dimensional array, whose subassignment by name dropped the dimension to a plain vector in R before 4.6.0 (the 4.6.0 NEWS lists it as a fix, PR#18973).

# Subassignment preserves attributes, even if the length changes
x <- structure(c(a = 1, b = 2, c = 3), unit = "pages")
x[2] <- 20
attributes(x)
#> $names
#> [1] "a" "b" "c"
#>
#> $unit
#> [1] "pages"
#>
x[5] <- 50
x
#>  a  b  c
#>  1 20  3 NA 50
#> attr(,"unit")
#> [1] "pages"
attributes(x)
#> $names
#> [1] "a" "b" "c" ""  ""
#>
#> $unit
#> [1] "pages"
#>

# Assigning past the end of a named vector pads the names with ""
y <- c(a = 1)
y[3] <- 3
y
#>  a
#>  1 NA  3
names(y)
#> [1] "a" ""  ""

# Assigning by a new name extends the vector and its names
z <- c(a = 1)
z["b"] <- 2
z
#> a b
#> 1 2

# The type may change, and the attributes remain
w <- structure(1:3, unit = "pages")
w[2] <- "two"
w
#> [1] "1"   "two" "3"
#> attr(,"unit")
#> [1] "pages"
attributes(w)
#> $unit
#> [1] "pages"
#>

# [[<- and $<- on a list keep the other attributes
l <- structure(list(a = 1), meta = "info")
l$b <- 2
attributes(l)
#> $names
#> [1] "a" "b"
#>
#> $meta
#> [1] "info"
#>

# Matrices: assigning with an index keeps dim and dimnames
m <- matrix(0, 2, 2, dimnames = list(c("a", "b"), c("x", "y")))
m[1, 2] <- 5
m
#>   x y
#> a 0 5
#> b 0 0

# Assigning to every element with an empty index keeps the matrix
m[] <- 1:4
m
#>   x y
#> a 1 3
#> b 2 4

# A one-dimensional array keeps its dim when assigned to by name
# (R 4.6.0 fixed a case where it was dropped to a plain vector)
one_d <- array(c(1L, 2L), dim = 2, dimnames = list(c("a", "b")))
one_d["a"] <- 10L
dim(one_d)
#> [1] 2
one_d
#>  a  b
#> 10  2

Combining, coercing and collecting

c() removes all attributes except the names; c() of dates and factors is carried out by their own methods (since R 4.1.0 c() of factors returns a factor; before, it returned the integer codes). as.vector() removes all attributes including the names for atomic vectors, but not the names of a list or expression; the as.numeric(), as.integer(), as.character() and as.logical() family removes all attributes, including the names. unlist() returns a vector with only the names that it builds, and sapply() and vapply() take the names of the result from the input (a character vector is used as names unless it already has names and USE.NAMES = FALSE).

# c() drops all attributes except names
x <- structure(c(a = 1, b = 2), unit = "pages")
attributes(c(x, 3))
#> $names
#> [1] "a" "b" ""
#>
attributes(c(x, x))
#> $names
#> [1] "a" "b" "a" "b"
#>
d <- as.Date("2026-06-30")
c(d, d)                    # a method for Date
#> [1] "2026-06-30" "2026-06-30"
c(unclass(d), unclass(d))  # without the method, the class is lost
#> [1] 20634 20634
f1 <- factor("a")
f2 <- factor("b")
c(f1, f2)                  # R >= 4.1.0 combines factors
#> [1] a b
#> Levels: a b

# as.vector() removes all attributes, including names, for atomic vectors
attributes(as.vector(x))
#> NULL
as.vector(c(a = 1, b = 2))
#> [1] 1 2
l <- list(a = 1, b = 2)
names(as.vector(l))        # but not names of a list
#> [1] "a" "b"
names(as.vector(l, mode = "numeric"))
#> NULL

# as.numeric() and friends drop all attributes
attributes(as.numeric(x))
#> NULL
attributes(as.integer(structure(c(a = 1.5), unit = "u")))
#> NULL
attributes(as.character(x))
#> NULL

# unlist() keeps the names it builds, and nothing else
attributes(unlist(list(a = x, b = 3)))
#> $names
#> [1] "a.a" "a.b" "b"
#>
unlist(list(a = 1:2, b = 3))
#> a1 a2  b
#>  1  2  3

# sapply()/vapply() results: names come from USE.NAMES and the input
vapply(c(first = 1, second = 2), \(v) v * 2, numeric(1))
#>  first second
#>      2      4
sapply(c("p", "q"), toupper)
#>   p   q
#> "P" "Q"
sapply(c("p", "q"), toupper, USE.NAMES = FALSE)
#> [1] "P" "Q"
vapply(list(a = 1:3, b = 4:6), range, numeric(2))
#>      a b
#> [1,] 1 4
#> [2,] 3 6
sapply(c(a = 1, b = 2), \(v) c(min = v, max = v + 1))
#>     a b
#> min 1 2
#> max 2 3

ifelse(test, yes, no) returns a value with the shape and attributes of test, and takes only the values from yes and no. The result for dates and factors is therefore a bare number or integer code, and the names of yes and no are ignored. For dates, use if/else for a single condition, or build the result with subassignment on a copy that keeps the class.

# ifelse(): the result has the attributes of the test, not of yes and no
test <- c(a = TRUE, b = FALSE, c = TRUE)
ifelse(test, 1, 0)
#> a b c
#> 1 0 1
ifelse(c(TRUE, FALSE), as.Date("2026-06-30"), as.Date("2026-07-01"))
#> [1] 20634 20635
ifelse(matrix(c(TRUE, FALSE, TRUE, FALSE), 2), "yes", "no")
#>      [,1]  [,2]
#> [1,] "yes" "yes"
#> [2,] "no"  "no"
ifelse(factor(c("a", "b")) == "a", "A", "B")
#> [1] "A" "B"

Other functions

The last script collects the results of a list of calls on one named vector with two user attributes and prints, for each result, the names of the attributes that remain. sort() keeps the names (sorted with the values) but drops user attributes; rev() is x[length(x):1L], so it follows the subsetting rule; math functions keep everything; and a function that returns a summary (sum(), range()) or a new vector (order(), paste()) returns none. x * 2L shows the arithmetic rule in a different order of attributes, which does not matter (the attributes are a set).

# One call per function, showing which attributes remain on the result
x <- structure(c(b = 3, a = 1, c = 2), unit = "pages", note = "demo")
results <- list(
  "x" = x,
  "sort(x)" = sort(x),
  "rev(x)" = rev(x),
  "x[order(x)]" = x[order(x)],
  "head(x, 2)" = head(x, 2),
  "rep(x, 2)" = rep(x, 2),
  "append(x, 4)" = append(x, 4),
  "cumsum(x)" = cumsum(x),
  "diff(x)" = diff(x),
  "round(x)" = round(x),
  "abs(x)" = abs(x),
  "trunc(x)" = trunc(x),
  "x * 2L" = x * 2L,
  "x > 1" = x > 1,
  "is.na(x)" = is.na(x),
  "is.finite(x)" = is.finite(x),
  "replace(x, 1, 99)" = replace(x, 1, 99),
  "as.integer(x)" = as.integer(x),
  "as.logical(x)" = as.logical(x),
  "paste(x)" = paste(x),
  "unique(x)" = unique(x),
  "which(x > 1)" = which(x > 1),
  "order(x)" = order(x),
  "sum(x)" = sum(x),
  "range(x)" = range(x),
  "lapply(x, identity)" = lapply(x, identity),
  "scale(x)" = scale(x),
  "table(c(\"a\", \"b\"))" = table(c("a", "b"))
)
for (call in names(results)) {
  kept <- names(attributes(results[[call]]))
  cat(sprintf("%-22s %s\n", call, paste(kept, collapse = ", ")))
}
#> x                      names, unit, note
#> sort(x)                names
#> rev(x)                 names
#> x[order(x)]            names
#> head(x, 2)             names
#> rep(x, 2)              names
#> append(x, 4)           names
#> cumsum(x)              names
#> diff(x)                names
#> round(x)               names, unit, note
#> abs(x)                 names, unit, note
#> trunc(x)               names, unit, note
#> x * 2L                 unit, note, names
#> x > 1                  names
#> is.na(x)               names
#> is.finite(x)           names
#> replace(x, 1, 99)      names, unit, note
#> as.integer(x)
#> as.logical(x)
#> paste(x)
#> unique(x)
#> which(x > 1)           names
#> order(x)
#> sum(x)
#> range(x)
#> lapply(x, identity)    names
#> scale(x)               dim, dimnames, scaled:center, scaled:scale
#> table(c("a", "b"))     dim, dimnames, class

The practical consequence is that a function written with a user attribute has to put it back on its result, or return an object with a class and a method for the operations that matter. Group generics let a class do that for arithmetic (see S3 Group Generics and Operator Overloading), and a [ method does it for subsetting (see S3 Classes and Generics).

Attributes of a data frame

A data frame is a list of equal-length columns with three attributes: names (the column names), class equal to "data.frame", and row.names. names(), colnames() and the attributes() entry names are the same thing, row.names() and rownames() read the third attribute, and the dimension of a data frame, computed by dim.data.frame(), is the length of the row names and the length of the list. There is no dim attribute.

source("scenario.R")

# A data frame is a list with three attributes
attributes(books[1:3, c("title", "year")])
#> $names
#> [1] "title" "year"
#>
#> $row.names
#> [1] 1 2 3
#>
#> $class
#> [1] "data.frame"
#>
names(attributes(books))
#> [1] "names"     "class"     "row.names"
class(books)
#> [1] "data.frame"
typeof(books)
#> [1] "list"

# names() and colnames() read the names attribute; rownames() the row.names
identical(names(books), colnames(books))
#> [1] TRUE
identical(attr(books, "names"), names(books))
#> [1] TRUE
identical(row.names(books), rownames(books))
#> [1] TRUE

# row.names are stored in a compact form for automatic row names
attributes(books)$row.names
#>  [1]  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16
.row_names_info(books, type = 0L)
#> [1]  NA -16
.row_names_info(books, type = 1L)
#> [1] -16
.row_names_info(books, type = 2L)
#> [1] 16
dim(books)
#> [1] 16  6

# attr(x, "row.names") expands the compact form; attributes() does as well
raw <- unclass(books)
attr(raw, "row.names")
#>  [1]  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16
.row_names_info(raw)
#> [1] -16

The row names of a data frame with automatic row names (1, 2, 3 and so on) are stored in a compact form that takes no memory: the internal value of the attribute is c(NA, -n) where n is the number of rows, and the negative sign marks the names as automatic. attributes(x)$row.names and attr(x, "row.names") expand it to 1:n for you; .row_names_info(x, type = 0L) shows the internal value, type = 1L gives -n for automatic row names and n for others, and type = 2L gives n always. .row_names_info is an internal-use function documented with the base internals and the names of its types are not a stable interface, so use nrow(x) and row.names(x) in programs. Setting character row names stores them as they are, and row.names(d) <- NULL returns to the automatic form.

# Setting character row names stores them as they are
d <- books[1:3, c("title", "year")]
rownames(d) <- c("a", "b", "c")
attributes(d)
#> $names
#> [1] "title" "year"
#>
#> $row.names
#> [1] "a" "b" "c"
#>
#> $class
#> [1] "data.frame"
#>
.row_names_info(d)
#> [1] 3
row.names(d) <- NULL
.row_names_info(d)
#> [1] -3
attributes(d)$row.names
#> [1] 1 2 3

# Subsetting rows keeps the integer row names that were selected
sub <- books[c(2, 5, 9), c("title", "year")]
attributes(sub)$row.names
#> [1] 2 5 9
.row_names_info(sub)
#> [1] 3
rownames(sub) <- NULL
.row_names_info(sub)
#> [1] -3

# Columns are ordinary vectors: attributes of columns survive
books$pages |> attributes()
#> NULL
d$year |> attributes()
#> NULL
attr(d$year, "unit") <- "calendar year"
attributes(d$year)
#> $unit
#> [1] "calendar year"
#>

# A data frame holding a classed column
dd <- data.frame(id = 1:2)
dd$when <- as.Date(c("2026-06-30", "2026-07-01"))
vapply(dd, class, "")
#>        id      when
#> "integer"    "Date"
str(dd)
#> 'data.frame':	2 obs. of  2 variables:
#>  $ id  : int  1 2
#>  $ when: Date, format: "2026-06-30" "2026-07-01"

The integer row names 2, 5, 9 that a row subset keeps are real (not automatic) row names, so .row_names_info() reports 3 rather than -3; rownames(sub) <- NULL resets them. The columns are ordinary vectors with their own attributes (a Date column keeps its class, and a user attribute such as unit stays on the column). See Data Frames for the rest of the data frame API.

Comparing objects and their attributes

identical(x, y) compares the attributes as a set when attrib.as.set = TRUE (the default), so the order of the attributes does not matter, and all.equal() reports the differences in names, dim and other attributes in words. A named integer vector is not identical to the same vector without names, and unname() is what makes them identical.

source("scenario.R")

# identical() compares attributes as a set; all.equal() reports differences
a <- structure(1:3, unit = "pages", note = "x")
b <- structure(1:3, note = "x", unit = "pages")
identical(a, b)
#> [1] TRUE
identical(a, b, attrib.as.set = FALSE)
#> [1] FALSE
identical(a, 1:3)
#> [1] FALSE
isTRUE(all.equal(a, 1:3))
#> [1] FALSE
all.equal(a, 1:3)
#> [1] "Attributes: < Modes: list, NULL >"
#> [2] "Attributes: < Lengths: 2, 0 >"
#> [3] "Attributes: < names for target but not for current >"
#> [4] "Attributes: < current is not list-like >"
all.equal(c(a = 1, b = 2), c(x = 1, y = 2))
#> [1] "Names: 2 string mismatches"
all.equal(1:3, c(1, 2, 3))
#> [1] TRUE
all.equal(a, a, check.attributes = FALSE)
#> [1] TRUE

# str() shows attributes with the - attr(*, "name") prefix
str(a)
#>  int [1:3] 1 2 3
#>  - attr(*, "unit")= chr "pages"
#>  - attr(*, "note")= chr "x"
str(books[1:2, c("title", "year")])
#> 'data.frame':	2 obs. of  2 variables:
#>  $ title: chr  "The Dispossessed" "Pride and Prejudice"
#>  $ year : int  1974 1813

# Numbers: a named integer vector against a plain one
identical(c(a = 1L), 1L)
#> [1] FALSE
identical(unname(c(a = 1L)), 1L)
#> [1] TRUE

User attributes as metadata

Attributes are the natural place for metadata that belongs to a value, not to its elements: the unit of a measurement, the shelf where a book stands, the source of a column. The rules of the previous section describe how fragile that is: an operation that does not preserve an attribute loses it silently, so metadata that must survive is better handled in one of three ways. First, make the object a classed list and keep the metadata in a named component (the book class: the shelf would be a seventh field, validated by validate_book()). Second, give the class a constructor and methods that propagate the attribute (a [.shelf_pages that restores unit). Third, put the metadata in an attribute and write a small helper that restores it, as keep_shelf() does for the book below. The last two lines are the usual way to test whether an object has a given attribute: attr() returns NULL for a missing attribute, so test with is.null(); beware of partial matching (exact = TRUE).

source("scenario.R")

# The book of "How print shows attributes", with its shelf attribute
b <- book(
  "Notes on Gravity", "Elias Moreau", "978-0-01-000104-4",
  year = 2017, genre = "Science fiction", pages = 410
)
attr(b, "shelf") <- "B2"

# Copying: modifying a copy leaves the original untouched
b2 <- b
attr(b2, "shelf") <- "C1"
attr(b, "shelf")
#> [1] "B2"
attr(b2, "shelf")
#> [1] "C1"

# Attributes are lost when a book is rebuilt from its fields
b3 <- book(b$title, b$author)
names(attributes(b3))
#> [1] "names" "class"

# A helper that restores the shelf attribute on a new object
keep_shelf <- function(new, old) {
  attr(new, "shelf") <- attr(old, "shelf")
  new
}
b4 <- keep_shelf(book("Ten Winters"), b)
attr(b4, "shelf")
#> [1] "B2"

# Check an attribute exists: attr() gives NULL, so test for NULL
is.null(attr(b3, "shelf"))
#> [1] TRUE
"shelf" %in% names(attributes(b))
#> [1] TRUE

The script in How print shows attributes showed what the shelf attributes look like, and why print(b) does not reveal them (the print method of the book class chooses what to show): ask attributes(b) when in doubt. The attributes class and names of the book list are intrinsic to its S3 structure, so they are always at the front of the list; the user attributes follow. A modified copy has its own attributes (copy-on-modify, see Memory and Copy Semantics).

Where older tutorials differ

Older tutorials and answers are written for R 2.x and 3.x. The table lists the differences that matter for attributes and names.

Older tutorial Current practice Why Version

structure(NULL, class = "x") to create an empty object

structure(list(), class = "x")

NULL cannot have attributes; the call was deprecated for years and is now an error (attempt to set an attribute on NULL)

R 4.6.0

attr(f, "doc") <- "..." on a primitive function such as sum

attach metadata to a closure or to a list that holds the function

setting attributes on a primitive function modified the shared function without copying; deprecated in R 4.5.0 and an error in 4.6.0 (cannot set an attribute on a 'builtin')

R 4.5.0 and 4.6.0

class(m) == "matrix" or class(m)[1] == "matrix"

is.matrix(m), inherits(m, "matrix")

the implicit class of a matrix is c("matrix", "array"), so a comparison with == has length two

R 4.0.0

c(f1, f2) returns the integer codes of two factors

c(f1, f2) returns a factor

c() has a method for factors

R 4.1.0

if (class(x) == "foo")

inherits(x, "foo")

class(x) can have length greater than one, and == in an if condition of length two is an error

R 4.2.0

attr(x, "unit") without thinking about partial matches

attr(x, "unit", exact = TRUE), or options(warnPartialMatchAttr = TRUE) while developing

the extractor matches unique partial names, so a mistyped name reads another attribute

always

dput() output with .Names, .Dim, .Dimnames, .Tsp and .Label

structure(1:6, dim = 2:3) with the plain names

deparsing stopped remapping dim, dimnames, levels, names and tsp to the old S-compatible names in R 4.2.0; the development version of R (the next 4.7.0, according to the R-devel NEWS) deprecates the old tags in structure()

R 4.2.0, R-devel

attributes(x)$names <- ... or attributes(x)$dim <- ... to modify one attribute

the accessors: names<-, dim<-, dimnames<-, levels<-, class<-

the accessors check the value; assigning through the attributes list replaces all the attributes and may give errors for the special ones

always

rownames(df) <- 1:nrow(df) to reset row names

rownames(df) <- NULL

the compact automatic form is smaller and is what read.csv() and data.frame() create

always

sapply(x, f) with names expected from x

vapply(x, f, numeric(1)) or sapply(..., USE.NAMES = FALSE)

for a character vector sapply() adds names from the values; vapply() documents the shape of the result

always

ifelse(cond, date1, date2) to select dates

if/else or subassignment on a copy

ifelse() returns the attributes of test, so the class of the dates is lost

always

For the full list of changes by release see What’s New and Migrating Older Code.

Where to go next

References