Skip to contents

Builds a publication-ready frequency or cross-tabulation table for one or many categorical variables selected with tidyselect syntax.

With by, produces grouped cross-tabulation summaries (using cross_tab() internally) with a p-value per variable, from Pearson's chi-squared test or Fisher's exact test (see test), and optional association measures. Without by, produces one-way frequency-style summaries.

Multiple output formats are available via output: a printed ASCII table ("default"), a numeric data.frame ("data.frame", the displayed table, or "long", one row per variable, level and group), or publication-ready tables ("tinytable", "gt", "flextable", "excel", "clipboard", "word").

Usage

table_categorical(
  data,
  select = tidyselect::everything(),
  by = NULL,
  labels = getOption("spicy.var_labels", FALSE),
  note_labels = NULL,
  levels_keep = NULL,
  factor_levels = c("observed", "all"),
  include_total = TRUE,
  total_row = FALSE,
  drop_na = FALSE,
  weights = NULL,
  rescale = getOption("spicy.rescale", FALSE),
  correct = FALSE,
  simulate_p = FALSE,
  simulate_B = 2000,
  test = c("auto", "chisq", "fisher", "midp", "n1"),
  p_value = NULL,
  p_adjust = "none",
  percent = c("column", "row"),
  percent_base = c("valid", "total"),
  percent_digits = 1,
  p_digits = 3,
  effect_size_digits = 2,
  assoc_measure = "auto",
  assoc_ci = FALSE,
  smd = FALSE,
  decimal_mark = ".",
  align = c("decimal", "center", "right"),
  note = NULL,
  note_add = NULL,
  output = c("default", "data.frame", "long", "tinytable", "gt", "flextable", "excel",
    "clipboard", "word"),
  indent_text = "  ",
  indent_text_excel_clipboard = strrep(" ", 6),
  add_multilevel_header = TRUE,
  blank_na_wide = FALSE,
  excel_path = NULL,
  excel_sheet = NULL,
  clipboard_delim = "\t",
  word_path = NULL,
  user_na = TRUE,
  style = NULL,
  v_digits
)

Arguments

data

A data frame.

select

Columns to include as row variables. Supports tidyselect syntax and character vectors of column names. When omitted, defaults to every eligible categorical column in data: factor, character, logical, and labelled (haven / labelled) columns, excluding the by column – matching the select-less defaults of table_continuous() and table_continuous_lm(). An explicit select is taken verbatim (numeric columns included), so numeric-coded categorical variables can still be tabulated by naming them. The by and weights columns are not part of the selection: everything() and the other helpers skip them, and naming one in select, or giving its position, is an error. A name that is not a column of data is an error (class spicy_missing_column), and so is a selection with no column to tabulate. The levels of a variable are its rows, in the order by gives its groups (see by), a labelled variable's included.

by

Optional grouping column used for columns/groups. Accepts an unquoted column name or a single character column name. Factor levels keep their declared order, which is the way to choose another order; any other by is sorted, as in freq() and cross_tab(): text in the C locale (capitals before lower case, the same on every machine), FALSE before TRUE, numbers ascending, whatever the order of the rows. The column of missing values comes last. A haven labelled by names its groups by their value labels, as the rows of a labelled variable name its levels ("[1] Women"), in the order of its labels attribute when every observed value carries a label and by ascending code otherwise; a value label no observation holds adds no group, and declared missing values follow user_na as usual. The rule of the whole family.

labels

How each variable is named in the table: in the rows, in the title, and in the notes. Four forms:

  • labels = FALSE shows the column name.

  • labels = TRUE shows the column's label attribute (as set by haven, labelled, or label_from_names()) when it is present, not NA, and not empty, and the column name otherwise.

  • labels = c(bmi = "Body mass index"), a named character vector, gives the columns it names these labels. Every other column follows options(spicy.var_labels): its name when the option is unset or FALSE, its label attribute when it is TRUE.

  • labels = var_labels(bmi = "BMI (kg/m2)") gives the columns it names these labels, and every other column its label attribute, whatever the option says. var_labels() alone is labels = TRUE.

A name that matches no column of the table warns (class spicy_unknown_name) and is left out, so one vector can still serve every table of a project. The default reads options(spicy.var_labels), and a TRUE or FALSE typed in the call wins over it. Any other value, NULL included, is an error. A variable label documents the data file; a published table carries the wording you set here. note_labels gives a variable a shorter label in the title and the notes.

note_labels

The label a variable takes where the table names it in a sentence, in the title and in the notes, in place of the one labels gives it: a named character vector, note_labels = c(sex = "sex at birth"), or a var_labels() object, which reads as one. labels can then name a row with a whole question while the note says "Missing values removed: sex at birth (2).". The rows, the column headers and the spanners keep the labels of labels, and so do the title and the notes for a variable note_labels does not name. A name that matches no column warns and is left out, as in labels, and NULL (the default) renames nothing. A note label that another variable of the table shows is an error (class spicy_label_collision): choose another one, or relabel the other variable.

levels_keep

Levels to keep, in the order to display them. If NULL (the default), all the levels factor_levels displays are kept. A declared level with no observation that it names is shown with a count of 0, whatever factor_levels says. Entries must match the level strings the table displays (for labelled columns these are the "[code] label" strings, not the bare label text). Two forms, as in table_categorical_svy():

  • a character vector applies to every variable: each keeps the levels it has, and a variable with none of them is dropped from the table with a classed warning (spicy_no_selection) listing its level strings;

  • a named list applies per variable (levels_keep = list(smoking = "Yes")): each name must be a variable of the table, each level one the variable declares or displays (an error otherwise), and the variables it does not name keep all their levels.

factor_levels

Character. Controls how factor and labelled values are displayed in the table, as in freq(). "observed" (the default; matches Stata's tab and SPSS FREQUENCIES, which both list only values present in the data) shows only levels present in the data. "all" keeps every declared level, including unused ones, which appear with n = 0 (in every column under by). A character or logical column declares no level. The rows of unused levels are displayed only: they change no percentage of the other rows and no test or association measure.

include_total

Logical. If TRUE (the default), includes a Total group when available. A level of by displayed with the label of that margin is then an error (class spicy_label_collision): rename the level, or give the margin another label with options(spicy.labels = list(header_margin_total = "All")).

total_row

Logical. If TRUE, closes the block of each variable with a Total row under its levels (after the "(Missing)" row when there is one): the count of the variable in each group column and in the Total column, the "(Missing)" row included, and its percentage, 100 in every column under percent = "column" and, under percent = "row", the share of each group in the variable's total, the base rate the row percentages are compared with (on the observed values under the default percent_base). Without by, the row holds the count of the variable and 100. A thin rule sets the row apart, in the console and in the rendered outputs. The row never enters the test, the association measure or the SMD. It takes the label of the margin, so a level displayed with that label is then an error (class spicy_label_collision), as beside the Total column. FALSE (the default) adds no row: the note gives the sample size.

drop_na

Logical. The rule of the family: a table that counts shows the missing values by default, as a frequency table reports how many answers are missing; a table that computes a statistic removes them by default and counts them in the note (see table_continuous()). This table counts, so FALSE (the default) displays the missing values as a dedicated "(Missing)" level (and, under by, a "(Missing)" group column) – the field convention for descriptive tables (gtsummary's "Unknown" row, janitor's NA row; see the Epidemiologist R Handbook, Descriptive tables). Under the default percent_base = "valid", the row shows their count and no percentage: the percentages are on the observed values, and the Total row of row percentages too. A level displayed beside that row or column with its label is an error (class spicy_label_collision): rename the level, or give the row another label with options(spicy.labels = list(row_missing_level = "(Not stated)")). If TRUE, rows with NA in the tabulated variable (and in by, when supplied) are removed BEFORE each cross-tabulation, and the removal is disclosed in a table note, by last ("Missing values removed: smoking (25), sex (40).") rather than silent: a missing by takes the row out of every variable of the table, a missing value only out of its own variable. A by that no observation holds is then an error (class spicy_invalid_data).

weights

Optional weights. Either NULL (the default), a numeric vector of length nrow(data), or a single column in data supplied as an unquoted name or a character string. Rows with an NA weight leave the table, and the note says how many.

rescale

Logical. If FALSE, weights are used as-is. If TRUE, rescales weights so total weighted N matches raw N. Passed to spicy::cross_tab(). The default reads options(spicy.rescale), FALSE when it is unset, matching cross_tab().

correct

Logical. If FALSE (the default), no continuity correction is applied. If TRUE, applies Yates correction in 2x2 chi-squared contexts. Passed to spicy::cross_tab(). A variable tested by Fisher's exact test, its mid-p version, or the N-1 chi-squared (see test) takes no correction.

simulate_p

Logical. If FALSE (the default), uses asymptotic p-values. If TRUE, uses Monte Carlo simulation. Passed to spicy::cross_tab(). Applies to the Pearson chi-squared test only (see test).

simulate_B

Integer. Number of Monte Carlo replicates when simulate_p = TRUE, and for the Monte Carlo version of Fisher's exact test (see test). Defaults to 2000.

test

Character. The test of each variable when by is used. One of "auto" (default), "chisq", "fisher", "midp", or "n1".

  • "auto": Pearson's chi-squared test when every expected count of the variable's table is at least 5, Fisher's exact test otherwise, the rule of gtsummary's add_p(). The choice is made per variable, and the note names the test of each.

  • "chisq": Pearson's chi-squared test for every variable, the behavior before 0.14.0.

  • "fisher": Fisher's exact test for every variable.

  • "midp": the Fisher mid-p test for a 2x2 table.

  • "n1": the N-1 chi-squared test for a 2x2 table whose expected counts are all at least 1, Fisher's exact test for one with a smaller count (Campbell's rule).

Under "midp" and "n1", a variable whose table is not 2x2 follows "auto". correct and simulate_p apply to the Pearson chi-squared test only. When the exact enumeration of a large sparse table fails, Fisher's p-value is its Monte Carlo estimate over simulate_B tables, and the note says so; call set.seed() first to reproduce it. See Tests. Ignored when by is not used or when p_value = FALSE.

p_value

Logical or NULL. If TRUE and by is used, adds the p-value column of the test chosen by test. When NULL (the default), the p-value is shown automatically whenever by is supplied, and hidden otherwise. Pass p_value = FALSE to remove the test from every output: the p column, and the test_type, statistic, df and p.value columns of "long"; test, correct and simulate_p are then ignored, with a warning. The association measure keeps its own switch, so p_value = FALSE, assoc_measure = "none" gives a purely descriptive table. Ignored when by is not used.

p_adjust

Multiple-comparison adjustment of the p column, applied to the family of every test in the table (one p-value per variable). One of "none" (default), "holm", "hochberg", "hommel", "bonferroni", "BH" / "fdr", or "BY", as in table_regression(); delegated to stats::p.adjust(). Needs by and the p column. See Multiple-comparison adjustment.

percent

The direction of the percentages under by, as in cross_tab(): "column" (the default), the distribution of each group over the levels of the variable, summing to 100 down each group; or "row", the share of each level falling in each group, summing to 100 across the groups, so that the Total column shows 100 on every level row. Unique abbreviations are accepted ("c", "r"). Only the displayed shares change: the counts, the test, the association measure and the SMD are the same in both directions. The note says the direction in both cases ("Percentages are column percentages.", "Percentages are row percentages."). Without by, each variable has one distribution to show, and percent = "row" is ignored with a warning (class spicy_ignored_arg).

percent_base

The base of the percentages of a variable whose missing values the "(Missing)" row shows (drop_na = FALSE). "valid" (the default) computes them on the observed values of the variable, as gtsummary does: the levels sum to 100, and the "(Missing)" row shows its count and no percentage (a blank cell, NA in the data frames). "total" computes them on every row, the "(Missing)" row included, as SPSS does and as this function did before 0.14.0. Under by, the base is the observed values of each column, the Total column and the column of missing by values included. Row percentages keep each row as its base, the "(Missing)" row included, which keeps its row percentages; the Total row of total_row = TRUE gives the share of each group on the observed values under "valid". The test, the association measure and the SMD run on the observed values whatever the base. Without missing values to show (drop_na = TRUE, or a variable with none) the two bases give the same percentages. Unique abbreviations are accepted ("v", "t").

percent_digits

Number of digits for percentages in report outputs. Defaults to 1. A whole number: a fraction is an error, as for every *_digits argument.

p_digits

Integer >= 1. Number of decimal places used to render p-values in the p column (default: 3, the APA Publication Manual standard). Both the displayed precision and the small-p threshold derive from this argument: p_digits = 3 prints .045 and <.001; p_digits = 4 prints .0451 and <.0001. Leading zeros are always stripped, following APA convention.

effect_size_digits

Number of digits for the association measure, its confidence interval and the SMD, as for the effect sizes of the other table functions. Defaults to 2.

assoc_measure

Which association measure to report alongside the p-value. Accepts four input shapes:

  • "none" – drop the column entirely.

  • "auto" (the default) – pick a measure per row variable based on the variable type: a 2x2 table (binary row variable vs. binary by) uses phi, a pair of ordered factors uses tau_b, every other case uses cramer_v.

  • a single string from c("cramer_v", "phi", "gamma", "tau_b", "tau_c", "somers_d", "lambda") – applied uniformly to every row variable.

  • a character vector with one entry per row variable. Both named (c(smoking = "phi", health = "tau_b"), recommended; unnamed variables fall back to "auto") and unnamed positional (c("phi", "tau_b", "auto"), paired up with select) are accepted. Named is more robust to reordering of select.

When a single measure is used for every row, its symbol heads the column and names it in the data frames, as the effect size of table_continuous_lm() is headed: \(V\) (cramer_v), \(\phi\) (phi), \(\tau_b\) (tau_b) or \(\gamma\) (gamma), the three others keeping their name ("Lambda"). The note defines the symbol as it defines any abbreviation, under the abbreviations rule of spicy_style(): "V = Cramér's V" by default, the Greek letters only under "all" and in the "jama" and "lancet" themes. When multiple measures are used (typically with "auto" on a heterogeneous select), the header collapses to "Effect size" and the table note names the measure used for each variable.

phi requires a 2x2 table; if explicitly requested for a non-2x2 variable, an error is raised so the user can choose another measure or fall back to "auto".

assoc_ci

Passed to cross_tab(). If TRUE, includes the 95\ and in rendered formats ("gt", "tinytable", "flextable", "word"), the CI is shown inline (e.g., .14 [.08, .19]) under a header that states its level (V [95% CI]). In the "data.frame", "excel" and "clipboard" outputs, two extra columns headed 95% CI LL / 95% CI UL are added, the level stated as the other tables state it; in the long output ("long") the bounds fill es_ci_lower / es_ci_upper. The note defines LL and UL in the "excel" and "clipboard" outputs only, whose headers show them. Defaults to FALSE.

smd

Logical. If TRUE, adds an SMD column holding the standardized mean difference between the two groups of by, the balance diagnostic of the Table 1 literature. Requires exactly two groups; the value sits on the variable row beside p, never on a level row. Signed for a two-category variable (group 1 minus group 2 on the second category), unsigned for three or more, where it is a distance. No confidence interval and no p-value, by design. Rounded with effect_size_digits. See the "Standardized mean difference" section below. Defaults to FALSE.

decimal_mark

The decimal mark, a single character: "." (the default) or "," in most styles; the Lancet style of spicy_style() uses the middle dot.

align

Horizontal alignment of numeric columns in the printed ASCII table and in the tinytable, gt, flextable, word, and clipboard outputs. The first column (Variable) is always left-aligned. One of:

  • "decimal" (default): align numeric columns on the decimal mark, the standard scientific-publication convention used by SPSS, SAS, and LaTeX siunitx. Numeric cells are pre-padded with figure-spaces (U+2007, digit-width) so every string in a column has the same width with the decimal mark at the same internal position; centering those uniform-width strings then stacks the decimal points vertically. The same pad-then-center strategy is applied on every rendering engine (gt, tinytable, flextable, word, ASCII print) for a homogeneous rendering, matching table_regression() and table_continuous_lm(). The clipboard output is delimited text meant to be parsed rather than read at a fixed width, so its cells travel unpadded (a padded number pastes as text next to an unpadded number).

  • "center": center-align all numeric columns.

  • "right": right-align all numeric columns.

In the excel output, "center" centers the numeric columns and "right" is the same rendering as the default: cell-string padding does not align decimals under a proportional font, so "decimal" right-aligns instead, which combined with the per-column numfmt already produces dot-aligned columns. Same default and same three values as table_continuous() / table_continuous_lm(), whose workbooks resolve "decimal" differently: table_continuous() right-aligns only the counts and the p-value there, and table_continuous_lm() applies that convention at every align.

note

The note under the table. NULL (the default) builds it: the general note, one paragraph opened by the lead-in of the style ("Note." by default, none under the jama and lancet themes; see note_label in spicy_style()), then, in a table_regression() with stars, the legend of the stars on a line of its own. A single string replaces the general note, the sentences the table writes about its data included. It is printed as typed, line breaks ("\n") included, after the lead-in of the style, which a string opening with a lead-in of its own does not repeat (note = "Note. Data weighted."), and the legend of the stars stays below it. "" keeps that legend alone, and no note at all without stars. FALSE removes the note from every output, the legend included.

note_add

Sentences to add to the note: a character vector of one or more strings, each printed as typed (no period is added) and joined to the note by a space. They close its paragraph, after the definitions of the abbreviations and before the legend of the stars, in every output and in attr(x, "note"), and the sentences the table writes stay. With a string in note, they follow it, so a wrapper can add a house sentence to every table while one table replaces its note. An NA or an empty string is an error, and so is note_add with note = FALSE, which leaves no note to add to.

output

Output format. One of:

  • "default" (an ASCII table object, printed when the call is bare)

  • "data.frame" (the displayed table, numeric)

  • "long" (a long numeric data.frame)

  • "tinytable" (requires tinytable)

  • "gt" (requires gt)

  • "flextable" (requires flextable)

  • "excel" (requires openxlsx2)

  • "clipboard" (requires clipr)

  • "word" (requires flextable and officer)

indent_text

Prefix used for modality labels in report table building. Defaults to " " (two spaces).

indent_text_excel_clipboard

Stronger indentation used in Excel and clipboard exports. Defaults to six non-breaking spaces.

add_multilevel_header

Logical. If TRUE (the default), merges top headers in Excel export. Only consulted for output = "excel" on a grouped table (by supplied): typed with another output, it is ignored with a warning (class spicy_ignored_arg), like the other arguments of one output (excel_path, excel_sheet, clipboard_delim, word_path, ...), and a one-way table, which has a single header row to begin with, does not use it.

blank_na_wide

Logical. If FALSE (the default), NA values are kept as-is in output = "data.frame". If TRUE, the empty cells of its statistic columns become empty strings (the columns become text).

excel_path

Path for output = "excel", ending in ".xlsx", in a directory that exists. Defaults to NULL.

excel_sheet

Sheet name for Excel export. NULL (the default) uses "Categorical".

clipboard_delim

Delimiter for clipboard text export. Defaults to "\t". A cell holding the delimiter itself, a double quote or a line break is quoted RFC 4180-style, so the grid survives whatever delimiter you choose.

word_path

File path for output = "word", ending in ".docx", in a directory that exists. Defaults to NULL. Before 0.13.0, supplying it with output = "flextable" also wrote a .docx as a side effect; it is now used exclusively by output = "word" (the contract shared with the rest of the table family) and is ignored, with a warning, under any other output.

user_na

Logical. If TRUE (the default), declared missing values in the row variables and in by are treated as missing: they join the "(Missing)" level under drop_na = FALSE, and under drop_na = TRUE they are removed with a dedicated disclosure line (Declared missing values removed: ...). If FALSE, the declared codes stay valid categories. See the "Declared missing values" section of freq().

style

A journal style: a theme name ("jama", "nejm", "lancet", "annals", "apa", "aer"), a spicy_style() object, or NULL (the default). A style only changes DEFAULTS – any argument you pass explicitly wins over it. Set options(spicy.style = ) for document-wide scope. A theme covers numeric formatting, the word that opens the note and the abbreviations the note defines, not full editorial conformity; ?spicy_style lists the exact rules each one encodes and the official document they come from. An unknown name is an error.

v_digits

Defunct. Renamed effect_size_digits in spicy 0.14.0, as in the other table functions; supplying v_digits is an error (class spicy_defunct).

Value

Depends on output:

  • "default": a data.frame of class "spicy_categorical_table" carrying the rendering metadata as attributes. Its columns are internal and can change without notice: read the numbers from "data.frame", "long", broom::tidy(), broom::glance() or as_structured(). The object is returned visibly, so a bare table_categorical(...) call auto-prints the styled ASCII table at the console while t <- table_categorical(...) stays silent (print t to display the table).

  • "data.frame": the displayed table with its numbers, what as.data.frame() of the default object returns: one row per printed row – the header row of each variable, which carries its p-value and its association measure, then one row per level. The key columns are variable (the column name), label (the label the rows display for it, see labels), level and .row_role ("factor_header", "level", "missing" for the row of missing values the table adds, or "total" for the Total row of total_row = TRUE), then one column per displayed column, the name of a column of a group joining the group printed above it to the header printed under it: one pair <group> n / <group> % per group level (plus Total when include_total = TRUE), p (absent with p_value = FALSE), the association measure (named by its header: the symbol of the measure, V, its name for a measure without one, or Effect size when the row variables do not share one), its bounds 95% CI LL / 95% CI UL with assoc_ci = TRUE, and SMD with smd = TRUE. When by = NULL, these columns are n and %.

  • "long": a long data.frame, one row per variable, level and group, with columns variable, label, level, .row_role (the role of level: "level", "missing" for the row of missing values, or "total" for the Total row of total_row = TRUE), then, when by is used, group, .group_role (the role of group: "group", "missing" for the column of missing by values, or "total" for the margin) and total (TRUE on the rows of the margin and of the Total row), then n, pct (the percentage the table displays, in the direction of percent), then, with by, the test of the variable unless p_value = FALSE: test_type ("chi_squared" for Pearson's, "n1_chi_squared" for the N-1 chi-squared, "fisher_exact", "fisher_midp" for the Fisher mid-p test, or "fisher_exact_mc" for the Monte Carlo estimate of Fisher's exact test), statistic and df (the chi-squared, Pearson's or the N-1, NA for a variable tested by an exact test), p.value (and p_unadjusted under p_adjust); and the association measure, whichever measure it is: es_type names it per row ("cramer_v", "phi", ...), es_value holds it, es_ci_lower / es_ci_upper its interval (NA unless assoc_ci = TRUE), and smd_type ("binary" or "multinomial", the kernel the value came from) / smd_value the standardized mean difference. The measure and SMD columns are there whether or not they were requested, NA when not, so the schema does not move with an argument. The statistics of a variable sit on every row of its block.

  • "tinytable": a tinytable object.

  • "gt": a gt_tbl object.

  • "flextable": a flextable object.

  • "excel" / "word": writes to disk and returns the file path invisibly.

  • "clipboard": copies the table and returns the copied text invisibly.

The note opens with the sample size, "N = 159." or, with by, "N = 159: Women (n = 80), Men (n = 79).": the rows of the data the table describes, and those of each group column in column order, the column of missing by values included, so that the n add up to N. A row missing by under drop_na = TRUE and a row with a missing weight leave the table and are not counted; the note says how many. Under weights the cells hold weighted counts and N counts rows: "N = 60 (unweighted).", a row of zero weight left out, as stats::nobs() leaves it out of a weighted fit. One N per table, the missing values of a variable in its "(Missing)" row or in the note. With by, the direction of the percentages follows, then the weights. Their base takes no sentence: the table shows it (see percent_base).

The note, the drop_na = TRUE disclosure included, travels with the table on every route, not just the console: "default" prints it under the ASCII table, "tinytable" / "gt" / "flextable" / "word" carry it as a table note, "excel" writes it below the body, and the default object and the data frames of "data.frame" / "long" keep the whole note as printed, its lead-in included, in attr(x, "note") (NULL when there is nothing to say), so a pipeline that renders the numbers itself can still state what left the table. The default object also keeps the title the console prints, in attr(x, "title"). The same three objects record the direction of their percentages in attr(x, "percent"): "column" or "row" (see percent), and "column" for a table without by; and their base in attr(x, "percent_base"), "valid" or "total". The percentage columns of "data.frame", pct in "long" and the proportion of broom::tidy() hold the shares the table displays, in that direction and on that base: NA on the "(Missing)" row under "valid". On the "tinytable" route the note is set one size down; options(spicy.note_style) governs that (see table_regression()).

The Excel sheet carries the same title the console prints on its first row; the table itself starts on row 3.

Tests

When by is used, each selected variable is cross-tabulated against the grouping variable with cross_tab() and tested on the complete cases of its observed levels, a "(Missing)" level and a declared level nobody holds left out: one p-value per variable, in the p column. A table left with a single row or column (a variable observed at one level, or in one group) is uninformative and gets no test and no association measure, whatever test and assoc_measure say: its p and measure cells show an en dash, the undefined cell, and the note names no measure for it. A blank cell only means a statistic that was not requested or does not apply.

The chi-squared approximation is unreliable when expected counts are small. Under test = "auto", the default, a variable takes Pearson's chi-squared test (stats::chisq.test()) when every expected count of its table is at least 5, and Fisher's exact test (stats::fisher.test()) otherwise: the rule gtsummary's add_p() applies, with the same p-values. Fisher's exact test of a table larger than 2x2 is the Freeman-Halton extension. When its exact enumeration fails, which happens on large sparse tables, the p-value is its Monte Carlo estimate over simulate_B tables drawn with the observed margins: it is random, so call set.seed() first to reproduce it. test = "chisq" tests every variable by the chi-squared, the behavior before 0.14.0, and test = "fisher" every variable by the exact test.

Fisher's exact test is conservative: its actual level is often well below the nominal one, at a cost in power. test = "midp" gives a 2x2 table (a two-level variable by two groups) the Fisher mid-p test, as Fagerland, Lydersen and Laake (2017, equation 1.12, p. 28, and section 4.4.6, p. 97) and Lydersen, Fagerland and Laake (2009, equation 12, p. 1165) define it: the tables with the observed margins are ordered by their hypergeometric probability, and the p-value adds the probabilities of the tables less probable than the observed one to half the probabilities of the tables as probable, the observed one included, ties being read within the relative tolerance of stats::fisher.test(). The mid-p test can exceed the nominal level, usually by little, and Fagerland, Lydersen and Laake (2017, p. 175) recommend it for all sample sizes. A variable whose table is not 2x2 follows "auto", and the note names the test of each variable. The central variant, twice the smaller one-sided mid-p (the exact2x2 and epitools packages; Agresti, 2019, p. 49), coincides with this one when the row totals or the column totals are equal, except that it gives 1 for the most probable table; it differs otherwise and is not computed.

test = "n1" applies the rule of Campbell (2007, pp. 18-19) to a 2x2 table. When every expected count is at least 1, the table takes the N-1 chi-squared test: Pearson's statistic, with no continuity correction, multiplied by (N - 1)/N and referred to a chi-squared distribution with 1 degree of freedom, which is the Mantel-Haenszel statistic of a single 2x2 table; statistic and df hold that statistic in the long output. Otherwise it takes Fisher's exact test, two-sided by Irwin's rule as stats::fisher.test() computes it. Campbell found the N-1 test close to its nominal level under that restriction, not without it. A variable whose table is not 2x2 follows "auto", and the note names the test of each variable.

The note names the test behind the p-values, "P-values: Pearson chi-squared test (sex, education); Fisher exact test (smoking).", or the one test when it covers them all, "P-value:" when the table shows a single p-value. test_type in the long output gives the test of each variable. See @param correct / simulate_p to switch on Yates' continuity correction or Monte Carlo p-values for the chi-squared test, and @param assoc_measure for the per-row dispatch table used by "auto" (2x2 -> Phi, both ordered -> Kendall's Tau-b, otherwise Cramér's V). Without by, the table reports the marginal frequency distribution of each variable with no inferential statistics.

The exact tests condition on counts. Under weights, they run on the weighted table when its cells are whole numbers (frequency weights); otherwise a variable that a rule sends to Fisher's exact test keeps the chi-squared test, Pearson's under test = "auto" and the N-1 under Campbell's rule, with a warning (spicy_fallback), and test = "fisher", or test = "midp" on a 2x2 table, is an error. For survey weights, table_categorical_svy() gives the design-based test.

The test and the measure are two decisions with two arguments: p_value = FALSE removes the test, assoc_measure = "none" the measure. Together they give a purely descriptive table, which keeps the group columns and their headers; smd stays available.

For model-based comparisons (cluster-robust SE, weighted contrasts, fitted means) on continuous outcomes, see table_continuous_lm(). For descriptive (empirical) comparisons on continuous outcomes, see table_continuous().

Groups with no data

A variable with no observed value in a group of by that holds rows, typically a question not asked in that group, has no data there, which is not a missing answer. Its cells in that group show an en dash (NA in the data frames, in tidy() and in as_structured()), and its rows there are left out of everything computed for that variable: the missing values the note counts, the Total column, the "(Missing)" row, the test, the association measure and the SMD. A variable left with fewer than two groups with data is not compared: its p and measure cells show the dash, and the note names no test and no measure for it. The note says "No data for X in G.", in one sentence for the variables with no data in the same groups ("No data for X and Y in G."). Without by, a variable with no observed value at all keeps its declared levels (its one row when it declares none) with the dash, and the note says "No data for X." The column of missing by values is never such a group, and a declared level of by that holds no row keeps its zero column.

Multiple-comparison adjustment

The p-values of a descriptive table describe: they test no prespecified hypothesis. STROBE (explanation of item 14) and the SAMPL guidelines advise against significance tests in descriptive and baseline tables, hence the default p_adjust = "none".

When a journal or a protocol asks for adjusted p-values anyway, p_adjust applies stats::p.adjust() to the family of every test in the table: one p-value per variable (one per block in table_outcome()), never the rows of one variable, which share one test. "holm" suits a confirmatory set of comparisons, "BH" a screening one; table_regression() lists the methods. A test that returned no p-value stays out of the family.

The adjustment runs before any formatting, so the p column and its <.001 threshold show the adjusted values, and the table note gives the number of tests and the method. The raw p-values stay in the object: a p_unadjusted column next to the p-value column of the returned data frames, and col_meta$p$p_unadjusted (with the method in p_adjust and the family size in p_adjust_m) in as_structured(). Without by, or with p_value = FALSE, the table shows no p-value to adjust, and a p_adjust other than "none" is an error.

Standardized mean difference

smd = TRUE adds an SMD column with the balance diagnostic of the Table 1 literature, on the variable row beside p. For a two-category variable it is the Bernoulli form,

$$\mathrm{SMD} = \frac{p_1 - p_2}{\sqrt{(p_1(1-p_1) + p_2(1-p_2)) / 2}}$$

with \(p\) the proportion of the SECOND category, signed, group 1 minus group 2 in the order the table displays them. Note the denominator: the Bernoulli variance \(p(1-p)\) at n, not var() at \(n-1\), which would be 19% off on a small table.

"Second category" is the order the table shows, which is worth knowing for a logical variable: spicy displays TRUE then FALSE, so the sign is taken on FALSE, where tableone and cobalt coerce with factor() (FALSE, TRUE) and take it on TRUE – the same magnitude with the opposite sign. Convert to a factor with the level order you want if the direction matters.

For three or more categories it is the multivariate form of Yang and Dalton (2012, SAS Global Forum 335-2012),

$$\mathrm{SMD} = \sqrt{T' S^{-} T}$$

with \(T\) the difference of the two profiles of proportions (first category dropped) and \(S\) the mean of their multinomial covariance matrices. This is a Mahalanobis distance: it is unsigned, it is not bounded by 1, and \(S^{-}\) is a pseudo-inverse, because a declared-but-unobserved category makes \(S\) singular and solve() would abort where the pseudo-inverse returns exactly the value that category's absence implies. Which kernel a row took is published as smd_type in the "long" output, and the unsigned reading is stated in the table note whenever a variable has more than two categories. The MASS package is needed for this arm only.

Two profiles with no category in common have an infinite standardized distance. The pseudo-inverse would quietly publish a finite number there, so the cell is an en-dash and a classed warning says why. The same applies when each group is constant on a different category, where the naive route publishes 0 – "perfectly balanced" for the most imbalanced variable possible.

Conventions shared with table_continuous(): exactly two groups (three or more are refused, not averaged over pairs); complete cases on the observed groups, so a drop_na = FALSE "(Missing)" level is displayed and never enters the diagnostic; no confidence interval and no p-value, by design. Under weights the profiles are the weighted proportions, which makes this column agree with both the frequency and the survey-design readings – a profile of proportions is invariant to a global rescaling of the weights, so rescale cannot move it. (Only the continuous arm has a convention to choose there.)

The SMD cell keeps its leading zero where the association cell drops it: the APA strip belongs to a bounded measure, and this one is not bounded. The two columns therefore print 0.45 and .45 side by side, on purpose.

A balance table usually shows no test: p_value = FALSE removes the p column here as it does in table_continuous(), so a table mixing continuous and categorical variables can drop both. inline() quotes the cell from the variable row (inline(tbl, smoking, column = "smd")).

Display conventions

Decimal alignment, p-value formatting, and required suggested packages per output engine are documented under @param align, @param p_digits, and @param output respectively.

Counts are displayed as integers: weighted counts are rounded (ties half to even, the R convention) at display time only, in cells and margins alike – the SPSS Crosstabs convention. Cells and margins are rounded independently, so small display discrepancies are possible (e.g. two cells of exactly 0.5 each display as 0 while their Total of 1.0 displays as 1). The machine outputs ("data.frame", "long") carry the exact weighted counts and full-precision percentages.

Declared missing values

Survey files imported with haven often carry declared missing values: codes such as 8 = Don't know or 9 = Refused that the source file marks as missing while keeping them distinct from a plain NA. Two kinds of declaration exist: na_values / na_range metadata on haven::labelled_spss() vectors, and tagged missing values created by haven::tagged_na() (the Stata .a, .b, ... convention). haven::read_sav() keeps the na_values / na_range declaration only with user_na = TRUE: its default turns the declared codes into NA on import.

spicy honors the declaration by default (user_na = TRUE): declared missing values are excluded from every statistic exactly like NA – valid percentages, means, chi-squared tests, association measures, row-wise summaries, and group definitions – but they are not erased from display. freq() lists each observed declared value as its own row of the Missing block, with its value label; cross_tab(), table_categorical(), and table_continuous() disclose the exclusion in the table note (Declared missing values removed: x (2).); varlist() counts them as missing in N_valid / NAs / N_distinct while still listing the declared codes in Values; code_book() counts them in n_declared_missing and flags them in its values table.

Every function involved offers the same escape hatch: set user_na = FALSE to ignore the declaration and treat the declared codes as valid values (the behavior of spicy before 0.13.0). Tagged missing values are genuine NAs either way; for them, user_na = FALSE only collapses the per-tag breakdown back into the regular NA count.

References

Agresti, A. (2019). An Introduction to Categorical Data Analysis (3rd ed.). Wiley.

Campbell, I. (2007). Chi-squared and Fisher-Irwin tests of two-by-two tables with small sample recommendations. Statistics in Medicine, 26(19), 3661-3675. doi:10.1002/sim.2832

Fagerland, M. W., Lydersen, S., & Laake, P. (2017). Statistical Analysis of Contingency Tables. Chapman and Hall/CRC. doi:10.1201/9781315374116

Lydersen, S., Fagerland, M. W., & Laake, P. (2009). Recommended tests for association in 2 x 2 tables. Statistics in Medicine, 28(7), 1159-1175. doi:10.1002/sim.3531

See also

table_continuous() for empirical comparisons on continuous outcomes; table_continuous_lm() for the model-based companion (heteroskedasticity-consistent / cluster-robust / bootstrap / jackknife SE, fitted means, weighted contrasts); cross_tab() for two-way cross-tabulations; freq() for one-way frequency tables.

Other spicy tables: table_continuous(), table_continuous_lm()

Examples

# --- Basic usage ---------------------------------------------------------

# Default: ASCII console table grouped by sex.
table_categorical(
  sochealth,
  select = c(smoking, physical_activity),
  by = sex
)
#> Categorical table by sex
#> 
#>                       Female      Male       Total              
#>                      ─────────  ─────────  ─────────            
#>  Variable          │  n    %     n    %     n    %     p     φ  
#> ───────────────────┼────────────────────────────────────────────
#>  smoking           │                                  .713  .01 
#>    No              │ 475  78.4  451  79.3  926  78.8            
#>    Yes             │ 131  21.6  118  20.7  249  21.2            
#>    (Missing)       │  14         11         25                  
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  physical_activity │                                  .832  .01 
#>    No              │ 334  53.9  316  54.5  650  54.2            
#>    Yes             │ 286  46.1  264  45.5  550  45.8            
#> 
#> Note. N = 1200: Female (n = 620), Male (n = 580). Percentages are column percentages. P-values: Pearson chi-squared test.

# One-way frequency-style table (no `by`).
table_categorical(
  sochealth,
  select = c(smoking, physical_activity)
)
#> Categorical table
#> 
#>  Variable            │   n      %    
#> ─────────────────────┼───────────────
#>  smoking             │               
#>    No                │  926    78.8  
#>    Yes               │  249    21.2  
#>    (Missing)         │   25          
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  physical_activity   │               
#>    No                │  650    54.2  
#>    Yes               │  550    45.8  
#> 
#> Note. N = 1200.

# Purely descriptive grouped table: no test, no association measure.
table_categorical(
  sochealth,
  select = c(smoking, physical_activity),
  by = sex,
  p_value = FALSE,
  assoc_measure = "none"
)
#> Categorical table by sex
#> 
#>                           Female          Male           Total     
#>                        ─────────────  ─────────────  ───────────── 
#>  Variable            │   n      %       n      %       n      %    
#> ─────────────────────┼─────────────────────────────────────────────
#>  smoking             │                                             
#>    No                │  475    78.4    451    79.3    926    78.8  
#>    Yes               │  131    21.6    118    20.7    249    21.2  
#>    (Missing)         │   14             11             25          
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  physical_activity   │                                             
#>    No                │  334    53.9    316    54.5    650    54.2  
#>    Yes               │  286    46.1    264    45.5    550    45.8  
#> 
#> Note. N = 1200: Female (n = 620), Male (n = 580). Percentages are column percentages.

# A small sample: `smoking` has an expected count below 5 and takes
# Fisher's exact test; the note names the test of each variable.
table_categorical(
  sochealth[1:40, ],
  select = c(smoking, physical_activity),
  by = sex
)
#> Categorical table by sex
#> 
#>                           Female         Male         Total                    
#>                        ────────────  ────────────  ────────────                
#>  Variable            │  n      %      n      %      n      %       p       φ   
#> ─────────────────────┼─────────────────────────────────────────────────────────
#>  smoking             │                                            .258    .20  
#>    No                │  16    72.7    16    88.9    32    80.0                 
#>    Yes               │   6    27.3     2    11.1     8    20.0                 
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  physical_activity   │                                            .726    .06  
#>    No                │  11    50.0     8    44.4    19    47.5                 
#>    Yes               │  11    50.0    10    55.6    21    52.5                 
#> 
#> Note. N = 40: Female (n = 22), Male (n = 18). Percentages are column percentages. P-values: Pearson chi-squared test (physical_activity); Fisher exact test (smoking).

# Pretty labels keyed by column name.
table_categorical(
  sochealth,
  select = c(smoking, physical_activity),
  by = education,
  labels = c(
    smoking           = "Current smoker",
    physical_activity = "Physical activity"
  )
)
#> Categorical table by education
#> 
#>                      Lower secondary  Upper secondary  Tertiary     Total   
#>                      ───────────────  ───────────────  ─────────  ───────── 
#>  Variable          │   n        %       n        %      n    %     n    %   
#> ───────────────────┼────────────────────────────────────────────────────────
#>  Current smoker    │                                                        
#>    No              │  179     69.6     415     78.7    332  84.9  926  78.8 
#>    Yes             │   78     30.4     112     21.3     59  15.1  249  21.2 
#>    (Missing)       │    4               12               9         25       
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  Physical activity │                                                        
#>    No              │  177     67.8     310     57.5    163  40.8  650  54.2 
#>    Yes             │   84     32.2     229     42.5    237  59.2  550  45.8 
#> 
#>  Variable          │   p     V  
#> ───────────────────┼────────────
#>  Current smoker    │ <.001  .14 
#>    No              │            
#>    Yes             │            
#>    (Missing)       │            
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌
#>  Physical activity │ <.001  .21 
#>    No              │            
#>    Yes             │            
#> 
#> Note. N = 1200: Lower secondary (n = 261), Upper secondary (n = 539), Tertiary (n = 400). Percentages are column percentages. P-values: Pearson chi-squared test. V = Cramér's V.

# Survey weights with rescaling.
table_categorical(
  sochealth,
  select = c(smoking, physical_activity),
  by = education,
  weights = "weight",
  rescale = TRUE
)
#> Categorical table by education
#> 
#>                      Lower secondary  Upper secondary  Tertiary     Total   
#>                      ───────────────  ───────────────  ─────────  ───────── 
#>  Variable          │   n        %       n        %      n    %     n    %   
#> ───────────────────┼────────────────────────────────────────────────────────
#>  smoking           │                                                        
#>    No              │  176     69.0     419     78.5    325  84.4  919  78.4 
#>    Yes             │   79     31.0     114     21.5     60  15.6  254  21.6 
#>    (Missing)       │    4               13              10         27       
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  physical_activity │                                                        
#>    No              │  174     67.2     315     57.7    166  41.9  655  54.6 
#>    Yes             │   85     32.8     231     42.3    229  58.1  545  45.4 
#> 
#>  Variable          │   p     V  
#> ───────────────────┼────────────
#>  smoking           │ <.001  .13 
#>    No              │            
#>    Yes             │            
#>    (Missing)       │            
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌
#>  physical_activity │ <.001  .19 
#>    No              │            
#>    Yes             │            
#> 
#> Note. N = 1200 (unweighted): Lower secondary (n = 261), Upper secondary (n = 539), Tertiary (n = 400). Percentages are column percentages. Statistics weighted by weight. P-values: Pearson chi-squared test. V = Cramér's V.

# Confidence interval for the association measure.
table_categorical(
  sochealth,
  select = smoking,
  by = education,
  assoc_ci = TRUE
)
#> Categorical table by education
#> 
#>                Lower secondary  Upper secondary  Tertiary     Total          
#>                ───────────────  ───────────────  ─────────  ─────────        
#>  Variable    │   n        %       n        %      n    %     n    %      p   
#> ─────────────┼───────────────────────────────────────────────────────────────
#>  smoking     │                                                         <.001 
#>    No        │  179     69.6     415     78.7    332  84.9  926  78.8        
#>    Yes       │   78     30.4     112     21.3     59  15.1  249  21.2        
#>    (Missing) │    4               12               9         25              
#> 
#>  Variable    │   V [95% CI]   
#> ─────────────┼────────────────
#>  smoking     │ .14 [.08, .19] 
#>    No        │                
#>    Yes       │                
#>    (Missing) │                
#> 
#> Note. N = 1200: Lower secondary (n = 261), Upper secondary (n = 539), Tertiary (n = 400). Percentages are column percentages. P-value: Pearson chi-squared test. V = Cramér's V; CI = confidence interval.

# --- Per-variable association measure ----------------------------------

# Default (`assoc_measure = "auto"`): one measure per row variable based on
# the variable type (2x2 -> Phi, both ordered factors -> Kendall's Tau-b,
# otherwise Cramer's V). When the chosen measures differ across rows, the
# column header collapses to `"Effect size"` and the note names the
# measure used for each variable.
table_categorical(
  sochealth,
  select = c(smoking, education),
  by = sex
)
#> Categorical table by sex
#> 
#>                       Female      Male       Total                      
#>                      ─────────  ─────────  ─────────                    
#>  Variable          │  n    %     n    %     n    %     p    Effect size 
#> ───────────────────┼────────────────────────────────────────────────────
#>  smoking           │                                  .713      .01     
#>    No              │ 475  78.4  451  79.3  926  78.8                    
#>    Yes             │ 131  21.6  118  20.7  249  21.2                    
#>    (Missing)       │  14         11         25                          
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  education         │                                  .344      .04     
#>    Lower secondary │ 134  21.6  127  21.9  261  21.8                    
#>    Upper secondary │ 290  46.8  249  42.9  539  44.9                    
#>    Tertiary        │ 196  31.6  204  35.2  400  33.3                    
#> 
#> Note. N = 1200: Female (n = 620), Male (n = 580). Percentages are column percentages. P-values: Pearson chi-squared test. Phi: smoking. Cramér's V: education.

# Force a uniform measure across all row variables: its symbol, V,
# heads the column, and the note defines it.
table_categorical(
  sochealth,
  select = c(smoking, education),
  by = sex,
  assoc_measure = "cramer_v"
)
#> Categorical table by sex
#> 
#>                       Female      Male       Total              
#>                      ─────────  ─────────  ─────────            
#>  Variable          │  n    %     n    %     n    %     p     V  
#> ───────────────────┼────────────────────────────────────────────
#>  smoking           │                                  .713  .01 
#>    No              │ 475  78.4  451  79.3  926  78.8            
#>    Yes             │ 131  21.6  118  20.7  249  21.2            
#>    (Missing)       │  14         11         25                  
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  education         │                                  .344  .04 
#>    Lower secondary │ 134  21.6  127  21.9  261  21.8            
#>    Upper secondary │ 290  46.8  249  42.9  539  44.9            
#>    Tertiary        │ 196  31.6  204  35.2  400  33.3            
#> 
#> Note. N = 1200: Female (n = 620), Male (n = 580). Percentages are column percentages. P-values: Pearson chi-squared test. V = Cramér's V.

# Per-variable override (recommended named form).
table_categorical(
  sochealth,
  select = c(smoking, education, self_rated_health),
  by = sex,
  assoc_measure = c(
    smoking           = "phi",        # binary x binary
    education         = "cramer_v",   # multi-category nominal
    self_rated_health = "tau_b"       # ordinal x binary, Tau-b
  )
)
#> Categorical table by sex
#> 
#>                       Female      Male       Total                      
#>                      ─────────  ─────────  ─────────                    
#>  Variable          │  n    %     n    %     n    %     p    Effect size 
#> ───────────────────┼────────────────────────────────────────────────────
#>  smoking           │                                  .713      .01     
#>    No              │ 475  78.4  451  79.3  926  78.8                    
#>    Yes             │ 131  21.6  118  20.7  249  21.2                    
#>    (Missing)       │  14         11         25                          
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  education         │                                  .344      .04     
#>    Lower secondary │ 134  21.6  127  21.9  261  21.8                    
#>    Upper secondary │ 290  46.8  249  42.9  539  44.9                    
#>    Tertiary        │ 196  31.6  204  35.2  400  33.3                    
#> ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
#>  self_rated_health │                                  .849      .01     
#>    Poor            │  31   5.1   30   5.3   61   5.2                    
#>    Fair            │ 143  23.4  123  21.6  266  22.5                    
#>    Good            │ 282  46.2  276  48.4  558  47.3                    
#>    Very good       │ 154  25.2  141  24.7  295  25.0                    
#>    (Missing)       │  10         10         20                          
#> 
#> Note. N = 1200: Female (n = 620), Male (n = 580). Percentages are column percentages. P-values: Pearson chi-squared test. Phi: smoking. Cramér's V: education. Kendall's Tau-b: self_rated_health.

# --- Output formats -----------------------------------------------------

# The rendered outputs below all wrap the same call:
#   table_categorical(sochealth,
#                     select = c(smoking, physical_activity),
#                     by = sex)
# only `output` changes. Assign each result to a variable -- some
# engines auto-print as a console-friendly text fallback inside
# the `?` help viewer.

# Wide data.frame (one row per modality).
table_categorical(
  sochealth,
  select = c(smoking, physical_activity),
  by = sex,
  output = "data.frame"
)
#>            variable             label     level     .row_role Female n Female %
#> 1           smoking           smoking      <NA> factor_header       NA       NA
#> 2           smoking           smoking        No         level      475 78.38284
#> 3           smoking           smoking       Yes         level      131 21.61716
#> 4           smoking           smoking (Missing)       missing       14       NA
#> 5 physical_activity physical_activity      <NA> factor_header       NA       NA
#> 6 physical_activity physical_activity        No         level      334 53.87097
#> 7 physical_activity physical_activity       Yes         level      286 46.12903
#>   Male n   Male % Total n  Total %         p           φ
#> 1     NA       NA      NA       NA 0.7125196 0.010749501
#> 2    451 79.26186     926 78.80851        NA          NA
#> 3    118 20.73814     249 21.19149        NA          NA
#> 4     11       NA      25       NA        NA          NA
#> 5     NA       NA      NA       NA 0.8316763 0.006135851
#> 6    316 54.48276     650 54.16667        NA          NA
#> 7    264 45.51724     550 45.83333        NA          NA

# Long data.frame (one row per (modality x group)).
table_categorical(
  sochealth,
  select = c(smoking, physical_activity),
  by = sex,
  output = "long"
)
#>             variable             label     level .row_role  group .group_role
#> 1            smoking           smoking        No     level Female       group
#> 2            smoking           smoking        No     level   Male       group
#> 3            smoking           smoking        No     level  Total       total
#> 4            smoking           smoking       Yes     level Female       group
#> 5            smoking           smoking       Yes     level   Male       group
#> 6            smoking           smoking       Yes     level  Total       total
#> 7            smoking           smoking (Missing)   missing Female       group
#> 8            smoking           smoking (Missing)   missing   Male       group
#> 9            smoking           smoking (Missing)   missing  Total       total
#> 10 physical_activity physical_activity        No     level Female       group
#> 11 physical_activity physical_activity        No     level   Male       group
#> 12 physical_activity physical_activity        No     level  Total       total
#> 13 physical_activity physical_activity       Yes     level Female       group
#> 14 physical_activity physical_activity       Yes     level   Male       group
#> 15 physical_activity physical_activity       Yes     level  Total       total
#>    total   n      pct   test_type statistic df   p.value es_type    es_value
#> 1  FALSE 475 78.38284 chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 2  FALSE 451 79.26186 chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 3   TRUE 926 78.80851 chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 4  FALSE 131 21.61716 chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 5  FALSE 118 20.73814 chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 6   TRUE 249 21.19149 chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 7  FALSE  14       NA chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 8  FALSE  11       NA chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 9   TRUE  25       NA chi_squared 0.1357733  1 0.7125196     phi 0.010749501
#> 10 FALSE 334 53.87097 chi_squared 0.0451784  1 0.8316763     phi 0.006135851
#> 11 FALSE 316 54.48276 chi_squared 0.0451784  1 0.8316763     phi 0.006135851
#> 12  TRUE 650 54.16667 chi_squared 0.0451784  1 0.8316763     phi 0.006135851
#> 13 FALSE 286 46.12903 chi_squared 0.0451784  1 0.8316763     phi 0.006135851
#> 14 FALSE 264 45.51724 chi_squared 0.0451784  1 0.8316763     phi 0.006135851
#> 15  TRUE 550 45.83333 chi_squared 0.0451784  1 0.8316763     phi 0.006135851
#>    es_ci_lower es_ci_upper smd_type smd_value
#> 1           NA          NA     <NA>        NA
#> 2           NA          NA     <NA>        NA
#> 3           NA          NA     <NA>        NA
#> 4           NA          NA     <NA>        NA
#> 5           NA          NA     <NA>        NA
#> 6           NA          NA     <NA>        NA
#> 7           NA          NA     <NA>        NA
#> 8           NA          NA     <NA>        NA
#> 9           NA          NA     <NA>        NA
#> 10          NA          NA     <NA>        NA
#> 11          NA          NA     <NA>        NA
#> 12          NA          NA     <NA>        NA
#> 13          NA          NA     <NA>        NA
#> 14          NA          NA     <NA>        NA
#> 15          NA          NA     <NA>        NA

# \donttest{
# Rendered HTML / docx objects -- best viewed inside a
# Quarto / R Markdown document or a pkgdown article.
if (requireNamespace("tinytable", quietly = TRUE)) {
  tt <- table_categorical(
    sochealth, select = c(smoking, physical_activity), by = sex,
    output = "tinytable"
  )
}
if (requireNamespace("gt", quietly = TRUE)) {
  tbl <- table_categorical(
    sochealth, select = c(smoking, physical_activity), by = sex,
    output = "gt"
  )
}
if (requireNamespace("flextable", quietly = TRUE)) {
  ft <- table_categorical(
    sochealth, select = c(smoking, physical_activity), by = sex,
    output = "flextable"
  )
}

# Excel and Word: write to a temporary file.
if (requireNamespace("openxlsx2", quietly = TRUE)) {
  tmp <- tempfile(fileext = ".xlsx")
  table_categorical(
    sochealth, select = c(smoking, physical_activity), by = sex,
    output = "excel", excel_path = tmp
  )
  unlink(tmp)
}
if (
  requireNamespace("flextable", quietly = TRUE) &&
    requireNamespace("officer", quietly = TRUE)
) {
  tmp <- tempfile(fileext = ".docx")
  table_categorical(
    sochealth, select = c(smoking, physical_activity), by = sex,
    output = "word", word_path = tmp
  )
  unlink(tmp)
}
# }

if (FALSE) { # \dontrun{
# Clipboard: writes to the system clipboard.
table_categorical(
  sochealth, select = c(smoking, physical_activity), by = sex,
  output = "clipboard"
)
} # }