Publication-quality Kaplan–Meier curves with survival + ggplot2
survival-analysis
ggplot2
biostatistics
The survfit object has everything you need; ggplot2 does the rest. A dependency-light recipe for survival curves you control completely.
Author
Rverse Analytics
Published
May 28, 2026
Dedicated packages for plotting survival curves come and go, but the underlying recipe is stable: fit with survival::survfit(), pull the step function out of the object, draw it with ggplot2. Fewer dependencies, full control over every visual detail.
Fit the model
We use the classic lung dataset shipped with the survival package — 228 patients with advanced lung cancer, compared here by sex:
library(survival)fit <-survfit(Surv(time, status) ~ sex, data = lung)fit
Call: survfit(formula = Surv(time, status) ~ sex, data = lung)
n events median 0.95LCL 0.95UCL
sex=1 138 112 270 212 310
sex=2 90 53 426 348 550
Extract the step function
Everything plot(fit) would draw lives inside the object:
km <-data.frame(time = fit$time,surv = fit$surv,lower = fit$lower,upper = fit$upper,group =rep(c("Male", "Female"), times = fit$strata))head(km, 3)
time surv lower upper group
1 11 0.9782609 0.9542301 1.0000000 Male
2 12 0.9710145 0.9434235 0.9994124 Male
3 13 0.9565217 0.9230952 0.9911586 Male
Draw it
library(ggplot2)ggplot(km, aes(time, surv, colour = group, fill = group)) +geom_step(linewidth =1.1) +geom_ribbon(aes(ymin = lower, ymax = upper),stat ="identity", alpha =0.12, colour =NA) +scale_colour_manual(values =c(Male ="#2f6fed", Female ="#17a2b8")) +scale_fill_manual(values =c(Male ="#2f6fed", Female ="#17a2b8")) +scale_y_continuous(labels = scales::percent, limits =c(0, 1)) +labs(title ="Kaplan–Meier survival estimates by sex",subtitle ="Advanced lung cancer (survival::lung, n = 228); shaded bands are 95% CIs",x ="Days since enrolment", y ="Survival probability",colour =NULL, fill =NULL ) +theme_minimal() +theme(legend.position ="bottom",plot.title =element_text(face ="bold", colour ="#1b2a4a"))
Figure 1
And the test to go with it
A figure without a test is only half the result. The log-rank test:
survdiff(Surv(time, status) ~ sex, data = lung)
Call:
survdiff(formula = Surv(time, status) ~ sex, data = lung)
N Observed Expected (O-E)^2/E (O-E)^2/V
sex=1 138 112 91.6 4.55 10.3
sex=2 90 53 73.4 5.68 10.3
Chisq= 10.3 on 1 degrees of freedom, p= 0.001
The group difference is statistically significant — and because we built the plot ourselves, adding risk tables, median-survival annotations or landmark lines later is just more ggplot2, not a fight with someone else’s defaults.