Author: Bhanoji Duppada
Edition: First Edition, 2026
Website: learnhub101.com
This work was created with AI assistance under the direction and editorial supervision of Bhanoji Duppada. All technical content has been reviewed, verified, and edited by the author. The author retains full copyright and creative responsibility.
Copyright ยฉ 2026 Bhanoji Duppada. All rights reserved.
No part of this publication may be reproduced or transmitted without prior written permission of the author.
By Bhanoji Duppada
50+ Ready-to-Use Recipes for Every Standard Clinical Output
Tables, Listings, and Figures (TLFs) consume 40-60% of a clinical programmer's time. This cookbook eliminates the guesswork by providing tested, copy-paste-ready recipes for every standard output in a clinical study report.
Each recipe follows a consistent format: the table shell (what the output should look like), the SAS code to produce it, the R equivalent using gtsummary/gt/rtables, and notes on customization. Think of it as your personal TLF library --- just pick the recipe, plug in your data, and generate.
The pharmaceutical industry demands precision, reproducibility, and regulatory compliance in every output. Whether you're producing a Phase I safety summary or a Phase III efficacy analysis for a pivotal submission, the fundamental challenge remains the same: transform complex clinical data into clear, standardized presentations that tell the story regulators and medical teams need to see. This book addresses that challenge head-on by providing battle-tested solutions developed across dozens of clinical trials and therapeutic areas.
Clinical programmers face a paradox. On one hand, TLF production is highly standardized---most outputs follow predictable patterns established by ICH E3 guidelines and company conventions. On the other hand, every study brings unique wrinkles: different treatment arms, varying visit schedules, protocol-specific endpoints, and sponsor-specific formatting requirements.
Existing resources tend to fall into two camps: academic texts that explain concepts without practical code, or company-specific macro libraries that don't translate across organizations. This cookbook bridges that gap by providing:
The goal is simple: reduce the time from receiving a table shell to delivering a validated output from days to hours.
Every recipe in this book follows a consistent five-part structure:
Use the table of contents to locate outputs by ICH E3 section number (e.g., Table 14.1.x for demographics) or by output type. Each chapter groups related outputs together, building from simple to complex variations.
No two sponsors format tables identically. The code in this book follows common industry conventions, but you'll need to adjust:
Look for /* CUSTOMIZE */ comments in SAS code and # CUSTOMIZE comments in R code marking the most common modification points.
To illustrate the cookbook format, here's a condensed example showing how each recipe presents a demographics table:
+------------------------------------------------------------------+
| Table 14.1.1 |
| Demographics and Baseline Characteristics |
| Safety Population |
+------------------------------------------------------------------+
| | Placebo | Drug 10mg | Drug 20mg | Total |
| | (N=XX) | (N=XX) | (N=XX) | (N=XX) |
+--------------------------+-----------+-----------+-----------+----------|
| Age (years) | | | | |
| n | xx | xx | xx | xx |
| Mean (SD) | xx.x (xx.x)| xx.x (xx.x)| xx.x (xx.x)| xx.x (xx.x)|
| Median | xx.x | xx.x | xx.x | xx.x |
| Min, Max | xx, xx | xx, xx | xx, xx | xx, xx |
| Sex, n (%) | | | | |
| Male | xx (xx.x%)| xx (xx.x%)| xx (xx.x%)| xx (xx.x%)|
| Female | xx (xx.x%)| xx (xx.x%)| xx (xx.x%)| xx (xx.x%)|
| Race, n (%) | | | | |
| White | xx (xx.x%)| xx (xx.x%)| xx (xx.x%)| xx (xx.x%)|
| Black or African American| xx (xx.x%)| xx (xx.x%)| xx (xx.x%)| xx (xx.x%)|
| Asian | xx (xx.x%)| xx (xx.x%)| xx (xx.x%)| xx (xx.x%)|
| Other | xx (xx.x%)| xx (xx.x%)| xx (xx.x%)| xx (xx.x%)|
+--------------------------+-----------+-----------+-----------+----------+
| Source: ADSL | Program: t_dm.sas | Page 1/1 |
+------------------------------------------------------------------+
/*******************************************************************************
* Program: t_dm.sas
* Purpose: Table 14.1.1 - Demographics and Baseline Characteristics
* Input: ADSL (Safety Population)
* Output: t_14_1_1.rtf
* Author: Clinical Programming Team
* Date: 2026-01-15
*******************************************************************************/
/* CUSTOMIZE: Update libnames for your environment */
libname adam "/clinical/study001/adam" access=readonly;
libname output "/clinical/study001/output";
/* CUSTOMIZE: Update population flag and treatment variable */
%let popfl = SAFFL;
%let popval = Y;
%let trtvar = TRT01AN;
/*--- Step 1: Create analysis dataset ---*/
data work.adsl;
set adam.adsl;
where &popfl. = "&popval.";
/* Create Total column */
output;
&trtvar. = 99; /* Total treatment code */
output;
run;
/*--- Step 2: Calculate Big N denominators ---*/
proc sql noprint;
select count(distinct usubjid) into :n1-:n4
from work.adsl
group by &trtvar.
order by &trtvar.;
quit;
/*--- Step 3: Continuous variable statistics (Age) ---*/
proc means data=work.adsl nway noprint;
class &trtvar.;
var age;
output out=age_stats n=n mean=mean std=std median=median min=min max=max;
run;
data age_report;
set age_stats;
length col1-col4 $50 row_label $200;
array cols{4} $50 col1-col4;
row_order = 1;
row_label = "Age (years)";
/* Format statistics */
stat_n = strip(put(n, 8.));
stat_mean_sd = strip(put(mean, 8.1)) || " (" || strip(put(std, 8.2)) || ")";
stat_median = strip(put(median, 8.1));
stat_minmax = strip(put(min, 8.)) || ", " || strip(put(max, 8.));
run;
/*--- Step 4: Categorical variable frequencies (Sex, Race) ---*/
%macro cat_freq(var=, label=, order=);
proc freq data=work.adsl noprint;
tables &trtvar.*&var. / out=freq_&var. outpct;
run;
data freq_&var._report;
set freq_&var.;
length col_value $50 row_label $200;
row_order = &order.;
row_label = "&label.";
sub_label = vvalue(&var.);
col_value = strip(put(count, 8.)) || " (" || strip(put(pct_row, 5.1)) || "%)";
run;
%mend;
%cat_freq(var=sex, label=Sex, order=2);
%cat_freq
---
# Chapter 1: Introduction --- How to Use This Cookbook
## What This Book Is
This is a recipe book. Each chapter contains complete, production-ready programs for standard clinical tables, listings, and figures. Copy the code, adapt the variable names, and run it.
Unlike traditional programming textbooks that teach concepts in isolation, this cookbook mirrors how clinical programmers actually work: you receive a table shell, you need working code, and you need it by Thursday. Theory is valuable, but when you're staring at a blank editor with 47 tables to program, you need patterns that work.
Every recipe includes:
- **The table shell** --- What the output should look like, with annotations explaining each component
- **The SAS recipe** --- Complete production SAS program that compiles and runs against ADaM datasets
- **The R recipe** --- Equivalent R program using tidyverse, gtsummary, gt, and ggplot2
- **Shell template** --- A reusable specification showing exactly how the output maps to your table shell
- **QC checklist** --- Step-by-step verification points to ensure your output matches requirements
- **Key tips** --- Common mistakes and how to avoid them, drawn from real production experience
The code in this book is not simplified for teaching purposes. These are the same patterns used in actual NDA and BLA submissions. When you copy a demographics table recipe, you're getting code that handles the edge cases---subjects with missing data, treatment groups with zero counts, footnotes that wrap correctly in RTF output.
## How the Recipes Are Structured
Each recipe chapter follows a consistent format designed for rapid implementation:
**Section 1: The Table Shell**
Every recipe begins with the exact table shell you're trying to produce. This is your contract with the statistician and medical writer. We annotate each element---column headers, row labels, statistics, footnotes---so you understand what each piece of code must generate.
**Section 2: Data Requirements**
Before writing code, you need to know what ADaM datasets and variables the recipe requires. We list them explicitly: ADSL for demographics, ADAE for adverse events, ADLB for labs. No hunting through code to figure out dependencies.
**Section 3: The SAS Recipe**
Complete, runnable SAS code organized into clearly labeled sections that map to the universal workflow. Every macro variable is explained. Every PROC step includes comments describing its purpose.
**Section 4: The R Recipe**
The equivalent R implementation using modern tidyverse conventions. We use gtsummary for summary statistics, gt for table formatting, and ggplot2 for figures. The R code produces output that matches the SAS output cell-for-cell.
**Section 5: QC Checklist**
A numbered checklist for quality control. Use this when you QC a colleague's table or when preparing your own work for review. Each item specifies exactly what to verify and how.
**Section 6: Pro Tips and Common Mistakes**
Hard-won knowledge from production programming. These sections save you from the errors that waste hours: the missing NOPRINT option that floods your log, the format that truncates at 200 characters, the denominator that excludes the wrong population.
## The Universal TLF Workflow
Every clinical table follows the same 6-step workflow. Master this pattern once, and you'll recognize it in every table you ever program:
Step 1: Read table shell โ Identify population, variables, format, statistics
Step 2: Big N โ Count distinct subjects per treatment from ADSL
Step 3: Compute statistics โ PROC MEANS/FREQ/SQL or dplyr/gtsummary
Step 4: Format display โ PUT() or sprintf() to create formatted character columns
Step 5: Transpose โ One column per treatment arm
Step 6: Output โ PROC REPORT to ODS RTF or gt/flextable to Word
Let's walk through what each step accomplishes:
**Step 1: Read the Table Shell**
The shell tells you everything: which population (Safety, ITT, Per-Protocol), which variables to summarize, what statistics to display (n, mean, median, SD, range), and how to format them (decimal places, alignment). Spend five minutes studying the shell before writing any code. Misreading the shell is the number one cause of rework.
**Step 2: Big N Calculation**
The "Big N" appears in every column header: "Placebo (N=85)". This count comes from ADSL using the appropriate population flag. This step runs once and feeds into every subsequent calculation as either a denominator or a header value.
**Step 3: Compute Statistics**
This is where the real work happens. Demographics tables need PROC MEANS for continuous variables and PROC FREQ for categorical. AE tables need counting logic with multiple denominator options. Lab tables need change-from-baseline calculations. Each recipe provides the exact code for its specific requirements.
**Step 4: Format Display**
Raw statistics aren't submission-ready. The value "23.4567" becomes "23.5" with proper decimal alignment. The count "15" becomes "15 (17.6%)" when paired with its percentage. This step transforms numeric results into the exact character strings that appear in your final output.
**Step 5: Transpose**
Statistical procedures produce vertical output---one row per statistic per treatment. Table shells show horizontal layout---one column per treatment. Transposition bridges this gap. In SAS, PROC TRANSPOSE handles this. In R, pivot_wider() accomplishes the same transformation.
**Step 6: Output**
The final step renders your formatted dataset as a Word-compatible RTF document (or PDF, HTML, or other format per your organization's requirements). PROC REPORT in SAS and gt or flextable in R provide the fine-grained control needed for submission-quality output.
Once you master this workflow with the demographics table in Chapter 3, every other table is a variation on Steps 3-4. AE tables change the counting logic. Lab tables change the statistics. But the overall architecture remains constant.
## A Minimal Working Example
To illustrate the workflow concretely, here's the skeleton of a SAS program that every recipe in this book follows:
```sas
/*============================================================================
Program: t_14_1_1_demog.sas
Purpose: Table 14.1.1 - Demographics and Baseline Characteristics
Input: ADSL (Analysis Subject Level)
Output: t_14_1_1_demog.rtf
Programmer: [Your Name]
Date: [Date]
Modification History:
[Date] [Initials] [Description]
============================================================================*/
/*--- Step 0: Environment Setup ---*/
%include "setup.sas"; /* Paths, formats, global macros */
/*--- Step 1: Read Shell & Define Parameters ---*/
%let population = SAFFL; /* Safety population flag */
%let trtvar = TRT01AN; /* Treatment variable (numeric) */
%let output = t_14_1_1_demog; /* Output filename */
/*--- Step 2: Big N Calculation ---*/
proc sql noprint;
select count(distinct usubjid) into :n1-:n3
from adam.adsl
where &population = 'Y'
group by &trtvar
order by &trtvar;
quit;
/*--- Step 3: Compute Statistics ---*/
/* [Recipe-specific code goes here] */
/*--- Step 4: Format Display ---*/
/* [Recipe-specific formatting goes here] */
/*--- Step 5: Transpose to One Column Per Treatment ---*/
proc transpose data=stats_fmt out=stats_wide prefix=col;
by sortord rowlbl;
id &trtvar;
var value;
run;
/*--- Step 6: Output to RTF ---*/
ods rtf file="&outpath./&output..rtf" style=tlf_style;
proc report data=stats_wide nowd split='|';
columns sortord rowlbl col1 col2 col3;
define sortord / order noprint;
define rowlbl / display "" style=[width=2.5in];
define col1 / display "Placebo|(N=&n1)";
define col2 / display "Low Dose|(N=&n2)";
define col3 / display "High Dose|(N=&n3)";
run;
ods rtf close;
This skeleton provides the scaffolding. Each recipe fills in Steps 3 and 4 with the specific logic for that table type. When you're programming a new table, start by copying this structure, then adapt the middle sections.
| Chapter | Recipe | Table Number | Key Techniques |
|---|---|---|---|
| 2 | Setup & Templates | --- | Project structure, formats, styles, macros |
| 3 | Demographics Table | 14.1.1 | Continuous & categorical summary, baseline |
| 4 | AE Summary Tables (3 recipes) | 14.3.1, 14.3.2 | Incidence rates, SOC/PT hierarchy, severity |
| 5 | Laboratory Tables (3 recipes) | 14.3.4, 14.3.5 | Shift tables, flagging, reference ranges |
| 6 | Vital Signs Tables | 14.2.1 |
A table shell (also called a mock table or table template) is the empty structure of a clinical table --- the headers, row labels, column layout, and footnotes --- with no data filled in. It's your blueprint.
You receive table shells from the statistician as part of the Statistical Analysis Plan (SAP). Your job is to write SAS or R code that fills in the numbers exactly as the shell specifies. If the shell says "n (%)" in a column, your output must show "45 (32.1)" --- not "32.1% (n=45)" or "45/140".
Before you write a single line of code, you must be able to read the shell fluently. This isn't skimming --- it's systematic extraction of programming requirements from what appears to be a simple Word document or PDF. The programmers who consistently deliver clean outputs on schedule are the ones who spend 30 minutes dissecting the shell before touching their keyboard.
Why shells matter more than you think:
Legal document: Table shells are part of the SAP, which is a binding document between the sponsor and regulatory agency. Deviating from the shell without approval is a protocol violation. This isn't hyperbole --- during FDA inspections, investigators compare submitted tables against approved shells. Unexplained differences can trigger findings.
QC reference: The QC programmer compares your output against the same shell. Any layout difference --- even cosmetic --- is flagged as a discrepancy. This includes spacing, capitalization, decimal alignment, and footnote ordering. Two programmers reading the same shell should produce byte-identical outputs.
Reviewer expectation: FDA reviewers are trained to read specific table layouts. Non-standard formatting slows their review and creates a negative impression. A reviewer who can't find the p-value where they expect it may question the entire submission's attention to detail.
Cross-study consistency: Pharmaceutical companies maintain shell libraries across programs. Reading shells correctly means your Table 14.1.1 looks identical whether it comes from Study 001 or Study 047 --- essential for integrated summaries.
Table 14.1.1
Summary of Demographics --- Safety Population
Placebo Drug 100mg Drug 200mg Total
(N=xxx) (N=xxx) (N=xxx) (N=xxx)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Age (years)
n xxx xxx xxx xxx
Mean (SD) xx.x (xx.xx) xx.x (xx.xx) xx.x (xx.xx) xx.x (xx.xx)
Median xx.x xx.x xx.x xx.x
Min, Max xx, xx xx, xx xx, xx xx, xx
Sex, n (%)
Male xx (xx.x) xx (xx.x) xx (xx.x) xx (xx.x)
Female xx (xx.x) xx (xx.x) xx (xx.x) xx (xx.x)
Race, n (%)
White xx (xx.x) xx (xx.x) xx (xx.x) xx (xx.x)
Black or African American xx (xx.x) xx (xx.x) xx (xx.x) xx (xx.x)
Asian xx (xx.x) xx (xx.x) xx (xx.x) xx (xx.x)
Other xx (xx.x) xx (xx.x) xx (xx.x) xx (xx.x)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Source: ADSL
[a] Percentages based on n in the Safety Population for each treatment group.
Program: t_14_1_1.sas Run: 15MAR2025 14:30:22
Decoding the shell --- what every element tells you:
| Element | What It Tells the Programmer |
|---|---|
Table 14.1.1 |
Output filename: t_14_1_1.rtf. Follow sponsor naming conventions. |
Safety Population |
WHERE clause: SAFFL = "Y" in ADSL. Never assume --- verify the flag variable name in your ADaM specifications. |
(N=xxx) |
Big N comes from ADSL population count, calculated once and placed in header. This is NOT the sum of the column. |
Mean (SD) |
Use PROC MEANS or equivalent. Output format: one decimal for mean, two for SD, with parentheses and space as shown. |
xx.x (xx.xx) |
Explicit decimal precision. Mean shows one decimal place; SD shows two. No exceptions. |
Min, Max |
Comma-separated, not hyphen. Integer display. Watch for this --- some shells use "Min - Max" or "Min--Max". |
n (%) |
Lowercase n for count. Percentage in parentheses. One decimal place for percentage based on xx.x pattern. |
Source: ADSL |
Your input dataset. If multiple sources appear, you'll need merges. |
[a] Percentages based on... |
Denominator specification. This footnote tells you to use column N, not overall N or non-missing n. |
Program: t_14_1_1.sas |
Confirms expected program name. Some sponsors require this in the output footer. |
Experienced programmers develop a systematic approach to shell reading. Here's a five-step process that ensures nothing is missed:
The title and footnotes define your denominator. Look for: - Population name (Safety, ITT, mITT, Per Protocol) - The corresponding flag variable (SAFFL, ITTFL, MITTFL, PPROTFL) - Any additional subsetting (e.g., "Subjects with at least one post-baseline assessment")
Create a treatment mapping table before coding:
| Shell Column | ADSL Variable | Value | Display Order |
|---|---|---|---|
| Placebo | TRT01A | "Placebo" | 1 |
| Drug 100mg | TRT01A | "Drug 100 mg" | 2 |
| Drug 200mg | TRT01A | "Drug 200 mg" | 3 |
| Total | --- | All subjects | 4 |
Note that shell labels may not exactly match data values. "Drug 100mg" in the shell might be "Drug 100 mg" (with space) in the data. Always verify.
For each parameter in the shell, document: - The statistic required (n, mean, SD, median, min, max, count, percentage) - The display format (decimals, parentheses, spacing) - The ordering (n before Mean? Median before Min/Max?)
Footnotes contain programming logic disguised as explanatory text:
- [a] Percentages based on n in each treatment group โ Use column N as denominator
- [b] P-value from Fisher's exact test โ Specific statistical procedure required
- [c] Subjects with missing values excluded โ Handle missing data explicitly
Capture details that affect PROC REPORT or flextable formatting: - Indentation (two spaces before "Male" and "Female") - Row grouping (blank row between Age and Sex sections) - Alignment (counts centered, text left-aligned) - Line breaks within cells
Before writing production code, create a specification dataset or control file that captures shell requirements programmatically:
/*=============================================================================
Program: shell_spec_t_14_1_1.sas
Purpose: Document shell specifications for Table 14.1.1
Shell: Summary of Demographics --- Safety Population
=============================================================================*/
data work.table_spec;
length block $20 param $40 statistic $20 format $15 indent 8 order 8;
/* Block 1: Age */
block = "Age"; param = "Age (years)"; statistic = "LABEL"; format = ""; indent = 0; order = 1; output;
block = "Age"; param = "n"; statistic = "N"; format = "8."; indent = 2; order = 2; output;
block = "Age"; param = "Mean (SD)"; statistic = "MEANSD"; format = "xx.x (xx.xx)"; indent = 2; order = 3; output;
block = "Age"; param = "Median"; statistic = "MEDIAN"; format = "8.1"; indent = 2; order = 4;
---
# Chapter 2: Setup Recipes --- The Foundation for Every TLF
A house built on sand will not stand. The same principle applies to TLF programming: without a solid, standardized setup foundation, you will spend countless hours debugging path errors, fixing inconsistent formatting, and reconciling output differences between programmers. This chapter establishes the essential infrastructure that every TLF program in your study should share.
The recipes in this chapter are not glamorous. They do not produce impressive visualizations or complex statistical summaries. However, they are arguably the most important code you will write on any study. Get these right, and every subsequent chapter becomes easier. Get them wrong, and you will fight the same battles repeatedly throughout the project lifecycle.
---
## Recipe 2.1: Standard TLF Program Template
Every TLF program starts with the same setup. Create this as a template and copy it for each new table, listing, or figure. Consistency across programs is not merely aesthetic---it enables efficient QC, simplifies maintenance, and allows any programmer on the team to quickly understand and modify any output.
### The Complete Template
```sas
/******************************************************************************
* TABLE: t_XX_X_X.sas
* TITLE: [TABLE TITLE FROM TABLE SHELL]
* INPUT: adam.[DATASET] (population: [SAFFL/ITTFL]="Y")
* OUTPUT: &OUTPATH/t_XX_X_X.rtf
* AUTHOR: [YOUR NAME]
* DATE: [DATE]
* MODIFIED: [DATE] [INITIALS] [DESCRIPTION]
*
* DESCRIPTION:
* Brief description of what this table displays and any special
* considerations for the analysis (e.g., subgroup handling,
* missing data conventions).
*
* DEPENDENCIES:
* - global_setup.sas
* - adam.adsl (for Big N denominators)
* - adam.[PRIMARY DATASET]
* - %format_pct macro
******************************************************************************/
/* ===== SETUP ===== */
%include "/programs/setup/global_setup.sas"; /* Paths, formats, styles */
/* Study-specific parameters */
%let TABLEID = t_14_1_1;
%let POPFLAG = SAFFL;
%let POPVAL = Y;
/* ===== BIG N DENOMINATORS ===== */
/* These counts appear in column headers: Treatment A (N=xxx) */
proc sql noprint;
/* Treatment group counts */
select count(distinct USUBJID)
into :N1 trimmed, :N2 trimmed, :N3 trimmed
from adam.adsl
where &POPFLAG = "&POPVAL"
group by TRT01AN
order by TRT01AN;
/* Treatment group labels */
select distinct TRT01A
into :TRT1 trimmed, :TRT2 trimmed, :TRT3 trimmed
from adam.adsl
where &POPFLAG = "&POPVAL"
order by TRT01AN;
/* Total column count */
select count(distinct USUBJID) into :NTOT trimmed
from adam.adsl
where &POPFLAG = "&POPVAL";
quit;
/* Verify Big N macro variables were created */
%put NOTE: [BIG N] &TRT1 N=&N1;
%put NOTE: [BIG N] &TRT2 N=&N2;
%put NOTE: [BIG N] &TRT3 N=&N3;
%put NOTE: [BIG N] Total N=&NTOT;
/* ===== DATA PROCESSING ===== */
/* [Table-specific analysis code goes here] */
/* ===== OUTPUT ===== */
ods _all_ close;
options nodate nonumber orientation=landscape;
ods rtf file="&OUTPATH/&TABLEID..rtf" style=styles.clinical bodytitle;
ods escapechar="~";
title1 "Table XX.X.X";
title2 "[TABLE TITLE]";
title3 "[POPULATION DESCRIPTION]";
footnote1 "Source: [DATASET]";
footnote2 "[Any additional footnotes from shell]";
footnote3 "Program: &TABLEID..sas Run: &SYSDATE &SYSTIME";
proc report data=final nowd split="|" style(report)=[outputwidth=9in];
/* [PROC REPORT code specific to this table] */
run;
ods rtf close;
ods listing;
/* ===== VALIDATION ===== */
%put NOTE: ======================================================;
%put NOTE: &TABLEID..rtf created successfully;
%put NOTE: Output location: &OUTPATH;
%put NOTE: ======================================================;
Header Block: The header serves as documentation that stays with the code. Include enough detail that another programmer can understand the table's purpose without opening the SAP. The MODIFIED section creates an audit trail---regulatory reviewers may examine this history.
Setup Section: A single %include pulls in all standard configurations. Never hard-code paths in individual programs; changes to the study structure should require updates in only one location.
Big N Section: Column headers typically display treatment group names with subject counts. Calculate these once at the program start using the appropriate population flag (SAFFL for safety tables, ITTFL for efficacy tables, etc.). Always order by the numeric treatment variable (TRT01AN) to ensure consistent ordering regardless of alphabetical label order.
Output Section: Close all existing ODS destinations before opening new ones to prevent output accumulation from interactive sessions. The bodytitle option places titles inside the RTF body rather than in headers, improving appearance in most document workflows.
The global setup file is the single source of truth for study-level configurations. Every programmer on the study should use the identical setup file, ensuring outputs are reproducible across workstations and sessions.
/******************************************************************************
* PROGRAM: global_setup.sas
* PURPOSE: Standard paths, formats, and styles for all TLF programs
* USAGE: %include "/programs/setup/global_setup.sas";
*
* MODIFICATION HISTORY:
* 2024-01-15 JKS Initial version
* 2024-02-01 JKS Added phase2 cutoff date
* 2024-03-10 ABC Updated output path for database lock
******************************************************************************/
/* ===== STUDY IDENTIFICATION ===== */
%let STUDYID = XYZ-001;
%let PROTOCOL = Protocol XYZ-001;
%let SPONSOR = Pharma Corp;
%let SNAPSHOT = 2024-03-15; /* Data cutoff date */
/* ===== PATH DEFINITIONS ===== */
%let ROOTPATH = /projects/&STUDYID;
%let OUTPATH = &ROOTPATH/output/production;
%let QCPATH = &ROOTPATH/output/qc;
%let LOGPATH = &ROOTPATH/logs/tlf;
%let MACPATH = &ROOTPATH/macros;
%let FMTPATH = &ROOTPATH/formats;
/* ===== LIBRARY ASSIGNMENTS ===== */
libname sdtm "&ROOTPATH/data/sdtm" access=readonly;
libname adam "&ROOTPATH/data/adam" access=readonly;
libname templib "&ROOTPATH/temp";
libname library "&FMTPATH"; /* For permanent formats */
/* ===== AUTOCALL MACROS ===== */
options sasautos=("&MACPATH" "&MACPATH/utility" sasautos);
/* ===== GLOBAL OPTIONS ===== */
options ls=200 ps=60 /* Line size, page size */
nodate nonumber /* Suppress date/page in output */
mprint /* Show macro expansion */
nomlogic nosymbolgen /* Reduce log clutter */
nofmterr /* Continue if format not found */
validvarname=v7 /* Enforce valid SAS names */
compress=yes; /* Compress work datasets */
/* ===== FORMAT CATALOG ===== */
options fmtsearch=(library.formats work);
/* Load study-specific formats */
%include "&FMTPATH/study_formats.sas";
/* ===== CUSTOM ODS STYLE ===== */
%include "&MACPATH/clinical_style.sas";
/* ===== STANDARD MACRO VARIABLES ===== */
/* Treatment group ordering - matches randomization */
%let TRT_ORDER = 1 2 3; /* Placebo, Low Dose, High Dose */
/* Standard decimal precision for common statistics */
%let MEAN_DEC = 1;
%let SD_DEC = 2;
%let PCT_DEC = 1;
/* Missing value display */
%let MISSING_CHAR = -;
%let MISSING_NUM = .;
---
# Chapter 3: Demographics Table --- The Complete Recipe
## Table 14.1.1: Summary of Demographics --- Safety Population
This is the first table in every CSR. It's also the most common interview question: "Walk me through how you'd program a demographics table."
### The Target Shell
Table 14.1.1 Summary of Demographics --- Safety Population
Placebo Drug 200mg Total
(N=100) (N=102) (N=202)
Age (years) n 100 102 202 Mean (SD) 58.3 (12.10) 59.1 (11.82) 58.7 (11.95) Median 59.0 60.0 59.5 Min, Max 22, 84 25, 87 22, 87
Sex, n (%) Male 62 (62.0) 59 (57.8) 121 (59.9) Female 38 (38.0) 43 (42.2) 81 (40.1)
Race, n (%) White 78 (78.0) 76 (74.5) 154 (76.2) Black or African American 12 (12.0) 15 (14.7) 27 (13.4) Asian 8 ( 8.0) 9 ( 8.8) 17 ( 8.4) Other 2 ( 2.0) 2 ( 2.0) 4 ( 2.0)
---
### SAS Recipe (Complete Production Program)
```sas
/******************************************************************************
* TABLE: t_14_1_1.sas
* TITLE: Summary of Demographics --- Safety Population
* INPUT: adam.adsl (SAFFL='Y')
******************************************************************************/
/* ===== STEP 1: Population and Big N ===== */
data pop;
set adam.adsl(where=(SAFFL="Y"));
run;
proc sql noprint;
select count(distinct USUBJID) into :N1 trimmed, :N2 trimmed, :N3 trimmed
from pop group by TRT01A order by TRT01A;
select distinct TRT01A into :TRT1 trimmed, :TRT2 trimmed, :TRT3 trimmed
from pop order by TRT01A;
/* Total */
select count(distinct USUBJID) into :NTOT trimmed from pop;
quit;
/* ===== STEP 2: Continuous variables (Age) ===== */
proc means data=pop noprint;
class TRT01A;
var AGE;
output out=age_stat n=n mean=mean std=std median=med min=min max=max;
run;
/* Also compute Total */
proc means data=pop noprint;
var AGE;
output out=age_tot n=n mean=mean std=std median=med min=min max=max;
run;
data age_tot; set age_tot; TRT01A = "Total"; run;
data age_stat;
set age_stat(where=(TRT01A ne "")) age_tot;
run;
/* Format continuous stats into display columns */
data age_rows;
set age_stat;
length col $30 label $40;
ord1 = 1;
ord2 = 1; label = " n"; col = strip(put(n, 5.)); output;
ord2 = 2; label = " Mean (SD)"; col = catx(" ", put(mean, 5.1),
cats("(", put(std, 6.2), ")")); output;
ord2 = 3; label = " Median"; col = put(med, 5.1); output;
ord2 = 4; label = " Min, Max"; col = catx(", ", put(min, 4.), put(max, 4.)); output;
keep TRT01A ord1 ord2 label col;
run;
/* ===== STEP 3: Categorical variables ===== */
%macro cat_stats(var=, varlbl=, ord1=);
/* Treatment groups */
proc freq data=pop noprint;
tables TRT01A * &var / outpct out=_f;
run;
/* Total */
proc freq data=pop noprint;
tables &var / out=_ftot;
run;
data _ftot;
set _ftot;
TRT01A = "Total";
PCT_ROW = PERCENT;
run;
data _fall;
set _f _ftot;
run;
data _rows;
set _fall;
length col $30 label $40;
ord1 = &ord1;
ord2 = _n_;
label = cats(" ", &var);
col = catx(" ", put(COUNT, 4.), cats("(", put(PCT_ROW, 5.1), ")"));
keep TRT01A ord1 ord2 label col;
run;
/* Add header row */
data _hdr;
length TRT01A $40 col $30 label $40;
ord1 = &ord1; ord2 = 0; label = "&varlbl"; col = "";
TRT01A = "&TRT1"; output;
TRT01A = "&TRT2"; output;
TRT01A = "Total"; output;
run;
proc append base=all_rows data=_hdr force; run;
proc append base=all_rows data=_rows force; run;
%mend;
/* Add header for Age */
data _hdr;
length TRT01A $40 col $30 label $40;
ord1 = 1; ord2 = 0; label = "Age (years)"; col = "";
TRT01A = "&TRT1"; output;
TRT01A = "&TRT2"; output;
TRT01A = "Total"; output;
run;
data all_rows;
set _hdr age_rows;
run;
%cat_stats(var=SEX, varlbl=Sex, ord1=2);
%cat_stats(var=RACE, varlbl=Race, ord1=3);
%cat_stats(var=AGEGR1, varlbl=Age Group, ord1=4);
/* ===== STEP 4: Transpose to one column per treatment ===== */
proc sort data=all_rows; by ord1 ord2 label TRT01A; run;
proc transpose data=all_rows out=final(drop=_name_) prefix=col_;
by ord1 ord2 label;
id TRT01A;
var col;
run;
/* ===== STEP 5: RTF Output ===== */
ods _all_ close;
options nodate nonumber orientation=landscape;
ods rtf file="&OUTPATH/t_14_1_1.rtf" style=styles.clinical bodytitle;
ods escapechar="~";
title1 "Table 14.1.1";
title2 "Summary of Demographics --- Safety Population";
footnote1 "Source: ADSL";
footnote2 "Program: t_14_1_1.sas &SYSDATE &SYSTIME";
proc report data=final nowd split="|" style(report)=[outputwidth=9in];
column ord1 ord2 label ("&TRT1|~{newline}(N=&N1)" col_&TRT1)
("&TRT2|~{newline}(N=&N2)" col_&TRT2)
("Total|~{newline}(N=&NTOT)" col_Total);
define ord1 / noprint order;
define ord2 / noprint order;
define label / "" style(column)=[width=2in just=l];
define col_&TRT1 / "" center style(column)=[width=1.8in];
define col_&TRT2 / "" center style(column)=[width=1.8in];
define col_Total / "" center style(column)=[width=1.8in];
run;
ods rtf close;
ods listing;
%put NOTE: t_14_1_1.rtf created successfully;
library(gtsummary)
library(haven)
adsl <- read_xpt("data/adam/adsl.xpt") %>%
filter(SAFFL == "Y")
tbl <- adsl %>%
select(TRT01A, AGE, SEX, RACE, AGEGR1) %>%
tbl_summary(
by = TRT01A,
statistic = list(
AGE ~ "{mean} ({sd})
{median}
{min}, {max}",
all_categorical() ~ "{n} ({p}%)"
),
digits = list(AGE ~ c(1, 2, 1, 0, 0)),
label = list(AGE ~ "Age (years)", SEX ~ "Sex",
RACE ~ "Race", AGEGR1 ~ "Age Group"),
missing = "no"
) %>%
add_overall(col_label = "**Total**") %>%
modify_header(label = "**Parameter**") %>%
bold_labels()
tbl %>% as_gt() %>% gt::gtsave("output/t_14_1_1.rtf")
SAS: ~120 lines. R: ~20 lines. Same output. This is why the industry is transitioning.
Chapter 4 covers AE summary tables --- the most complex clinical table type, requiring nested SOC/PT counting with treatment-emergent filtering.
The AE summary table is the most important safety table. It shows the incidence of different AE categories by treatment group.
Table 14.3.1
Overall Summary of Treatment-Emergent Adverse Events --- Safety Population
Placebo Drug 200mg Total
(N=100) (N=102) (N=202)
Any TEAE 65 (65.0) 72 (70.6) 137 (67.8)
Serious AE 12 (12.0) 15 (14.7) 27 (13.4)
AE leading to discontinuation 5 ( 5.0) 8 ( 7.8) 13 ( 6.4)
Drug-related AE 28 (28.0) 42 (41.2) 70 (34.7)
Severe AE 8 ( 8.0) 11 (10.8) 19 ( 9.4)
AE leading to death 1 ( 1.0) 2 ( 2.0) 3 ( 1.5)
/* Step 1: Big N from ADSL */
proc sql noprint;
select count(distinct USUBJID) into :N1 trimmed, :N2 trimmed, :NTOT trimmed
from adam.adsl where SAFFL="Y"
group by TRT01A order by TRT01A;
quit;
/* Step 2: Count distinct subjects for each AE category */
proc sql;
create table ae_counts as
select TRT01A,
/* Any TEAE */
count(distinct case when TRTEMFL="Y"
then USUBJID end) as N_TEAE,
/* Serious */
count(distinct case when TRTEMFL="Y" and AESER="Y"
then USUBJID end) as N_SAE,
/* Leading to discontinuation */
count(distinct case when TRTEMFL="Y" and AEACN="DRUG WITHDRAWN"
then USUBJID end) as N_DISC,
/* Drug-related */
count(distinct case when TRTEMFL="Y"
and AEREL in ("POSSIBLE","PROBABLE","DEFINITE")
then USUBJID end) as N_REL,
/* Severe */
count(distinct case when TRTEMFL="Y" and AESEV="SEVERE"
then USUBJID end) as N_SEV,
/* Death */
count(distinct case when TRTEMFL="Y" and AEOUT="FATAL"
then USUBJID end) as N_DEATH
from adam.adae
where SAFFL="Y"
group by TRT01A;
quit;
/* Also compute Total column */
proc sql;
create table ae_total as
select "Total" as TRT01A,
count(distinct case when TRTEMFL="Y" then USUBJID end) as N_TEAE,
count(distinct case when TRTEMFL="Y" and AESER="Y" then USUBJID end) as N_SAE,
count(distinct case when TRTEMFL="Y" and AEACN="DRUG WITHDRAWN" then USUBJID end) as N_DISC,
count(distinct case when TRTEMFL="Y" and AEREL in ("POSSIBLE","PROBABLE","DEFINITE") then USUBJID end) as N_REL,
count(distinct case when TRTEMFL="Y" and AESEV="SEVERE" then USUBJID end) as N_SEV,
count(distinct case when TRTEMFL="Y" and AEOUT="FATAL" then USUBJID end) as N_DEATH
from adam.adae where SAFFL="Y";
quit;
data ae_all;
set ae_counts ae_total;
run;
/* Step 3: Format into display rows */
data display;
set ae_all;
length label $50 col $20;
/* Get Big N for this treatment */
if TRT01A = "Total" then _bign = input("&NTOT", best.);
else if TRT01A = "&TRT1" then _bign = input("&N1", best.);
else _bign = input("&N2", best.);
ord = 1; label = "Any TEAE";
col = catx(" ", put(N_TEAE,4.), cats("(",put(N_TEAE/_bign*100,5.1),")")); output;
ord = 2; label = " Serious AE";
col = catx(" ", put(N_SAE,4.), cats("(",put(N_SAE/_bign*100,5.1),")")); output;
ord = 3; label = " AE leading to discontinuation";
col = catx(" ", put(N_DISC,4.), cats("(",put(N_DISC/_bign*100,5.1),")")); output;
ord = 4; label = " Drug-related AE";
col = catx(" ", put(N_REL,4.), cats("(",put(N_REL/_bign*100,5.1),")")); output;
ord = 5; label = " Severe AE";
col = catx(" ", put(N_SEV,4.), cats("(",put(N_SEV/_bign*100,5.1),")")); output;
ord = 6; label = " AE leading to death";
col = catx(" ", put(N_DEATH,4.), cats("(",put(N_DEATH/_bign*100,5.1),")")); output;
keep TRT01A ord label col;
run;
/* Step 4: Transpose + PROC REPORT */
proc sort data=display; by ord label TRT01A; run;
proc transpose data=display out=final prefix=col_; by ord label; id TRT01A; var col; run;
ods rtf file="&OUTPATH/t_14_3_1.rtf" style=styles.clinical bodytitle;
title1 "Table 14.3.1";
title2 "Overall Summary of Treatment-Emergent Adverse Events --- Safety Population";
proc report data=final nowd split="|";
column ord label col_:;
define ord / noprint order;
define label / "" style(column)=[width=2.5in just=l];
define col_&TRT1 / "&TRT1|(N=&N1)" center;
define col_&TRT2 / "&TRT2|(N=&N2)" center;
define col_Total / "Total|(N=&NTOT)" center;
run;
ods rtf close;
# The entire AE summary table in R
ae_summary <- adae %>%
filter(SAFFL == "Y", TRTEMFL == "Y") %>%
bind_rows(mutate(., TRT01A = "Total")) %>%
group_by(TRT01A) %>%
summarise(
`Any TEAE` = n_distinct(USUBJID),
` Serious AE` = n_distinct(USUBJID[AESER == "Y"]),
` AE leading to disc.` = n_distinct(USUBJID[AEACN == "DRUG WITHDRAWN"]),
` Drug-related AE` = n_distinct(USUBJID[AEREL %in% c("POSSIBLE","PROBABLE","DEFINITE")]),
` Severe AE` = n_distinct(USUBJID[AESEV == "SEVERE"]),
` AE leading to death` = n_distinct(USUBJID[AEOUT == "FATAL"])
) %>%
pivot_longer(-TRT01A, names_to = "Category", values_to = "n") %>%
left_join(big_n_df, by = "TRT01A") %>%
mutate(display = sprintf("%4d (%5.1f)", n, n/N*100)) %>%
select(Category, TRT01A, display) %>%
pivot_wider(names_from = TRT01A, values_from = display)
This is the most complex table in any submission --- nested counting with SOC headers and PT sub-rows, all using first-occurrence flags to avoid double-counting.
/* Key: Use AOCCFL (first occurrence per PT) and AOCCSFL (first per SOC) */
/* This ensures each subject is counted ONCE per SOC and ONCE per PT */
proc sql;
create table soc_pt_counts as
/* SOC-level counts */
select TRT01A, AEBODSYS, "" as AEDECOD,
count(distinct USUBJID) as N, 1 as level
from adam.adae
where TRTEMFL="Y" and AOCCSFL="Y" and SAFFL="Y"
group by TRT01A, AEBODSYS
union all
/* PT-level counts */
select TRT01A, AEBODSYS, AEDECOD,
count(distinct USUBJID) as N, 2 as level
from adam.adae
where TRTEMFL="Y" and AOCCFL="Y" and SAFFL="Y"
group by TRT01A, AEBODSYS, AEDECOD
order by AEBODSYS, level, AEDECOD, TRT01A;
quit;
Why AOCCFL/AOCCSFL matter: Without these flags, a subject who had Headache three times would be counted 3 times under "Nervous System Disorders." With AOCCFL="Y", they're counted once per PT. With AOCCSFL="Y", they're counted once per SOC.
Recipe 4.3: AE Leading to Study Drug Discontinuation (subset of 14.3.2 filtered to AEACN="DRUG WITHDRAWN")
Laboratory tables are the most technically challenging standard TLFs. They combine large datasets (millions of LB records), multiple summary statistics, shift analyses, and clinically significant value flagging. A typical study report includes 5-10 lab tables covering chemistry, hematology, urinalysis, and specialty panels.
The standard lab summary shows descriptive statistics by treatment group and visit for each parameter:
/* Lab summary table --- one section per PARAMCD */
%macro LAB_SUMMARY(dsn=,paramcd=,param=,trtvar=TRTP,outfile=);
proc sort data=&dsn(where=(PARAMCD="¶mcd" and SAFFL='Y' and AVAL ne .))
out=_lab_;
by &trtvar AVISITN;
run;
proc means data=_lab_ n mean std median min max noprint;
class &trtvar AVISIT AVISITN;
var AVAL;
output out=_stats_ n=n mean=mean std=std median=median min=min max=max;
run;
data _display_;
set _stats_(where=(_type_=3));
length n_c mean_sd median_c range $50;
n_c = strip(put(n,8.));
mean_sd = cats(strip(put(mean,8.1)),' (',strip(put(std,8.2)),')');
median_c = strip(put(median,8.1));
range = cats(strip(put(min,8.1)),' - ',strip(put(max,8.1)));
run;
/* Also calculate change from baseline */
proc means data=_lab_(where=(ABLFL ne 'Y' and CHG ne .)) n mean std noprint;
class &trtvar AVISIT AVISITN;
var CHG;
output out=_chg_ n=n_chg mean=mean_chg std=std_chg;
run;
proc datasets lib=work noprint; delete _lab_ _stats_ _chg_; quit;
%mend LAB_SUMMARY;
R equivalent:
lab_summary <- adlb %>%
filter(PARAMCD == "ALT", SAFFL == "Y", !is.na(AVAL)) %>%
group_by(TRTP, AVISIT, AVISITN) %>%
summarise(
n = n(),
mean = mean(AVAL),
sd = sd(AVAL),
median = median(AVAL),
min = min(AVAL),
max = max(AVAL),
.groups = "drop"
) %>%
mutate(
mean_sd = sprintf("%.1f (%.2f)", mean, sd),
range = sprintf("%.1f - %.1f", min, max)
)
The shift table cross-tabulates baseline normal range status against worst post-baseline status:
/* Shift table: baseline vs worst post-baseline */
proc sql;
create table _worst_ as
select USUBJID, TRTP, PARAMCD, BNRIND,
case when max(case when ANRIND='HIGH' then 3
when ANRIND='NORMAL' then 2
when ANRIND='LOW' then 1 else 0 end) = 3 then 'HIGH'
when max(case when ANRIND='HIGH' then 3
when ANRIND='NORMAL' then 2
when ANRIND='LOW' then 1 else 0 end) = 1 then 'LOW'
else 'NORMAL' end as WORST_ANRIND
from adlb
where SAFFL='Y' and ABLFL ne 'Y' and ANRIND ne '' and BNRIND ne ''
and PARAMCD="¶mcd"
group by USUBJID, TRTP, PARAMCD, BNRIND;
quit;
proc freq data=_worst_ noprint;
tables TRTP * BNRIND * WORST_ANRIND / out=_shift_(drop=percent);
run;
R equivalent:
shift_table <- adlb %>%
filter(PARAMCD == "ALT", SAFFL == "Y", ABLFL != "Y",
!is.na(ANRIND), !is.na(BNRIND)) %>%
group_by(USUBJID, TRTP, PARAMCD, BNRIND) %>%
summarise(
WORST = case_when(
any(ANRIND == "HIGH") ~ "HIGH",
any(ANRIND == "LOW") ~ "LOW",
TRUE ~ "NORMAL"
),
.groups = "drop"
) %>%
count(TRTP, BNRIND, WORST, name = "n") %>%
pivot_wider(names_from = WORST, values_from = n, values_fill = 0)
Flag and summarize lab values that meet PCS criteria:
/* PCS criteria for common lab parameters */
data _pcs_;
set adlb(where=(SAFFL='Y' and ABLFL ne 'Y'));
length PCS_FLAG $1 PCS_CRITERIA $100;
PCS_FLAG = '';
select(PARAMCD);
when('ALT') do;
if AVAL > 3 * ANRHI then do; PCS_FLAG='Y'; PCS_CRITERIA='ALT > 3x ULN'; end;
end;
when('AST') do;
if AVAL > 3 * ANRHI then do; PCS_FLAG='Y'; PCS_CRITERIA='AST > 3x ULN'; end;
end;
when('TBILI') do;
if AVAL > 2 * ANRHI then do; PCS_FLAG='Y'; PCS_CRITERIA='TBILI > 2x ULN'; end;
end;
when('CREAT') do;
if AVAL > 1.5 * ANRHI then do; PCS_FLAG='Y'; PCS_CRITERIA='CREAT > 1.5x ULN'; end;
end;
when('HGB') do;
if AVAL < 80 and SEX='M' then do; PCS_FLAG='Y'; PCS_CRITERIA='HGB < 80 g/L (Male)'; end;
if AVAL < 70 and SEX='F' then do; PCS_FLAG='Y'; PCS_CRITERIA='HGB < 70 g/L (Female)'; end;
end;
otherwise;
end;
if PCS_FLAG = 'Y';
run;
/* Summary: n subjects with PCS values by parameter and treatment */
proc sql;
create table pcs_summary as
select TRTP, PARAMCD, PCS_CRITERIA,
count(distinct USUBJID) as n_subjects
from _pcs_
group by TRTP, PARAMCD, PCS_CRITERIA;
quit;
Hy's Law identifies potential drug-induced liver injury. The criteria: ALT or AST > 3x ULN AND total bilirubin > 2x ULN, in the absence of other causes:
hys_law <- adlb %>%
filter(SAFFL == "Y", ABLFL != "Y") %>%
select(USUBJID, TRTP, PARAMCD, AVAL, ANRHI) %>%
pivot_wider(
id_cols = c(USUBJID, TRTP),
names_from = PARAMCD,
values_from = c(AVAL, ANRHI),
values_fn = max
) %>%
mutate(
ALT_3ULN = AVAL_ALT > 3 * ANRHI_ALT,
AST_3ULN = AVAL_AST > 3 * ANRHI_AST,
TBILI_2ULN = AVAL_TBILI > 2 * ANRHI_TBILI,
HYS_LAW = (ALT_3ULN | AST_3ULN) & TBILI_2ULN
)
# Summary
hys_law %>%
group_by(TRTP) %>%
summarise(
n_alt_3uln = sum(ALT_3ULN, na.rm = TRUE),
n_tbili_2uln = sum(TBILI_2ULN, na.rm = TRUE),
n_hys_law = sum(HYS_LAW, na.rm = TRUE)
)
This is a free sample of the first 3 chapters. The complete guide continues with dozens more chapters of production-ready code, CDISC standards, and FDA submission practices.