
Clinical Data Listings in R: Patient Listings and RTF Output with rlistings
Build the row-per-observation appendix of a Clinical Study Report — a patient adverse-event listing with repeated key columns blanked and long output paginated — with rlistings, then export it to a submission-grade RTF with r2rtf, on public pharmaverse data
A complete, runnable tutorial for the clinical data listing — the row-per-observation appendix of a Clinical Study Report that shows every record for every subject, unsummarized. Learn what a listing is and how it differs from a summary table, then build a patient adverse-event listing with rlistings: as_listing() with key_cols (the repeated columns blanked on repeat) and disp_cols, paginate_listing() for the long output, and finally a submission-grade RTF exported with r2rtf’s rtf_title / rtf_colheader / rtf_body pipe — on public pharmaverseadam ADAE data, so every line runs.
- A clinical data listing is the row-per-observation appendix of a Clinical Study Report — it shows every record for every subject, unsummarized. Where a table answers “how many?”, a listing answers “show me each one.” It is the traceable, auditable backing for the summary tables.
- Listings are not deduplicated. A subject with three adverse events gets three rows. This is the opposite of the adverse-event table, which counts each subject once — and it is exactly why regulators ask for both.
rlistingsbuilds the listing object;r2rtfrenders the deliverable.as_listing()makes a structuredlisting_dfwith key columns (repeated values blanked on repeat) and display columns; the directr2rtfpipe renders it to a submission RTF.- Key columns are blanked on repeat.
key_cols = c("USUBJID", "TRT01A")prints the subject ID and arm on the first of a subject’s rows and leaves them blank on the rest — the listing hallmark that makes a long, dense appendix readable. - Long listings paginate.
paginate_listing()splits a listing into fixed-length pages without ever cutting a subject’s block across a page boundary awkwardly — what turns thousands of rows into a paged submission document.
Introduction
A regulator asks to see every adverse event in the trial, subject by subject: not “how many subjects had a headache”, but the actual rows — this subject, this event, this severity, on this study day. That request is answered by a clinical data listing: the row-per-observation appendix that sits behind every summary table in a Clinical Study Report (CSR). A listing shows the data as recorded, unsummarized, so a reviewer can trace any number in a table back to the individual observations that produced it.
Listings live in the appendices of the CSR — section 16 (the Appendices) of the ICH E3 guideline, which defines the structure and content of a study report; §16 collects the study’s supporting material and includes the individual patient data listings (§16.2 and §16.4). They are the least glamorous and most voluminous output in the TLF (tables, listings, and figures) workflow, and for a large study they can run to thousands of pages. That is precisely why they need tooling: a listing must blank repeated key values so it stays readable, paginate cleanly, and export to the RTF (Rich Text Format) that regulatory submissions use.
We build one the industry way — with the pharmaverse rlistings package for the listing object and r2rtf for the RTF — on public pharmaverse data, so every line runs as written. The example is a patient adverse-event (AE) listing, the most common listing in the safety appendix.
Here is the shape of the data we are about to lay out — how many adverse-event records each subject contributes. Most have a handful; a few have many. A summary table hides this spread; a listing shows every one of these rows, which is why it needs pagination:
By the end you will have turned these records into a proper patient listing — key columns blanked, output paginated, and an RTF file written to disk — the deliverable a clinical programmer hands to the submission. If “ADAE”, “safety population”, or “treatment-emergent” are new, the earlier lessons on building the analysis datasets cover them; here we start from analysis-ready data and focus on the listing.
What a clinical data listing is
A listing and a summary table are built from the same data but answer different questions. The table aggregates — it counts subjects, computes percentages, collapses. The listing enumerates — it prints every record, in order, with nothing thrown away. Both ship in the CSR because they serve different readers: the table for the person reading the result, the listing for the person who needs to verify it.
A well-formed listing has a fixed anatomy:
| Part | What it is | In this lesson |
|---|---|---|
| Rows | One row per observation — not per subject | One row per adverse-event record |
| Key columns | The identifying columns, repeated down the rows but blanked on repeat | USUBJID (unique subject identifier), TRT01A (actual treatment arm) |
| Display columns | The data columns shown for each record | AE term, severity, serious flag, study day |
| Ordering | A deterministic sort so the same data always lays out the same way | By subject, then study day |
| Pagination | Fixed-length pages for a paged document, subjects kept intact | paginate_listing() splits the rows |
Two conventions make a listing a listing rather than a printed data frame:
- Key columns are blanked on repeat. The subject ID and arm print once, on a subject’s first row, and are left blank on that subject’s remaining rows. Your eye groups the block without the identifier shouting on every line.
rlistingsdoes this for any column you name inkey_cols. - Nothing is deduplicated or summarized. If a subject reported the same preferred term three times, all three rows appear. That is the point — the listing is the audit trail. (The adverse-event table, by contrast, would count that subject once.)
rlistings vs rtables — which do I use?
Both are pharmaverse layout packages, and they are complementary, not competing. Pick by what the output is:
| rlistings | rtables / tern | |
|---|---|---|
| Produces | A listing — every record, one row per observation | A table — counts, means, percentages, aggregated |
| Core object | listing_df from as_listing() |
TableTree from basic_table() + layout functions |
| Answers | “Show me each observation” | “How many / what is the average” |
| In the CSR | Section 16 appendices (patient data listings) | Section 14 summary tables |
| Deduplicates? | No — shows every row | Yes — counts each subject once |
A rule of thumb: if the reviewer needs to trace a number back to individual records, it is a listing (rlistings); if they need the summary a number rolls up to, it is a table (rtables with tern). Both export to RTF the same way, with r2rtf.
The data
We use the ADAE dataset — the adverse-event ADaM (Analysis Data Model) dataset, one row per adverse-event record — shipped in pharmaverseadam, the synthetic, license-free CDISC (Clinical Data Interchange Standards Consortium) pilot study comparing placebo against two doses of Xanomeline. The pharmaverse datasets are tibbles, so the first move is to coerce to a plain data.frame — rlistings and r2rtf expect base data frames, and passing a tibble is the single most common source of a confusing error downstream.
We keep the treatment-emergent adverse events for the safety population: SAFFL == "Y" (the safety-population flag — every subject who took at least one dose) and TRTEMFL == "Y" (the treatment-emergent flag — events that started on or after the first dose). A treatment-emergent adverse event (TEAE) is the safety analysis’s unit of interest, so this is the standard AE-listing filter.
library(pharmaverseadam)
# ADAE is a tibble — coerce to a plain data.frame for rlistings / r2rtf.
adae <- as.data.frame(pharmaverseadam::adae)
# Treatment-emergent AEs, safety population.
teae <- adae[adae$SAFFL == "Y" & adae$TRTEMFL == "Y", ]
# The columns the listing will show, ordered by subject then study day.
cols <- c("USUBJID", "TRT01A", "AEDECOD", "AESEV", "AESER", "ASTDY")
teae <- teae[order(teae$USUBJID, teae$ASTDY), cols]
nrow(teae) # every treatment-emergent AE record becomes a listing row[1] 1191
head(teae, 6) USUBJID TRT01A AEDECOD AESEV AESER ASTDY
1 01-701-1015 Placebo APPLICATION SITE ERYTHEMA MILD N 2
2 01-701-1015 Placebo APPLICATION SITE PRURITUS MILD N 2
3 01-701-1015 Placebo DIARRHOEA MILD N 8
4 01-701-1023 Placebo ERYTHEMA MODERATE N 3
5 01-701-1023 Placebo ERYTHEMA MILD N 3
6 01-701-1023 Placebo ERYTHEMA MILD N 3
The filter keeps well over a thousand records — one row per adverse event, exactly what the listing will enumerate. The six columns are the two key columns (USUBJID, the unique subject identifier; TRT01A, the actual treatment arm) and four display columns: the dictionary-derived preferred term (AEDECOD), the severity (AESEV), the serious flag (AESER), and the study day the event started (ASTDY). We ordered the rows by subject and then study day so the listing is deterministic — the same data always lays out the same way, which a submission requires.
To keep the printed output on this page readable, we build the listing from the first several subjects. In a real submission you would pass the whole teae frame; nothing else changes.
library(pharmaverseadam)
adae <- as.data.frame(pharmaverseadam::adae)
teae <- adae[adae$SAFFL == "Y" & adae$TRTEMFL == "Y", ]
cols <- c("USUBJID", "TRT01A", "AEDECOD", "AESEV", "AESER", "ASTDY")
teae <- teae[order(teae$USUBJID, teae$ASTDY), cols]
# first 15 records — a legible slice for the page
lst_data <- head(teae, 15)Build the listing with rlistings
as_listing() turns a data frame into a structured listing_df. You tell it two things: which columns are key columns (key_cols — repeated identifiers that get blanked on repeat) and which are display columns (disp_cols — the data shown for each record). Everything else about how a listing looks — the blanking, the alignment, the spacing — follows from that.
library(rlistings)
library(pharmaverseadam)
adae <- as.data.frame(pharmaverseadam::adae)
teae <- adae[adae$SAFFL == "Y" & adae$TRTEMFL == "Y", ]
cols <- c("USUBJID", "TRT01A", "AEDECOD", "AESEV", "AESER", "ASTDY")
teae <- teae[order(teae$USUBJID, teae$ASTDY), cols]
lst_data <- head(teae, 15)
lsting <- as_listing(
lst_data,
key_cols = c("USUBJID", "TRT01A"), # blanked on repeat
disp_cols = c("AEDECOD", "AESEV", "AESER", "ASTDY") # shown for every record
)
lsting USUBJID TRT01A AEDECOD AESEV AESER ASTDY
————————————————————————————————————————————————————————————————————————————————————————————————————
01-701-1015 Placebo APPLICATION SITE ERYTHEMA MILD N 2
APPLICATION SITE PRURITUS MILD N 2
DIARRHOEA MILD N 8
01-701-1023 Placebo ERYTHEMA MODERATE N 3
ERYTHEMA MILD N 3
ERYTHEMA MILD N 3
ATRIOVENTRICULAR BLOCK SECOND DEGREE MILD N 22
01-701-1028 Xanomeline High Dose APPLICATION SITE ERYTHEMA MILD N 3
APPLICATION SITE PRURITUS MILD N 21
01-701-1034 Xanomeline High Dose APPLICATION SITE PRURITUS MILD N 58
FATIGUE MILD N 125
01-701-1047 Placebo HIATUS HERNIA MODERATE N 1
HIATUS HERNIA MODERATE N 1
UPPER RESPIRATORY TRACT INFECTION MILD N 23
BUNDLE BRANCH BLOCK LEFT MILD N 27
Read the printed listing top to bottom and the blanking is obvious: a subject’s ID and arm appear on the first of their rows and are blank on the rest, so each subject reads as one visual block. Notice too that nothing was collapsed — where a subject has the same preferred term on more than one record, both rows are present. That is the listing doing its job: it is the row-by-row audit trail, not a summary.
The object lsting is a listing_df — it still behaves like a data frame (you can subset it), but it carries the layout instructions rlistings and r2rtf need to render it correctly.
Paginate a long listing
A real listing is far too long for one screen or page. paginate_listing() splits it into pages of a fixed number of lines per page (lpp), so it becomes a paged document. It returns a list — one listing_df per page — and keeps the layout (header, key-column blanking) on every page.
library(rlistings)
library(pharmaverseadam)
adae <- as.data.frame(pharmaverseadam::adae)
teae <- adae[adae$SAFFL == "Y" & adae$TRTEMFL == "Y", ]
cols <- c("USUBJID", "TRT01A", "AEDECOD", "AESEV", "AESER", "ASTDY")
teae <- teae[order(teae$USUBJID, teae$ASTDY), cols]
lst_data <- head(teae, 15)
lsting <- as_listing(
lst_data,
key_cols = c("USUBJID", "TRT01A"),
disp_cols = c("AEDECOD", "AESEV", "AESER", "ASTDY")
)
pages <- paginate_listing(lsting, lpp = 8) # ~8 lines per page--- Page 1/3 ---
USUBJID TRT01A AEDECOD AESEV AESER ASTDY
————————————————————————————————————————————————————————————————————————————————————————————————————
01-701-1015 Placebo APPLICATION SITE ERYTHEMA MILD N 2
APPLICATION SITE PRURITUS MILD N 2
DIARRHOEA MILD N 8
01-701-1023 Placebo ERYTHEMA MODERATE N 3
ERYTHEMA MILD N 3
ERYTHEMA MILD N 3
--- Page 2/3 ---
USUBJID TRT01A AEDECOD AESEV AESER ASTDY
————————————————————————————————————————————————————————————————————————————————————————————————————
01-701-1023 Placebo ATRIOVENTRICULAR BLOCK SECOND DEGREE MILD N 22
01-701-1028 Xanomeline High Dose APPLICATION SITE ERYTHEMA MILD N 3
APPLICATION SITE PRURITUS MILD N 21
01-701-1034 Xanomeline High Dose APPLICATION SITE PRURITUS MILD N 58
FATIGUE MILD N 125
01-701-1047 Placebo HIATUS HERNIA MODERATE N 1
--- Page 3/3 ---
USUBJID TRT01A AEDECOD AESEV AESER ASTDY
————————————————————————————————————————————————————————————————————————————————————————————————————
01-701-1047 Placebo HIATUS HERNIA MODERATE N 1
UPPER RESPIRATORY TRACT INFECTION MILD N 23
BUNDLE BRANCH BLOCK LEFT MILD N 27
length(pages) # number of pages this listing produces[1] 3
cat(toString(pages[[1]])) # the first page, formatted USUBJID TRT01A AEDECOD AESEV AESER ASTDY
————————————————————————————————————————————————————————————————————————————————————————————————————
01-701-1015 Placebo APPLICATION SITE ERYTHEMA MILD N 2
APPLICATION SITE PRURITUS MILD N 2
DIARRHOEA MILD N 8
01-701-1023 Placebo ERYTHEMA MODERATE N 3
ERYTHEMA MILD N 3
ERYTHEMA MILD N 3
With lpp = 8 our 15-row slice becomes a small number of pages; the first is shown above, with the same header and blanking as the full listing. In a submission you set lpp to match the page geometry (font, margins, orientation) so each printed page fills correctly — the r2rtf step below owns the final page layout, and paginate_listing() is how you preview and control the row splitting.
Export to a submission RTF with r2rtf
The listing object is the review artifact; the RTF file is the regulatory deliverable. r2rtf renders a data frame to the Rich Text Format that submission documents use, through a small pipe of layer functions: rtf_title() sets the title and subtitle, rtf_colheader() the column headings, rtf_body() the data (with column widths and per-column justification), then rtf_encode() and write_rtf() produce the file.
We render the listing data (a plain data frame of the rows) directly with the r2rtf pipe — the canonical pharmaverse pattern. Column widths are given as relative weights (col_rel_width), and justification is one letter per column ("l" left, "c" centre). We write to a temporary file and report its size so nothing is left in the project.
library(r2rtf)
library(pharmaverseadam)
adae <- as.data.frame(pharmaverseadam::adae)
teae <- adae[adae$SAFFL == "Y" & adae$TRTEMFL == "Y", ]
cols <- c("USUBJID", "TRT01A", "AEDECOD", "AESEV", "AESER", "ASTDY")
teae <- teae[order(teae$USUBJID, teae$ASTDY), cols]
lst_data <- head(teae, 15)
out <- tempfile(fileext = ".rtf")
lst_data |>
rtf_title(
"Listing of Treatment-Emergent Adverse Events",
"Safety Population"
) |>
rtf_colheader(
"Subject | Treatment | AE Term | Severity | Serious | Study Day",
col_rel_width = c(3, 3, 4, 2, 2, 2)
) |>
rtf_body(
col_rel_width = c(3, 3, 4, 2, 2, 2),
text_justification = c("l", "l", "l", "c", "c", "c")
) |>
rtf_encode() |>
write_rtf(out)
file.info(out)$size # bytes written — a real RTF the submission can open[1] 15238
write_rtf() returns the path and writes the file; file.info()$size confirms a non-empty RTF was produced. Open it in Word or any RTF reader and you get the titled, column-headed, paginated listing — the deliverable. The col_rel_width weights (here 3,3,4,2,2,2) share the page width in proportion, so the AE term gets the widest column and the flags the narrowest; they must have one entry per column in both rtf_colheader() and rtf_body().
One deliberate detail: we sent the listing data (lst_data) through the r2rtf pipe, not the listing_df object. The direct data-frame pipe is the robust, canonical pharmaverse pattern for RTF output; it gives you full control over titles, column widths, and justification. rlistings owns the on-screen review object; r2rtf owns the submission file. Two tools, two jobs.
Ask Prova “how do I add a page-by-page footnote and a ‘Page x of y’ footer to my r2rtf listing?” — it answers grounded in this pillar’s lessons, with runnable rlistings and r2rtf code you can try on the example pharmaverse data. The runtime is the judge. Ask Prova →
Common issues
You passed a tibble and got a cryptic error. The pharmaverse datasets (pharmaverseadam::adae) are tibbles, and both rlistings and r2rtf expect a plain data.frame. A tibble can trip the layout or the RTF encoder with an error that does not mention tibbles at all. Coerce first: adae <- as.data.frame(pharmaverseadam::adae). This is the single most common listing bug.
formatters::export_as_rtf() errors on your listing. It is tempting to export the listing_df object with formatters::export_as_rtf(), but that bridge into r2rtf is fragile and can fail inside rtf_strwidth. Use the direct r2rtf pipe on the listing data instead (lst_data |> rtf_title() |> rtf_colheader() |> rtf_body() |> rtf_encode() |> write_rtf()), as above — it is the canonical pharmaverse pattern and gives you explicit control of the layout.
The key columns are not blanking. Blanking on repeat only happens for columns named in key_cols, and only when equal values are adjacent. If the subject ID keeps printing on every row, either you did not list it in key_cols or the data is not sorted — as_listing() blanks a repeated value only when the identical value sits directly above. Sort by your key columns first (teae[order(teae$USUBJID), ]).
col_rel_width has the wrong length. rtf_colheader() and rtf_body() each need one width per column, and text_justification one letter per column. A vector that is too short or too long throws an error or silently misaligns the RTF. Count your display columns (plus the key columns you print) and match the vector length exactly — here six columns, so col_rel_width = c(3, 3, 4, 2, 2, 2).
Frequently asked questions
A clinical data listing is the row-per-observation appendix of a Clinical Study Report — it prints every record for every subject, unsummarized, so a reviewer can trace any number in a summary table back to the individual observations behind it. Listings sit in section 16 (the Appendices) of the ICH E3 guideline, as the individual patient data listings (§16.2 and §16.4). Unlike a table, a listing is not deduplicated: a subject with three adverse events appears on three rows.
Use rlistings for a listing (every record, one row per observation — the audit trail) and rtables (usually with tern) for a summary table (counts, means, percentages — aggregated). Rule of thumb: if the reviewer needs to trace a number back to individual records, it is a listing; if they need the summary the records roll up to, it is a table. Both export to RTF with r2rtf.
Use the r2rtf package. Pipe your data frame through the layer functions: df |> rtf_title("Title", "Subtitle") |> rtf_colheader("Col A | Col B", col_rel_width = c(3, 2)) |> rtf_body(col_rel_width = c(3, 2)) |> rtf_encode() |> write_rtf("out.rtf"). rtf_encode() builds the RTF string and write_rtf() writes the file — the Rich Text Format that regulatory submissions use, openable in Word.
That is key-column blanking, the listing hallmark. Columns named in key_cols print their value on the first of a group of identical adjacent rows and are left blank on the rest, so each subject reads as one visual block instead of repeating the identifier on every line. It only works when the data is sorted by those key columns, so the identical values are adjacent.
Use paginate_listing() from rlistings: paginate_listing(lsting, lpp = 40) splits the listing into pages of about lpp lines each and returns a list of one listing_df per page, repeating the header and key-column blanking on every page. Set lpp to match your page geometry so each printed page fills correctly; the final page layout is owned by the r2rtf export step.
Test your understanding
Starting from the treatment-emergent adverse-event data in this lesson, build a listing of the serious adverse events only (AESER == "Y"), keeping the same key columns (USUBJID, TRT01A) and display columns (AEDECOD, AESEV, ASTDY), then export it to an RTF with a title that says “Listing of Serious Treatment-Emergent Adverse Events”. How many rows does the serious-AE listing have compared with the full one, and why is that the listing regulators read first?
Add one more condition to the base-R subset: teae[teae$AESER == "Y", ]. Everything else — the as_listing() call and the r2rtf pipe — is unchanged; you are just filtering the rows before you lay them out. Remember to coerce the tibble with as.data.frame() first, and match the col_rel_width length to your number of columns (five here: two key + three display).
library(rlistings)
library(r2rtf)
library(pharmaverseadam)
adae <- as.data.frame(pharmaverseadam::adae)
# treatment-emergent AND serious, safety population
ser <- adae[adae$SAFFL == "Y" & adae$TRTEMFL == "Y" & adae$AESER == "Y", ]
cols <- c("USUBJID", "TRT01A", "AEDECOD", "AESEV", "ASTDY")
ser <- ser[order(ser$USUBJID, ser$ASTDY), cols]
nrow(ser) # far fewer rows than the full TEAE listing
# the listing object (key-column blanking, display columns)
lsting <- as_listing(
ser,
key_cols = c("USUBJID", "TRT01A"),
disp_cols = c("AEDECOD", "AESEV", "ASTDY")
)
lsting
# export to a submission RTF
out <- tempfile(fileext = ".rtf")
ser |>
rtf_title("Listing of Serious Treatment-Emergent Adverse Events", "Safety Population") |>
rtf_colheader("Subject | Treatment | AE Term | Severity | Study Day",
col_rel_width = c(3, 3, 4, 2, 2)) |>
rtf_body(col_rel_width = c(3, 3, 4, 2, 2),
text_justification = c("l", "l", "l", "c", "c")) |>
rtf_encode() |>
write_rtf(out)
file.info(out)$sizeThe serious-AE listing has far fewer rows than the full treatment-emergent listing — serious events are, thankfully, the minority. It is the listing safety reviewers read first because a serious adverse event (one that is life-threatening, causes hospitalization, or is otherwise medically important) drives the trial’s benefit-risk assessment; regulators want to see each one, subject by subject, not just a count.
A. A listing counts each subject once; a table shows every record B. A listing shows every record (one row per observation); a table aggregates and counts each subject once C. They are the same output in two file formats
B. A listing enumerates — one row per observation, nothing deduplicated, so a subject with three adverse events has three rows (rlistings). A table aggregates — it counts each subject once and reports n and percent (rtables/tern). A reverses the two; C is wrong because they answer different questions (trace-the-record vs summarize-the-result), which is why the CSR carries both.
Conclusion
A clinical data listing is the row-per-observation backbone of a Clinical Study Report — the appendix that shows every record so a reviewer can verify every table. Building one comes down to two tools with two jobs: rlistings makes the listing object with as_listing() — key columns blanked on repeat, display columns shown, and paginate_listing() for the length — and r2rtf renders the submission file through the rtf_title() |> rtf_colheader() |> rtf_body() |> rtf_encode() |> write_rtf() pipe. Get three things right — coerce the tibble to a data.frame, name the correct key_cols on sorted data so blanking works, and match the col_rel_width length to your columns — and you have produced the auditable, paginated, submission-ready listing that sits behind every summary table in the report.
This lesson is reproducible: every result on this page was produced by the code shown — copy any block and run it to reproduce them. The runtime is the judge.
Reuse
Citation
@online{2026,
author = {},
title = {Clinical {Data} {Listings} in {R:} {Patient} {Listings} and
{RTF} {Output} with Rlistings},
date = {2026-07-01},
url = {https://www.datanovia.com/learn/pharma-clinical/04-tlf-generation/patient-listings-rlistings},
langid = {en}
}