Tables in Go PDFs: column widths, striped rows, page breaks
Tables are the hardest part of a Go PDF. gpdf collapses widths, stripes, and multi-page header repeat into one Table call — here is the whole API and what it costs.
TL;DR
Tables are the part of PDF generation that wrecks weekends. Column widths that don't add up, header rows that disappear on page 2, stripes drawn by a row loop with an off-by-one. gpdf collapses the whole thing into one call:
c.Table(header, rows,
template.ColumnWidths(40, 15, 20, 25),
template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)
That handles widths, stripes, and automatic header repeat on every page break. No row loop. No PageBreak option. The layout engine notices when the table won't fit and re-emits the Header slice at the top of the next page. For colspan, rowspan, or a footer that repeats too, you drop one layer down to document.Table — same building blocks, more control.
This post is about why those are the three things that matter, what gpdf does for each, and where the abstraction stops on purpose.
Why this article exists
gpdf is a Go library for generating PDFs. It's MIT, zero dependencies, and renders a single page in about 13 µs. The high-level table API is tiny — eight TableOption constructors — but the design pressure on it is enormous, because tables are where most PDF projects get stuck.
The three things that wreck a table in Go PDF land:
- Column widths. The web has CSS
<col>andcolgroup. PDF has nothing. You either compute every column width yourself in points, or you accept whatever the library gives you — usually equal splits. - Striped rows. You want every other body row tinted gray for readability. Most low-level libraries make you write the row loop and track parity yourself, which is the source of half the table-rendering bugs in any codebase.
- Page breaks. A 200-row report doesn't fit on one A4 page. The library has to (a) split the body somewhere reasonable, (b) close the page, (c) start a new one, and (d) repeat the header on the new page so the reader knows what column they're looking at. Forget any one of these and the table is unusable.
This post walks through how gpdf solves each, and what trade-offs the design makes. If you only want copy-paste recipes, the per-option recipes are linked at the bottom. This is the long form for people who want to know whether they can trust the API before they commit a ten-thousand-row monthly statement to it.
The shape of the API
There's one entry point in the builder layer:
func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption)
Header is a slice of strings, rows is a slice-of-slices of strings, and the variadic opts configure everything else. Eight option constructors exist:
| Option | What it controls |
|---|---|
ColumnWidths(...float64) | Per-column widths as percentages of the parent Col |
TableHeaderStyle(...TextOption) | Header background and text color |
TableStripe(pdf.Color) | Background color for alternating body rows |
TableCellVAlign(document.VerticalAlign) | Vertical alignment for body cells (top/middle/bottom) |
WithTableBorder(BorderSpec) | Outer frame around the entire table |
WithTableCellBorder(BorderSpec) | Same border around every cell — the grid look |
WithTableBorderCollapse(bool) | CSS border-collapse: collapse semantics |
WithTableBackground(pdf.Color) | Fill behind the entire table |
That's the whole surface. Anything you can build in the builder you build with these eight. Anything beyond — colspan, rowspan, a footer, fixed-pt widths — is a document.Table call instead. We'll get there.
Working code: a six-month invoice ledger
Here's a complete, runnable program that produces a multi-page striped invoice ledger. Save as main.go, run go run ., get ledger.pdf.
package main
import (
"fmt"
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/pdf"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := gpdf.NewDocument(
gpdf.WithPageSize(gpdf.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
brand := pdf.RGBHex(0x1A237E)
stripe := pdf.RGBHex(0xF5F5F5)
hairline := template.Border(
template.BorderWidth(document.Pt(0.5)),
template.BorderColor(pdf.Gray(0.85)),
)
header := []string{"Date", "Invoice #", "Customer", "Amount"}
rows := make([][]string, 0, 120)
for i := 1; i <= 120; i++ {
rows = append(rows, []string{
fmt.Sprintf("2026-%02d-%02d", (i%6)+1, (i%28)+1),
fmt.Sprintf("INV-%05d", 10000+i),
fmt.Sprintf("Customer #%d", i),
fmt.Sprintf("$%d.00", 100+i*7),
})
}
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("H1 2026 Ledger", template.FontSize(18), template.Bold())
c.Spacer(document.Mm(4))
c.Table(header, rows,
template.ColumnWidths(20, 20, 40, 20),
template.TableHeaderStyle(
template.TextColor(pdf.White),
template.BgColor(brand),
),
template.TableStripe(stripe),
template.WithTableCellBorder(hairline),
)
})
})
data, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("ledger.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
120 rows on A4 spills onto roughly five pages. On every page the dark-blue header reappears at the top, the body picks up where it left off, the alternating gray stripe stays consistent across the page break. You don't have to touch any of that.
The thing to look at in this snippet is what's missing: there is no row loop, no page-counter, no manual if i == lastRowOnPage check, no PageBreak() call, no header re-render. The four lines of options say what the table looks like; the engine handles when and where to split.
Column widths: what the percentages actually mean
ColumnWidths(40, 15, 20, 25) looks like CSS <col width="40%">. It nearly is, with three sharp edges worth knowing.
The percentage is of the parent Col, not the page. A r.Col(6, ...) claims half the row's content width. A table inside that Col with ColumnWidths(50, 50) produces two columns each at 25% of the row width, not 50%. The percentages are local to wherever the table lives. This matters when you move a table from a full-width row into a side-by-side layout — the option call doesn't change.
No normalization. If your widths sum to 90, you get 10% empty space on the right. If they sum to 110, the rightmost column overflows the parent and bleeds into wherever the page lets it. gpdf trusts your arithmetic. There's no warning, and there shouldn't be — auto-correcting the values you wrote is worse than the bug.
Trailing missing values auto-distribute. Pass fewer widths than you have columns and the remaining columns split the leftover equally:
// Five-column table, three widths given.
template.ColumnWidths(40, 10, 20)
// → 40% / 10% / 20% / 15% / 15% (30% split between the trailing two)
That's a useful trick for "I care about these specific columns; let the rest sort themselves out." Pass 0 explicitly for a column to mark it as auto:
template.ColumnWidths(0, 30, 30) // → 40% / 30% / 30% on a three-column table
For a deep dive on the widths-and-percentages corner cases, see the column widths recipe. The summary: percentages cover 95% of layouts, and when they don't, you drop one layer down — described later in this post.
Stripes: the row loop you don't write
template.TableStripe(pdf.RGBHex(0xF5F5F5))
That's it. gpdf walks the body rows with index i from 0 and tints rows where i % 2 == 1. The header is its own slice and isn't counted, so the first body row sits clean and the second is shaded — the Bootstrap convention.
Why this option exists at all: in gofpdf and gopdf, you write the loop yourself, set SetFillColor per row, and call CellFormat with the fill flag. It's eight or ten lines of code and the off-by-one error rate is high enough that StackOverflow has a dedicated set of answers for it. Pulling that into one option means the bug class disappears.
The constraints are deliberate:
- One stripe color, not two. No "alternate between blue and gray." The page is already white, so the no-stripe row is automatically white. Asking for a third color cycle is asking the reader to think harder, and zebra striping is supposed to do the opposite.
- No way to flip parity. First body row is always plain, second is always tinted. If you really want it inverted, prepend a blank row to your data. You don't, because nobody actually wants that.
- Stripes cross page breaks correctly. Body row 14 stays parity-14 even when it lands on page 2. The engine carries the index across the split.
For color choice and the dark-theme variant, the zebra stripes recipe has the palette discussion. For this post, the point is that a property of the table (the alternation) is configured at the table call, not at the row level.
Page breaks: the part that's actually hard
This is where most Go PDF stories fall apart, and it's also the place where gpdf's design pays off most.
The simple version: write a table with more rows than fit on one page, and gpdf paginates it for you. The Header slice is repeated at the top of every continuation page. No options to enable it. No method to call. It's the default behavior of the layout engine.
The real version is more interesting. The block layout engine (document/layout/block.go) lays out the table with the available height. When the body doesn't fit, the result includes an Overflow field — a new *document.Table with the same Header, the same Footer, and the remaining body rows. The page system flushes the laid-out portion to the current page, opens the next page, and feeds the overflow table back into the layout engine with the new page's available height. Repeat until the overflow is empty.
Two consequences fall out of this design:
- The header lives in
tbl.Header, not in the loop. Because the overflow table reuses the sameHeaderslice, the header repeats automatically on every continuation page. Same styling, same column widths, same everything. - There's no "header doesn't fit on this page" edge case to think about. The layout engine reserves space for the header before measuring how many body rows fit. If the page can't hold the header plus at least one body row, the entire table is pushed to the next page.
Footers — when you use them at the document layer — work the same way: they're carried on every continuation page automatically.
The pieces you don't get: a "keep this row group together" annotation, page break suppression on a specific row, or "start this table on a fresh page." The first two are TODOs. The third you do at the page level — doc.AddPage() before the row that contains the table.
When you've outgrown the builder API
The builder is good for the cases that are common. When you need cell spanning, fixed-point widths, a footer that repeats, or anything that mixes content types per cell, you drop to document.Table.
import (
"github.com/gpdf-dev/gpdf/document"
)
footer := document.TableRow{
Cells: []document.TableCell{
{
Content: []document.DocumentNode{
&document.Text{Content: "Total", TextStyle: document.DefaultStyle()},
},
ColSpan: 3, // ← span the first three columns
RowSpan: 1,
},
{
Content: []document.DocumentNode{
&document.Text{Content: "$48,720.00", TextStyle: document.DefaultStyle()},
},
ColSpan: 1,
RowSpan: 1,
},
},
}
tbl := &document.Table{
Columns: []document.TableColumn{
{Width: document.Pct(20)},
{Width: document.Pct(20)},
{Width: document.Auto},
{Width: document.Pt(80)}, // fixed 80pt regardless of page width
},
Header: /* ... */,
Body: /* ... */,
Footer: []document.TableRow{footer},
}
A few things to notice. TableColumn.Width is a document.Value — Pt, Mm, Cm, In, Em, Pct, or the special Auto. You can mix them in one table. Auto columns share whatever's left after the fixed and percentage columns are subtracted. This is closer to the CSS <col> element than to the builder's percentage-only model.
TableCell.ColSpan and RowSpan are integers, default 1, expand as expected. The example above is the classic invoice footer: three header columns merge to spell "Total", and the fourth column holds the sum.
document.Table.Footer is a []TableRow that repeats on every page, same as the header. The builder API doesn't expose it because most short tables don't need one — when you do need one, you've already left the "common case" zone.
This is the gpdf pattern in general: the high-level builder covers the 90% case ergonomically, and the document layer is right there for the 10% case. They're not separate libraries. You can mix builder-built rows with manually-built ones in the same document. The builder is just a constructor for the same document.Table node.
Borders and the box model
Three border options, three different jobs:
template.WithTableBorder(spec) // outer frame around the whole table
template.WithTableCellBorder(spec) // same border around every cell
template.WithTableBorderCollapse(true) // merge adjacent cell borders
By default a table has no borders. Add WithTableBorder for an outer frame. Add WithTableCellBorder to draw the same border around every cell — the grid look. Add both for a frame around a grid. The BorderSpec itself is built with template.Border(template.BorderWidth(...), template.BorderColor(...)).
WithTableBorderCollapse(true) is the CSS analog: adjacent cell borders merge into a single line, instead of drawing twice (once for each cell's edge). For a hairline grid where the borders matter visually, collapse looks cleaner. For thick borders where you want the doubling effect on purpose, leave it off. The default is separated.
A useful pairing is hairline cell borders + light stripes:
c.Table(header, rows,
template.ColumnWidths(40, 20, 15, 25),
template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
template.WithTableCellBorder(template.Border(
template.BorderWidth(document.Pt(0.5)),
template.BorderColor(pdf.Gray(0.85)),
)),
template.WithTableBorderCollapse(true),
)
That's the look every accountant's spreadsheet print preview lands on, and it's the right default for any finance-adjacent document — invoices, statements, ledgers, expense reports.
How this compares to the alternatives
For context, here's what the same multi-page striped table costs in the libraries gpdf usually replaces:
| Library | Lines for the table | Page break header repeat | Stripes | Notes |
|---|---|---|---|---|
| gpdf | ~10 | automatic | TableStripe(...) | Builder + low-level both available |
| jung-kurt/gofpdf (archived 2021) | 40–60 | manual: track Y, call AddPage, re-emit header | manual row loop with SetFillColor | Foundational, no longer maintained |
| go-pdf/fpdf (archived 2025) | 40–60 | same as above | same as above | Was a gofpdf fork; same model |
| signintech/gopdf | 50–80 | manual | manual | Lower-level still |
| johnfercher/maroto v2 | ~15 | automatic | manual WithBackgroundColor per row | Built on gofpdf; nice API but inherits the dependency story |
| unidoc/unipdf | ~12 | automatic | row-style helper | Commercial license required |
The builder line counts are the tight part. The actual difference shows up at month 6 of using one of these, when the requirements drift — a new column needs a different alignment, the report has to ship in Japanese, the customer wants the row count printed in the footer. With gofpdf or gopdf, every drift requires touching the row loop. With gpdf, the option list grows and the body code stays the same.
For benchmarks — the actual µs-per-table numbers — see why gpdf is faster. For the broader library showdown across more dimensions, the 2026 showdown goes column by column.
CJK in tables
One thing that's invisible in the comparison table above: gpdf renders CJK glyphs natively. There's no "table mode" for Japanese — you register a font once and the table uses it for everything.
ttf, _ := os.ReadFile("NotoSansJP-Regular.ttf")
doc := gpdf.NewDocument(
gpdf.WithPageSize(gpdf.A4),
gpdf.WithFont("NotoSansJP", ttf),
gpdf.WithDefaultFont("NotoSansJP"),
)
c.Table(
[]string{"日付", "請求書番号", "顧客名", "金額"},
[][]string{
{"2026-04-01", "INV-10001", "株式会社サンプル", "¥120,000"},
{"2026-04-02", "INV-10002", "山田商店", "¥38,500"},
},
template.ColumnWidths(20, 20, 40, 20),
)
The header is Japanese, the body is Japanese, the column widths are still percentages, the page-break header repeat still works. The font is subset to only the glyphs the document uses, so the output PDF is small even with full Noto Sans JP available — about 50 KB for a single page versus the 6 MB of the unsubset font file.
For the font setup itself, embed a Japanese TrueType font is the recipe. The point here is that nothing about the table API changes when the data is CJK.
Frequently asked
Q: Does gpdf support per-row styling?
Not in the builder API. The builder takes [][]string for body rows, which means every body cell shares the same Style derived from the column. To style individual rows differently, build the table at the document.Table layer where each TableCell carries its own CellStyle. The pattern is straightforward; it just costs you the convenience of the [][]string shape.
Q: Can I put images or other tables inside a cell?
Yes, at the document.Table layer. TableCell.Content is []DocumentNode, which accepts any node — *Text, *Image, even another *Table. The builder's string-based API doesn't expose this because it's a sharper edge than most users want, but the underlying model supports it.
Q: How does gpdf decide where to split the body across pages?
Row by row. The layout engine measures each body row in order and adds it to the current page until the next row would exceed the available height. That row becomes the first row of the overflow table. There's no "keep these rows together" annotation yet — every row is split-eligible. For invoice line items where you really need a logical group on one page, you'd need to start a fresh page manually before the group, or fall back to the document layer to insert page break hints.
Q: What's the largest table gpdf can render?
We've tested at 10,000 body rows on A4. It paginates correctly, the header repeats on every page, the resulting PDF is ~150 pages and a few hundred KB. The bottleneck is not the table layout — it's the text shaping for the cell content, which is O(rows × columns). If you need 100,000+ rows, write to disk in chunks (multiple Generate calls per ~10k rows) or feed pre-shaped runs at the document.Table layer.
Q: Can I get the footer to show only on the last page?
Not built in. document.Table.Footer repeats on every page by design — that's the common case (column totals shown per page). If you need a one-shot summary at the document end, append it as a separate row block after the table, not inside it.
Q: Does WithTableCellBorder affect the header too?
Yes. Cell borders apply uniformly to header and body. If you want a different border on the header (say, thicker bottom border under the header row), build the header at the document layer and apply per-cell CellStyle.Border there.
The shape of the design
If there's one thing to take away: gpdf's table API is small because most table problems are the same three problems. Widths, stripes, page breaks. Everything else is a long tail. Putting the common cases in the builder and the long tail in the document layer is the trade — you get five-line tables for the things that come up every day, and you don't pay for the abstraction when you need to do something the builder can't express.
The cost is honest: there's no setRowStyle(i, ...) shortcut, and there won't be one. If you want to style row 4 differently from row 5, you've crossed a complexity line that the builder isn't trying to handle. Drop a layer. The boundary is clear and stable.
That's the whole article. Twenty minutes of reading for a part of the API that's worth getting right once and then not thinking about again.
Try gpdf
gpdf is a Go library for generating PDFs. MIT licensed, zero external dependencies, native CJK support.
go get github.com/gpdf-dev/gpdf
⭐ Star on GitHub · Read the docs
Related reading
- How do I set column widths in a gpdf table? —
ColumnWidthscorner cases in detail - How do I create striped (zebra) table rows? — color choice and dark-theme variants
- Bootstrap thinking for PDF: gpdf's 12-column grid — what the parent Col is that table percentages resolve against
- Generate an invoice PDF in Go in under 50 lines — a real-world table inside a complete document
- Why gpdf is faster than gofpdf, gopdf, and Maroto — the µs numbers behind the comparison table