R PROGRAMMING • DATA STRUCTURES IN R

Factors — Understand factors and convert between factor and character

Master R's categorical data type and fluently convert between factor and character representations for robust data analysis.

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.

1976
S Language at Bell Labs
John Chambers and colleagues at Bell Labs develop the S language, which introduces an early form of categorical encoding that would later evolve into the factor type.
1993
R Development Begins
Ross Ihaka and Robert Gentleman begin building R at the University of Auckland, inheriting S's factor semantics and refining them for open-source statistical computing.
2000
R 1.0.0 Released
The first stable R release ships with factors as a core data structure, with stringsAsFactors = TRUE as the default in data.frame(), reflecting the assumption that character columns are almost always categorical.
2016
forcats Package Released
Hadley Wickham releases the forcats package as part of the tidyverse, providing a consistent, verb-based API for factor manipulation that reduces common mistakes.
2020
stringsAsFactors Default Flipped
R 4.0.0 changes the default to 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.

1

Levels

The 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.
2

Integer Encoding

Internally, each observation is stored as an integer from 1 to nlevels(x). Calling as.integer() on a factor returns these codes, not the labels — a frequent source of bugs.
3

Ordered vs. Unordered

Setting 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.
4

Factor ↔ Character Conversion

Use 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.
5

The Numeric Trap

When factor levels look like numbers (e.g., "3", "7", "12"), calling as.numeric(f) returns the internal codes, not the numeric values of the labels. The safe idiom is as.numeric(as.character(f)).
KEY TAKEAWAY
Think of a factor like a hash map from integers to strings: the vector of integers is your array of keys, and the levels attribute is your lookup table of values. Just as you would never confuse a hash key with its value, you should never confuse a factor's internal integer code with its human-readable level label. When you need the label back, you must explicitly dereference through as.character() — the language does not do this implicitly in all contexts.

Visual Explanation — Factor Internal Structure

The diagram shows how the factor 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.

FACTOR CONSTRUCTION
factor(x) ≡ structure(match(x, sort(unique(x))), levels = sort(unique(x)), class = "factor")
where 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.

FACTOR TO CHARACTER
as.character(f) ≡ levels(f)[unclass(f)]
where 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.

THE NUMERIC TRAP
as.numeric(factor(c("10","20","30"))) → c(1, 2, 3) ≠ c(10, 20, 30)
Safe conversion: 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

This diagram maps every major conversion path among 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.
Complete conversion reference table
ConversionFunctionResult TypePitfall
character → factorfactor(x) or as.factor(x)factor (integer + levels)Default alphabetical level order may not match desired order
factor → characteras.character(f)characterNone — this is always safe
factor → numeric (codes)as.integer(f) or as.numeric(f)integer / doubleReturns internal codes, not label values
factor → numeric (labels)as.numeric(as.character(f))doubleFails 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.

Survey Satisfaction — Factor Manipulation Pipeline
1
Step 1 — Inspect the raw character dataAfter reading the CSV, inspect the column with 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.
Type confirmed: character vector of length 5
2
Step 2 — Convert to ordered factor with custom levelsCreate the factor with explicit level ordering that reflects the underlying ordinal scale: 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.
Ordered factor with 4 levels: Dissatisfied < Neutral < Satisfied < Very Satisfied
3
Step 3 — Verify internal representationCheck the internal codes: 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.
Internal codes: c(4, 2, 1, 3, 2)
4
Step 4 — Convert back to character for exportFor JSON export, you need plain strings: 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.
Original character labels recovered losslessly
5
Step 5 — Safely extract numeric year valuesFor the year column, suppose 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).
Numeric years: 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.

Factor vs. Character comparison across key criteria
CriterionFactorCharacter
MemoryCompact for high-cardinality repeated values — stores one copy of each level string plus integer codesR's global string pool (CHARSXP cache) deduplicates identical strings, so modern R character vectors are also compact
ModelingModel functions automatically generate dummy variables and correct contrasts; ordered factors get polynomial contrastsMust be manually converted or wrapped in factor() inside formulas; no ordering semantics
Level ValidationEnforces a fixed set of allowed values — assigning an unlisted value produces NA with a warning, preventing silent data corruptionAccepts any string without validation, which is flexible but allows typos and inconsistencies
Sort / Plot OrderLevel order controls bar chart ordering, legend order, and table arrangement in predictable waysDefaults to alphabetical; custom ordering requires ad-hoc reordering at each call site
String OperationsMost string functions (grep, gsub, paste) silently coerce factors to character — but this can produce unexpected results if you forgetAll string operations work natively without coercion
Merging / BindingCombining factors with different level sets can produce NA or mismatched codes — requires careful harmonizationCharacter vectors concatenate seamlessly with c() or rbind()
KEY TAKEAWAY
Think of factors as an enum type from languages like Java or C++: they constrain a variable to a known set of values, provide meaningful ordering, and enable the type system (here, R's method dispatch) to generate appropriate behavior automatically. Use factors when the set of valid categories is known and fixed — like HTTP status codes or experiment conditions. Use character vectors when the data is free-form text or when you need maximal flexibility for string manipulation. The R 4.0.0 default change reflects a modern consensus: opt in to factor semantics explicitly rather than having them imposed silently on import.

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 vs. forcats for common factor operations
Base R Approachforcats EquivalentPurpose
factor(x, levels = custom_order)fct_relevel(x, custom_order)Manually set level order
Manual tapply + reorder logicfct_reorder(x, y, .fun = median)Reorder levels by a summary of another variable
Nested ifelse + factor reconstructionfct_collapse(x, group = c("a", "b"))Merge multiple levels into one
Table + subsetting + relevelfct_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 subsettingfct_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.

🔭 Looking Ahead
As you progress to building production R pipelines, explore the 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

PROBLEM 1CONCEPTUAL
Explain why 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?
PROBLEM 2BASIC CALCULATION
Given 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).
PROBLEM 3INTERMEDIATE
You have a factor 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.
PROBLEM 4APPLIED
You are building a data pipeline that reads a CSV of clinical trial data into a data frame. The 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.
PROBLEM 5CRITICAL THINKING
Consider the following code: 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.

Varsity Tutors • R Programming • Factors — Understand factors and convert between factor and character