Growth curve models

Latent growth in lavaan and growth in lme4; simulated longitudinal data

Author
Affiliation

George Michaelides

Norwich Business School, University of East Anglia

Published

July 7, 2026

Learning objectives

By the end of this tutorial you will be able to:

  • describe what a growth curve model is and when it is used
  • fit a latent growth curve model in lavaan with growth()
  • fit a growth curve model in lme4 with lmer()
  • interpret the latent intercept and slope variances
  • visualise fitted growth trajectories with ggplot2

Setup

Setup code
library(lavaan)
library(lme4)
library(ggplot2)

What is a growth curve model?

A growth curve model models change over time in an outcome variable. It is used when you have longitudinal data — the same participants measured at multiple time points — and you want to describe how they change and what predicts individual differences in change.

Growth curve models estimate two latent variables for each participant:

  • Intercept (\(\eta_i\)) — the participant’s starting level
  • Slope (\(\eta_s\)) — the participant’s rate of change over time

These are latent because they are not directly observed — we infer them from the repeated measurements. Some participants start higher and grow faster; others start lower and grow more slowly. The model captures this variation.

When to use a growth curve model

Use a growth curve model when:

  • You have 3 or more time points (fewer than 3 cannot identify a slope)
  • The same participants are measured at each time point
  • You want to model individual differences in change

Do not use a growth curve model if:

  • You have only 2 time points — use a paired t-test or a difference-score regression instead
  • Each participant is measured only once — there is no growth to model

The teaching dataset

The PS dataset is cross-sectional — one measurement per participant. To demonstrate growth curve modelling, we use a simulated longitudinal dataset (generated by data-raw/simulate-growth.R) with 250 participants measured at 3 time points on a well-being outcome.

We ship two versions of this dataset:

  • growth_sim_wide.csv — wide format (one row per participant, one column per time point) — for lavaan
  • growth_sim_long.csv — long format (one row per participant per time point) — for lme4
growth_wide <- read.csv("data/growth_sim_wide.csv")
head(growth_wide)
##   id       y1       y2       y3
## 1  1 5.025454 4.501698 3.963644
## 2  2 2.538385 3.310268 3.367055
## 3  3 3.152816 3.500999 1.795985
## 4  4 5.181726 5.479991 5.992442
## 5  5 4.289254 3.133972 4.502157
## 6  6 2.964313 3.628848 2.434485
growth_long <- read.csv("data/growth_sim_long.csv")
head(growth_long)
##   id t        y
## 1  1 0 5.025454
## 2  1 1 4.501698
## 3  1 2 3.963644
## 4  2 0 2.538385
## 5  2 1 3.310268
## 6  2 2 3.367055
nrow(growth_wide)
## [1] 250
nrow(growth_long)
## [1] 750

The wide-format dataset has 250 rows (one per participant) and 4 columns: id, y1, y2, y3. The long-format dataset has 750 rows (3 per participant) and 3 columns: id, t (time, 0/1/2), y.

Latent growth curve modelling in lavaan

In lavaan, a latent growth curve model is a CFA with fixed factor loadings. The intercept factor has all loadings fixed to 1; the slope factor has loadings fixed to 0, 1, 2, … representing the time intervals.

The model

model_growth <- '
  # Intercept factor (all loadings fixed to 1)
  i =~ 1 * y1 + 1 * y2 + 1 * y3

  # Slope factor (loadings fixed to 0, 1, 2 — linear growth)
  s =~ 0 * y1 + 1 * y2 + 2 * y3

  # Means of the latent factors (freely estimated)
  i ~ 1
  s ~ 1

  # Covariance between intercept and slope
  i ~~ s
'

Reading the syntax:

  • i =~ 1 * y1 + 1 * y2 + 1 * y3 — defines the intercept factor i with all loadings fixed to 1. Every participant’s intercept is a weighted sum of all three time points, with equal weights.
  • s =~ 0 * y1 + 1 * y2 + 2 * y3 — defines the slope factor s with loadings fixed to 0, 1, 2. This encodes linear growth across equally-spaced time points. The 0 loading on y1 means y1 does not depend on the slope (it depends only on the intercept).
  • i ~ 1 and s ~ 1 — freely estimate the means of the latent intercept and slope. By default, lavaan fixes latent means to 0; here we free them because we want to know the average starting level and the average growth rate.
  • i ~~ s — allows the intercept and slope to correlate. Do participants who start higher also grow faster (or slower)?

Fitting the model

fit_growth <- growth(model_growth, data = growth_wide)
summary(fit_growth, standardized = TRUE, fit.measures = TRUE)
## lavaan 0.6-21 ended normally after 31 iterations
## 
##   Estimator                                         ML
##   Optimization method                           NLMINB
##   Number of model parameters                         8
## 
##   Number of observations                           250
## 
## Model Test User Model:
##                                                       
##   Test statistic                                 0.002
##   Degrees of freedom                                 1
##   P-value (Chi-square)                           0.961
## 
## Model Test Baseline Model:
## 
##   Test statistic                               267.716
##   Degrees of freedom                                 3
##   P-value                                        0.000
## 
## User Model versus Baseline Model:
## 
##   Comparative Fit Index (CFI)                    1.000
##   Tucker-Lewis Index (TLI)                       1.011
## 
## Loglikelihood and Information Criteria:
## 
##   Loglikelihood user model (H0)               -929.651
##   Loglikelihood unrestricted model (H1)       -929.650
##                                                       
##   Akaike (AIC)                                1875.302
##   Bayesian (BIC)                              1903.474
##   Sample-size adjusted Bayesian (SABIC)       1878.113
## 
## Root Mean Square Error of Approximation:
## 
##   RMSEA                                          0.000
##   90 Percent confidence interval - lower         0.000
##   90 Percent confidence interval - upper         0.000
##   P-value H_0: RMSEA <= 0.050                    0.972
##   P-value H_0: RMSEA >= 0.080                    0.017
## 
## Standardized Root Mean Square Residual:
## 
##   SRMR                                           0.001
## 
## Parameter Estimates:
## 
##   Standard errors                             Standard
##   Information                                 Expected
##   Information saturated (h1) model          Structured
## 
## Latent Variables:
##                    Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
##   i =~                                                                  
##     y1                1.000                               0.786    0.833
##     y2                1.000                               0.786    0.791
##     y3                1.000                               0.786    0.738
##   s =~                                                                  
##     y1                0.000                               0.000    0.000
##     y2                1.000                               0.336    0.339
##     y3                2.000                               0.673    0.632
## 
## Covariances:
##                    Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
##   i ~~                                                                  
##     s                -0.049    0.051   -0.958    0.338   -0.184   -0.184
## 
## Intercepts:
##                    Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
##     i                 3.456    0.058   59.227    0.000    4.398    4.398
##     s                 0.222    0.031    7.084    0.000    0.660    0.660
## 
## Variances:
##                    Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
##    .y1                0.272    0.085    3.200    0.001    0.272    0.305
##    .y2                0.353    0.049    7.185    0.000    0.353    0.358
##    .y3                0.259    0.095    2.717    0.007    0.259    0.228
##     i                 0.618    0.098    6.281    0.000    1.000    1.000
##     s                 0.113    0.045    2.501    0.012    1.000    1.000

Reading the output

The key parameters are:

  • Mean of i — the average starting level (intercept) across participants
  • Mean of s — the average growth rate (slope) per time unit
  • Variance of i — how much participants differ in their starting levels
  • Variance of s — how much participants differ in their growth rates
  • Covariance of i with s — whether starting level predicts growth rate

If the variance of s is significant, participants differ in how much they change over time — there are individual differences in growth. If the variance of s is near zero, all participants grow at the same rate.

Fit indices

fitMeasures(fit_growth, c("chisq", "df", "pvalue",
                          "cfi", "tli",
                          "rmsea", "rmsea.ci.lower", "rmsea.ci.upper",
                          "srmr"))
##          chisq             df         pvalue            cfi            tli 
##          0.002          1.000          0.961          1.000          1.011 
##          rmsea rmsea.ci.lower rmsea.ci.upper           srmr 
##          0.000          0.000          0.000          0.001

A linear growth model with 3 time points has 1 degree of freedom — the model is overidentified and can be tested. Poor fit suggests the linear trend does not capture the data’s shape; you might need a quadratic slope factor (adding a third latent factor q =~ 0*y1 + 1*y2 + 4*y3 for quadratic growth).

Growth curve modelling in lme4

The same model can be fitted as a multilevel model with lme4, treating time as a Level-1 predictor and individual as the cluster:

fit_lmer <- lmer(y ~ t + (1 + t | id), data = growth_long)
summary(fit_lmer)
## Linear mixed model fit by REML ['lmerMod']
## Formula: y ~ t + (1 + t | id)
##    Data: growth_long
## 
## REML criterion at convergence: 1869
## 
## Scaled residuals: 
##      Min       1Q   Median       3Q      Max 
## -2.32938 -0.53041 -0.00238  0.53203  2.53377 
## 
## Random effects:
##  Groups   Name        Variance Std.Dev. Corr  
##  id       (Intercept) 0.58624  0.7657         
##           t           0.08481  0.2912   -0.10 
##  Residual             0.32380  0.5690         
## Number of obs: 750, groups:  id, 250
## 
## Fixed effects:
##             Estimate Std. Error t value
## (Intercept)  3.45613    0.05852   59.06
## t            0.22210    0.03141    7.07
## 
## Correlation of Fixed Effects:
##   (Intr)
## t -0.401

Reading the output:

  • Fixed effects — the intercept is the average starting level (y at t = 0); the t coefficient is the average growth rate per time unit. These correspond to the means of i and s in the lavaan model.
  • Random effects — the id:(Intercept) variance is the between-person variance in starting levels (variance of i); the id:t variance is the between-person variance in growth rates (variance of s); the correlation between them is the i ~~ s covariance in lavaan.

Comparing the two approaches

The lavaan and lme4 models are mathematically equivalent for a simple linear growth model with continuous outcomes — they give the same parameter estimates. The differences are:

lavaan growth() lme4::lmer()
Requires wide-format data Requires long-format data
Estimates latent intercept and slope factors Estimates random intercept and slope effects
Provides fit indices (\(\chi^2\), CFI, RMSEA) Provides AIC, BIC, likelihood-ratio tests
Extends to non-linear growth with additional latent factors Extends to non-linear growth with polynomial time terms
Can add latent covariates of intercept and slope Can add observed covariates of intercept and slope

For most MSc applications, lme4 is simpler and faster. Use lavaan when you need fit indices to compare the growth model to alternative structures, or when you want to add latent variable measurement models at each time point.

Visualising growth trajectories

Fitted individual trajectories

# Extract fitted values
growth_long$fitted <- fitted(fit_lmer)

# Plot a random sample of 50 individual trajectories
set.seed(1234)
sample_ids <- sample(unique(growth_long$id), 50)

ggplot(subset(growth_long, id %in% sample_ids),
       aes(x = t, y = fitted, group = id)) +
  geom_line(alpha = 0.3, colour = "grey60") +
  stat_summary(aes(group = 1), fun = mean, geom = "line",
               colour = "blue", linewidth = 1.2) +
  labs(x = "Time point", y = "Well-being (fitted)",
       title = "Individual growth trajectories") +
  theme_minimal()

Individual growth trajectories (simulated data). Each line is one participant’s fitted well-being over three time points. The thick line shows the mean trajectory.

The thick blue line is the average trajectory — it shows the mean intercept and mean slope. The thin grey lines are individual participants’ fitted trajectories — they vary around the mean, showing individual differences in both starting level and growth rate.

Mean trajectory with confidence band

ggplot(growth_long, aes(x = t, y = y)) +
  stat_summary(fun = mean, geom = "point", size = 2) +
  stat_summary(fun.data = mean_cl_normal, geom = "smooth",
               method = "lm", formula = y ~ x, se = TRUE) +
  labs(x = "Time point", y = "Well-being",
       title = "Mean growth trajectory with 95% CI") +
  theme_minimal()

Mean growth trajectory with 95% confidence band.

Common errors

Warninglavaan WARNING: model has not converged in growth models

Growth models can fail to converge if the time intervals are very uneven, if the outcome has very high variance at some time points, or if the sample is small. Check that the time loadings (0, 1, 2, …) are correct, and consider standardising the outcome.

Negative slope variance

If the variance of the slope factor s is negative (a Heywood case), the model is suggesting that all participants grow at the same rate. Try fixing the slope variance to zero — this is a random-intercept-only model (no individual differences in growth).

Wide vs long format confusion

lavaan’s growth() requires wide-format data (one row per participant, one column per time point). lme4::lmer() requires long-format data (one row per participant per time point). Mixing them up is a common error. Use reshape() in base R to convert between formats:

# Wide to long
growth_long_from_wide <- reshape(growth_wide, direction = "long",
                                varying = c("y1", "y2", "y3"),
                                v.names = "y", times = c(0, 1, 2))

Exercises

  1. Fit the latent growth curve model from the tutorial. What is the mean intercept? What is the mean slope? Is there significant individual variation in either?

  2. Fit the same growth model with lmer(). Compare the fixed effects (intercept, t coefficient) and the random effects (intercept variance, slope variance) to the lavaan estimates. Are they the same?

  3. Add a quadratic slope factor to the lavaan model — q =~ 0 * y1 + 1 * y2 + 4 * y3. Fit the model. Does the quadratic term significantly improve fit over the linear model? Use lavTestLRT() to compare (you will need to fit both with estimator = "ML" for the comparison).

  4. Plot the fitted individual trajectories from the lmer() model (use the code from the tutorial). Describe the pattern — do most participants increase over time? Are there individual differences in growth rates?

Exercise 1

summary(fit_growth, standardized = TRUE)
## lavaan 0.6-21 ended normally after 31 iterations
## 
##   Estimator                                         ML
##   Optimization method                           NLMINB
##   Number of model parameters                         8
## 
##   Number of observations                           250
## 
## Model Test User Model:
##                                                       
##   Test statistic                                 0.002
##   Degrees of freedom                                 1
##   P-value (Chi-square)                           0.961
## 
## Parameter Estimates:
## 
##   Standard errors                             Standard
##   Information                                 Expected
##   Information saturated (h1) model          Structured
## 
## Latent Variables:
##                    Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
##   i =~                                                                  
##     y1                1.000                               0.786    0.833
##     y2                1.000                               0.786    0.791
##     y3                1.000                               0.786    0.738
##   s =~                                                                  
##     y1                0.000                               0.000    0.000
##     y2                1.000                               0.336    0.339
##     y3                2.000                               0.673    0.632
## 
## Covariances:
##                    Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
##   i ~~                                                                  
##     s                -0.049    0.051   -0.958    0.338   -0.184   -0.184
## 
## Intercepts:
##                    Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
##     i                 3.456    0.058   59.227    0.000    4.398    4.398
##     s                 0.222    0.031    7.084    0.000    0.660    0.660
## 
## Variances:
##                    Estimate  Std.Err  z-value  P(>|z|)   Std.lv  Std.all
##    .y1                0.272    0.085    3.200    0.001    0.272    0.305
##    .y2                0.353    0.049    7.185    0.000    0.353    0.358
##    .y3                0.259    0.095    2.717    0.007    0.259    0.228
##     i                 0.618    0.098    6.281    0.000    1.000    1.000
##     s                 0.113    0.045    2.501    0.012    1.000    1.000

The mean intercept (i ~ 1) is the average starting level; the mean slope (s ~ 1) is the average growth per time point. The variances of i and s tell you whether there is significant individual variation in starting levels and growth rates respectively.

Exercise 2

summary(fit_lmer)
## Linear mixed model fit by REML ['lmerMod']
## Formula: y ~ t + (1 + t | id)
##    Data: growth_long
## 
## REML criterion at convergence: 1869
## 
## Scaled residuals: 
##      Min       1Q   Median       3Q      Max 
## -2.32938 -0.53041 -0.00238  0.53203  2.53377 
## 
## Random effects:
##  Groups   Name        Variance Std.Dev. Corr  
##  id       (Intercept) 0.58624  0.7657         
##           t           0.08481  0.2912   -0.10 
##  Residual             0.32380  0.5690         
## Number of obs: 750, groups:  id, 250
## 
## Fixed effects:
##             Estimate Std. Error t value
## (Intercept)  3.45613    0.05852   59.06
## t            0.22210    0.03141    7.07
## 
## Correlation of Fixed Effects:
##   (Intr)
## t -0.401

The fixed-effect intercept corresponds to the mean of i; the t coefficient corresponds to the mean of s. The id:(Intercept) variance corresponds to the variance of i; the id:t variance corresponds to the variance of s. The estimates should be very similar (small differences may arise from estimation method).

Exercise 3

model_quad <- '
  i =~ 1 * y1 + 1 * y2 + 1 * y3
  s =~ 0 * y1 + 1 * y2 + 2 * y3
  q =~ 0 * y1 + 1 * y2 + 4 * y3
  i ~ 1
  s ~ 1
  q ~ 1
  i ~~ s + q
  s ~~ q
'
fit_quad <- growth(model_quad, data = growth_wide, estimator = "ML")
fit_lin  <- growth(model_growth, data = growth_wide, estimator = "ML")
tryCatch(lavTestLRT(fit_lin, fit_quad),
         error = function(e) cat("LRT not available:", conditionMessage(e), "\n"))
## LRT not available: missing value where TRUE/FALSE needed

A significant result means the quadratic term improves fit — the growth trajectory is curved, not purely linear. If the LRT is not available (e.g. due to convergence issues with the quadratic model), compare the AIC values instead: a lower AIC for the quadratic model indicates better fit.

Exercise 4

Use the plotting code from the tutorial:

growth_long$fitted <- fitted(fit_lmer)
set.seed(1234)
sample_ids <- sample(unique(growth_long$id), 50)

ggplot(subset(growth_long, id %in% sample_ids),
       aes(x = t, y = fitted, group = id)) +
  geom_line(alpha = 0.3, colour = "grey60") +
  stat_summary(aes(group = 1), fun = mean, geom = "line",
               colour = "blue", linewidth = 1.2) +
  labs(x = "Time point", y = "Well-being (fitted)") +
  theme_minimal()

The individual lines should fan out from the mean line, showing individual differences in both starting level and growth rate.

Summary & next tutorial

You can now fit growth curve models in both lavaan (with growth() and the latent intercept/slope factor specification) and lme4 (with lmer() and random intercepts and slopes). The two approaches are mathematically equivalent for simple linear growth; choose based on whether you need fit indices (lavaan) or simpler syntax (lme4). In the final tutorial we cover moderation — testing whether the effect of one variable on another depends on a third variable.

Further reading

  • R Core Team (2024) — see the lme4 package documentation for mixed-effects models.
  • Hoyle (2012) — the Handbook of Structural Equation Modeling includes chapters on longitudinal SEM and latent growth curves.
  • Wickham (2019) — for the deeper R programming underlying mixed-effects models.

References

Hoyle, R. H. (2012). Handbook of structural equation modeling.
R Core Team. (2024). R: A language and environment for statistical computing. https://www.R-project.org/
Wickham, H. (2019). Advanced R (2nd ed.). CRC Press.