Version Compatibility
Reference examples tested with: gtsummary 2.0+, gt 0.10+, flextable 0.9+, kableExtra 1.4+, great_tables 0.13+, pandas 2.2+, tableone 0.9+, openpyxl 3.1+
Before using code patterns, verify installed versions match. If versions differ:
- R:
packageVersion('gtsummary') then ?tbl_summary (gtsummary had a major API refresh at v2.0)
- Python:
pip show great_tables then help(great_tables.GT.save)
If code throws an error, introspect the installed package and adapt the example to the actual API rather than retrying.
Publication-Ready Tables
"Make my Table 1" / "export this results table for the paper" -> Generate the table programmatically with the right statistics and export it to the journal's target format.
- R:
gtsummary::tbl_summary(data, by=arm) then as_flex_table() -> Word
- Python:
great_tables.GT(df) -> HTML/PNG; tableone.TableOne(...) for a descriptive table
The Load-Bearing Idea: A Table Is Structure + Precision + the Right Statistics
Three orthogonal concerns, and conflating them is where tables go wrong:
- Structure - rows are units (subjects, genes, models), columns are variables/groups, spanners group columns, footnotes/source-notes carry the apparatus. This is the grammar of tables that gt, great_tables, and flextable all encode.
- Precision - report to MEANINGFUL precision, not the float default. P-values to 2-3 significant figures or "<0.001"; estimates to the precision the CI supports; percentages to 0-1 decimal. A
3.14159265 mean is noise.
- The right statistics - a table is DESCRIPTIVE (summarize the sample: n(%), mean(SD) or median(IQR)) or INFERENTIAL (estimate + CI + test statistic, with the effect size primary and the p-value never alone). Declare which.
The deepest framing, shared with figures: a table is a deterministic function of data + code. Same input + same code -> the same table, byte for byte. Manual edits in Word break this; every number must trace to a line of code. That principle dictates the tooling - generate programmatically and never hand-edit the output.
Target format dominates: Word for most biomedical journals, LaTeX for some genomics/physics venues, HTML for web/preprints/Quarto, CSV/Excel for machine-readable supplements.
gt has the broadest R export (HTML/PNG/PDF/RTF/LaTeX/Word). great_tables.save() is image+PDF only (HTML via as_raw_html()/write_raw_html()) - NO native Word or LaTeX, a real limitation vs the R stack. DT is for interactive exploration and online-only/interactive HTML supplements - never for a static print/PDF table.
Table 1 Is Descriptive, Not Inferential
Table 1 reports baseline characteristics so the reader can judge who was studied and how comparable the groups are. It describes the sample; it is not a place to test hypotheses.
The p-value fallacy (randomized trials): adding a p-value column comparing arms in a randomized trial is discouraged by CONSORT and statisticians. In a properly randomized trial any baseline imbalance is by definition due to chance, so the test asks whether a difference could have arisen by chance when the assignment WAS by chance - it tests a null already known true. A "significant" baseline p-value is a Type I error by construction; a non-significant one tells nothing new. The right response to a worrying imbalance on a prognostic covariate is to adjust for it (pre-specified ANCOVA covariate), not test it (Senn 1994; CONSORT 2010 item 15). gtsummary's documentation cautions against add_p() on a randomized Table 1 for this reason.
- Randomized Table 1: no p-value column. To convey balance, use standardized mean differences (SMD) - they describe the magnitude of imbalance (|SMD| > 0.1 is a common "notable" rule of thumb) without the inferential fallacy. If a journal or regulator nonetheless requires a baseline comparison column, report it but interpret per Senn: a "significant" baseline difference in a properly randomized trial is a Type I error, not evidence of confounding. Note
add_difference() compares exactly two groups; for >2 arms, SMD is defined pairwise, so report reference-group or all-pairs SMDs rather than one omnibus value.
- Observational studies: a comparison column can be defensible (the groups genuinely may differ), but multiplicity (many rows -> many tests) and "significant does not mean important" still bite, and SMD is the standard balance diagnostic in propensity-score/causal contexts. SMD is preferred over p-values for balance in essentially all cases.
Continuous Summaries and Missingness
- mean(SD) vs median(IQR) is distribution-driven. Symmetric -> mean(SD); skewed/heavy-tailed (most biomarkers, counts, lab values, length-of-stay) -> median(IQR), because the mean is pulled by the tail. gtsummary defaults continuous variables to
median (p25, p75) - defensible because biomedical variables are usually skewed and normality cannot be assumed column by column. Override per-variable (statistic = list(age ~ "{mean} ({sd})")) only after checking normality. Categorical: n(%), and state row% vs column% (baseline tables want column%).
- Missingness must be SHOWN, not silently dropped. The cardinal sin is computing percentages on complete cases with no indication rows were dropped - a reader cannot tell 90% from 90%-of-the-60%-with-data. gtsummary's
missing = "ifany" (default) shows a missing row when any value is absent; missing_text = "Unknown" labels it. Never compute denominators that hide missingness; if rows are dropped, report the analyzed N in the table or a footnote.