Log-rank test: comparing survival curves in R
How the log-rank test compares survival curves, how to read survdiff output, why crossing curves cost it power, and when to use a Peto-Peto weighting.
Updated . Written by Matt Gencer.
On this page
The log-rank test asks one question: do two or more groups share the same survival curve over the whole follow-up? It answers with a chi-square statistic and a p-value, and nothing else. It does not say how large the difference is, which group is better in which period, or what the difference is after adjusting for anything. This post shows how the statistic is built, how to read R’s survdiff() output, and when to reach for a weighted version.
The worked example uses the public NCCTG advanced lung cancer cohort that ships with R’s survival package: 228 people, time in days to death or last contact, compared between men and women. It is an observational research dataset used to show how the numbers read, and nothing here is a statement about any individual’s outlook. For the full workflow around it, see Survival analysis: Kaplan-Meier, Cox and AFT in R.
What the log-rank test compares
The test looks at every moment at which someone has the event and asks how many events each group would have had if the groups shared one curve. At each such moment, the people still at risk are counted, and the events at that time (one, or several when deaths are tied) are shared out in proportion to each group’s share of that risk set. A group with more events than that share is “ahead” of expectation, and a group with fewer is “behind”.
The expected counts are added up over all event times to give one expected total per group, which is set against the observed total. For two groups, the statistic is the squared gap between observed and expected events in one group, divided by the variance of that gap. It is referred to a chi-square distribution with one degree of freedom, or with the number of groups minus one for more groups.
Because everything is built from risk sets, censored people take part exactly as long as they are observed. A person censored at day 200 counts in the risk set at every event time before day 200 and in none after. That is why the test handles censored data without any special step.
The same test appears under several names. Mantel (1966) described the observed-versus-expected construction, and Peto and colleagues (1977) set it out for trials with long follow-up. It is also called the Mantel-Cox test. The names refer to one test.
The worked example
The engine ran the test on the lung data with sex as the group (coded 1 for men and 2 for women). It found a log-rank chi-square of 10.33 on 1 degree of freedom, p = 0.00131, from 228 people and 165 deaths. The Kaplan-Meier medians were 270 days for men (95% CI 212 to 310) and 426 days for women (348 to 550).
The call that produces the test in R is a single line:
library(survival)
survdiff(Surv(time, status) ~ sex, data = cancer)
Its printed table has one row per group, and the numbers are worth reading column by column:
| Group | N | Observed deaths | Expected deaths |
|---|---|---|---|
| Men | 138 | 112 | 91.6 |
| Women | 90 | 53 | 73.4 |
The observed deaths add to 165, matching the engine’s event count, and the expected deaths add to the same total. That is by construction: the test only redistributes the events that happened. Men had about 20 more deaths than the shared-curve expectation (112 minus 91.6), and women had the same number fewer. The chi-square of 10.33 is that gap squared, divided by its variance.
The ratio of observed to expected deaths gives a rough hazard ratio, a classic by-product of the test. Women’s ratio is 53 divided by 73.4, about 0.72, and men’s is 112 divided by 91.6, about 1.22. Dividing the first by the second gives about 0.59, close to the engine’s Cox hazard ratio of 0.588 (95% CI 0.4237 to 0.816). The engine’s estimate is the one to report; the observed-over-expected ratio is a quick consistency check.
How to read the output
Three numbers matter, and the first two are easy to over-read.
- The chi-square and p-value measure how incompatible the data are with identical curves. A p of 0.00131 says an observed-minus-expected gap this large would be unusual if the two groups truly shared one curve. It does not say the difference is large, or that sex caused it.
- The observed and expected columns show the direction. Men have more deaths than expected, so their curve sits lower overall. This is the only place in the printout that tells you which group did worse.
- The degrees of freedom are the number of groups minus one. With three groups the test is an omnibus test: a small p says at least one curve differs, and not which one.
Notice what is missing. There is no effect size and no interval in the survdiff() printout. A report that gives only the p-value leaves the reader unable to judge importance. Pair the test with the medians (or survival at stated times) with intervals, and with a hazard ratio and its interval. How to read the ratio itself is covered in how to interpret a hazard ratio.
One more point on direction: the chi-square is squared, so it carries no sign, and relabelling men and women changes nothing in it. Direction comes only from the observed and expected columns, or from a hazard ratio, which then needs a stated reference group.
Assumptions and what they mean for the test
The log-rank test has two assumptions that matter for validity and one that matters for power.
- Independent observations. Each person appears once and is followed independently. In the lung data each row is one person, though people were enrolled at several institutions, which the plain test ignores. If the groups are clusters or repeated measurements, a different method is needed.
- Non-informative censoring. Whether someone is censored should not tell you anything about their risk. If sicker people drop out of follow-up early, the curves are biased and no test repairs that. This is a design question, not something the data can confirm.
- Proportional hazards, for power only. The test is most sensitive when one group’s hazard is a roughly constant multiple of the other’s (Peto and Peto 1972). It stays valid without that; it just loses sensitivity. Stensrud and Hernán (2020) argue that this is a reason to think about what question you want answered rather than to run a pretest.
The engine’s check for the example, based on scaled Schoenfeld residuals, did not flag a departure from proportional hazards (p = 0.091). That is as much as such a check can say: with 165 events it has limited power, and it does not prove the hazards are proportional. It does mean the equal-weight test is a sensible choice here.
When the log-rank test loses power
The weakness of the log-rank test is built into the way it adds. Every event time contributes a difference between observed and expected, and those differences are summed with their signs. If one group does worse early and better late, the early differences are positive and the late ones negative, and they cancel.
Picture two curves that start together, separate early with group A lower, then cross at the middle of follow-up and end with group B lower. A real and potentially important difference exists, but the sum of the signed gaps can be close to zero and the test reports little evidence. Li and colleagues (2015) compared methods proposed for crossing curves in simulations and found that adaptive Neyman smooth tests and a two-stage procedure kept higher and more stable power than the others, whether the curves crossed early, in the middle or late.
Three practical consequences follow.
- Look at the curves before you trust a non-significant log-rank p. A visible crossing is a reason to doubt the result, whatever p says. The lung data do not show this problem. Apart from the first ten days, when a single death in a woman on day 5 briefly puts the women’s curve below the men’s, the estimated survival of women is higher than that of men throughout, including at 180, 365, 540 and 730 days.
- Do not switch tests after seeing the plot. Picking whichever test gives the smaller p-value inflates the false-positive rate. If crossing is plausible, name the alternative in the plan before the data are in.
- Summarise the difference some other way as well. Survival at stated times, read off both curves, does not cancel in this way. Restricted mean survival time is an area between the curves, so it can still partly cancel when they cross.
A significant log-rank p with crossing curves is also easy to misread. The test says the curves differ somewhere. It does not say that one group is better throughout.
The Peto-Peto alternative
A weighted log-rank test multiplies each event time’s observed-minus-expected gap by a weight before summing. The equal weight is the standard test. The Harrington and Fleming (1982) family weights each event time by the pooled survival estimate raised to a power called rho. With rho at 0 the weight is 1 and the test is the usual log-rank test. With rho at 1 the weight shrinks as survival falls, so early event times count for more.
The survdiff() help page in the survival package (version 3.8-6) states that rho = 1 is equivalent to the Peto and Peto modification of the Gehan-Wilcoxon test (Peto and Peto 1972). It is requested with an extra argument:
survdiff(Surv(time, status) ~ sex, data = cancer, rho = 1)
On the lung data this gives a chi-square of 12.7 on 1 degree of freedom, p = 0.0004. The observed and expected counts are weighted sums, so they are no longer whole deaths: about 70.4 observed and 55.6 expected for men, 28.7 and 43.5 for women.
The two tests agree here, which is the usual outcome when one curve stays above the other. The Peto-Peto version gave a somewhat smaller p-value here. That is a reason to describe the weighting you chose, not a reason to prefer it. The engine’s log-rank run uses the standard equal weights only, so these rho = 1 numbers come from the base R call above on the same data, and the downloadable script does not print them.
Choose the weighting before you look. Early-weighted tests suit questions where early differences matter most, and late-weighted ones suit the reverse. If you cannot defend a choice in advance, report the standard log-rank test and say how the curves behave over time.
More than two groups, and adjusting
With three or more groups, the log-rank test is an omnibus test, as noted above. To find which groups differ, run pairwise log-rank tests and correct for the number of comparisons; the options are laid out in multiple comparisons. A trend version exists for ordered groups, but it needs the ordering to be meaningful before you start.
The plain test compares groups with nothing else held fixed. If the groups differ in age or baseline severity, the comparison mixes that difference with the one you care about. Two routes exist:
- Stratify. A stratified log-rank test compares groups within levels of a categorical variable, such as the enrolling institution, and pools the results. In R, add
+ strata(inst)to the formula. - Use a model. A Cox regression gives an adjusted hazard ratio with an interval, and handles continuous covariates. The Kaplan-Meier and Cox regression guide shows when each fits.
For two groups with no adjustment, the log-rank test and the score test of a one-predictor Cox model agree closely, which is why the two analyses rarely disagree on the same data.
How to write it up
State the groups, the number of events, the test with its statistic and degrees of freedom, the p-value, and an effect measure with an interval. An example for this analysis:
Of 228 participants, 165 died during follow-up and the rest were censored at last contact. Median survival was 270 days in men (95% CI 212 to 310) and 426 days in women (348 to 550). A log-rank test found evidence that the survival curves differed (chi-square 10.33, 1 df, p = 0.0013); the hazard ratio for women relative to men was 0.588 (95% CI 0.4237 to 0.816).
Check each of these before you submit:
- The test is named, and the weighting is stated if it is not the standard one.
- The number of events is reported, not only the number of people.
- An effect measure and its interval accompany the p-value.
- The curves are shown, ideally with the number still at risk beneath them.
- A non-significant result is not described as “no difference” without a look at the curves.
- The language is associational, since the data are observational.
For wider conventions on presenting estimates, see reporting statistics in APA style. To pick between this test and other approaches for a new question, choosing a statistical test is the place to start. The method details behind this example are on the Kaplan-Meier reference page, and the hazard ratio comes from the same family of models as the Cox reference page.
Rerunning the example
The example is reproducible: the downloadable script reruns the Kaplan-Meier fit and log-rank test on the public dataset with a fixed seed and recomputes the recorded statistic and p-value. It was produced by the same engine that Celeus uses, so you can compare your own software’s output with it. Its printout includes the survdiff() table, so the per-group N, observed and expected deaths shown above appear there too. The observed-over-expected ratios are arithmetic on that table, and the rho = 1 results come from a separate base R call on the same data, so the script does not print either.
Worked example
Data: Loprinzi CL, Laurie JA, Wieand HS, Krook JE, Novotny PJ, Kugler JW, et al. (1994). Prospective evaluation of prognostic variables from patient-completed questionnaires. Journal of Clinical Oncology 12(3):601-607. doi:10.1200/JCO.1994.12.3.601. Seed: 20261028.
Run on Celeus engine 0.32.1, seed 20261028, public data.
Output
# Results - Test: Kaplan-Meier (log-rank test) - n: 228 - statistic: 10.33 - p-value: 0.00131 - effect size: hazard ratio = 0.588 (95% CI 0.4237 to 0.816) - seed: 20261028 ## Assumption checks - sample size: passed=TRUE (adequate n) - number of events: passed=TRUE (adequate events) - proportional hazards (cox.zph): passed=TRUE (no evidence against proportional hazards) ## Power - Power is not calculated for this test.
R script
# Celeus - reproduce.R (run from this package folder; bundled data in data/)
# Test: Kaplan-Meier (log-rank test) | engine 0.32.1 | catalog c39-d3cd
# Research context: not provided for this run.
# Reproduces the recorded result deterministically.
set.seed(20261028)
.data_path <- "data/log-rank-test.csv"
if (!file.exists(.data_path)) stop("Run reproduce.R from the package folder (the bundled data is in data/).")
data <- readr::read_csv(.data_path, show_col_types = FALSE) # needs the readr package
clean_cells <- function (df)
{
ctrl_collapse <- "[\r\n\t\v\f]+"
zero_width_chars <- intToUtf8(c(8203L, 65279L), multiple = TRUE)
uni_space_chars <- intToUtf8(c(160L, 8239L, 8199L), multiple = TRUE)
clean_text <- function(x) {
out <- x
for (ch in zero_width_chars) out <- gsub(ch, "", out,
fixed = TRUE)
out <- gsub(ctrl_collapse, " ", out)
for (ch in uni_space_chars) out <- gsub(ch, " ", out,
fixed = TRUE)
trimws(gsub(" +", " ", out))
}
changed_cols <- character(0)
cells_cleaned <- 0L
retyped <- character(0)
for (j in seq_along(df)) {
col <- df[[j]]
if (!is.character(col))
next
cleaned <- clean_text(col)
n_changed <- sum(!is.na(col) & !is.na(cleaned) & col !=
cleaned, na.rm = TRUE)
if (n_changed > 0L) {
changed_cols <- c(changed_cols, names(df)[j])
cells_cleaned <- cells_cleaned + n_changed
}
df[[j]] <- cleaned
non_blank <- cleaned[!is.na(cleaned) & nzchar(cleaned)]
if (length(non_blank) && all(grepl("^[+-]?(0|[1-9][0-9]*)([.][0-9]+)?$",
non_blank))) {
parsed <- suppressWarnings(as.numeric(cleaned))
was_blank <- is.na(cleaned) | !nzchar(cleaned)
lost <- (is.na(parsed) | is.infinite(parsed)) & !was_blank
if (!any(lost)) {
finite <- parsed[!is.na(parsed)]
all_whole <- all(finite == floor(finite))
in_range <- all(abs(finite) <= .Machine$integer.max)
df[[j]] <- if (all_whole && in_range)
as.integer(parsed)
else parsed
retyped <- c(retyped, names(df)[j])
}
}
}
names(df) <- clean_text(names(df))
if (length(changed_cols)) {
attr(df, "ingest_clean_columns") <- changed_cols
attr(df, "ingest_clean_cells_cleaned") <- cells_cleaned
}
if (length(retyped))
attr(df, "ingest_clean_retyped") <- retyped
df
}
data <- clean_cells(data)
# Complete-case analysis: drop rows missing any analysis value.
data[["status"]] <- as.integer(factor(data[["status"]], levels = c("1", "2"))) - 1L
.acols <- c("sex", "time", "status")
data <- data[stats::complete.cases(data[, .acols, drop = FALSE]), , drop = FALSE]
result <- { .f <- survival::Surv(`time`, `status`) ~ factor(`sex`); list(logrank = survival::survdiff(.f, data = data), km = summary(survival::survfit(.f, data = data))$table, cox = summary(survival::coxph(.f, data = data))) }
print(result)Download the files
Common questions
What does the log-rank test actually test?
It tests whether the survival curves of two or more groups are the same over the whole follow-up. The null hypothesis is that every group has one common survival curve, so a small p-value says the data are hard to reconcile with that, not how large the gap is.
Is the log-rank test the same as the Mantel-Cox test?
In practice yes. The names refer to the same test of observed against expected events at each event time, summed over follow-up. Mantel described the observed-versus-expected construction in 1966, and Peto and colleagues set it out for randomised trials in 1977.
When should I use a Peto-Peto test instead of the log-rank test?
Use it when you expect the groups to differ mostly early in follow-up and have said so before looking at the data. It weights early events more heavily. Choosing it after seeing which version gives the smaller p-value inflates the false-positive rate.
Can the log-rank test adjust for age or other covariates?
Not directly. The plain test compares groups with nothing else held fixed. You can stratify it on a categorical variable such as institution, or switch to a Cox model, which gives an adjusted hazard ratio with a confidence interval.
Do I need proportional hazards for the log-rank test to be valid?
No. The test stays valid without it, because it only needs the curves to be the same under the null. Proportional hazards matters for power: the test is most sensitive when one group's hazard is a constant multiple of the other's.
Sources
- Mantel N (1966). Evaluation of survival data and two new rank order statistics arising in its consideration. Cancer Chemotherapy Reports 50(3):163-170. pubmed.ncbi.nlm.nih.gov
- Peto R, Pike MC, Armitage P, Breslow NE, Cox DR, Howard SV, et al. (1977). Design and analysis of randomized clinical trials requiring prolonged observation of each patient. II. Analysis and examples. British Journal of Cancer 35(1):1-39. doi:10.1038/bjc.1977.1
- Peto R, Peto J (1972). Asymptotically efficient rank invariant test procedures. Journal of the Royal Statistical Society: Series A 135(2):185-207. doi:10.2307/2344317
- Harrington DP, Fleming TR (1982). A class of rank test procedures for censored survival data. Biometrika 69(3):553-566. doi:10.1093/biomet/69.3.553
- Therneau TM. survival: Survival Analysis. R package reference manual (survdiff help page), version 3.8-6. CRAN. cran.r-project.org
- Bland JM, Altman DG (2004). The logrank test. BMJ 328(7447):1073. doi:10.1136/bmj.328.7447.1073
- Li H, Han D, Hou Y, Chen H, Chen Z (2015). Statistical inference methods for two crossing survival curves: a comparison of methods. PLoS ONE 10(1):e0116774. doi:10.1371/journal.pone.0116774
- Stensrud MJ, Hernán MA (2020). Why test for proportional hazards? JAMA 323(14):1401-1402. doi:10.1001/jama.2020.1267
Methods discussed
Try it on your own data
Upload a dataset and Celeus suggests a method, checks its assumptions, runs it in R, and gives you the script to rerun it.
Try it in Celeus