How to Format Survival Data in R: The Surv() Object, Censoring & Validation

Turn a messy clinical spreadsheet into an analysis-ready survival dataset — build the Surv(time, status) object, code right/left/interval censoring correctly, set up the counting-process (start, stop, event) layout, and validate the data before you model

Biostatistics

Get your data ready for survival analysis in R. Build the Surv(time, status) object, understand the (time, status) contract and status coding (0/1 vs 1/2 — and the gotcha), handle the three censoring types (right, left, and interval), structure time-varying data in the counting-process (start, stop, event) layout, and run base-R checks for impossible times, missing status, duplicated IDs, and ties before you fit a single model.

Published

June 26, 2026

Modified

July 7, 2026

TipKey takeaways
  • The response in survival analysis is a pair: a follow-up time and a status flag saying whether the event happened or the observation was censored. You package the pair with Surv(time, status) — the left-hand side of every survival formula.
  • Surv() accepts two status codings0 = censored / 1 = event, or 1 = censored / 2 = event. It guesses from the values; a printed + after a time means that patient is censored. Coding the status backwards is the single most common survival-data bug.
  • Three censoring types: right (the default — still event-free at last contact), left (type = "left" — the event already happened before the first observation), and interval (type = "interval2" — the event fell between two assessment visits).
  • Time-varying covariates and recurrent events need the counting-process layout: split each subject into (start, stop, event) intervals and use Surv(start, stop, event).
  • Validate before you model. Catch zero/negative times, missing status, status that isn’t a clean event/censor code, duplicated IDs, and ties with a handful of base-R checks — a bad row silently corrupts every curve and hazard ratio downstream.

Introduction

A trial coordinator hands you a spreadsheet: one row per patient, a column of dates, a “dead/alive” column, some blanks, and a couple of rows that look duplicated. Before you can estimate a single Kaplan-Meier curve or fit a Cox model, that spreadsheet has to become an analysis-ready survival dataset — and survival data has a structure that trips people up, because the outcome is not one number but two: how long you observed each subject, and whether you saw the event or ran out of time.

This lesson is the data-prep step everyone skips and then regrets. You will build the Surv() object that every survival function expects, get the status coding right (the 0/1 vs 1/2 trap), handle the three kinds of censoring, structure time-varying data in the counting-process layout, and run a short validation pass that catches the errors which silently bias your results. We use the lung and colon datasets from the survival package so every example reproduces, and build small messy examples in base R where we need to show a problem.

(Designing endpoints — overall survival, progression-free survival — and building regulatory CDISC ADTTE datasets is a separate, clinical topic; see Survival data for regulatory submissions (CDISC ADTTE). Here we focus on the R mechanics that apply to any survival dataset.)

The (time, status) contract

Every survival observation carries two pieces of information:

  • time — how long the subject was observed: from study entry to the event, or to the last time you knew they were event-free.
  • status — what happened at the end of that time: the event occurred, or the observation was censored (the time is only a lower bound — the true event time is longer, or unknown).

You can’t analyse the time alone. A follow-up time of 200 days means something completely different for a patient who died at day 200 than for one who was still alive at day 200 and then left the study. The status flag is what tells survival methods to treat the second patient as “survived at least 200 days” rather than “died at 200 days” — which is the whole reason ordinary regression fails on this data.

Here is what that looks like for six patients, drawn as a follow-up timeline — the mental model behind every survival dataset:

# A small illustrative cohort: time = follow-up length, status = 1 event / 0 censored
pt   <- data.frame(
  id     = 1:6,
  time   = c(5, 9, 3, 11, 7, 12),
  status = c(1, 0, 1, 0,  1, 0)   # 1 = event (death), 0 = censored (event-free at last contact)
)

plot(NA, xlim = c(0, 13), ylim = c(0.5, 6.5),
     xlab = "Follow-up time (months)", ylab = "Patient", yaxt = "n",
     main = "Who had the event, who was censored")
axis(2, at = 1:6, labels = paste("P", 1:6))
abline(v = 12, lty = 2, col = "grey60")           # data cutoff
text(12, 6.4, "data cutoff", pos = 2, col = "grey40", cex = 0.8)

for (i in 1:6) {
  segments(0, pt$id[i], pt$time[i], pt$id[i], col = "#3a86d4", lwd = 3)
  if (pt$status[i] == 1) {
    points(pt$time[i], pt$id[i], pch = 19, col = "#d1495b", cex = 1.6)   # event
  } else {
    points(pt$time[i], pt$id[i], pch = ">", col = "#3a86d4", cex = 1.6)  # censored
  }
}
legend("bottomright", bty = "n",
       legend = c("event (death)", "censored (event-free)"),
       pch = c(19, 62), col = c("#d1495b", "#3a86d4"))

A follow-up timeline for six patients. Each patient is a horizontal line running from study entry at time zero to their last contact. Patients 1, 3, and 5 end in a solid dot marking the observed event (death). Patients 2, 4, and 6 end in an open right-pointing arrow marking that they were still event-free at last follow-up (right-censored). A dashed vertical line marks the study data-cutoff time, after which no events can be observed.

The red dots are events; the blue arrows are censored patients — they were event-free when you last saw them, so their true survival time is at least that long. Survival analysis exists to use both kinds of patient correctly. Now let’s encode that pair in R.

The Surv() object

Surv() packages time and status into the single response object every survival function reads. Use the lung dataset (228 advanced lung-cancer patients, ships with survival — reference it directly, no data() call):

library(survival)

# In lung: time = follow-up days; status = 1 censored, 2 = dead
head(Surv(lung$time, lung$status))
[1]  306   455  1010+  210   883  1022+

Read the output: 306 and 455 are deaths; 1010+ carries a +, marking a censored patient — alive at 1010 days, so all we know is their survival exceeds 1010 days. That + is the printed signal of censoring, and it is the first thing to check when you build a Surv() object: do the censored patients have the plus sign? If everyone, or no one, has it, your status coding is wrong.

Status coding: 0/1 or 1/2 (the gotcha)

Surv() accepts two conventions for status, and it infers which one you used from the values present:

  • 0 = censored, 1 = event — the most common convention (and what most other packages expect).
  • 1 = censored, 2 = event — the convention the lung dataset happens to use.
  • A logical TRUE/FALSE (event/censored) also works.

Both produce an identical Surv object — what matters is that the larger code is the event:

library(survival)

# Same four patients, two equivalent codings
Surv(c(5, 8, 12, 3), c(1, 0, 1, 0))   # 0 = censored, 1 = event
[1]  5   8+ 12   3+
Surv(c(5, 8, 12, 3), c(2, 1, 2, 1))   # 1 = censored, 2 = event
[1]  5   8+ 12   3+

Both print 5 8+ 12 3+ — patients 2 and 4 are censored either way. The trap: if your data uses 1 = dead, 0 = alive but you think it’s 1 = censored, 2 = event, or you feed a 0/1/2-mixed column, Surv() may silently read the event flag backwards. Then every censored patient is treated as an event and vice versa — the survival curve, median, and hazard ratios all invert, with no error message. Always confirm with a quick cross-tab that the code you call “event” really is the death/relapse:

library(survival)

# Confirm the coding before you trust it: how many events vs censored?
table(status = lung$status)   # 1 = censored (63), 2 = dead (165) in lung
status
  1   2 
 63 165 

165 deaths and 63 censored — that matches what we expect from this cohort, so the 1/2 coding is right. If a column were labelled dead with 1 = dead, you would recode the event to the larger value (or pass event =) so Surv() reads it correctly.

Right censoring is the default

The Surv() calls above all use right censoring — the overwhelmingly common case, where the event hasn’t happened yet at last contact (the subject is still event-free, lost to follow-up, or the study ended). The time is a lower bound on the true event time. Because it’s the default, you don’t pass a type argument; Surv(time, status) is right-censored survival data.

The next two sections cover the two less-common censoring types — reach for them only when your data actually has that structure.

Left and interval censoring

Left censoring

Left censoring means the event had already happened before you first observed the subject — the true event time is an upper bound. Classic example: at a patient’s first clinic visit a biomarker is already elevated, so the change occurred at some unknown time before that visit. Pass type = "left":

library(survival)

# status: 1 = event already occurred (left-censored handling), 0 = exact/right
Surv(c(5, 8, 12), c(1, 0, 1), type = "left")
[1]  5   8- 12 

A - after the time (e.g. 8-) marks the left-censored observation — the event happened at or before that time.

Interval censoring

Interval censoring is the realistic case for studies with scheduled assessment visits: you know the event happened between two visits but not exactly when. A tumour is absent at the month-6 scan and present at the month-9 scan — progression occurred somewhere in (6, 9]. Encode it with type = "interval2", giving a lower and upper time per subject; NA on one side means open-ended:

library(survival)

# lower = last time known event-free; upper = first time known event had occurred
lower <- c(2, 4, NA, 6)    # NA lower = left-censored (event before `upper`)
upper <- c(NA, 5, 3, 8)    # NA upper = right-censored (still event-free after `lower`)
Surv(lower, upper, type = "interval2")
[1] 2+     [4, 5] 3-     [6, 8]

Read the print-out: 2+ is right-censored (event-free past 2, upper unknown), [4, 5] is interval- censored (event in that window), 3- is left-censored (event by time 3), and [6, 8] is another interval. interval2 is the one format that expresses all censoring types at once, which is why it’s the practical choice when visit-based data is mixed. Interval-censored data needs methods that respect the interval (e.g. survfit with the Turnbull estimator, or parametric models) — a standard Kaplan-Meier, which assumes exact or right-censored times, is not appropriate.

The counting-process layout: (start, stop, event)

Everything so far has one row per subject. Two situations break that: a covariate whose value changes during follow-up (a time-varying covariate), and recurrent events (a patient who can have the event more than once). Both need the counting-process layout — split each subject into consecutive time intervals, one row per interval, with three columns:

  • start — when the interval opens (the subject becomes at risk for this row);
  • stop — when it closes;
  • event — whether the event happened at stop (1) or the interval ended event-free (0).

You build the Surv response with Surv(start, stop, event). Here is one patient split into two intervals — say a treatment that switches on at time 7:

library(survival)

# One patient, two intervals: event-free (0,7], then the event at 15
# Counting-process form is Surv(start, stop, event) — the first two args are the interval ends
Surv(c(0, 7), c(7, 15), c(0, 1))
[1] (0, 7+] (7,15] 

The print-out (0, 7+] then (7, 15] shows the half-open intervals: the first ends censored (the patient was still event-free at 7, where the covariate changed), the second ends with the event at 15. The intervals for one subject must be contiguous and non-overlapping (each start equals the previous stop).

The colon dataset is a real counting-process-style example — each patient has up to two rows, one for recurrence and one for death:

library(survival)

# colon has multiple rows per patient id (recurrence + death)
head(colon[, c("id", "time", "status", "etype", "rx")], 4)
  id time status etype      rx
1  1 1521      1     2 Lev+5FU
2  1  968      1     1 Lev+5FU
3  2 3087      0     2 Lev+5FU
4  2 3087      0     1 Lev+5FU

Patient 1 appears on two rows (different etype), patient 2 on two more. Counting-process data is the general structure all the others are special cases of — a single-interval (0, time, status) row is the ordinary right-censored case. Building these intervals by hand is error-prone; the survival package’s tmerge() automates it, covered in time-varying covariates.

Validate before you model

Survival methods give you a curve and a p-value from any numeric input — they don’t know a time of -5 is impossible or that a status of 3 is meaningless. A single bad row silently corrupts everything downstream. Run these base-R checks before the first survfit(). Build a deliberately messy frame to see each check fire:

# A messy clinical extract — every row has a different problem
dat <- data.frame(
  id     = c(101, 102, 102, 103, 104, 105),
  time   = c(120,  -5,   0,  340,  88,  200),
  status = c(  1,   1,   1,    3,  NA,   0)
)
dat
   id time status
1 101  120      1
2 102   -5      1
3 102    0      1
4 103  340      3
5 104   88     NA
6 105  200      0

1. Impossible times — zero or negative. Follow-up time must be positive. A 0 or negative time is a date-arithmetic error (event before entry, swapped dates) and most functions will drop or choke on it:

# Flag any non-positive follow-up time
dat[which(dat$time <= 0), ]
   id time status
2 102   -5      1
3 102    0      1

Rows for patient 102 have time = -5 and time = 0 — both impossible. Fix the dates at source; never just delete the rows without understanding why.

2. Status that isn’t a clean event/censor code. The status column should contain only your two codes (0/1 or 1/2). Anything else — a stray 3, a typo — means a miscoded outcome:

# Which status values appear? Anything outside {0,1} (or {1,2}) is suspect
table(dat$status, useNA = "ifany")

   0    1    3 <NA> 
   1    3    1    1 

A value of 3 and an NA show up — a missing outcome and an out-of-range code, both of which must be resolved (an NA status can’t be analysed; an out-of-range code is a data error).

3. Duplicated IDs. In one-row-per-subject data, a repeated id means an accidental duplicate (it is expected only in counting-process/recurrent-event data, where you’d validate differently):

# IDs appearing more than once
dat$id[duplicated(dat$id)]
[1] 102

Patient 102 is duplicated. Decide whether it’s a genuine second interval (counting-process) or an erroneous copy to remove.

4. Ties. Ties — several events at the exact same time — are not an error, but they affect which methods/options you should use (e.g. the Cox model’s tie-handling, ties = "efron"). Know how many you have:

library(survival)

# Count event times shared by more than one death in the real lung data
event_times <- lung$time[lung$status == 2]
sum(table(event_times) > 1)   # number of time points with tied deaths
[1] 24

The lung data has many tied death times — normal for day-resolution follow-up, and the reason the Cox model defaults to a tie-correction.

The before-you-model checklist

NoteFive checks before the first survfit()
  1. Times are positive — no zero or negative follow-up (time <= 0).
  2. Status is a clean code — only your two values; no strays, no NA (table(status)).
  3. Event coding is the right way round — the larger code is the event; the Surv() print shows + on the censored patients, not the events.
  4. One row per subject (unless it’s counting-process data) — no accidental duplicate IDs.
  5. Censoring type matches the data — right by default; type = "left"/"interval2" only when the structure truly is left/interval-censored.

Try it live

Build a Surv() object and run the validation checks yourself — change the status coding, or break a time and watch the check catch it. The sandbox boots on first Run.

🟢 With an AI agent

Ask Prova “here’s my clinical spreadsheet — build the Surv(time, status) object, check whether my status is coded as 0/1 or 1/2, and validate the times and IDs before I fit a Kaplan-Meier curve” — it answers with survival code you can run on your own data and shows you the censoring signs to confirm the coding. The runtime is the judge. Ask Prova →

Common issues

Your survival curve looks upside-down (everyone “dies” immediately, or no one does). Your status coding is backwardsSurv() read your censored patients as events. Print the Surv() object and check the + signs: they must sit on the censored patients. If they’re on the events (or missing entirely), recode so the larger value is the event (0/1 or 1/2), then rebuild. This is the number-one survival bug.

survfit() errors or silently drops rows. Almost always an impossible time — a zero or negative follow-up time from swapped or mis-subtracted dates. Run dat[dat$time <= 0, ]; fix the dates at the source rather than deleting rows, since a negative time usually signals a systematic date error affecting more than that row.

A standard Kaplan-Meier on interval-censored data gives wrong-looking curves. Plain survfit() assumes exact or right-censored times. If your events are only known to fall between visits, you have interval-censored data — encode it with Surv(lower, upper, type = "interval2") and use a method that respects the interval (the Turnbull NPMLE or a parametric model), not the ordinary KM step function.

Frequently asked questions

A Surv object is the response that every survival function in R expects — it bundles the follow-up time with the status (event or censored) into one object. You build it with Surv(time, status) and put it on the left of the formula, e.g. survfit(Surv(time, status) ~ group, data). When printed, a + after a time marks a censored observation.

Use the status flag. Surv() accepts 0 = censored / 1 = event or 1 = censored / 2 = event (a logical FALSE/TRUE also works) — it infers the convention, and the larger value is always the event. A censored row means the subject was event-free at last contact, so its time is a lower bound. Confirm the coding by printing the Surv() object: the + signs must fall on the censored subjects.

Right censoring (the default): the event hasn’t happened yet at last contact — the time is a lower bound. Left censoring: the event already happened before the first observation — the time is an upper bound (type = "left"). Interval censoring: the event happened between two assessment times, known only to a window (type = "interval2", giving lower and upper times). Most clinical data is right-censored; visit-based studies often produce interval-censored progression data.

It’s a multi-row-per-subject layout where each row is a time interval (start, stop] with an event flag at stop. You build the response with Surv(start, stop, event). It’s required for time-varying covariates (the covariate takes a different value in each interval) and recurrent events (a subject has the event more than once). An ordinary single-interval row (0, time, status) is just its special case.

Run five base-R checks: (1) all times positive (time <= 0 flags impossible values); (2) status is a clean code with no strays or NA (table(status)); (3) the event coding is the right way round (the Surv() print shows + on the censored rows); (4) no duplicate IDs unless it’s counting-process data; and (5) the censoring type matches the data structure. A single bad row silently biases every curve and hazard ratio.

Test your understanding

  1. Build and read it. In the live cell below, build a Surv() object from these five patients — time = c(8, 12, 5, 20, 15), status = c(1, 0, 1, 0, 1) (0 = censored, 1 = event). Which patients carry a +, and what does it mean about their true event time?
  2. Spot the bug. A colleague’s Kaplan-Meier curve drops to zero almost immediately. They coded status = 1 for “alive at last follow-up” and status = 2 for “died”. What did Surv() treat as the event, and how should they fix it?

Print the Surv() object and look at where the + signs land — those are the censored patients (event-free at last contact). For question 2, remember that Surv() always treats the larger status value as the event.

library(survival)
Surv(c(8, 12, 5, 20, 15), c(1, 0, 1, 0, 1))
#> [1]  8  12+  5  20+ 15

The + lands on patients 2 (time 12) and 4 (time 20) — the censored ones. Their times are lower bounds: patient 4 was event-free at 20 and may survive much longer.

For question 2: the colleague used 1 = alive (censored), 2 = died (event) — a valid Surv() coding (larger = event). So that part is fine. If the curve still collapses, the real bug is the opposite mistake: feeding a 1 = dead, 0 = alive column while assuming 1 = censored. The fix is always the same — print the Surv() object and verify the + signs sit on the censored patients; if they don’t, recode so the larger value is the event and rebuild.

TipQuick check

You build Surv(time, status) and the printout shows a + after almost every time. The dataset is a cohort where most patients died during follow-up. What has gone wrong?

The status coding is backwards. A + marks a censored patient, so “a + on almost every row” means Surv() is treating nearly everyone as censored — but you said most patients died. Your event flag is inverted (e.g. you passed 1 = alive where the event should be the larger code). Recode so the event is the larger value (0/1 or 1/2) and rebuild; the + signs should then appear only on the genuinely censored patients.

Conclusion

You can now turn a raw clinical spreadsheet into an analysis-ready survival dataset: package the follow-up time and outcome into a Surv(time, status) object, get the status coding right (the 0/1 vs 1/2 trap, confirmed by the + signs on censored rows), handle right, left, and interval censoring with the matching type, structure time-varying and recurrent data in the counting-process (start, stop, event) layout, and run a short base-R validation for impossible times, bad status codes, duplicate IDs, and ties. With the data clean and correctly encoded, you’re ready to estimate your first Kaplan-Meier curve.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {How to {Format} {Survival} {Data} in {R:} {The} {Surv()}
    {Object,} {Censoring} \& {Validation}},
  date = {2026-06-26},
  url = {https://www.datanovia.com/learn/biostatistics/survival-analysis/preparing-survival-data},
  langid = {en}
}
For attribution, please cite this work as:
“How to Format Survival Data in R: The Surv() Object, Censoring & Validation.” 2026. June 26. https://www.datanovia.com/learn/biostatistics/survival-analysis/preparing-survival-data.