Quickstart
Two ways in: the GT facade (one object, every rendering, lazily computed)
or the explicit build / render_* functions. They do the same work —
GT just holds the document for you.
The facade
from greater_tables import GT, Fabricator
df = Fabricator(seed=42).make(6, 'fdi', index_levels=2)
gt = GT(df, caption='Demo table')
gt # rich display in JupyterLab / Quarto
gt.html # HTML fragment, stylesheet embedded
gt.tikz # TikZ/LaTeX source
gt.text # monospace rendering
gt.doc # the table document (the IR)
print(gt) # same as gt.text
print(gt) on that table:
Demo table
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
i_0 i_1 │ turbulence energy approval
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
victims integer │ 217,478 2025-03-27 23:22 68,625
icons │ -365,061 2024-07-04 13:11 57,346
candidly │ 101,784 2023-08-04 20:48 76,166
──────────────────────────────────────────────────────────
gossip integer │ 207,004 2021-05-25 12:53 86,918
icons │ 101,475 2021-02-03 10:04 52,297
candidly │ 67,528 2024-04-27 07:15 81,821
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Note what the engine decided without being told: text left, numbers right with thousands separators and no decimals (they are large), dates centered and ISO-formatted, the repeated outer index value sparsified into a rowspan, and a rule where the outer index changes — a break depth, not a hardcoded line. Every one of those is a semantic decision that lives in the document, so all four renderers make the same one.
A GT is immutable and everything on it is cached: constructing one
computes nothing, and asking for HTML never computes TikZ. To change options,
build a new one.
The explicit API
from greater_tables import build, render_html, render_text, render_tikz, TableSpec
spec = TableSpec(caption='Loss ratios', ratio_cols='lr|ratio',
year_cols=['year'], max_rows=200)
doc = build(df, spec) # → TableDoc: validated, hash-stamped
render_html(doc) # fragment + <style>
render_tikz(doc) # table environment + tikzpicture
render_text(doc) # the conformance oracle's view
build never mutates the DataFrame. The same DataFrame and spec always
produce the same document, byte for byte — see The table document (IR v1).
Telling the engine what the columns mean
TableSpec is the whole user-facing surface.
Column selectors (ratio_cols, year_cols, date_cols, raw_cols,
html_cols, unbreakable) each accept a list of labels, the string
'all', or a regex matched against column names:
TableSpec(
ratio_cols='all', # regex / list / 'all'
formatters={'paid': ',.1f', 'count': 0}, # per column
float_format=',.2f', # house rule for all floats
aligners='llrrc', # positional, or {label: 'r'}
row_flags={-1: ['total']}, # or a callable (pos, row)
notes=['Amounts in USD thousands.'],
include_raw='data', # ship raw values for clients
max_rows=500, # truncate with a note
)
Formats accept both dialects: csv-grid spec strings (',.1f'), Python
format strings ('{x:.1%}'), a plain int (decimal digits, grouped), a
mapping of FormatSpec fields, an explicit
FormatSpec, or — in formatters only — a callable.
The presentation types are f decimal, d integer, % percent,
e scientific, g general and s string, in either dialect. g
follows Python and counts significant figures, so '.3g' gives
1.23e+03 for 1234.5 and 0.00123 for 0.0012345 — where '.3f'
would give three decimal places in both cases. Uppercase G and E are
not parsed.
House rules, one per kind
float_format, int_format, ratio_format and date_format are the
5.x default_*_str settings. Each replaces its own kind’s built-in and
loses to an explicit formatters entry, so the resolution per column is:
its kind (semantic tag if it has one, else dtype), then
formatters[col] → the house default → the built-in.
Anything you leave unset is inferred from the column’s dtype and its own
statistics — magnitude decides decimals, and a float column spanning many
orders of magnitude switches to engineering notation. Setting
float_format turns that inference off for floats entirely, including
the engineering switch: a stated house rule beats a guess, which is what
makes twelve exhibits in one report agree with each other.
table_float_format is the exception that overrides even a per-column
entry, on untagged float columns only. Use it at a call site for a quick
“show me everything at 6 dp” pass; never put it in a config file, where it
would silently defeat every formatters entry you write later.
Because these are ordinary spec fields, one YAML file can govern a whole document:
# house.yaml
float_format: ',.2f'
ratio_format: '.1%'
date_format: '%d %b %Y'
ratio_cols: 'lr|ratio$'
formatters: {paid: {kind: dec, digits: 1, negative: paren}}
from functools import partial
from greater_tables import GT, load_spec
from greater_tables.config import write_template
myGT = partial(GT, config_path='house.yaml') # every exhibit, one rule
spec = load_spec('house.yaml') # or the explicit route
write_template('house.yaml') # commented starting point
write_template('house.yaml', gt.spec) # this table's actual settings
Unknown keys raise, so typos surface at load. A partial is also the way to
make a domain-specific GT — partial(GT, year_cols=r'^(year|ay)$',
ratio_cols=r'lr|ratio$') — since the column tags are regexes.
Notebooks and Quarto
GT implements _repr_mimebundle_, and always publishes text/plain
— every measured consumer renders it, so a table never degrades to silence or
to <GT at 0x…>. What joins it depends on the target:
target |
keys published |
|---|---|
Quarto PDF ( |
|
is |
|
JupyterLab, Quarto HTML, anything |
|
else |
text/html is omitted on the PDF path on purpose: pandoc prefers html when
offered and its LaTeX writer then discards it, so the table would vanish
entirely. Nothing global is touched on import — no pandas options, no
warnings filters.
from greater_tables import GT
GT(df, caption='Exhibit 1', label='tbl-exhibit-1') # label = Quarto xref id
One target cannot be detected: quarto render --to gfm looks exactly like
an HTML render from the environment, and pandoc’s commonmark writer leaks raw
html into the markdown. Pin the bundle for the whole build:
from functools import partial
render = partial(GT, mimes=('text/plain',)) # for a gfm document
mimes=None (the default) detects; an explicit tuple publishes exactly
those types. Members are validated at construction, so a typo raises rather
than silently producing a bundle nothing can display.
repr() gives the text table too, which is what makes GT usable from a
plain script or a REPL.
The stylesheet is embedded with every fragment by default (~2 KB, idempotent
— safe for saved and cleared notebooks). render_html(doc, css='once')
emits it once per kernel; css='none' never; inline_css=True flattens
the same rules onto style attributes for HTMX fragments and email.
PDF from TikZ
from greater_tables import GT, Etcher
etcher = Etcher(GT(df).tikz, file_name='exhibit-1')
etcher.compile() # → PDF (tectonic by default, pdflatex fallback)
Outputs cache by content hash, so recompiling an unchanged table is free.
In the browser
The same document renders client-side with the bundled zero-dependency
walker, which ships as package data (gt-render.esm.js,
gt-render.iife.js, gt.css):
from importlib.resources import files
assets = files('greater_tables') / 'assets' # serve these
import { renderTable } from './gt-render.esm.js';
const t = renderTable(doc, { mount: el, allowHtml: true });
// t.el, t.doc, t.toCSV(), t.destroy()
Serving the walker from the installed package means it cannot version-skew against the document the same process emits. See Design for the walker contract and the csv-grid handoff.