Historical Context & Motivation
Statistical computing has long required a way to represent categorical variables — data that takes on a finite, often unordered set of values such as treatment groups, geographic regions, or Likert-scale responses. In early statistical software like SAS and SPSS, categorical data was typically encoded as integer codes with separate label metadata, an approach that was memory-efficient but error-prone when labels drifted out of sync with their underlying codes. When Ross Ihaka and Robert Gentleman designed the S language's successor at the University of Auckland in the early 1990s, they inherited the factor abstraction from S, recognizing that a first-class categorical type would let modeling functions like lm() and glm() automatically generate the correct dummy-variable contrasts without manual intervention from the analyst.
stringsAsFactors = TRUE as the default in data.frame(), reflecting the assumption that character columns are almost always categorical.stringsAsFactors = FALSE, ending two decades of debate and signaling that factors should be an intentional choice rather than a silent default.The central question this lesson addresses is deceptively simple: how does R internally represent categorical data, and what happens — semantically and computationally — when you convert between the factor and character types? Misunderstanding this conversion is one of the most common sources of subtle bugs in R code, particularly when numeric-looking factor levels are coerced to integers instead of their string representations.
Core Principles & Definitions
A factor in R is an integer vector augmented with two attributes: a levels attribute containing a character vector of the unique category labels, and a class attribute set to "factor". Each element of the integer vector is an index into the levels vector, so the factor stores compact integer codes while exposing human-readable labels. This dual representation is what makes factors both powerful and occasionally confusing: the printed output shows labels, but the underlying storage is integers.
Levels
levels() attribute is a character vector containing every unique category in a defined order. By default, levels are sorted alphabetically, but you can specify a custom order to control plotting and modeling behavior.Integer Encoding
nlevels(x). Calling as.integer() on a factor returns these codes, not the labels — a frequent source of bugs.Ordered vs. Unordered
ordered = TRUE creates an ordinal factor where comparisons like < and > are meaningful. Modeling functions use polynomial contrasts for ordered factors and treatment contrasts for unordered ones.Factor ↔ Character Conversion
as.character(f) to extract labels and factor(x) or as.factor(x) to encode a character vector as a factor. The key invariant: as.character(factor(x)) should always return the original x.The Numeric Trap
as.numeric(f) returns the internal codes, not the numeric values of the labels. The safe idiom is as.numeric(as.character(f)).as.character() — the language does not do this implicitly in all contexts.Visual Explanation — Factor Internal Structure
f stores the vector c("low", "high", "med", "low", "high", "high"). The levels attribute holds three unique strings sorted alphabetically: "high" (index 1), "low" (index 2), "med" (index 3). Each element in the integer storage is a pointer to its corresponding level. The dashed arrows illustrate the mapping from integer codes back to human-readable labels.The critical insight from this diagram is that typeof(f) returns "integer", not "character". The levels attribute is what bridges the gap between the compact integer representation and the string labels you see when you print the factor. When R displays a factor, it automatically dereferences each integer through the levels vector — but functions like as.numeric() bypass this display logic and expose the raw integer codes. This is precisely why the conversion path matters: going from factor to character requires explicit invocation of as.character() to retrieve the label, not the code.
How Factor ↔ Character Conversion Works
Factor Construction
When you call factor(x, levels, labels, ordered), R performs the following steps internally. First, if levels is not supplied, R computes sort(unique(x)) to determine the set of allowed categories. Second, each element of x is matched against the levels vector using match(x, levels), producing an integer vector of positions. Elements of x that do not appear in levels are mapped to NA. Finally, the resulting integer vector is decorated with the class and levels attributes.
x is a character (or coercible) vector, match() returns integer indices, and structure() attaches attributes to the integer vector.Factor → Character
The function as.character(f) performs a simple lookup: for each integer code i in the underlying vector, it returns levels(f)[i]. This is semantically equivalent to levels(f)[as.integer(f)]. The result is a plain character vector with no levels attribute — the categorical semantics are discarded.
unclass(f) strips the factor class to expose the raw integer vector, and the bracket indexing performs vectorized lookup into the levels.Character → Factor
Both factor(x) and as.factor(x) convert a character vector to a factor, but they differ in a subtle way. The function as.factor(x) first checks whether x is already a factor; if so, it returns x unchanged. The function factor(x) always reconstructs the factor from scratch, which can drop unused levels from a previously subsetted factor. This distinction matters in data pipelines where you might re-factor a vector to clean up stale levels after filtering.
as.numeric(as.character(f)) or equivalently as.numeric(levels(f))[f]. The second form is more efficient for large vectors because it converts only nlevels(f) strings instead of length(f).Conversion Paths — A Complete Map
character, factor, and numeric types. The red arrow from factor to numeric is flagged with a warning because as.numeric() on a factor returns internal codes, not label values. The green box shows the safe two-step pattern for extracting numeric values from factors whose levels happen to be numeric strings.| Conversion | Function | Result Type | Pitfall |
|---|---|---|---|
| character → factor | factor(x) or as.factor(x) | factor (integer + levels) | Default alphabetical level order may not match desired order |
| factor → character | as.character(f) | character | None — this is always safe |
| factor → numeric (codes) | as.integer(f) or as.numeric(f) | integer / double | Returns internal codes, not label values |
| factor → numeric (labels) | as.numeric(as.character(f)) | double | Fails with NA if levels are non-numeric strings |
| factor → factor (re-level) | factor(f, levels = ...) | factor (rebuilt) | Values not in new levels become NA |
Worked Example — Survey Data Pipeline
Consider a scenario where you have loaded a CSV file containing survey responses. One column encodes satisfaction ratings as strings: c("Very Satisfied", "Neutral", "Dissatisfied", "Satisfied", "Neutral"). You need to convert this character vector to an ordered factor with a semantically meaningful level order, use it in analysis, and later convert it back to character for export to a JSON API. Along the way, you also have a column of year values stored as factor levels ("2019", "2020", "2021") that you need as numeric.
str(df$satisfaction). It reports chr [1:5] "Very Satisfied" "Neutral" "Dissatisfied" "Satisfied" "Neutral". Confirm it is character type using is.character(df$satisfaction) which returns TRUE.df$satisfaction <- factor(df$satisfaction, levels = c("Dissatisfied", "Neutral", "Satisfied", "Very Satisfied"), ordered = TRUE). Now levels(df$satisfaction) returns the levels in the specified order, and comparisons like df$satisfaction[1] > df$satisfaction[2] evaluate to TRUE because "Very Satisfied" > "Neutral" in the ordering.as.integer(df$satisfaction) returns c(4, 2, 1, 3, 2). The integer 4 maps to "Very Satisfied" (the 4th level), 2 maps to "Neutral" (the 2nd level), and so on. Note how the codes reflect the custom ordering, not the alphabetical default.c(4, 2, 1, 3, 2)as.character(df$satisfaction) returns c("Very Satisfied", "Neutral", "Dissatisfied", "Satisfied", "Neutral") — the original labels are perfectly recovered. The ordering information is lost, which is expected since JSON has no native factor type.df$year is a factor with levels "2019", "2020", "2021". Naively calling as.numeric(df$year) would yield c(1, 2, 3) — the internal codes. Instead, use the safe idiom: as.numeric(as.character(df$year)) which correctly returns c(2019, 2020, 2021).Factors vs. Characters — Strengths & Limitations
Choosing between storing categorical data as factors versus plain character vectors is not merely a stylistic decision — it has real consequences for memory consumption, modeling behavior, and data integrity. The following comparison highlights when each representation is preferable and where each falls short.
| Criterion | Factor | Character |
|---|---|---|
| Memory | Compact for high-cardinality repeated values — stores one copy of each level string plus integer codes | R's global string pool (CHARSXP cache) deduplicates identical strings, so modern R character vectors are also compact |
| Modeling | Model functions automatically generate dummy variables and correct contrasts; ordered factors get polynomial contrasts | Must be manually converted or wrapped in factor() inside formulas; no ordering semantics |
| Level Validation | Enforces a fixed set of allowed values — assigning an unlisted value produces NA with a warning, preventing silent data corruption | Accepts any string without validation, which is flexible but allows typos and inconsistencies |
| Sort / Plot Order | Level order controls bar chart ordering, legend order, and table arrangement in predictable ways | Defaults to alphabetical; custom ordering requires ad-hoc reordering at each call site |
| String Operations | Most string functions (grep, gsub, paste) silently coerce factors to character — but this can produce unexpected results if you forget | All string operations work natively without coercion |
| Merging / Binding | Combining factors with different level sets can produce NA or mismatched codes — requires careful harmonization | Character vectors concatenate seamlessly with c() or rbind() |
Connection to Advanced Theory — forcats & Tidyverse Integration
While base R provides the essential factor(), levels(), and relevel() functions, real-world data wrangling frequently demands more expressive factor operations: reordering levels by a summary statistic, collapsing rare categories into an "Other" group, or recoding specific levels. The forcats package — whose name is an anagram of "factors" — was designed to fill these gaps with a consistent, pipe-friendly API that integrates seamlessly with dplyr and ggplot2.
| Base R Approach | forcats Equivalent | Purpose |
|---|---|---|
factor(x, levels = custom_order) | fct_relevel(x, custom_order) | Manually set level order |
| Manual tapply + reorder logic | fct_reorder(x, y, .fun = median) | Reorder levels by a summary of another variable |
| Nested ifelse + factor reconstruction | fct_collapse(x, group = c("a", "b")) | Merge multiple levels into one |
| Table + subsetting + relevel | fct_lump_n(x, n = 5) | Keep top n levels, lump rest into "Other" |
factor(x, levels = rev(levels(x))) | fct_rev(x) | Reverse level order (useful for horizontal bar charts) |
factor(as.character(x)) after subsetting | fct_drop(x) | Remove unused levels |
Beyond the tidyverse, factor-character conversion becomes critical when interfacing R with other systems. When writing data to databases via DBI or to Parquet files via arrow, factors are often converted to their character representation for interoperability, since most external systems lack a native factor type. Conversely, when reading categorical columns from Apache Arrow or database enums back into R, the arrow and DBI packages can reconstruct factors from the metadata. Understanding the factor-character duality is therefore essential not just for in-memory R analysis but for the entire modern data engineering stack.
vctrs package, which provides a rigorous type system for vectors that treats factors as a first-class case. The vctrs::vec_ptype2() and vctrs::vec_cast() functions formalize the coercion rules between factors and characters, making type-safe data pipelines possible.Practice Problems
typeof(factor(c("a", "b", "c"))) returns "integer" rather than "character". What does this reveal about the internal representation of factors in R, and why is this design choice useful?f <- factor(c("banana", "apple", "cherry", "apple")), predict the output of each of the following: (a) levels(f), (b) as.integer(f), (c) as.character(f), and (d) nlevels(f).temps <- factor(c("72", "68", "75", "68", "80")) where the levels represent temperature readings in Fahrenheit. Write two different correct R expressions to convert temps to a numeric vector containing the actual temperature values. Explain why as.numeric(temps) alone would give the wrong answer, and describe which of your two approaches is more computationally efficient for a vector with 10 million elements but only 50 unique levels.treatment_arm column contains values "Placebo", "Low Dose", and "High Dose". You need this column as an ordered factor for a proportional odds regression model, with "Placebo" as the reference level. After fitting the model, you need to export the data to a JSON file for a web dashboard. Write the complete sequence of R operations, explain each step's purpose, and identify where factor-character conversion occurs.f1 <- factor(c("A", "B", "C"))
f2 <- factor(c("B", "C", "D"))
combined <- c(f1, f2)
Predict what combined contains and what class it has. Then propose and compare two strategies for correctly combining these factors into a single factor that preserves all labels. Discuss how the forcats::fct_c() function solves this problem, and analyze what happens under the hood in terms of level harmonization and integer re-encoding.Lesson Summary
R's factor type represents categorical data as an integer vector paired with a levels attribute containing the unique category labels. This dual representation enables compact storage, automatic dummy-variable generation in modeling functions, and principled ordering of categories in plots and tables. Converting from factor to character with as.character() performs a safe lookup from integer codes to level labels, while converting from character to factor with factor() encodes strings into the integer-plus-levels representation.
The most critical pitfall is the numeric trap: calling as.numeric() on a factor returns internal integer codes, not the numeric value of the labels. The safe idiom is as.numeric(as.character(f)). Use factors when working with fixed, known categories — especially for statistical modeling and visualization — and use character vectors for free-form text or when maximum flexibility is needed. The forcats package extends base R's factor tools with expressive, pipe-friendly verbs for reordering, collapsing, and harmonizing factor levels in modern data pipelines.