Regression with Categorical Variables in R: Dummy Coding
Put factors into a linear model — how R dummy-codes a categorical predictor, how to read the coefficients against the reference level, change the baseline, and handle factors with more than two levels
Biostatistics
Use categorical (factor) predictors in linear regression in R. See how R automatically dummy-codes a factor, interpret each coefficient as a difference from the reference level, change the baseline with relevel(), handle factors with more than two levels, and get the overall factor test with car::Anova().
Published
June 23, 2026
Modified
July 7, 2026
TipKey takeaways
A categorical predictor (factor) goes straight into lm() — R dummy-codes it automatically into 0/1 indicator variables.
A factor with two levels adds one coefficient = the difference between the two groups; a factor with n levels adds n − 1 coefficients, each a difference from the reference level.
The reference (baseline) level is the first factor level; change it with relevel() — it changes the interpretation, not the model fit.
Read a coefficient like sexMale as “the average difference from the reference (Female), holding other predictors constant.”
For the overall test of a multi-level factor, use car::Anova(model) — one p-value for the whole factor, not per-level.
Introduction
Regression needs numbers, but real data is full of categories — sex, treatment group, department, rank. A categorical predictor (factor) can go directly into a linear model: R recodes it into dummy (indicator) variables behind the scenes, and the coefficients become group differences.
The scenario: a college tracks faculty salaries and wants to know whether they differ by sex, and how salary varies across professor rank — controlling for experience and discipline. That’s a regression with categorical predictors.
The data: academic salaries
We use the Salaries dataset (from carData, installed with the car package) — 2008–09 nine-month salaries for college faculty, with sex, rank, discipline and yrs.service:
rank discipline yrs.since.phd yrs.service sex salary
1 Prof B 19 18 Male 139750
2 Prof B 20 16 Male 173200
3 AsstProf B 4 3 Male 79750
4 Prof B 45 39 Male 115000
A two-level factor: sex
Put the factor straight into lm(). R creates a dummy variable sexMale (1 = Male, 0 = Female) and the coefficient is the salary difference between the groups:
data("Salaries", package ="carData")model <-lm(salary ~ sex, data = Salaries)summary(model)$coef
Estimate Std. Error t value Pr(>|t|)
(Intercept) 101002.41 4809.386 21.001103 2.683482e-66
sexMale 14088.01 5064.579 2.781674 5.667107e-03
Interpret it against the reference level (Female, the first level):
(Intercept) = 101,002 — the average salary for the reference group (Female).
sexMale = 14,088 — males earn on average $14,088 more; so the male average is 101,002 + 14,088 = 115,090. The small p-value says this raw difference is significant.
Male is coded 1, Female 0 — Female is the baseline. The choice is arbitrary and doesn’t change the fit, only which group the coefficients are measured against.
Change the reference level with relevel()
Use relevel() to make Male the baseline instead. The numbers mirror: now the intercept is the male average and the coefficient is negative:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 115090.42 1587.378 72.503463 2.459122e-230
sexFemale -14088.01 5064.579 -2.781674 5.667107e-03
Now (Intercept) = 115,090 (male average) and sexFemale = −14,088 — being Female is associated with a $14,088 decrease relative to Males. Same model, same fit; only the interpretation flipped.
A factor with more than two levels: rank
A factor with n levels becomes n − 1 dummy variables. rank has three levels (AsstProf, AssocProf, Prof), so it adds two coefficients — each a contrast with the reference (AsstProf). Look at the groups first:
library(ggpubr)data("Salaries", package ="carData")ggboxplot(Salaries, x ="rank", y ="salary",color ="rank", palette ="jco",add ="jitter", add.params =list(size =0.6),xlab ="Rank", ylab ="Salary", legend ="none")
R’s model.matrix() shows the dummy coding it builds for rank:
data("Salaries", package ="carData")res <-model.matrix(~ rank, data = Salaries)head(res[, -1]) # the two dummy columns (AsstProf is the baseline = both 0)
R uses treatment contrasts by default (each level vs a reference) — the most natural coding for most analyses, and what every interpretation here assumes. Other schemes exist (e.g. effect/sum coding, contr.sum, which compares each level to the grand mean using a −1/+1 pattern); switch with options(contrasts = ...) or contrasts()<- only when a specific analysis calls for it.
Test the whole factor and adjust for other variables
A multi-level factor’s per-level p-values don’t tell you whether the factor as a whole matters. Fit the full model and use car::Anova() for one p-value per predictor (it handles unbalanced designs correctly):
Adjusting for the others, rank and discipline are highly significant, while sex is no longer significant (p = 0.22) — the raw sex gap is largely explained by rank, discipline and experience. To read the individual contrasts, look at the coefficients:
For example, disciplineB = 13,473 — applied departments (B) pay on average $13,473 more than theoretical departments (A), holding rank, experience and sex constant.
Note
ANOVA is regression with categorical predictors. A one-way ANOVA is just lm(y ~ factor); car::Anova() extracts the classic ANOVA table from any linear model. See one-way ANOVA for the test-focused view of the same machinery.
Report
A linear regression predicted salary from years of service, rank, discipline and sex. Adjusting for the other variables, rank (F(2, 391) = 100, p < 0.001) and discipline (F(1, 391) = 34, p < 0.001) were significant predictors, whereas sex was not (F(1, 391) = 1.5, p = 0.22). Faculty in applied disciplines earned on average $13,473 more than those in theoretical disciplines.
NoteThe math (optional): dummy coding
A factor with levels \(L_0, L_1, \dots, L_{k}\) is encoded as \(k\) indicator variables \(D_1, \dots, D_k\), where \(D_j = 1\) when the observation is in level \(L_j\) and 0 otherwise; the reference level \(L_0\) has all \(D_j = 0\). The model \(y = \beta_0 + \beta_1 D_1 + \dots + \beta_k D_k\) then makes \(\beta_0\) the mean of the reference group and each \(\beta_j\) the difference between level \(L_j\) and the reference — which is exactly why “treatment” (default) contrasts read as group comparisons.
Try it live
Change the reference level of rank to "Prof" and refit — how do the coefficients change? The sandbox boots on first Run.
🟢 With an AI agent
Ask Prova“add a categorical predictor to my regression and interpret the coefficients against the reference level” — it answers with code you can run on your own factors and explains each contrast. The runtime is the judge.Ask Prova →
Common issues
You interpreted a dummy coefficient as an absolute mean. Every factor coefficient is a difference from the reference level, not a group mean. The group mean is intercept + that coefficient (with all other predictors at their reference / zero).
You read per-level p-values as the factor’s significance. For a factor with 3+ levels, the individual summary() p-values test single contrasts; for the overall “does this factor matter” question use car::Anova(model).
Your “categorical” variable is actually numeric codes. If a factor is stored as 1/2/3, R treats it as a number (a linear trend), not categories. Wrap it in factor() so it gets dummy-coded.
Frequently asked questions
NoteHow does R handle categorical variables in regression?
R dummy-codes them automatically: a factor with n levels becomes n − 1 indicator (0/1) variables, with the first level as the reference. You just put the factor in the formula (lm(y ~ group)); each coefficient is the difference between that level and the reference.
NoteHow do I interpret dummy variable coefficients?
Each coefficient is the average difference in the outcome between that level and the reference level, holding other predictors constant. The intercept is the reference group’s predicted value. So sexMale = 14088 means males average $14,088 more than the reference (Female).
NoteHow do I change the reference (baseline) category?
Use relevel(factor, ref = "level") before fitting, or set the factor levels explicitly with factor(x, levels = ...). It changes which group the coefficients are compared against — the interpretation — but not the model’s fit or predictions.
NoteHow do I test whether a multi-level factor is significant overall?
Use car::Anova(model), which gives one p-value per predictor (the whole factor), not per dummy level. It uses Type-II sums of squares by default, which handles unbalanced designs correctly — preferable to the base anova() for observational data.
Test your understanding
ImportantPractice
Run it. In the live cell, relevel rank to "Prof" and refit salary ~ rank. What does the rankAsstProf coefficient now mean?
Conceptual. In the full model, sex is non-significant, but a simple lm(salary ~ sex) is highly significant. Which result better describes whether sex causes a pay gap, and why?
NoteHint
Fill the blank with "Prof". With Prof as the baseline, the other coefficients are differences from full professors (so they’ll be negative). For question 2, think about what the full model adjusts for.
NoteSolution
With rank releveled to "Prof", rankAsstProf is the average salary difference of assistant professors relative to full professors (a large negative number). For question 2: the full model is more informative about a causal pay gap. The raw lm(salary ~ sex) mixes sex with rank, discipline and experience; once you adjust for those, the direct sex effect is not significant — the raw gap is largely explained by men holding higher ranks and longer service, not by sex per se.
You can now use categorical variables in regression in R: drop a factor into lm() and let R dummy-code it, interpret each coefficient as a difference from the reference level, switch the baseline with relevel(), handle factors with more than two levels, and get the factor’s overall test with car::Anova(). Categories and numbers live happily in the same model.
Every result on this page was produced by the code shown, run at build time against a pinned R environment — edit any block and Run to reproduce it yourself.