The Listing That Shipped Zero Rows
MACRO DEBUGGING · CLINICAL LISTING QC
The Listing That Shipped Zero Rows
The Symptom
• Macro ran clean — zero errors, zero warnings
• One RTF appeared on disk
• Headers only — no data rows
The Root Cause
• SAS ran generated code, not the typed macro
• An unseen WHERE clause targeted a flag value
• That flag value is not in this data cut
Roadmap
Examples trace to source — no simulated counts
Metadata-table driver
Parameter discipline
%local hygiene
MPRINT / SYMBOLGEN / MLOGIC
Open with the concrete QC failure that motivates the lesson: a listing macro runs clean and produces an RTF with headers but no data rows.
Speaker notes
A listing macro can run clean — zero errors, zero warnings — and still ship an empty table, listing, or figure — an empty TLF. You open the one Rich Text Format file — the one RTF on disk — and find headers only, no data rows; that is the failure mode this course exists to prevent in clinical listing quality control, QC. The log shows what SAS executed, and what SAS executed is the code your macro generated, not the code you typed. A hidden WHERE clause can resolve against a flag value that simply is not in this data cut. Over the next scenes we follow a roadmap: the metadata-table driver pattern, parameter discipline, %local hygiene, then MPRINT, SYMBOLGEN, and MLOGIC debugging. Every displayed value and code line traces to the source — no simulated counts.
One Logic, Many Outputs
One Logic, Many Outputs
Why macros earn their place in TLF work
Repeated TLF Logic
• Filter ADSL
• Count by treatment
• Layout + titles
• Write the RTF
Varies per Output
• Output ID
• Population flag
• Title set
• Sort order
• Hard-coded column layout means one miss ships
• Review reads the miss as an analysis error
• Macro payoff: reuse logic across multiple outputs
Establish why macros earn their place in TLF work: dozens of outputs repeat one skeleton with only a small set of variations.
Speaker notes
Here is the pattern that makes a macro worth writing. A Tables, Listings, and Figures output, a TLF, repeats the same moves: filter a population from the Subject-Level Analysis Dataset, ADSL, count subjects by treatment, lay out columns, hang titles and footnotes, write the Rich Text Format, RTF, file. What differs between two outputs is small: the output ID, the population flag, the title set, maybe the sort order. Hard-code one block per treatment column and a column-order change edits every block; the one you miss ships. At review, that miss reads as an analysis error, not a typo. So macros pay off when the logic repeats across outputs, populations, or treatment columns, and a one-off stays a plain program.
Driver Pattern: Variation in a Table
DRIVER PATTERN
Variation in a Table
tlfmeta
row per output
Driver macro
small %do loop
%mk_listing
written once
tlfmeta rows — one row per output
| outid | popfl | ttl1 |
|---|---|---|
| 14.1.1 | SAFFL | Summary of Demographics |
| 16.2.1.1 | SAFFL | Assignment to Analysis Populations |
| 16.2.2.1 | SAFFL | Adverse Events by System Organ Class |
Present the central design pattern: keep the logic once, put the variation in a metadata table, and drive a small loop over it.
Speaker notes
Here is the driver pattern: the logic lives once, in the workhorse macro %mk_listing, and the variation lives in metadata. That metadata table, tlfmeta, holds one row per output, with columns outid, popfl, and ttl1. Its three rows are 14.1.1 for Summary of Demographics, 16.2.1.1 for Assignment to Analysis Populations, and 16.2.2.1 for Adverse Events by System Organ Class, each with a population flag of SAFFL. The driver macro, %drive_listings, declares %local i nouts, reads the row count into :nouts and every row into :id1-, :fl1-, and :t1- using SQL INTO, then loops %do i = 1 %to &nouts. Inside that loop it calls %mk_listing with data=adam.adsl, outid=&&id&i, popfl=&&fl&i, and ttl1=&&t1&i. Notice the two-step &&id&i: it only resolves when the call executes, so SYMBOLGEN will show you each step later.
The Workhorse Contract and %local Hygiene
The Workhorse Contract and %local Hygiene
The Workhorse Contract
• Keyword params: data=, outid=, popfl=, ttl1=
• No globals read on the side
• No hidden %let chain
%local Hygiene
• Every variable the macro creates gets %local
• Indexes & counters declared first
• mk_listing: %local nsub before counting
• Driver & helper both loop with index i; only one declares %local i
• Helper %do loop overwrites driver's i — loop restarts / repeats / skips
• No error in the log — same leak ships 16.2.1.1 twice, 16.2.2.1 never
• Titles + ODS begin and end inside the macro — one call, one complete output
Explain parameter discipline and why undeclared macro variables leak across macro boundaries, with the concrete failure mode.
Speaker notes
Here is the workhorse contract. Everything the macro needs arrives through keyword parameters — data=, outid=, popfl=, and ttl1= — so the macro reads no global on the side and depends on no hidden %let chain. The second rule is %local hygiene: every macro variable the macro creates gets %local, indexes and counters declared first, and mk_listing declares %local nsub before it counts subjects. The failure recipe is two macros looping with an index named i where only one declares %local i; the helper's %do loop overwrites the driver's copy, so the loop restarts, repeats a block, or skips an output. No error appears in the log, yet the same leak can ship 16.2.1.1 twice while 16.2.2.1 never appears. Titles and the ODS destination belong inside the macro, so one call gives one complete output, with nothing inherited and nothing leaked.
The Debugging Trio: MPRINT, SYMBOLGEN, MLOGIC
The Debugging Trio
MPRINT · SYMBOLGEN · MLOGIC
options mprint symbolgen mlogic;
Debug run: all 3 ON together · validated run: all 3 OFF
| Classic failure | First switch | Resolved-log evidence |
| Clean run, table empty | MPRINT | MPRINT(MK_LISTING): where SAFFL="Y" — if generated line shows where POPFL="Y", a flag value was passed instead of variable name |
| Wrong counts / stale N | SYMBOLGEN | SYMBOLGEN: Macro variable POPFL resolves to SAFFL — compare what resolves before the call |
| Repeated blocks / runaway loop | MLOGIC | MLOGIC traces each %DO boundary — loop start/stop exposes the runaway |
Teach the diagnostic table that maps each classic macro failure to its first switch, grounded in resolved log evidence.
Speaker notes
Here is the debugging trio: MPRINT, SYMBOLGEN, and MLOGIC. Switch all three on together for a debugging run with options mprint symbolgen mlogic; and turn them off for the validated run. A clean run that produces an empty table usually means the population filter resolved to nothing, so the first switch is MPRINT: read the WHERE clause that actually ran. Healthy evidence looks like SYMBOLGEN reporting that the macro variable POPFL resolves to SAFFL, followed by MPRINT(MK_LISTING): where SAFFL = "Y" ;. If the resolved line instead reads where POPFL = "Y", someone passed the flag value instead of the variable name, and you have found the bug in ten seconds. Wrong counts or a stale N point to SYMBOLGEN, while repeated blocks or a runaway loop send you to MLOGIC, which traces each %DO boundary.
Knowledge Check: Patterns and Scope
1 In a metadata-driven Table, Listing, and Figure (TLF) program, what is the primary contract between one row of the `tlfmeta` table and the reporting macro that the driver calls?
2 A driver macro runs `%DO I = 1 %TO ...` and calls a helper macro that also uses `I` as a loop variable but does not declare it `%local`. Which statements best describe the risk and the correct hygiene rule? (select all that apply, then Check)
3 Select all first-debugging-switch pairings that correctly match a macro-driven TLF failure symptom to the SAS option that should be turned on at the first diagnostic step. (select all that apply, then Check)
Speaker notes
This checkpoint quiz covers the core metadata contract and macro hygiene patterns before the walkthrough and interactive debugging practice. First, in a metadata-driven Table, Listing, and Figure, or TLF, program, what is the primary contract between one row of the tlfmeta table and the reporting macro that the driver calls? The correct answer is A: the row supplies macro-parameter values, such as the TLF name, source data set, and population flag variable names, to the reporting macro, because each row represents one requested output and the driver turns its column values into macro-parameter values, passing population flags such as saffl as variable names. Next, if a driver macro runs %DO I = 1 %TO ... and calls a helper macro that also uses I but does not declare it %local, what is the risk and the correct hygiene rule? The correct answers are A and B: temporary variables used only by the helper, including its loop index, should be declared %local, and if the driver's &I is not %local, a helper writing to &I can alter the driver's loop counter, producing a repeated output block or unexpected loop behavior; statements recommending %global or claiming SYMBOLGEN repairs overrides are incorrect. Finally, which first-debugging-switch pairings correctly match a macro-driven TLF failure symptom to the SAS option to turn on? The correct answers are A, B, and C: empty output with MPRINT to confirm the expected data step or PROC REPORT code was generated and executed; wrong counts with SYMBOLGEN to inspect whether population-flag and source-data macro variables resolved as intended; and repeated block with MLOGIC to trace %DO loop execution, including whether the loop index is reset after a helper macro call, because MPRINT shows generated SAS statements, SYMBOLGEN shows macro variable resolution, and MLOGIC traces macro execution and loop flow.
Walkthrough: One Metadata Row to Generated Code
Walkthrough: One Metadata Row to Generated Code
| SOURCE METADATA ROW | MACRO VARIABLE | WORKHORSE OUTPUT |
|---|---|---|
| 16.2.1.1 | outid | routes this TLF under output ID 16.2.1.1; meta-derived only |
| SAFFL | popfl | emits SAFFL = "Y" because popfl supplies the variable name |
| Assignment to Analysis Populations | ttl1 | title text, copied intact from metadata |
Takeaway — same flag drives header N and body rows:
select count(distinct usubjid) from &data where &popfl = "Y"
• All concrete values trace to source row 16.2.1.1 — no invented patient data.
Trace an exact tlfmeta row through the driver and workhorse to the generated WHERE clause, using only source-document values.
Speaker notes
Let's follow one metadata row through the table, listing, and figure (TLF) pipeline. When the driver loop reaches row 16.2.1.1, it calls the workhorse with outid, popfl, and ttl1 taken straight from that row. So outid becomes 16.2.1.1, popfl becomes SAFFL, and ttl1 becomes Assignment to Analysis Populations. Inside the workhorse, popfl holds the variable name SAFFL, so the macro emits the filter SAFFL = "Y" — it never looks up a flag value. That same flag drives the header count, which uses the unique subject identifier, or USUBJID: select count(distinct usubjid) from &data where &popfl = 'Y'. Because the count and the body rows share that flag, the N in title2 cannot diverge from the body rows — and all these values trace to source metadata, not to any patient-level data.
Hands-On: Read Resolved Evidence, Find the Leak
Hands-on interactive — if it does not load, open the paired article and try the exercise there.
Speaker notes
This exercise is done hands-on on the website, so open jaimeyan.com/learn in your browser rather than waiting for the video. You will practise reading resolved MPRINT and SYMBOLGEN evidence, classifying each symptom to its first switch, and marking every macro variable that needs a %local declaration in the drive_listings and mk_listing snippets. After you finish this video, go to jaimeyan.com/learn and try the exercise yourself, then validate your answers against healthy-call evidence where SYMBOLGEN resolves POPFL to SAFFL and MPRINT shows where SAFFL = "Y" ;
Anti-pattern Gallery and the Three-Shell Test
Anti-pattern Gallery and the Three-Shell Test
One shared root: variation stored in code, not in data.
1 · Six-Level Nesting: %mk → %fmt → %pop → %cnt — resolved code unreadable; nobody can QC it.
2 · Macro-as-Configuration: 40 %let at the top read silently — changing one = archaeology, breaks the second caller.
3 · Copy-Paste Output Forks: %tab141/%tab142/%tab143 — a fix lands in one fork; the rest drift.
Portability test: run the macro against shells from three different studies.
Each study still needs its own edit? Then you wrote a template, not a macro.
Show the recurring structural anti-patterns and the portability test that separates a real macro from a glorified template.
Speaker notes
Three anti-patterns share one root: variation stored in code instead of in data. Six-level nesting—%mk calls %fmt calls %pop calls %cnt—makes resolved code unreadable, and nobody, including you, can quality control (QC) it. Macro-as-configuration puts forty %let statements at the top, read silently; changing one value is archaeology and breaks the second caller. Copy-paste output forks like %tab141, %tab142, and %tab143 let a fix land in one fork while the rest drift until the next data cut. Here is the portability test: run the macro against shells from three different studies; if each study still needs its own edit, you wrote a template, not a macro.
Beyond the Macro: CI, Generated Metadata, Agents
Beyond the Macro: CI, Generated Metadata, Agents
L2 · Generated metadata — machine-readable shells
Macro unchanged; driver generated; no hand-typed metadata.
L2 · Git + CI — parameter discipline protects the runner
Every commit regenerates outputs; hidden scope bug = red build
L2 · Data-port design — specs live in tibble/DataFrame
One function per output; walk/apply replaces the %do loop
L3 · Agent-drafted macros — volatile, source-dated
Omit %local; params buried in %let chains; second-call bug
Verification habit
Call macro twice from driver: options mprint symbolgen mlogic
List every macro variable it creates
and where each is declared.
Place the pattern in the modern workflow (L2) and flag the time-sensitive agentic guidance (L3) with its verification habit.
Speaker notes
Beyond the macro, think about continuous integration, or CI. When shells are machine-readable, the driver is generated from them; the macro layer is unchanged, and metadata stops being hand-typed. Under Git and CI, every commit regenerates outputs, so a scope bug hidden interactively becomes a red build; parameter discipline protects the runner. The design ports: a tibble or DataFrame holds specs, one function per output replaces the workhorse, and walk or apply replaces the %do loop. Agent-drafted macros are volatile: they often omit %local and bury parameters in %let chains, creating second-call bugs. So call the macro twice from a driver under options mprint symbolgen mlogic, then list every macro variable it creates and where each is declared.
Final Check: Debug and Decide
1 During QC (quality control) review of a TLF (table, listing, and figure) macro that filters on USUBJID (unique subject identifier), the SAS log shows `MPRINT(TLF_MAC): WHERE USUBJID = '01-701-1015';`. The macro source contains `WHERE USUBJID = "&usubjid";`. Which conclusion does the generated-log excerpt justify?
2 Suppose the global macro variable `keep` is set before a macro call. Inside the workhorse macro `%apply_filter`, the statement `%let keep = 0;` is executed without `%local keep;`, so `%apply_filter` changes the global variable instead of using a temporary. After the macro returns, later code sees `keep=0` instead of its original value. Which fix is correct?
3 During QC review, you compare two designs for generating RTF (rich text format) TLF outputs. Which code structures are anti-patterns that violate the rule that variation belongs in a metadata table rather than in code? Select all that apply. (select all that apply, then Check)
4 An AI drafting agent proposes `%make_tlf` to generate RTF outputs from a metadata table for several table shells, including ADSL (subject-level analysis dataset), AE, and lab tables. Describe how you would apply the three-shell portability test and the second-call rule during QC. State what each test reveals and how it shows that table-specific variation is represented in metadata rather than in macro code. (reflect, then reveal)
Reveal analysis
Speaker notes
This is the final checkpoint: debug and decide. In the first question, you review a table, listing, and figure (TLF) macro that filters on unique subject identifier (USUBJID) during quality control (QC), and the SAS log shows an MPRINT line with a resolved WHERE clause. The correct answer is A: MPRINT prints resolved SAS statements, so this line confirms that &usubjid resolved to the expected USUBJID value in the emitted WHERE clause; SYMBOLGEN shows individual macro-variable resolutions, and MLOGIC shows execution flow, not generated SAS code. The second question asks how to fix a workhorse macro that changes a global macro variable because it omits %local. The correct answer is A: add %local keep; inside %apply_filter immediately after the macro header, so the %let affects only the local macro variable and protects the caller's global table. The third question asks which code structures are anti-patterns for rich text format (RTF) TLF generation, because variation belongs in a metadata table, not in code. The correct answers are A and C: table-specific %if branching inside the macro, and duplicated macro calls with hard-coded file names, input data sets, and filter subjects. For the short-answer question, the model answer is: run %make_tlf on three table shells whose metadata rows differ meaningfully, such as a subject-level analysis dataset (ADSL) table, an adverse event table, and a lab listing, changing only the metadata rows, and then call it a second time to confirm the macro produces the required RTF output without editing its source, which shows table-specific variation is in metadata rather than macro code.
Summary: Patterns That Keep Generated Code Honest
Patterns That Keep Generated Code Honest
LESSON SUMMARY
1 Metadata drives outputs — output ID, population flag, title set; one row per output.
2 Parameterize every input — declare %local for each macro-created variable, indexes first.
3 Read generated code — MPRINT for empty output, SYMBOLGEN for wrong values, MLOGIC for bad flow; all three on.
4 Prove reuse with three shells — if every shell needs an edit, it is a template, not a macro.
5 Mirror of the Bootcamp, Part 18 — no subject-level CSV supplied, therefore no simulated patient counts.
Recap the lesson's durable rules and link back to the paired bootcamp article, noting the material gap transparently.
Speaker notes
Keep the logic once and drive variation from a metadata table — one row per output, with output ID, population flag, and title set — the pattern behind Tables, Listings, and Figures, or TLF, production. Everything enters as a parameter; declare %local for each variable it creates, indexes first; population flags come from the subject-level analysis dataset, or ADSL, keyed by the unique subject identifier, or USUBJID, and outputs render to Rich Text Format, or RTF. When a macro misbehaves, read the generated code: MPRINT for empty output, SYMBOLGEN for wrong values, MLOGIC for bad flow, all three on — your first quality control, or QC, step. Test three shells from different studies; if each needs an edit, it is a template, not a macro; this mirrors Bootcamp Part 18, no subject-level CSV rows were supplied, so no simulated patient counts appear.