Factors

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 except one short section that uses forcats 1.0.1 to show how its functions map onto base R; lintr 3.4.0 default linters were used only to check the scripts. The page follows the shelf: a factor is how R represents a column such as books$genre that takes a small set of values, and it is both the most useful and the most surprising data type of the language. It is useful because a factor knows its set of allowed values and their order, so table(), tapply(), plots and model formulae can work on groups. It is surprising because a factor looks like a character vector when printed and behaves like an integer vector when it is converted, and because a factor refuses most arithmetic.

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. The examples read the running shelf scenario of the section, described on the section index: books.csv with read.csv("books.csv") and loans.csv with read.csv("loans.csv", colClasses = "character"). Errors and warnings that a script shows on purpose are caught with tryCatch() and printed with conditionMessage(), so the text is exactly what R says. R quotes names in its messages with typographic quotes (‘<’) in a UTF-8 locale and with plain apostrophes elsewhere. The sort order of strings depends on the locale, so the page says which locale an output comes from: the examples ran in an English UTF-8 locale (en_US.UTF-8 is only an example name; yours may differ and may sort differently).

What a factor is

The R Language Definition describes a factor as an object that describes items with a finite number of values: it has a levels attribute and the class "factor", and it may also carry a contrasts attribute that controls how the factor is coded in a model. The manual adds that factors are currently implemented using an integer array for the actual values and a second array of names that are mapped to the integers, and warns that users often rely on that implementation although it is an implementation issue and not guaranteed to hold in all implementations of R (The R Language Definition: Factors). An Introduction to R puts the purpose in one sentence: a factor is a vector object used to specify a discrete classification (grouping) of the components of other vectors of the same length (An Introduction to R: Ordered and unordered factors).

The first script builds the factor of the raw books$genre column, which contains nine spellings of six genres, and looks at its three parts: the codes, the levels and the class.

books <- read.csv("books.csv")

# A factor is an integer vector with a levels attribute and a class
genre <- factor(books$genre)
genre
#>  [1] Science fiction Classic         Fiction         fiction
#>  [5] Mystery         Non-fiction     Poetry          Classic
#>  [9] Non-fiction     Fiction         Poetry          Science fiction
#> [13] Non-fiction     science fiction Fiction         Mystery
#> 9 Levels: Classic fiction Fiction Fiction  Mystery Non-fiction ... Science fiction
levels(genre)
#> [1] "Classic"         "fiction"         "Fiction"         "Fiction "
#> [5] "Mystery"         "Non-fiction"     "Poetry"          "science fiction"
#> [9] "Science fiction"
nlevels(genre)
#> [1] 9

typeof(genre)
#> [1] "integer"
class(genre)
#> [1] "factor"
is.factor(genre)
#> [1] TRUE
attributes(genre)
#> $levels
#> [1] "Classic"         "fiction"         "Fiction"         "Fiction "
#> [5] "Mystery"         "Non-fiction"     "Poetry"          "science fiction"
#> [9] "Science fiction"
#>
#> $class
#> [1] "factor"
#>

# The codes: each element is the position of its label in levels(genre)
as.integer(genre)
#>  [1] 9 1 3 2 5 6 7 1 6 4 7 9 6 8 3 5
unclass(genre)
#>  [1] 9 1 3 2 5 6 7 1 6 4 7 9 6 8 3 5
#> attr(,"levels")
#> [1] "Classic"         "fiction"         "Fiction"         "Fiction "
#> [5] "Mystery"         "Non-fiction"     "Poetry"          "science fiction"
#> [9] "Science fiction"

# Decoding by hand gives back the labels
levels(genre)[as.integer(genre)]
#>  [1] "Science fiction" "Classic"         "Fiction"         "fiction"
#>  [5] "Mystery"         "Non-fiction"     "Poetry"          "Classic"
#>  [9] "Non-fiction"     "Fiction "        "Poetry"          "Science fiction"
#> [13] "Non-fiction"     "science fiction" "Fiction"         "Mystery"
identical(levels(genre)[genre], as.character(genre))
#> [1] TRUE

The output shows the whole design:

  • typeof(genre) is "integer" while class(genre) is "factor". The data are integer codes; the class is what makes printing, comparison, summary(), table() and subsetting treat them as labels (see Attributes and Names for how an attribute and a class are stored).

  • levels(genre) returns the character vector of labels, and nlevels(genre) its length. The code 1 means the first level, "Classic", and so on; levels(genre)[as.integer(genre)] is exactly as.character(genre). The help page promises that the levels attribute is of mode character and has unique entries.

  • unclass(genre) removes the class and shows the integer vector with its levels attribute, which is the definition in the manual made visible. attributes(genre) lists the two attributes in the order levels, class.

  • The factor has nine levels, not six, because factor() takes the strings exactly as they are: "fiction", "Fiction" and "Fiction " (with a trailing blank) are three different levels, as are "Science fiction" and "science fiction". A factor does not clean data; the section Cleaning the shelf genre normalises them before the factor is made.

The help page also explains what the integers mean: if x[i] equals levels[j], the i-th element of the result is j, and if there is no match (which happens for excluded values) the element is NA. The levels are the reduced set after removing those in exclude, and labels can rename them. factor(x) loses all attributes of x except names, and resets levels and class; applying factor() to a factor returns a factor with just the levels that occur (R documentation: factor).

A factor is not smaller than a character vector in current R. The help page notes that storing character data as a factor used to be more space efficient when there was even a small proportion of repeats, but that identical character strings now share storage, so the difference is small in most cases. Choose a factor because the data are categorical and have a fixed set of values, not to save memory.

Creating factors with factor()

The signature of the constructor is factor(x = character(), levels, labels = levels, exclude = NA, ordered = is.ordered(x), nmax = NA). The next table summarises each argument, with the behaviour that the help page documents.

Argument Meaning

x

a vector of data, usually with a small number of distinct values. It only needs an as.character method and to be sortable by order().

levels

the allowed values, as a vector. The default is the unique values of x, sorted into increasing order of x. The set can be smaller than sort(unique(x)); values of x not in it become NA.

labels

new names for the levels, in the order of levels (after removing those in exclude), or a single string used as a prefix with a sequence number. Duplicated labels merge several values into one level.

exclude

values to remove from the levels; default NA. exclude = NULL keeps NA as a level.

ordered

whether the levels are ordered (class c("ordered", "factor")); ordered() is the S-compatible shortcut.

nmax

an upper bound on the number of levels, which speeds up unique() for long vectors.

The following script shows each of them on small vectors. The first lines are about the order of the levels, which is the single most common source of surprise: levels are sorted, and the sort depends on the collation rules of the locale. In an English UTF-8 locale "apple" sorts before "Apple"; under the "C" collation upper-case letters come first. The help page says so in its warning: the levels of a factor are by default sorted, but the sort order may well depend on the locale at the time of creation, and should not be assumed to be ASCII.

# By default the levels are the sorted unique values (locale collation)
x <- c("pear", "Apple", "apple", "Banana", "banana")
levels(factor(x))
#> [1] "apple"  "Apple"  "banana" "Banana" "pear"

# Under the "C" collation capital letters sort before lower-case letters
old <- Sys.getlocale("LC_COLLATE")
invisible(Sys.setlocale("LC_COLLATE", "C"))
levels(factor(x))
#> [1] "Apple"  "Banana" "apple"  "banana" "pear"
invisible(Sys.setlocale("LC_COLLATE", old))

# Numbers are sorted as numbers, then converted to character labels
pages <- c(214L, 88L, 387L, 96L, 88L)
levels(factor(pages))
#> [1] "88"  "96"  "214" "387"
sort(unique(as.character(pages)))
#> [1] "214" "387" "88"  "96"

# Explicit levels fix the order and the universe of allowed values
sizes <- c("small", "large", "medium", "small", "huge")
factor(sizes, levels = c("small", "medium", "large"))
#> [1] small  large  medium small  <NA>
#> Levels: small medium large

# labels renames the levels; duplicated labels merge values into one level
f <- factor(c(1, 2, 3, 2, 1), levels = 1:3, labels = c("low", "mid", "high"))
f
#> [1] low  mid  high mid  low
#> Levels: low mid high
factor(
  c("a", "b", "c", "d"),
  labels = c("vowel", "vowel", "consonant", "consonant")
)
#> [1] vowel     vowel     consonant consonant
#> Levels: vowel consonant

# A single label becomes a prefix with a sequence number
factor(c("x", "y", "x"), labels = "grp")
#> [1] grp1 grp2 grp1
#> Levels: grp1 grp2

# exclude removes values from the levels (their codes become NA)
factor(c("a", "b", "c", "a"), exclude = "b")
#> [1] a    <NA> c    a
#> Levels: a c

# as.factor() is the short form; on an existing factor it returns it unchanged
g <- factor(c("b", "a", "b"))
identical(as.factor(g), g)
#> [1] TRUE
is.factor(as.factor(c("b", "a", "b")))
#> [1] TRUE

Points to notice in the output:

  • Numbers are sorted as numbers ("88", "96", "214", "387") before they are converted to the character labels; sorting the labels as strings would give "214" first. Only a factor made from numbers needs this care when it is later converted back, see Factors to numbers.

  • A value that is not in levels becomes NA without any message: "huge" is <NA>. This is the fastest way to turn a typing mistake into missing data, so give levels only when the set of valid values is known.

  • labels with duplicates is a merge. factor(x, levels, labels) has allowed duplicated labels (but not duplicated levels) since R 3.5.0, which lets you map different values of x to the same level directly (R NEWS: Changes in R 3.5.0). A single string in labels produces grp1, grp2, and so on.

  • exclude = "b" removes the level and gives its elements the code NA. Since R 3.4.0 excluding a character level that does not exist is the same as excluding nothing, and factor(x, exclude = NULL) on a factor without NA is a no-operation unless there are unused levels (R documentation: factor).

  • as.factor(x) is the abbreviated form of factor(x): on a factor it returns its argument unchanged (including unused levels), whereas factor(f) drops unused levels. is.factor() tests the class (it is generic) and is.ordered() the ordered class.

  • To keep the levels in the order of first appearance, give them explicitly: factor(x, levels = unique(x)). The script in A second example: loans per member does so.

The levels of a factor are not required to be sorted. The order is part of the data: it is the order of the rows of table(), of the groups of tapply(), of the bars of a plot and of the columns that a model builds, which is why most of the work with factors is about getting the right order.

Missing values and NA levels

An NA in the data is an NA code. It is not a level, so table() does not count it, levels() does not list it and factor() prints it as <NA>. The shelf has one book without a year, which makes the decade of publication a good example. The help page gives the recipe to count it: for a numeric x, set exclude = NULL to make NA an extra level (printed as <NA>, and by default the last level), or use addNA() on an existing factor. addNA(x, ifany = TRUE) adds the level only if some element is missing. Since the level NA and the code NA are two different things, the way to set an element to missing (rather than to the missing level) when NA is a level is is.na(f)[i] <- TRUE; indexing inside is.na() does not work.

books <- read.csv("books.csv")

# An NA in the data is an NA code, not a level
decade <- factor(books$year %/% 10L * 10L)
decade
#>  [1] 1970 1810 2010 2020 2010 2000 <NA> 1990 2010 2020 2000 2010 2020 2010 2020
#> [16] 2010
#> Levels: 1810 1970 1990 2000 2010 2020
table(decade)
#> decade
#> 1810 1970 1990 2000 2010 2020
#>    1    1    1    2    6    4
anyNA(decade)
#> [1] TRUE

# exclude = NULL (or addNA()) makes NA a level of its own
factor(books$year %/% 10L * 10L, exclude = NULL)
#>  [1] 1970 1810 2010 2020 2010 2000 <NA> 1990 2010 2020 2000 2010 2020 2010 2020
#> [16] 2010
#> Levels: 1810 1970 1990 2000 2010 2020 <NA>
addNA(decade)
#>  [1] 1970 1810 2010 2020 2010 2000 <NA> 1990 2010 2020 2000 2010 2020 2010 2020
#> [16] 2010
#> Levels: 1810 1970 1990 2000 2010 2020 <NA>
addNA(decade, ifany = TRUE)
#>  [1] 1970 1810 2010 2020 2010 2000 <NA> 1990 2010 2020 2000 2010 2020 2010 2020
#> [16] 2010
#> Levels: 1810 1970 1990 2000 2010 2020 <NA>
levels(addNA(decade))
#> [1] "1810" "1970" "1990" "2000" "2010" "2020" NA

# A factor can contain NA codes without an NA level: is.na<- sets one
f <- factor(c("a", "b", "a"))
is.na(f) <- 2
f
#> [1] a    <NA> a
#> Levels: a b

The help page recommends restraint: there are some anomalies associated with factors that have NA as a level, and it suggests using them sparingly, for example only for tabulation. addNA() is therefore best applied at the moment of counting. For the other uses of the missing value, such as is.na(), anyNA() and na.rm, see Missing and Special Values.

Cleaning the shelf genre

The genre column of books.csv is a real-world mess: nine spellings of six genres, with inconsistent case and a trailing blank. A factor made directly from it keeps all nine as levels (the first script of the page). The cleaning belongs before the factor, in plain string functions (Strings and Regular Expressions covers them): trimws() removes the blanks and tolower() unifies the case. Then factor() is called with explicit levels (the set the library accepts and the order of the shelf) and labels (how they are shown). Everything that is not one of the allowed values becomes NA, so the last lines of the script make that visible.

books <- read.csv("books.csv")

# The raw column has nine spellings of six genres
table(books$genre)
#>
#>         Classic         fiction         Fiction        Fiction          Mystery
#>               2               1               2               1               2
#>     Non-fiction          Poetry science fiction Science fiction
#>               3               2               1               2

# Normalise first, then make the factor with explicit levels
genre_clean <- tolower(trimws(books$genre))
unique(genre_clean)
#> [1] "science fiction" "classic"         "fiction"         "mystery"
#> [5] "non-fiction"     "poetry"

genre <- factor(
  genre_clean,
  levels = c(
    "fiction", "non-fiction", "science fiction", "mystery", "classic", "poetry"
  ),
  labels = c(
    "Fiction", "Non-fiction", "Science fiction", "Mystery", "Classic", "Poetry"
  )
)
genre
#>  [1] Science fiction Classic         Fiction         Fiction
#>  [5] Mystery         Non-fiction     Poetry          Classic
#>  [9] Non-fiction     Fiction         Poetry          Science fiction
#> [13] Non-fiction     Science fiction Fiction         Mystery
#> Levels: Fiction Non-fiction Science fiction Mystery Classic Poetry
table(genre)
#> genre
#>         Fiction     Non-fiction Science fiction         Mystery         Classic
#>               4               3               3               2               2
#>          Poetry
#>               2

# A value that is not in levels becomes NA (with no warning from factor())
factor(c("fiction", "cookery"), levels = c("fiction", "poetry"))
#> [1] fiction <NA>
#> Levels: fiction poetry

The raw table has nine entries; the clean factor has six levels in the order that was asked for, and the labels are capitalised for display. Because the levels and the labels were given in the same order, the mapping is positional: if the two vectors are not in the same order, the labels are attached to the wrong values and R does not warn. When the labels are only a cosmetic change of the values, it is safer to clean the strings first and keep them as levels, and to rename later with levels(f) <- value (see Dropping, reordering and merging levels).

Turning numbers into factors with cut()

cut() divides the range of a numeric vector into intervals and codes each value by the interval it falls in; the leftmost interval is level one. Its signature is cut(x, breaks, labels = NULL, include.lowest = FALSE, right = TRUE, dig.lab = 3, ordered_result = FALSE, ...). breaks is either a vector of two or more unique cut points or a single number of equal-width intervals; values outside the breaks, NaN and NA become NA; and labels = FALSE returns plain integer codes instead of a factor (R documentation: cut).

The intervals are closed on the right and open on the left, (a, b], unless right = FALSE. The consequence is the classic trap: the lowest break is not included, so a value equal to the first break is NA unless include.lowest = TRUE. The next script cuts the year of publication of the shelf at 1950 and 2000, and shows two further things in its output: the default labels use dig.lab = 3 digits and so turn the year breaks into scientific notation (1.95e+03), which is almost never wanted, and giving dig.lab = 4 or explicit labels fixes that.

books <- read.csv("books.csv")

# cut() turns a number into a factor of intervals: (a, b] by default
era <- cut(books$year, c(1800, 1950, 2000, 2100))
era
#>  [1] (1.95e+03,2e+03]   (1.8e+03,1.95e+03] (2e+03,2.1e+03]    (2e+03,2.1e+03]
#>  [5] (2e+03,2.1e+03]    (2e+03,2.1e+03]    <NA>               (1.95e+03,2e+03]
#>  [9] (2e+03,2.1e+03]    (2e+03,2.1e+03]    (2e+03,2.1e+03]    (2e+03,2.1e+03]
#> [13] (2e+03,2.1e+03]    (2e+03,2.1e+03]    (2e+03,2.1e+03]    (2e+03,2.1e+03]
#> Levels: (1.8e+03,1.95e+03] (1.95e+03,2e+03] (2e+03,2.1e+03]
table(era, useNA = "ifany")
#> era
#> (1.8e+03,1.95e+03]   (1.95e+03,2e+03]    (2e+03,2.1e+03]               <NA>
#>                  1                  2                 12                  1

# The default dig.lab = 3 formats the break labels in scientific notation
levels(cut(books$year, c(1800, 1950, 2000, 2100), dig.lab = 4))
#> [1] "(1800,1950]" "(1950,2000]" "(2000,2100]"

# right = FALSE gives [a, b) intervals; the labels show the difference
levels(cut(books$year, c(1800, 1950, 2000, 2100), right = FALSE, dig.lab = 4))
#> [1] "[1800,1950)" "[1950,2000)" "[2000,2100)"

# A value on the lowest break is NA unless include.lowest = TRUE
cut(c(1800, 1950, 2000), c(1800, 1950, 2000), dig.lab = 4)
#> [1] <NA>        (1800,1950] (1950,2000]
#> Levels: (1800,1950] (1950,2000]
cut(
  c(1800, 1950, 2000), c(1800, 1950, 2000),
  include.lowest = TRUE, dig.lab = 4
)
#> [1] [1800,1950] [1800,1950] (1950,2000]
#> Levels: [1800,1950] (1950,2000]

# Own labels, and an ordered result
size <- cut(
  books$pages,
  breaks = c(0, 200, 350, Inf),
  labels = c("short", "medium", "long"),
  ordered_result = TRUE
)
size
#>  [1] long   long   medium short  medium <NA>   short  medium long   medium
#> [11] short  long   short  medium <NA>   medium
#> Levels: short < medium < long
table(size, useNA = "ifany")
#> size
#>  short medium   long   <NA>
#>      4      6      4      2

# breaks can also be a single number of equal-width intervals
levels(cut(books$pages, breaks = 3))
#> [1] "(87.7,203]" "(203,317]"  "(317,432]"

# findInterval() gives the interval number as a plain integer
findInterval(books$pages, c(200, 350))
#>  [1]  2  2  1  0  1 NA  0  1  2  1  0  2  0  1 NA  1

The output has six notable lines:

  • The book without a year is <NA>, and useNA = "ifany" shows it in the table.

  • The default labels are (1.8e+03,1.95e+03] for the years. The help page says that dig.lab is the minimum number of digits, that a larger value (up to 12) is used if needed to distinguish the endpoints, and that the formatting is done by formatC(). The remedy is dig.lab = 4 or, more readable, labels = c("before 1950", "1950-2000", "after 2000").

  • right = FALSE flips the interval notation to [a, b).

  • cut(c(1800, 1950, 2000), c(1800, 1950, 2000)) gives NA for 1800; include.lowest = TRUE includes it and the first label becomes [1800,1950].

  • ordered_result = TRUE returns an ordered factor, printed with < between the levels (the next section). The two books with unknown page counts stay NA.

  • breaks = 3 splits the range in three intervals of equal width and moves the outer limits 0.1% of the range away, so that the extremes fall inside; the default labels ((87.7,203]) show the effect.

The help page adds that hist(x, br, plot = FALSE) is more efficient than table(cut(x, br)) and that findInterval() is more efficient than cut(*, labels = FALSE); the last lines of the script show findInterval(), which returns the interval number (zero below the first break) as a plain integer. In R 4.6.1, cut(1:11, {}) signals an error for the invalid breaks (R NEWS: Changes in R 4.6.1).

Ordered factors

An ordered factor is a factor whose levels have a rank. It is created by ordered(x, levels = ) or by factor(x, levels = , ordered = TRUE) (the two are the same object), converted with as.ordered(), tested with is.ordered(), and its class is c("ordered", "factor"). The help page says that ordered factors differ from factors only in their class, but that methods and model-fitting functions may treat the two classes quite differently (see options("contrasts")). An Introduction to R has a short section on them (An Introduction to R: Ordered factors).

The difference that matters for a program is comparison. The Ops group has factor and ordered methods: for an unordered factor only == and != are meaningful, and the factor can only be compared to another factor with an identical set of levels or to a character vector. For an ordered factor all the comparison operators are available, compared by the position of the level, and min(), max() and range() work. The other operators are not defined for factors: arithmetic and the other comparisons give a warning and NA, and the Math and Summary functions signal an error. Ordered and unordered factors cannot be compared with each other: the general dispatch mechanism precludes it (R documentation: factor).

books <- read.csv("books.csv")
size <- cut(
  books$pages,
  breaks = c(0, 200, 350, Inf),
  labels = c("short", "medium", "long"),
  ordered_result = TRUE
)

# Ordered factors have class c("ordered", "factor")
class(size)
#> [1] "ordered" "factor"
is.ordered(size)
#> [1] TRUE
levels(size)
#> [1] "short"  "medium" "long"

# The same object from ordered() or factor(ordered = TRUE)
sizes <- c("long", "short", "medium", "short")
a <- ordered(sizes, levels = c("short", "medium", "long"))
b <- factor(sizes, levels = c("short", "medium", "long"), ordered = TRUE)
identical(a, b)
#> [1] TRUE
a
#> [1] long   short  medium short
#> Levels: short < medium < long

# Comparisons use the order of the levels
a < "long"
#> [1] FALSE  TRUE  TRUE  TRUE
a >= "medium"
#> [1]  TRUE FALSE  TRUE FALSE
size[1:6] < "long"
#> [1] FALSE FALSE  TRUE  TRUE  TRUE    NA

# Summary functions work on ordered factors
max(a)
#> [1] long
#> Levels: short < medium < long
range(a)
#> [1] short long
#> Levels: short < medium < long
sort(a)
#> [1] short  short  medium long
#> Levels: short < medium < long
which.max(table(size))
#> medium
#>      2

# On an unordered factor < gives NA and a warning
f <- factor(c("short", "long"), levels = c("short", "long"))
msg <- tryCatch(f < "long", warning = conditionMessage)
msg
#> [1] "‘<’ not meaningful for factors"
suppressWarnings(f < "long")
#> [1] NA NA

# Mixed ordered and unordered comparisons
tryCatch(a < f, error = \(e) "error", warning = \(w) conditionMessage(w))
#> [1] "Incompatible methods (\"Ops.ordered\", \"Ops.factor\") for \"<\""

# as.ordered() and ordering an existing factor
as.ordered(f)
#> [1] short long
#> Levels: short < long
ordered(f, levels = c("long", "short"))
#> [1] short long
#> Levels: long < short

The output shows that a < "long" compares by rank: "short", "medium" and "short" are less than "long", so the result is FALSE TRUE TRUE TRUE for long, short, medium, short. max(a) is long and range(a) returns the lowest and highest levels that occur, and sort() orders by rank, not by alphabet. The unordered comparison f < "long" returns NA for every element together with the warning ‘<’ not meaningful for factors, which is the standard message for a factor operation that has no meaning (the same text appears for +, see Errors and warnings). Comparing an ordered with an unordered factor gives the warning Incompatible methods ("Ops.ordered", "Ops.factor") for "<" and NA.

Use an ordered factor when the order is part of the meaning (sizes, ratings, severity levels, the age bands of cut()), and an unordered one when it is only a convenient display order. A model that receives an ordered factor uses polynomial contrasts by default, which is rarely what a beginner expects; Statistical Models and Formulas describe how factors enter a model.

Counting with table()

table() is the main consumer of factors. Called with one factor it counts the elements of each level; with two or more it cross-tabulates them. It uses the levels of its arguments (or the sorted unique values when they are not factors), so a level with no elements shows a zero, which is exactly why the levels are worth fixing. The result is an integer array of class "table" with dimnames, and useNA and exclude control the missing values: useNA = "no" (the default) ignores NA, "ifany" adds a column for them only if any occurs, and "always" always adds it; exclude = NULL is equivalent to useNA = "ifany" (R documentation: table).

The helpers around it are prop.table() (proportions() is the new name, introduced in R 4.0.0 together with marginSums(); the old names are kept as aliases), which divides by the total or by the margin given with margin = 1 (rows) or margin = 2 (columns); addmargins() from the stats package, which appends the totals; and margin.table() (marginSums()), which sums over the dimensions you keep. as.data.frame() turns a table into a long data frame with the counts in Freq. An Introduction to R shows the one-way case in its section on frequency tables from factors (An Introduction to R: Frequency tables from factors).

books <- read.csv("books.csv")
genre <- factor(tolower(trimws(books$genre)))
decade <- factor(books$year %/% 10L * 10L)

# One-way table: counts per level
t1 <- table(genre)
t1
#> genre
#>         classic         fiction         mystery     non-fiction          poetry
#>               2               4               2               3               2
#> science fiction
#>               3
names(t1)
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"
t1[["fiction"]]
#> [1] 4
class(t1)
#> [1] "table"
str(unclass(t1))
#>  int [1:6(1d)] 2 4 2 3 2 3
#>  - attr(*, "dimnames")=List of 1
#>   ..$ genre: chr [1:6] "classic" "fiction" "mystery" "non-fiction" ...

# Proportions and percentages
prop.table(t1)
#> genre
#>         classic         fiction         mystery     non-fiction          poetry
#>          0.1250          0.2500          0.1250          0.1875          0.1250
#> science fiction
#>          0.1875
round(100 * prop.table(t1), 1)
#> genre
#>         classic         fiction         mystery     non-fiction          poetry
#>            12.5            25.0            12.5            18.8            12.5
#> science fiction
#>            18.8

# Two-way table: genre by publication era
era <- cut(
  books$year, c(1800, 1950, 2000, 2100),
  labels = c("before 1950", "1950-2000", "after 2000")
)
t2 <- table(genre, era)
t2
#>                  era
#> genre             before 1950 1950-2000 after 2000
#>   classic                   1         1          0
#>   fiction                   0         0          4
#>   mystery                   0         0          2
#>   non-fiction               0         0          3
#>   poetry                    0         0          1
#>   science fiction           0         1          2
dim(t2)
#> [1] 6 3
dimnames(t2)
#> $genre
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"
#>
#> $era
#> [1] "before 1950" "1950-2000"   "after 2000"
#>

# Row and column proportions, and margins
prop.table(t2, margin = 1)
#>                  era
#> genre             before 1950 1950-2000 after 2000
#>   classic           0.5000000 0.5000000  0.0000000
#>   fiction           0.0000000 0.0000000  1.0000000
#>   mystery           0.0000000 0.0000000  1.0000000
#>   non-fiction       0.0000000 0.0000000  1.0000000
#>   poetry            0.0000000 0.0000000  1.0000000
#>   science fiction   0.0000000 0.3333333  0.6666667
addmargins(t2)
#>                  era
#> genre             before 1950 1950-2000 after 2000 Sum
#>   classic                   1         1          0   2
#>   fiction                   0         0          4   4
#>   mystery                   0         0          2   2
#>   non-fiction               0         0          3   3
#>   poetry                    0         0          1   1
#>   science fiction           0         1          2   3
#>   Sum                       1         2         12  15
addmargins(prop.table(t2))
#>                  era
#> genre             before 1950  1950-2000 after 2000        Sum
#>   classic          0.06666667 0.06666667 0.00000000 0.13333333
#>   fiction          0.00000000 0.00000000 0.26666667 0.26666667
#>   mystery          0.00000000 0.00000000 0.13333333 0.13333333
#>   non-fiction      0.00000000 0.00000000 0.20000000 0.20000000
#>   poetry           0.00000000 0.00000000 0.06666667 0.06666667
#>   science fiction  0.00000000 0.06666667 0.13333333 0.20000000
#>   Sum              0.06666667 0.13333333 0.80000000 1.00000000
margin.table(t2, 1)
#> genre
#>         classic         fiction         mystery     non-fiction          poetry
#>               2               4               2               3               1
#> science fiction
#>               3

# NA is dropped unless useNA says otherwise
table(era, useNA = "ifany")
#> era
#> before 1950   1950-2000  after 2000        <NA>
#>           1           2          12           1
table(era, useNA = "always")
#> era
#> before 1950   1950-2000  after 2000        <NA>
#>           1           2          12           1
table(decade, exclude = NULL)
#> decade
#> 1810 1970 1990 2000 2010 2020 <NA>
#>    1    1    1    2    6    4    1

# as.data.frame() turns a table into a long data frame, one row per cell
as.data.frame(t1)
#>             genre Freq
#> 1         classic    2
#> 2         fiction    4
#> 3         mystery    2
#> 4     non-fiction    3
#> 5          poetry    2
#> 6 science fiction    3
vapply(as.data.frame(t1), class, "")
#>     genre      Freq
#>  "factor" "integer"
as.data.frame(t1, responseName = "n", stringsAsFactors = FALSE)
#>             genre n
#> 1         classic 2
#> 2         fiction 4
#> 3         mystery 2
#> 4     non-fiction 3
#> 5          poetry 2
#> 6 science fiction 3
vapply(as.data.frame(t1, stringsAsFactors = FALSE), class, "")
#>       genre        Freq
#> "character"   "integer"

Notes on the output:

  • The first row of the one-way table is headed genre because table() takes its dimension names from the argument names (deparse.level = 1); names(t1) and t1[["fiction"]] select by level. str(unclass(t1)) shows the 1-d integer array with its dimnames.

  • prop.table(t1) gives fractions that sum to one; round(100 * prop.table(t1), 1) gives percentages. prop.table(t2, margin = 1) normalises each row to one, so the proportion of each genre by era is obtained directly. addmargins(t2) appends the Sum row and column.

  • The era factor has one NA (the book without a year), so the two-way table counts 15 books, not 16. useNA = "ifany" and "always" show the missing column, and exclude = NULL does the same for the decade factor.

  • as.data.frame(t1) returns the long form with the columns genre and Freq. Its stringsAsFactors argument still defaults to TRUE in R 4.6.1, so the genre column is a factor, as sapply(..., class) shows; stringsAsFactors = FALSE makes it character, which is the form that merges with other data frames, and responseName = "n" renames the count column.

For a table with a formula interface see xtabs() and ftable(), and for tests on tables see Descriptive Statistics and Tests.

Summaries by group: tapply(), split() and interaction()

tapply() applies a function to the groups of one vector defined by a factor. An Introduction to R presents it as the tool for ragged arrays, where the groups have different lengths (An Introduction to R: The function tapply() and ragged arrays). The signature is tapply(X, INDEX, FUN = NULL, ..., default = NA, simplify = TRUE), where INDEX is one factor or a list of factors of the length of X (other types are coerced by as.factor()). The result is an array with one dimension per factor, named by the levels, with NA (or default) in the cells with no data. If FUN does not always return a scalar, the result is a list with a dim attribute. default exists since R 3.4.0; before it was hard coded to NA. Extra arguments in ... are passed to FUN whole, not divided into cells (R documentation: tapply).

split() returns the groups themselves as a list named by the levels, and is the first step of the split-apply-combine pattern: vapply(split(x, f), FUN, numeric(1)) is tapply() with a named vector result and a guaranteed type. interaction() builds the factor of all combinations of the levels of several factors, with the levels of the first factor varying fastest; drop = TRUE removes the combinations that do not occur, and sep sets the separator (default .). aggregate() with a formula is the data frame form (R documentation: split and R documentation: aggregate).

books <- read.csv("books.csv")
books$genre <- tolower(trimws(books$genre))
genre <- factor(books$genre)

# tapply(): apply a function to the groups defined by a factor
tapply(books$pages, genre, mean)
#>         classic         fiction         mystery     non-fiction          poetry
#>           356.0              NA           272.5              NA            92.0
#> science fiction
#>           373.0
tapply(books$pages, genre, mean, na.rm = TRUE)
#>         classic         fiction         mystery     non-fiction          poetry
#>        356.0000        222.6667        272.5000        263.5000         92.0000
#> science fiction
#>        373.0000
tapply(books$pages, genre, length)
#>         classic         fiction         mystery     non-fiction          poetry
#>               2               4               2               3               2
#> science fiction
#>               3

# The result is an array with one dimension per factor, named by the levels
r <- tapply(books$pages, genre, max, na.rm = TRUE)
class(r)
#> [1] "array"
dim(r)
#> [1] 6
names(r)
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"

# Two factors give a matrix; empty cells are NA
era <- cut(
  books$year, c(1800, 1950, 2000, 2100),
  labels = c("before 1950", "1950-2000", "after 2000")
)
tapply(books$pages, list(genre, era), mean, na.rm = TRUE)
#>                 before 1950 1950-2000 after 2000
#> classic                 432       280         NA
#> fiction                  NA        NA   222.6667
#> mystery                  NA        NA   272.5000
#> non-fiction              NA        NA   263.5000
#> poetry                   NA        NA    88.0000
#> science fiction          NA       387   366.0000

# A function that returns more than one value gives a list-array
tapply(books$pages, genre, range, na.rm = TRUE)[1:2]
#> $classic
#> [1] 280 432
#>
#> $fiction
#> [1] 198 256
#>

# split() returns the groups themselves
by_genre <- split(books$title, genre)
names(by_genre)
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"
by_genre[["poetry"]]
#> [1] "Garden of Small Fires" "Paper Moons"
lengths(by_genre)
#>         classic         fiction         mystery     non-fiction          poetry
#>               2               4               2               3               2
#> science fiction
#>               3

# split() on a data frame, then vapply() over the pieces
pieces <- split(books, genre)
vapply(pieces, \(d) mean(d$pages, na.rm = TRUE), numeric(1))
#>         classic         fiction         mystery     non-fiction          poetry
#>        356.0000        222.6667        272.5000        263.5000         92.0000
#> science fiction
#>        373.0000

# interaction() is the factor of all combinations
ig <- interaction(genre, era)
head(levels(ig), 8)
#> [1] "classic.before 1950"         "fiction.before 1950"
#> [3] "mystery.before 1950"         "non-fiction.before 1950"
#> [5] "poetry.before 1950"          "science fiction.before 1950"
#> [7] "classic.1950-2000"           "fiction.1950-2000"
nlevels(ig)
#> [1] 18
table(interaction(genre, era, drop = TRUE))
#>
#>        classic.before 1950          classic.1950-2000
#>                          1                          1
#>  science fiction.1950-2000         fiction.after 2000
#>                          1                          4
#>         mystery.after 2000     non-fiction.after 2000
#>                          2                          3
#>          poetry.after 2000 science fiction.after 2000
#>                          1                          2
interaction(genre, era, drop = TRUE, sep = "/")[1:3]
#> [1] science fiction/1950-2000 classic/before 1950
#> [3] fiction/after 2000
#> 8 Levels: classic/before 1950 classic/1950-2000 ... science fiction/after 2000

# aggregate() is the data frame form, with a formula
aggregate(pages ~ genre, data = books, FUN = mean)
#>             genre    pages
#> 1         classic 356.0000
#> 2         fiction 222.6667
#> 3         mystery 272.5000
#> 4     non-fiction 263.5000
#> 5          poetry  92.0000
#> 6 science fiction 373.0000

The output answers the question of the shelf, the mean number of pages by genre: classic 356, science fiction 373 and poetry 92. Without na.rm = TRUE the result is NA for fiction and non-fiction, because each of them has a book with unknown pages; this is the usual missing-value rule, not a feature of tapply(). The two-factor call returns a genre-by-era matrix with NA for the combinations that do not exist, and the call with range() returns a list-array, shown by the first two elements. interaction(genre, era) has 18 levels (six genres times three eras) of which only eight occur, so drop = TRUE is the form to use before counting. The apply Family and Functionals covers vapply(), Map() and Reduce(), which are used the same way on the pieces, and Data Frames covers aggregate(), merge() and the column operations. The dplyr verbs group_by() and summarise() do the same job with a different syntax; they are the subject of The tidyverse and dplyr.

Dropping, reordering and merging levels

A factor keeps all its levels when it is subset. genre[genre %in% c("poetry", "mystery")] has four elements and six levels, four of them unused, and table() shows the zeros. droplevels() (a generic for factors and data frames) removes the unused levels, factor(f) does the same, and subsetting with drop = TRUE does it in one step. The unused levels are a feature when a table must have a row for every possible value and a nuisance everywhere else.

Changing the levels is the other frequent task, and R offers four tools: relevel() moves one level to the front (the reference level in a model; it applies to unordered factors only), factor(f, levels = ) reorders or removes any levels, levels<- renames them, and reorder() sorts the levels by a summary of another variable. The assignment form of levels<- has two features: when the value is a vector, the levels are renamed by position and the codes do not change; when two levels receive the same new label they are merged; and when the value is a list (the levels<-.factor method), each name of the list is a new level and its elements are the old levels that it replaces. An NA in the new value removes that level and sets its elements to NA. Duplicated levels in a factor are invalid: factor() refuses them (see Errors and warnings), R 3.4.0 ended a seven-year deprecation of them (R NEWS: Changes in R 3.4.0), and .valid.factor() reports a factor whose levels attribute was edited to contain duplicates. That is why the help page prefers levels(x) <- value to attr(x, "levels") <- value.

books <- read.csv("books.csv")
genre <- factor(tolower(trimws(books$genre)))

# Subsetting keeps unused levels; droplevels() removes them
few <- genre[genre %in% c("poetry", "mystery")]
few
#> [1] mystery poetry  poetry  mystery
#> Levels: classic fiction mystery non-fiction poetry science fiction
levels(few)
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"
table(few)
#> few
#>         classic         fiction         mystery     non-fiction          poetry
#>               0               0               2               0               2
#> science fiction
#>               0
droplevels(few)
#> [1] mystery poetry  poetry  mystery
#> Levels: mystery poetry
factor(few)
#> [1] mystery poetry  poetry  mystery
#> Levels: mystery poetry
genre[genre %in% c("poetry", "mystery"), drop = TRUE]
#> [1] mystery poetry  poetry  mystery
#> Levels: mystery poetry

# relevel() moves one level to the front (the reference level of a model)
levels(relevel(genre, ref = "poetry"))
#> [1] "poetry"          "classic"         "fiction"         "mystery"
#> [5] "non-fiction"     "science fiction"

# Reordering all levels: factor(f, levels = ...)
levels(factor(genre, levels = rev(levels(genre))))
#> [1] "science fiction" "poetry"          "non-fiction"     "mystery"
#> [5] "fiction"         "classic"
levels(factor(genre, levels = names(sort(table(genre), decreasing = TRUE))))
#> [1] "fiction"         "non-fiction"     "science fiction" "classic"
#> [5] "mystery"         "poetry"

# levels<- renames in place; the codes do not change
g <- genre
levels(g)[levels(g) == "non-fiction"] <- "nonfiction"
levels(g)
#> [1] "classic"         "fiction"         "mystery"         "nonfiction"
#> [5] "poetry"          "science fiction"

# An NA in the new value removes that level; its elements become NA
k <- factor(c("a", "b", "a"))
levels(k) <- c("x", NA)
k
#> [1] x    <NA> x
#> Levels: x

# Assigning duplicated labels merges levels
h <- genre
levels(h) <- c(
  "other", "fiction", "fiction", "other", "other", "science fiction"
)
h
#>  [1] science fiction other           fiction         fiction
#>  [5] fiction         other           other           other
#>  [9] other           fiction         other           science fiction
#> [13] other           science fiction fiction         fiction
#> Levels: other fiction science fiction
table(h)
#> h
#>           other         fiction science fiction
#>               7               6               3

# A list gives the same merge with readable names
h2 <- genre
levels(h2) <- list(
  fiction = c("fiction", "mystery"),
  other = c("classic", "non-fiction", "poetry"),
  sf = "science fiction"
)
table(h2)
#> h2
#> fiction   other      sf
#>       6       7       3

# reorder() sorts the levels by a summary of another variable
by_pages <- reorder(genre, books$pages, FUN = mean, na.rm = TRUE)
levels(by_pages)
#> [1] "poetry"          "fiction"         "non-fiction"     "mystery"
#> [5] "classic"         "science fiction"
round(attr(by_pages, "scores"), 1)
#>         classic         fiction         mystery     non-fiction          poetry
#>           356.0           222.7           272.5           263.5            92.0
#> science fiction
#>           373.0

The output shows that:

  • few still has six levels and four of them are empty; droplevels(few), factor(few) and genre[..., drop = TRUE] all give the two levels mystery and poetry.

  • relevel(genre, ref = "poetry") puts poetry first and keeps the other levels in their order; factor(genre, levels = rev(levels(genre))) reverses all of them; and names(sort(table(genre), decreasing = TRUE)) sorts the levels by frequency. This is what forcats::fct_infreq() does, see forcats in one paragraph.

  • Assigning to levels(g)[levels(g) == "non-fiction"] renames one level by position in the character vector that levels() returns, which is the idiom for a single rename. The codes are not changed, so every element that was non-fiction is now nonfiction.

  • The vector form merged classic, non-fiction and poetry into other and fiction with mystery into fiction; the list form gives the same merge with clearer code, with the names of the list as the new levels. Both tables count 6 fiction, 7 other and 3 science fiction books, and levels(k) <- c("x", NA) shows that an NA in the value removes a level.

  • reorder(genre, books$pages, FUN = mean, na.rm = TRUE) returns a factor with the same elements and levels sorted by the group mean, and stores the means in the "scores" attribute. It is what a bar chart sorted by value needs.

Combining, sorting and replacing

Most vector functions work on factors and keep the class. rev(), sort(), unique() and rep() return factors with the same levels; sort() and order() use the codes, so the order of the levels decides the order of the result, not the alphabet; summary(f) is the table of counts, with the missing values counted under NAs (since R 4.6.0 the default summary() and its factor method use that label, not NA's; R NEWS: Changes in R 4.6.0); levels(unique(f)) keeps all levels, including those that do not occur in unique(f).

Combining is different. Before R 4.1.0, c(f1, f2) returned the integer codes. Since R 4.1.0, using c() to combine a factor with other factors gives a factor, and an ordered factor when the arguments are ordered factors with identical levels (R NEWS: Changes in R 4.1.0), and the help page of c() says that there is a c.factor method which combines factors into a factor. The levels of the result are the union of the level sets in the order they occur, which makes the result of c() the same as unlist() on a list of factors (R documentation: c). The trap that remains is a mixture: if any argument is not a factor, the default method works on the codes.

books <- read.csv("books.csv")
genre <- factor(tolower(trimws(books$genre)))

# c() of factors combines the levels (R >= 4.1.0)
a <- factor(c("fiction", "poetry"))
b <- factor(c("mystery", "fiction"))
c(a, b)
#> [1] fiction poetry  mystery fiction
#> Levels: fiction poetry mystery
levels(c(a, b))
#> [1] "fiction" "poetry"  "mystery"

# c() of a factor and a character vector is not a factor
c(a, "classic")
#> [1] "1"       "2"       "classic"
c(a, 1L)
#> [1] 1 2 1

# Ordered factors with the same levels stay ordered
o <- ordered(c("low", "high"), levels = c("low", "high"))
c(o, o)
#> [1] low  high low  high
#> Levels: low < high
class(c(o, ordered(c("high"), levels = c("low", "high"))))
#> [1] "ordered" "factor"

# rev(), sort(), order(), unique() and rep() keep the factor class and levels
rev(genre)[1:3]
#> [1] mystery         fiction         science fiction
#> Levels: classic fiction mystery non-fiction poetry science fiction
sort(genre)[1:4]
#> [1] classic classic fiction fiction
#> Levels: classic fiction mystery non-fiction poetry science fiction
unique(genre)
#> [1] science fiction classic         fiction         mystery
#> [5] non-fiction     poetry
#> Levels: classic fiction mystery non-fiction poetry science fiction
levels(unique(genre))
#> [1] "classic"         "fiction"         "mystery"         "non-fiction"
#> [5] "poetry"          "science fiction"
rep(genre[1:2], times = 2)
#> [1] science fiction classic         science fiction classic
#> Levels: classic fiction mystery non-fiction poetry science fiction

# order() and sort() use the codes, so the levels decide the order
head(books$title[order(genre)], 4)
#> [1] "Pride and Prejudice"  "The Lamp at Noon"     "  the quiet streets "
#> [4] "Loud Roofs"

# Assigning a value that is not a level gives NA, so add the level first
g <- genre[1:3]
g[2] <- "poetry"
g
#> [1] science fiction poetry          fiction
#> Levels: classic fiction mystery non-fiction poetry science fiction
levels(g) <- c(levels(g), "cookery")
g[2] <- "cookery"
g
#> [1] science fiction cookery         fiction
#> 7 Levels: classic fiction mystery non-fiction poetry ... cookery

# summary() of a factor is the table of counts
summary(genre)
#>         classic         fiction         mystery     non-fiction          poetry
#>               2               4               2               3               2
#> science fiction
#>               3
summary(addNA(factor(books$year %/% 10L * 10L)))
#> 1810 1970 1990 2000 2010 2020 <NA>
#>    1    1    1    2    6    4    1

c(a, b) has the levels fiction poetry mystery, the union in order, and its elements are labels. c(a, "classic"), on the other hand, returns the character vector "1" "2" "classic": the codes of the factor followed by the string, and not the labels, so a program that mixes the two silently stores the integer codes. The safe form is c(as.character(a), "classic"), or adding a level first with levels(a) <- c(levels(a), "classic"). Replacement has the same rule: g[2] <- "cookery" gives NA and the warning invalid factor level, NA generated unless "cookery" is already a level, so the script adds it first.

Factors to numbers

Numbers that arrive as a factor are the best known trap of the language. It happens when a column of numbers is read as text because of one stray value (a note, an n/a) or when a script from the era of stringsAsFactors = TRUE still runs. The factor holds the labels "10", "5", "20" as levels and the integer codes 1, 3, 2; as.numeric(f) or as.integer(f) returns the codes, which look like numbers and are plausible, and the program goes on with wrong data.

The R FAQ answers the question "How do I convert factors to numeric?" (R FAQ 7.10: How do I convert factors to numeric?): use as.numeric(as.character(f)), or, "more efficient, but harder to remember", as.numeric(levels(f))[as.integer(f)], and in any case do not call as.numeric() or its likes directly, since as.numeric() and unclass() give the internal codes. The help page of factor() agrees, recommending as.numeric(levels(f))[f] as slightly more efficient than as.numeric(as.character(f)) (R documentation: factor).

# Numbers that arrived as a factor, as from read.csv(stringsAsFactors = TRUE)
f <- factor(c("10", "5", "20", "5"))
levels(f)
#> [1] "10" "20" "5"
as.integer(f)
#> [1] 1 3 2 3

# The trap: as.numeric() returns the codes, not the numbers
as.numeric(f)
#> [1] 1 3 2 3

# Two correct conversions
as.numeric(as.character(f))
#> [1] 10  5 20  5
as.numeric(levels(f))[f]
#> [1] 10  5 20  5
identical(as.numeric(as.character(f)), as.numeric(levels(f))[f])
#> [1] TRUE

# The same trap with unclass() and with arithmetic on codes
unclass(f)
#> [1] 1 3 2 3
#> attr(,"levels")
#> [1] "10" "20" "5"
sum(as.numeric(f))
#> [1] 9
sum(as.numeric(as.character(f)))
#> [1] 40

# Values that are not numbers become NA, with a warning
bad <- factor(c("12", "n/a", "7"))
suppressWarnings(as.numeric(as.character(bad)))
#> [1] 12 NA  7
msg <- tryCatch(as.numeric(as.character(bad)), warning = conditionMessage)
msg
#> [1] "NAs introduced by coercion"

# A year column read as a factor
yr <- factor(c(1974, 1813, 1999))
as.numeric(yr)
#> [1] 2 1 3
as.integer(as.character(yr))
#> [1] 1974 1813 1999

The levels are sorted as numbers when the factor is made from numbers, but as strings when it is made from strings: levels(f) is "10" "20" "5", so the codes 1 3 2 3 bear no relation to the values 10 5 20 5, and sum(as.numeric(f)) is 9 instead of 40. Both correct conversions give the right vector. If a level is not a number, the conversion gives NA with the warning NAs introduced by coercion (shown by tryCatch() in the script) and the offending levels are easy to find with levels(f)[is.na(as.numeric(levels(f)))]. The same applies to years: as.numeric(yr) gives the codes 2 1 3 and as.integer(as.character(yr)) gives the years. The permanent fix is not to create the factor: read the column as numeric with colClasses, or as character and convert it explicitly (see Reading and Writing Text Data).

Factors in data frames and the stringsAsFactors history

Before R 4.0.0 a character column was converted to a factor whenever a data frame was created by data.frame(), read.table() or read.csv(), unless stringsAsFactors = FALSE or I() was used or options(stringsAsFactors = FALSE) was set. That default is the origin of most of the factor surprises in older tutorials: columns that looked like text were factors, ifelse() and c() returned codes, df$name == "x" worked but df$name[1] <- "new" produced NA with a warning, and numbers read with a stray character became factors. R 4.0.0 changed the default. The NEWS says that R "now uses a stringsAsFactors = FALSE default" and no longer converts strings to factors in calls to data.frame() and read.table(), and that a large number of packages relied on the previous behaviour (R NEWS: Changes in R 4.0.0). The help page of data.frame() repeats it: the factory-fresh default had been TRUE and has been changed to FALSE for R 4.0.0 (R documentation: data.frame).

The related changes are: default.stringsAsFactors() was formally deprecated in R 4.2.0, with a warning when called, and defunct in R 4.3.0 (R NEWS: Changes in R 4.3.0); type.convert() warns when its as.is argument is not given, and its default is TRUE, in line with the change in read.table() (R 4.1.0); and in R 4.6.1 options(stringsAsFactors = TRUE) gives the warning 'options(stringsAsFactors = TRUE)' is deprecated and will be disabled and has no effect on data.frame(). The warning appeared only on the first such call in the session that was tested, so a script cannot rely on seeing it. Do not set the option: a package that needs factors should say so with stringsAsFactors = TRUE or with an explicit factor() call.

books <- read.csv("books.csv")

# Since R 4.0.0 character data stay character
str(books$genre)
#>  chr [1:16] "Science fiction" "Classic" "Fiction" "fiction" "Mystery" ...
str(data.frame(a = c("x", "y")))
#> 'data.frame':	2 obs. of  1 variable:
#>  $ a: chr  "x" "y"
class(as.data.frame(list(a = c("x", "y")))$a)
#> [1] "character"

# The old behaviour is one argument away
str(data.frame(a = c("x", "y"), stringsAsFactors = TRUE))
#> 'data.frame':	2 obs. of  1 variable:
#>  $ a: Factor w/ 2 levels "x","y": 1 2
str(read.csv("books.csv", stringsAsFactors = TRUE)$genre)
#>  Factor w/ 9 levels "Classic","fiction",..: 9 1 3 2 5 6 7 1 6 4 ...

# Only the columns you choose become factors
books$genre <- factor(tolower(trimws(books$genre)))
str(books[c("title", "genre", "pages")])
#> 'data.frame':	16 obs. of  3 variables:
#>  $ title: chr  "The Dispossessed" "Pride and Prejudice" "  the quiet streets " "Loud Roofs" ...
#>  $ genre: Factor w/ 6 levels "classic","fiction",..: 6 1 2 2 3 4 5 1 4 2 ...
#>  $ pages: int  387 432 214 198 301 NA 96 280 352 256 ...
vapply(books, class, "")
#>       title      author        isbn        year       genre       pages
#> "character" "character" "character"   "integer"    "factor"   "integer"

# options(stringsAsFactors = TRUE) is deprecated and ignored by data.frame()
msg <- tryCatch(options(stringsAsFactors = TRUE), warning = conditionMessage)
msg
#> [1] "'options(stringsAsFactors = TRUE)' is deprecated and will be disabled"
str(data.frame(a = c("x", "y")))
#> 'data.frame':	2 obs. of  1 variable:
#>  $ a: chr  "x" "y"
invisible(options(stringsAsFactors = NULL))

# default.stringsAsFactors() was removed in R 4.3.0
tryCatch(default.stringsAsFactors(), error = conditionMessage)
#> [1] "'default.stringsAsFactors' is defunct.\nUse '`stringsAsFactors = FALSE`' instead.\nSee help(\"Defunct\")"

The first lines show the current default: books$genre is chr and data.frame(a = c("x", "y")) has a character column. stringsAsFactors = TRUE restores the old behaviour for one call, in data.frame() as in read.csv(), and the nine levels of the raw shelf genre appear again. The recommended pattern is the middle of the script: read the data as it is, clean the column that is categorical, and convert exactly those columns with factor(); sapply(books, class) shows the result, with one factor column among character and integer columns. The last two lines of the script show the deprecated option (a warning in 4.6.1 and no effect on the data frame) and the removed function (an error whose text says that default.stringsAsFactors is defunct and points to stringsAsFactors = FALSE).

Two older details help when reading code from the R 3 era. R 3.0.0 made model.frame(), model.matrix() and replications() convert character vectors to factors automatically, without a warning, to support options(stringsAsFactors = FALSE), and switch(f, ...) has warned since R 3.1.0 when f is a factor, because it is treated as an integer (R NEWS: Changes in R 3.1.0). Models still convert character predictors to factors internally, so a character column is fine in lm(); what it loses is a chosen reference level, which is why the levels of a model factor are set with factor() or relevel() beforehand.

A second example: loans per member

The loans table has the same kind of problem in member: ten distinct strings for eight people (one name written with different case and a trailing blank) and an empty string. The pipeline is the same as for the genres: normalise, turn the empty string into NA, make the factor, and count.

loans <- read.csv("loans.csv", colClasses = "character")

# Ten distinct strings: eight people (one spelled twice) and an empty string
length(unique(loans$member))
#> [1] 10
sort(unique(loans$member))
#>  [1] ""              "Ana Ruiz"      "ana ruiz "     "Ben Okafor"
#>  [5] "Chloe Martin"  "Dev Patel"     "Eva Lindqvist" "Farid Karimi"
#>  [9] "Grace Liu"     "Hugo Silva"

# Normalise, turn empty strings into NA, then make the factor
member <- tolower(trimws(loans$member))
member[member == ""] <- NA
member <- factor(member)
nlevels(member)
#> [1] 8
levels(member)
#> [1] "ana ruiz"      "ben okafor"    "chloe martin"  "dev patel"
#> [5] "eva lindqvist" "farid karimi"  "grace liu"     "hugo silva"

# Levels in order of first appearance instead of alphabetical order
member_seen <- factor(member, levels = unique(member[!is.na(member)]))
levels(member_seen)
#> [1] "hugo silva"    "ben okafor"    "dev patel"     "eva lindqvist"
#> [5] "grace liu"     "ana ruiz"      "chloe martin"  "farid karimi"

# Loans per member, most active first; the NA is counted on request
counts <- table(member, useNA = "ifany")
sort(counts, decreasing = TRUE)
#> member
#>      ana ruiz    hugo silva  chloe martin     grace liu     dev patel
#>            14            10             9             7             6
#> eva lindqvist    ben okafor  farid karimi          <NA>
#>             6             5             2             1

# A factor in a data frame: summary() counts the levels
loans$member <- member
summary(loans$member)
#>      ana ruiz    ben okafor  chloe martin     dev patel eva lindqvist
#>            14             5             9             6             6
#>  farid karimi     grace liu    hugo silva           NAs
#>             2             7            10             1
str(loans[c("loan_id", "member")])
#> 'data.frame':	60 obs. of  2 variables:
#>  $ loan_id: chr  "L001" "L002" "L003" "L004" ...
#>  $ member : Factor w/ 8 levels "ana ruiz","ben okafor",..: 8 2 4 5 2 8 7 1 8 8 ...

The factor has eight levels and one NA (the loan with no member); table(member, useNA = "ifany") shows it as the last column and sort(counts, decreasing = TRUE) orders the people by number of loans, so ana ruiz leads with 14. The summary() of the column labels the missing value NAs, and str() of a data frame shows the factor as Factor w/ 8 levels followed by the first levels and the codes. Giving levels = unique(member[!is.na(member)]) keeps the order of first appearance. For more on dates in the loan table see Dates and Times.

Errors and warnings

Factors have their own vocabulary of messages. The script collects the ones that a program meets most often; each call is wrapped in a function that returns the message as text, with the prefix warning: or error:. The curly quotes are those of an English UTF-8 locale.

# Each call is wrapped so that the message R produces is shown as text
msg <- function(expr) {
  tryCatch(
    {
      expr
      "no condition"
    },
    warning = \(w) paste("warning:", conditionMessage(w)),
    error = \(e) paste("error:", conditionMessage(e))
  )
}

f <- factor(c("short", "long", "short"), levels = c("short", "long"))
g <- factor(c("a", "b", "a"))

# Arithmetic and summaries are not meaningful for unordered factors
msg(f + 1)
#> [1] "warning: ‘+’ not meaningful for factors"
msg(sum(f))
#> [1] "error: ‘sum’ not meaningful for factors"
msg(max(f))
#> [1] "error: ‘max’ not meaningful for factors"
msg(mean(f))
#> [1] "warning: argument is not numeric or logical: returning NA"
msg(f < "long")
#> [1] "warning: ‘<’ not meaningful for factors"

# A value outside the levels becomes NA
h <- f
msg(h[2] <- "huge")
#> [1] "warning: invalid factor level, NA generated"

# Different level sets cannot be compared, and factor() needs unique levels
msg(f == g)
#> [1] "error: level sets of factors are different"
msg(factor(c("a", "b"), levels = c("a", "a")))
#> [1] "error: factor level [2] is duplicated"

# switch() on a factor uses the integer codes
msg(switch(f[1], short = "S", long = "L"))
#> [1] "warning: EXPR is a \"factor\", treated as integer.\n Consider using 'switch(as.character( * ), ...)' instead."
switch(as.character(f[1]), short = "S", long = "L")
#> [1] "S"

# relevel() and cut() reject invalid arguments
msg(relevel(f, ref = "tall"))
#> [1] "error: 'ref' must be an existing level"
msg(relevel(ordered(f), ref = "long"))
#> [1] "error: 'relevel' only for unordered factors"
msg(cut(1:5, breaks = c(1, 1, 5)))
#> [1] "error: 'breaks' are not unique"
msg(cut(1:5, breaks = 1))
#> [1] "error: invalid number of intervals"
msg(cut(1:5, breaks = c(1, 3, 5), labels = c("a", "b", "c")))
#> [1] "error: number of intervals and length of 'labels' differ"

# table() and tapply() need arguments of equal length
msg(table(f, g[1:2]))
#> [1] "error: all arguments must have the same length"
msg(tapply(1:3, g[1:2], sum))
#> [1] "error: arguments must have same length"

# ifelse() drops the class and returns the integer codes
ifelse(c(TRUE, FALSE), f[1:2], "none")
#> [1] "1"    "none"
Message Cause Fix

‘+’ not meaningful for factors (warning, result NA)

arithmetic on a factor; the same for -, *, <, > and the other Ops

convert with as.character() or, for numbers, as in Factors to numbers; use an ordered factor for <

‘sum’ not meaningful for factors (error), likewise max

Summary group on an unordered factor (min, max and range work on ordered factors)

use table() for counts, or make the factor ordered

argument is not numeric or logical: returning NA (warning)

mean() of a factor

mean(as.numeric(as.character(f))) when the levels are numbers

invalid factor level, NA generated (warning)

assigning a value that is not a level

add the level first with levels(f) <- c(levels(f), "new")

level sets of factors are different (error)

== between factors with different level sets

compare as.character(f) with as.character(g), or give both the same levels

factor level [2] is duplicated (error)

levels with a repeated value in factor()

give unique levels; merge values with duplicated labels instead

EXPR is a "factor", treated as integer. (warning)

switch() on a factor

switch(as.character(f), ...)

'ref' must be an existing level, 'relevel' only for unordered factors (errors)

relevel() with a level that is not there, or on an ordered factor

use factor(f, levels = ) for ordered factors

'breaks' are not unique, invalid number of intervals, number of intervals and length of 'labels' differ (errors)

cut() with repeated breaks, breaks = 1, or a wrong number of labels

unique() the breaks; length(labels) is length(breaks) - 1

all arguments must have the same length, arguments must have same length (errors)

table() or tapply() with vectors of different length

check the lengths; the usual cause is a filtered column

The last line of the script is the silent one: ifelse(c(TRUE, FALSE), f[1:2], "none") returns "1" and "none", because ifelse() takes the attributes from test and uses the codes of yes. There is no message. It is the reason why a factor is converted with as.character() before it goes in an ifelse(), a paste() or a c() with strings. A comparison with a string, such as f == "short", is fine, because the factor method compares labels.

forcats in one paragraph

The forcats package (version 1.0.1 was used here; it belongs to the tidyverse and is community material, not part of R) collects the factor tasks of this page under names that start with fct_ and keep the factor’s order explicit, and its functions return factors without warnings for the common mistakes. The five that answer the tasks above are fct_relevel() (move levels by name, relevel() for several at once), fct_infreq() (levels by decreasing frequency), fct_lump_n() and the other fct_lump_*() functions (collapse the rare levels into "Other"; fct_lump() is the older, superseded form), fct_recode() and fct_collapse() (rename and merge) and fct_reorder() (levels sorted by a summary of another variable). The script shows the equivalents; forcats and base R give the same result for relevel, infrequency order, reverse and reorder, and differ in one detail for lumping: fct_lump_n(genre, n = 2) keeps the three levels that tie for the second place (ties.method = "min" by default), so it needs ties.method = "first" to match the base R version.

library(forcats)

books <- read.csv("books.csv")
genre <- factor(tolower(trimws(books$genre)))

# Base R and forcats side by side: the same result, a different spelling
levels(fct_relevel(genre, "poetry"))
#> [1] "poetry"          "classic"         "fiction"         "mystery"
#> [5] "non-fiction"     "science fiction"
levels(relevel(genre, ref = "poetry"))
#> [1] "poetry"          "classic"         "fiction"         "mystery"
#> [5] "non-fiction"     "science fiction"

levels(fct_infreq(genre))
#> [1] "fiction"         "non-fiction"     "science fiction" "classic"
#> [5] "mystery"         "poetry"
levels(factor(genre, levels = names(sort(table(genre), decreasing = TRUE))))
#> [1] "fiction"         "non-fiction"     "science fiction" "classic"
#> [5] "mystery"         "poetry"

levels(fct_rev(genre))
#> [1] "science fiction" "poetry"          "non-fiction"     "mystery"
#> [5] "fiction"         "classic"
levels(factor(genre, levels = rev(levels(genre))))
#> [1] "science fiction" "poetry"          "non-fiction"     "mystery"
#> [5] "fiction"         "classic"

fct_recode(genre, "sci-fi" = "science fiction")[1:3]
#> [1] sci-fi  classic fiction
#> Levels: classic fiction mystery non-fiction poetry sci-fi
fct_collapse(genre, other = c("classic", "poetry", "non-fiction"))[1:6]
#> [1] science fiction other           fiction         fiction
#> [5] mystery         other
#> Levels: other fiction mystery science fiction

# Lumping the rare levels into "Other": ties are kept by default in forcats
table(fct_lump_n(genre, n = 2))
#>
#>         fiction     non-fiction science fiction           Other
#>               4               3               3               6
table(fct_lump_n(genre, n = 2, ties.method = "first"))
#>
#>     fiction non-fiction       Other
#>           4           3           9

keep <- names(sort(table(genre), decreasing = TRUE))[1:2]
lumped <- genre
levels(lumped)[!levels(lumped) %in% keep] <- "Other"
table(lumped)
#> lumped
#>       Other     fiction non-fiction
#>           9           4           3

# Reordering levels by a summary of another variable
levels(
  fct_reorder(genre, books$pages, .fun = mean, na.rm = TRUE, .na_rm = TRUE)
)
#> [1] "poetry"          "fiction"         "non-fiction"     "mystery"
#> [5] "classic"         "science fiction"
forcats Base R Notes

fct_relevel(f, "a")

relevel(f, ref = "a") or factor(f, levels = c("a", setdiff(levels(f), "a")))

relevel() takes one level; fct_relevel() takes several and a function

fct_infreq(f)

factor(f, levels = names(sort(table(f), decreasing = TRUE)))

fct_inorder() is factor(f, levels = unique(f))

fct_rev(f)

factor(f, levels = rev(levels(f)))

fct_lump_n(f, n = 2)

levels(f)[!levels(f) %in% keep] <- "Other"

ties are kept by forcats unless ties.method says otherwise

fct_recode(f, new = "old"), fct_collapse(f, new = c("a", "b"))

levels(f)[levels(f) == "old"] <- "new", or levels(f) <- list(new = c("a", "b"), ...)

fct_recode() warns about old names that are not levels (Unknown levels in f); base R does not

fct_reorder(f, x, .fun = mean)

reorder(f, x, FUN = mean)

fct_reorder() warns that it removes missing values of x unless .na_rm is given

The functions are documented at forcats package documentation: fct_recode, fct_reorder and fct_infreq. See The tidyverse and dplyr for the package family; the base R spelling is enough for most scripts and has no dependency.

Where older tutorials differ

Many tutorials predate the changes described above. When one of them disagrees with this page, the table says why.

Older tutorial Current practice Why Version

character columns of data.frame() and read.csv() are factors, so stringsAsFactors = FALSE appears everywhere

read the data as characters and make factors for the columns that are categorical

the default changed

R 4.0.0

options(stringsAsFactors = FALSE) at the top of a script

remove it; use stringsAsFactors = TRUE only where factors are wanted

options(stringsAsFactors = TRUE) is deprecated and ignored by data.frame()

R 4.0.0 default, warning in R 4.6.1

default.stringsAsFactors() in package code

the explicit argument

deprecated in R 4.2.0 and defunct since R 4.3.0

R 4.2.0, R 4.3.0

c(f1, f2) returns integer codes, so use unlist(list(f1, f2)) or factor(c(as.character(f1), as.character(f2)))

c(f1, f2) returns a factor with the union of the levels

c.factor method

R 4.1.0

as.numeric(f) to recover numbers

as.numeric(as.character(f)) or as.numeric(levels(f))[f]

as.numeric() returns the integer codes

always (R FAQ 7.10)

duplicated labels in factor() are not allowed

duplicated labels merge values into one level; duplicated levels are still an error

factor(x, levels, labels) allows duplicated labels (not levels)

R 3.5.0

switch(f, ...) on a factor

switch(as.character(f), ...)

the factor is used as its integer codes

warns since R 3.1.0

cut(year, c(1800, 1950, 2000)) labelled with 1.95e+03

dig.lab = 4 or explicit labels

default dig.lab = 3

always

table(f) and NA ignored silently

table(f, useNA = "ifany")

NA is not a level

always

factor() or table() on text with mixed case and blanks

normalise with trimws() and tolower() first

each spelling is a separate level

always

prop.table() only

proportions() and marginSums() as the preferred names

prop.table() and margin.table() remain available

R 4.0.0

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

Where to go next

References