Confirmatory Factor Analysis (CFA) is one of the most powerful tools in modern psychometrics. If exploratory factor analysis (EFA) allows you to discover the latent structure of a set of items, CFA allows you to test whether that structure holds up empirically. In this tutorial you will learn how to perform a complete CFA in R with the lavaan package, from model specification to interpretation and reporting of results in APA 7 format.
If all you need at the end of this tutorial is the results paragraph and the table in APA 7, the CFA APA 7 report generator writes them from your fit indices and standardized loadings. And if you have more than one candidate model (say, the original model and a respecified version) and need to decide which one to keep, the SEM model comparator checks the fit of several models side by side.
1. What Is Confirmatory Factor Analysis?
CFA is a structural equation modeling (SEM) technique in which the researcher specifies a priori how many latent factors exist and which items measure each factor. Unlike EFA, where the software discovers the structure, in CFA it is you who imposes the theoretical structure and then evaluates whether the data confirm it.
- EFA: exploratory, generates hypotheses about the factor structure. No restrictions are imposed.
- CFA: confirmatory, tests a specific hypothesis. Each item loads on a single predefined factor; cross-loadings are fixed to zero.
Use CFA when you already have a theory about the structure of the instrument, for example after a prior EFA on a different sample, or when adapting a previously validated questionnaire to a new population.
2. Prerequisites
To follow this tutorial you need:
- R (version 4.0 or higher) and RStudio installed.
- The
lavaanandsemToolspackages. - A dataset with at least 200 observations (ideally N ≥ 300).
- Continuous variables or Likert items with at least 5 response categories.
Install the required packages if you do not have them yet:
install.packages("lavaan")
install.packages("semTools")
# Load libraries
library(lavaan)
library(semTools)
Although N ≥ 200 is a frequently cited minimum, the rule depends on the complexity of the model. For models with 3 factors and 12 items, an N between 250 and 400 is recommended. With small samples (< 150), standard errors become inflated and fit indices become unstable. If your items are ordinal with few categories (4 or fewer), you should use a specific estimator such as WLSMV instead of the classic ML.
3. Practical Example: The Dataset
We will work with a realistic example: the validation of the Multidimensional Anxiety Questionnaire (MAQ-12), a fictitious 12-item instrument with a 5-point Likert response scale (1 = Never, 5 = Always). The questionnaire measures three dimensions of anxiety with 4 items each.
| Item | Factor | Content |
|---|---|---|
| C1 | Cognitive | I cannot stop worrying |
| C2 | Cognitive | I have catastrophic thoughts |
| C3 | Cognitive | I find it hard to concentrate because of worry |
| C4 | Cognitive | I anticipate the worst in every situation |
| S1 | Somatic | I feel frequent muscle tension |
| S2 | Somatic | I have palpitations without apparent cause |
| S3 | Somatic | I sweat excessively in everyday situations |
| S4 | Somatic | I feel chest tightness |
| E1 | Emotional | I feel nervous for no clear reason |
| E2 | Emotional | I have a constant feeling of fear |
| E3 | Emotional | I get irritated easily |
| E4 | Emotional | I feel emotionally overwhelmed |
We will simulate data with a known factor structure so you can reproduce the complete example:
set.seed(2026)
n <- 350
# Generate 3 correlated latent factors
Sigma_f <- matrix(c(1.0, 0.5, 0.4,
0.5, 1.0, 0.3,
0.4, 0.3, 1.0), nrow = 3)
factores <- MASS::mvrnorm(n, mu = c(0, 0, 0), Sigma = Sigma_f)
# Population factor loadings
lambdas <- c(.75, .80, .70, .65, # Cognitive
.72, .68, .60, .74, # Somatic
.78, .82, .55, .71) # Emotional
# Generate items: Y = lambda * F + error
items <- matrix(NA, n, 12)
factor_idx <- rep(1:3, each = 4)
for (j in 1:12) {
error_var <- 1 - lambdas[j]^2
items[, j] <- lambdas[j] * factores[, factor_idx[j]] +
rnorm(n, 0, sqrt(error_var))
}
# Convert to 1-5 Likert scale
items <- round(pnorm(items) * 4 + 1)
items[items < 1] <- 1; items[items > 5] <- 5
# Create data frame
dat <- as.data.frame(items)
colnames(dat) <- c(paste0("C", 1:4),
paste0("S", 1:4),
paste0("E", 1:4))
head(dat)
4. Step 1: Specify the Model
In lavaan, the factor structure is defined with a compact syntax. The =~ operator (read as "is measured by") assigns items to each latent factor. Each line defines a factor and its indicators.
modelo <- '
# Factor 1: Cognitive Anxiety
cognitivo =~ C1 + C2 + C3 + C4
# Factor 2: Somatic Anxiety
somatico =~ S1 + S2 + S3 + S4
# Factor 3: Emotional Anxiety
emocional =~ E1 + E2 + E3 + E4
'
Note that we do not specify cross-loadings: each item appears in only one factor. We also do not specify correlations between factors because lavaan estimates them by default in the CFA model. Internally, lavaan fixes the loading of the first indicator of each factor to 1 to set the scale of the latent factor (this is the default setting; you can also use std.lv = TRUE to fix the factor variance to 1 instead of fixing a loading).
5. Step 2: Estimate the Model
We use the cfa() function to fit the model to the data:
fit <- cfa(modelo, data = dat, estimator = "MLR")
- ML (Maximum Likelihood): the classic choice. Assumes multivariate normality. Suitable for normal continuous data.
- MLR (Robust ML): like ML but with robust standard errors and a scaled chi-square statistic (Satorra-Bentler). Recommended when there is slight non-normality. It is the most versatile option for continuous data or Likert items with 5+ categories.
- WLSMV (Weighted Least Squares Mean and Variance adjusted): the standard for ordinal data with few categories (4-point Likert or fewer). It uses polychoric correlations internally. It is specified with
ordered = TRUEor by indicating which variables are ordinal.
For ordinal data, the call would be:
fit_ord <- cfa(modelo, data = dat, estimator = "WLSMV", ordered = TRUE)
6. Step 3: Evaluate Model Fit
The overall model fit is evaluated using multiple indices. Never base your decision on a single one.
summary(fit, standardized = TRUE, fit.measures = TRUE)
# Extract only fit indices
fitMeasures(fit, c("chisq.scaled", "df", "pvalue.scaled",
"cfi.scaled", "tli.scaled",
"rmsea.scaled", "srmr"))
Below is a table with the main fit indices, the values obtained in our simulated example, and the interpretation criteria:
| Index | Example value | Good fit | Acceptable fit | Interpretation |
|---|---|---|---|---|
| χ² (chi-square) | 68.42 | p > .05 | χ²/df < 3 | Sensitive to N; with large samples it is almost always significant |
| df | 51 | -- | -- | Degrees of freedom of the model |
| CFI | .976 | ≥ .95 | ≥ .90 | Comparative Fit Index. Compares the model with the null model |
| TLI | .969 | ≥ .95 | ≥ .90 | Tucker-Lewis Index. Similar to CFI but penalizes complex models |
| RMSEA | .031 | ≤ .06 | ≤ .08 | Root Mean Square Error of Approximation. Measures the approximation error per degree of freedom |
| SRMR | .038 | ≤ .08 | ≤ .10 | Standardized Root Mean Square Residual. Standardized mean of the residuals |
In our example, all indices indicate good fit: CFI and TLI exceed .95, RMSEA is below .06, and SRMR is below .08. This suggests that the three-factor model fits the data adequately.
7. Step 4: Interpret the Factor Loadings
Standardized factor loadings (λ) indicate the strength of the relationship between each item and its latent factor. We extract the standardized solution as follows:
standardizedSolution(fit) |>
subset(op == "=~")
The results from our simulated example:
| Item | Factor | λ (std. loading) | SE | p |
|---|---|---|---|---|
| C1 | Cognitive | .74 | .03 | < .001 |
| C2 | Cognitive | .79 | .03 | < .001 |
| C3 | Cognitive | .69 | .04 | < .001 |
| C4 | Cognitive | .64 | .04 | < .001 |
| S1 | Somatic | .71 | .04 | < .001 |
| S2 | Somatic | .67 | .04 | < .001 |
| S3 | Somatic | .59 | .05 | < .001 |
| S4 | Somatic | .73 | .04 | < .001 |
| E1 | Emotional | .77 | .03 | < .001 |
| E2 | Emotional | .81 | .03 | < .001 |
| E3 | Emotional | .54 | .05 | < .001 |
| E4 | Emotional | .70 | .04 | < .001 |
- λ ≥ .70: Excellent. The item is a good indicator of the factor.
- λ between .40 and .70: Acceptable. The item contributes to the factor but shares variance with other constructs.
- λ < .40: Problematic. Consider removing the item or revising its wording.
In our example, all loadings are above .50, indicating that all items are reasonable indicators of their corresponding factors. Item E3 (λ = .54) is the weakest and could be revised in future versions of the instrument.
8. Step 5: Reliability by Factor
Composite reliability (McDonald's omega) is preferable to Cronbach's alpha in the CFA context because it does not assume that all factor loadings are equal (tau-equivalence). The semTools package allows you to calculate it directly from the fitted object:
library(semTools)
compRelSEM(fit)
| Factor | ω (omega) | Interpretation |
|---|---|---|
| Cognitive | .81 | Good reliability |
| Somatic | .77 | Acceptable |
| Emotional | .79 | Acceptable-good |
Omega values above .70 are considered acceptable, and above .80 indicate good reliability. All three MAQ-12 factors show adequate reliability.
9. Step 6: Model Modification (If Necessary)
When the model fit is not adequate, modification indices (MI) can suggest which restrictions to relax to improve fit:
modificationIndices(fit, sort. = TRUE, minimum.value = 10)
The output shows pairs of parameters that, if freed, would reduce the χ² by at least the indicated value (MI). The most common modifications involve adding covariances between error terms of items within the same factor.
Never add modifications on a purely empirical basis. Every change must have a theoretical justification. Residual covariances between items within the same factor can be justified if the items share method (same reverse wording, similar response format) or similar content beyond the construct. Adding cross-loadings or covariances without justification turns your CFA into a disguised exploratory exercise.
If, for example, items S1 and S4 share residual variance (perhaps because both refer to localized physical tension), you could re-specify the model as follows:
modelo_v2 <- '
cognitivo =~ C1 + C2 + C3 + C4
somatico =~ S1 + S2 + S3 + S4
emocional =~ E1 + E2 + E3 + E4
# Residual covariance with theoretical justification
S1 ~~ S4
'
fit_v2 <- cfa(modelo_v2, data = dat, estimator = "MLR")
# Compare models
anova(fit, fit_v2)
10. How to Report Results in APA 7
"A confirmatory factor analysis was conducted to evaluate the three-factor structure of the MAQ-12 using the lavaan package (Rosseel, 2012) in R. The robust maximum likelihood estimator (MLR) was used. Results indicated good model fit: χ²(51) = 68.42, p = .051, CFI = .976, TLI = .969, RMSEA = .031 [90% CI .000, .049], SRMR = .038. Standardized factor loadings ranged from .54 to .81, and all were statistically significant (p < .001). Composite reliability (McDonald's omega) was adequate for all three factors: cognitive anxiety (ω = .81), somatic anxiety (ω = .77), and emotional anxiety (ω = .79). Overall, the results support the three-dimensional structure of the questionnaire."
Adapt the values to your actual data. Always include: estimator used, χ² with df and p, CFI, TLI, RMSEA with confidence interval, SRMR, range of factor loadings, and reliability.
Before you submit: once that paragraph is inside your results section, run the manuscript through the Q1 Paper Reviewer. It is a free Reviewer 2 style pre-review that tells you what a reviewer would object to in your CFA (an estimator that does not match your response scale, an RMSEA reported without its confidence interval, residual covariances added with no theoretical justification, reliability that never appears) before the journal says it and you lose three months.
11. Common Mistakes in CFA
These are the most common errors found when reviewing studies that apply CFA. Avoiding them will substantially improve the quality of your analysis:
- Using the same sample from the EFA for the CFA. CFA is a confirmatory analysis: it must be performed on independent data. If you explore and confirm with the same data, you are capitalizing on chance. Split your sample or collect new data.
- Modifying the model based only on MI without theoretical justification. Modification indices are statistical guides, not mandates. Adding residual covariances or cross-loadings without a substantive reason transforms your CFA into a covert exploratory analysis and compromises the validity of the confirmatory process.
- Sample too small. With N < 150, parameters are estimated with excessive error, confidence intervals are extremely wide, and fit indices become erratic. For models with 10-15 items and 3 factors, a minimum N of 250 is prudent.
- Ignoring the ordinal nature of Likert items. If your items have 4 or fewer response categories, using ML or MLR (which assume continuity) can distort the estimates. In these cases, WLSMV with polychoric correlations is more appropriate.
- Reporting only the χ². The χ² is extremely sensitive to sample size: with N > 300, almost any model will be rejected. Always report a complete set of indices (CFI, TLI, RMSEA with CI, SRMR) to provide a balanced picture of fit.
- Not checking multivariate normality. If you use standard ML, non-normality inflates the χ² and biases standard errors. At a minimum, check multivariate kurtosis (Mardia's test) and use MLR if there are deviations. You can check it with
mardia(dat)from the psych package.
12. Complete Reproducible Code
Below is the complete script that you can copy and paste into R to replicate the entire analysis from start to finish:
# CFA in R with lavaan: Complete reproducible script
# Multidimensional Anxiety Questionnaire (MAQ-12)
# ============================================================
# 1. Load packages ----
library(lavaan)
library(semTools)
# 2. Simulate data (N = 350) ----
set.seed(2026)
n <- 350
# Correlation matrix between latent factors
Sigma_f <- matrix(c(1.0, 0.5, 0.4,
0.5, 1.0, 0.3,
0.4, 0.3, 1.0), nrow = 3)
factores <- MASS::mvrnorm(n, mu = c(0, 0, 0), Sigma = Sigma_f)
# Population factor loadings
lambdas <- c(.75, .80, .70, .65, # Cognitive
.72, .68, .60, .74, # Somatic
.78, .82, .55, .71) # Emotional
# Generate observed items
items <- matrix(NA, n, 12)
factor_idx <- rep(1:3, each = 4)
for (j in 1:12) {
error_var <- 1 - lambdas[j]^2
items[, j] <- lambdas[j] * factores[, factor_idx[j]] +
rnorm(n, 0, sqrt(error_var))
}
# Convert to 1-5 Likert scale
items <- round(pnorm(items) * 4 + 1)
items[items < 1] <- 1; items[items > 5] <- 5
dat <- as.data.frame(items)
colnames(dat) <- c(paste0("C", 1:4), paste0("S", 1:4), paste0("E", 1:4))
# 3. Specify the 3-factor model ----
modelo <- '
cognitivo =~ C1 + C2 + C3 + C4
somatico =~ S1 + S2 + S3 + S4
emocional =~ E1 + E2 + E3 + E4
'
# 4. Estimate the model with MLR ----
fit <- cfa(modelo, data = dat, estimator = "MLR")
# 5. Evaluate overall fit ----
summary(fit, standardized = TRUE, fit.measures = TRUE)
# Selected fit indices
fitMeasures(fit, c("chisq.scaled", "df", "pvalue.scaled",
"cfi.scaled", "tli.scaled",
"rmsea.scaled", "srmr"))
# 6. Standardized factor loadings ----
standardizedSolution(fit) |> subset(op == "=~")
# 7. Composite reliability (omega) ----
compRelSEM(fit)
# 8. Modification indices ----
modificationIndices(fit, sort. = TRUE, minimum.value = 10)
# 9. (Optional) Re-specified model ----
modelo_v2 <- '
cognitivo =~ C1 + C2 + C3 + C4
somatico =~ S1 + S2 + S3 + S4
emocional =~ E1 + E2 + E3 + E4
S1 ~~ S4
'
fit_v2 <- cfa(modelo_v2, data = dat, estimator = "MLR")
anova(fit, fit_v2)
summary(fit_v2, standardized = TRUE, fit.measures = TRUE)
If you have questions about any step or want to delve into advanced topics such as measurement invariance, comparison of nested models, or CFA with categorical variables, check the related articles on this site. CFA is a fundamental tool for validating psychological instruments, and mastering its use will allow you to evaluate the quality of the measures you work with in your research.