Page numbers, headers, and footers that just work in Go PDFs
Add headers, footers, and 'Page X of Y' in Go PDFs with gpdf: two builder methods, a two-pass paginator that fills in the totals, no shims required.
A 60-page financial report. Someone opens page 12 in the print queue and asks one question: which page is this, and how many are left? If the footer just says 12, nobody knows. It needs to say 12 of 60.
That 60 is the part most PDF libraries get wrong. Either the total page count isn't available at the time you write the footer, or it ships behind some AliasNbPages token you have to call after the build, or you end up rendering the document twice and discarding the first pass.
gpdf gets this right with two builder methods and a two-pass paginator. This is the post on how it works, what the API looks like, and the one rough edge you should know about.
TL;DR
doc.Header(fn)anddoc.Footer(fn)register a closure that runs on every page.- Inside that closure, use the same 12-column grid you use for body content.
c.PageNumber()prints the current page number.c.TotalPages()prints the total.- The total is resolved in a second pass, after pagination completes. There is no two-pass build step you have to write yourself.
- One sharp edge: there is no
c.PageNumberOf(total)helper that prints"3 of 12"as one inline string. You compose it from three columns. More on this below.
The code is real. Every snippet in this post is pulled from gpdf/_examples/builder/26_page_number_test.go, which is part of the test suite.
The whole thing in one file
This is a complete program. Save it as main.go, run go run main.go, get a four-page PDF with a header on every page showing the total, and a footer showing the current page.
package main
import (
"os"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/pdf"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := template.New(
template.WithPageSize(document.A4),
template.WithMargins(document.UniformEdges(document.Mm(20))),
)
doc.Header(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("Quarterly Report", template.Bold(), template.FontSize(10))
})
r.Col(6, func(c *template.ColBuilder) {
c.TotalPages(template.AlignRight(), template.FontSize(9),
template.TextColor(pdf.Gray(0.5)))
})
})
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Line(template.LineColor(pdf.RGBHex(0x1565C0)))
c.Spacer(document.Mm(3))
})
})
})
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Spacer(document.Mm(3))
c.Line(template.LineColor(pdf.Gray(0.7)))
c.Spacer(document.Mm(2))
})
})
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("Generated by gpdf", template.FontSize(8),
template.TextColor(pdf.Gray(0.5)))
})
r.Col(6, func(c *template.ColBuilder) {
c.PageNumber(template.AlignRight(), template.FontSize(8),
template.TextColor(pdf.Gray(0.5)))
})
})
})
for i, title := range []string{"Introduction", "Background", "Analysis", "Conclusion"} {
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text(title, template.FontSize(18), template.Bold())
c.Spacer(document.Mm(5))
c.Text("Body content for section " + title + ".")
})
})
_ = i
}
out, err := doc.Generate()
if err != nil {
panic(err)
}
_ = os.WriteFile("report.pdf", out, 0o644)
}
Four pages, each with a header line saying Quarterly Report ........ 4 and a footer saying Generated by gpdf ........ 1 through 4. The total 4 appears on every header without you ever telling gpdf how many pages the document has — because gpdf doesn't know until after pagination, either.
Why "Page X of Y" is the hard part
Y is annoying because the layout engine doesn't know it when it's drawing page 1. Imagine a 50-page document where page 47 happens to be split across a page boundary because a table row didn't fit. The total is 50 only after the paginator finishes. Page 1's footer was drawn long before that.
Every PDF library hits this wall. Here's how the most-used Go ones get around it:
| Library | "Page X of Y" approach |
|---|---|
| gofpdf | pdf.AliasNbPages("{nb}") — you write {nb} as literal text, then call this method, then it rewrites the PDF stream after the fact, replacing every occurrence of {nb} with the total. Works, but you have to remember to call it, and the placeholder is a magic string. |
| go-pdf/fpdf | Same as gofpdf. (It's a fork.) |
| signintech/gopdf | No first-class support. You compute the total yourself by building the document, counting pages, and rebuilding. |
| maroto v2 | Provides a Header/Footer registration similar to gpdf. Page totals are resolved by a similar two-pass approach internally. Slower because the underlying engine is gofpdf-based (~10× slower than gpdf on common workloads). |
| gpdf | c.PageNumber() / c.TotalPages() — typed method calls, no magic strings, resolved by an internal second pass. |
The gpdf approach is the only one where the page-number primitive is part of the typed builder API rather than a string token. If you typo {nb} as {nB} in gofpdf, you get a {nB} literally printed in your footer. With c.TotalPages(), the worst you can do is forget to call it — and then there's just no number, not a wrong one.
How the second pass works
Internally, gpdf renders c.PageNumber() as a placeholder string — a sentinel that no real font glyph will ever match. When the paginator finishes laying out every page and knows the total, it walks the rendered text instructions and substitutes:
- Pass one (paginate): render every page, including header and footer, treating
PageNumberandTotalPagesas fixed-width tokens. Compute the total page count. - Pass two (resolve): walk back through the page tree, find each sentinel, and replace it with the actual current/total page number.
The width of the placeholder is sized to fit the largest possible number (rough heuristic based on the document's expected page count), so the post-substitution layout doesn't shift. In practice this means right-aligned page numbers stay aligned even when the digit count changes from 9 to 10.
You don't write the second pass. You don't render the document twice. You call doc.Generate() and get bytes.
Header and footer are just normal layout
This part trips up people coming from gofpdf, where SetHeaderFunc runs a callback at a fixed Y coordinate and you place text with absolute Cell(...) calls. In gpdf, the header closure receives a *template.PageBuilder — the same type the body uses. The grid is the same. Rows and columns are the same. Styling is the same.
doc.Header(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(2, func(c *template.ColBuilder) {
c.Image("logo.png", template.ImageHeight(document.Mm(12)))
})
r.Col(8, func(c *template.ColBuilder) {
c.Text("Annual Report 2026", template.Bold(), template.FontSize(14))
})
r.Col(2, func(c *template.ColBuilder) {
c.TotalPages(template.AlignRight())
})
})
})
That's a header with a logo on the left, a title in the middle, and the total page count on the right. Notice the column spans add up to 12, the same as a body row.
Header height is measured automatically. gpdf calls your header closure once before laying out body content, measures the height of the rendered output, and subtracts that from the available body height on every page. Footer the same way. You don't pass a headerHeight parameter. If you add a row to the header, the body shrinks accordingly.
Both repeat on every page, including pages created by content overflow. If a long table spills into page 12, page 12 gets the header and footer. There's no firstPageOnly flag — see the gotchas section below.
The rough edge: "Page X of Y" in one line
Here's the one place I think the API could be better. There is no c.PageOf("Page %d of %d") helper. To produce the literal string "Page 3 of 12" you have to compose it from columns, because c.Text() and c.PageNumber() are independent column children:
r.Col(12, func(c *template.ColBuilder) {
c.AutoRow(func(r *template.RowBuilder) {
r.Col(3, func(c *template.ColBuilder) {
c.Text("Page", template.AlignRight())
})
r.Col(2, func(c *template.ColBuilder) {
c.PageNumber(template.AlignCenter())
})
r.Col(2, func(c *template.ColBuilder) {
c.Text("of", template.AlignCenter())
})
r.Col(3, func(c *template.ColBuilder) {
c.TotalPages(template.AlignLeft())
})
r.Col(2, func(c *template.ColBuilder) {})
})
})
It works. Visually it looks fine. But it's four columns to express what most people would write as a one-line format string. I'd call this a paper cut. We've been considering adding a c.PageOf(format string, opts ...TextOption) helper that takes a fmt.Sprintf-style template with %d placeholders for the two numbers. If you have an opinion on the shape of that API, the issue is open on GitHub.
For now, the four-column approach is what's there. The pragmatic shortcut is to drop the prefix and just print c.PageNumber() and c.TotalPages() in two adjacent columns separated by a slash:
r.Col(6, func(c *template.ColBuilder) {
c.PageNumber(template.AlignRight())
})
r.Col(1, func(c *template.ColBuilder) {
c.Text("/", template.AlignCenter())
})
r.Col(5, func(c *template.ColBuilder) {
c.TotalPages(template.AlignLeft())
})
3 / 12 reads fine in a footer. The full "Page 3 of 12" looks nicer but costs you three more columns of fiddling.
Patterns that come up
A few configurations people actually want.
Header line under the title. Add a second AutoRow with c.Line(). That's what the example at the top does. The line spans the full content width because the row spans 12 columns.
Footer centered with confidentiality notice. One row, one column, AlignCenter. The simplest case.
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("Confidential — Internal Use Only",
template.AlignCenter(),
template.FontSize(8),
template.TextColor(pdf.Gray(0.5)))
})
})
})
Logo on the left, page number on the right. Two columns split 8/4 or 6/6. Image in the left column, page number in the right with AlignRight. Done.
Footer that says "Continued on next page" on non-last pages. Not currently supported. The header/footer closure receives a PageBuilder, not the current page index, so you can't branch on "is this the last page?" from inside the closure. If you need this, you have to add the trailing line to your body content on every page except the last, which means you need to know the page count ahead of time — which defeats the purpose. It's on the list.
Different header on the first page. Same issue. The closure doesn't know which page it's rendering. For now, the workaround is to leave the header empty on page 1 by putting a tall spacer at the top of page 1's body, then start your real header from page 2 onward by content placement — clunky. A doc.HeaderOn(pages, fn) variant is in design.
CJK in the footer
Because gpdf renders TrueType subsets without CGO, you can put Japanese, Chinese, or Korean text into headers and footers as plain c.Text(...) calls. No AddUTF8Font dance, no tofu boxes if the font is loaded. The only requirement is that the font you use covers the characters you want:
doc := template.New(
template.WithPageSize(document.A4),
template.WithFont("NotoSansJP", notoSansJPRegular),
)
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("社外秘", template.FontFamily("NotoSansJP"), template.FontSize(8))
})
r.Col(6, func(c *template.ColBuilder) {
c.PageNumber(template.AlignRight(), template.FontSize(8))
})
})
})
The font you register is the font your header and footer use. The subset embedded in the final PDF includes only the glyphs that appear in the document. For a 60-page report with "社外秘" in the footer, that's three glyphs from NotoSansJP, not 20,000.
Performance
This part matters if you're generating PDFs at scale.
The two-pass resolver isn't free, but it's cheap. On a 100-page document, the second pass takes under 50µs on an M1 — well under 1% of total generation time. gpdf's single-page benchmark is 13µs; the 100-page benchmark is 683µs. The page-number resolution is a constant factor independent of page complexity.
For comparison, gofpdf's AliasNbPages does a string replace over the entire content stream after compression decisions are made, which is slower and forces a recompression pass on stream objects that contain the alias. We measured this at roughly 2–4% of total time on a 100-page document in gofpdf's own benchmarks. The gpdf approach is faster because the replacement happens before stream encoding.
If you're rendering a million PDFs a day, the difference matters. If you're rendering ten, it doesn't.
FAQ
Does the header/footer height count against the page margin?
Yes. gpdf measures the rendered header and footer height once, then computes the available body height as pageHeight - top_margin - headerHeight - footerHeight - bottom_margin. If you have a 20mm top margin and a 15mm header, your body starts at 35mm from the top of the page.
Can the header height change per page? No. The header closure is evaluated once for measurement, and the result is fixed for the whole document. If you need a variable-height header per page, you have to design it around a fixed maximum height and leave whitespace.
What happens on a page with no body content? gpdf doesn't generate empty pages. If your body content fits on three pages, you get three pages. The header and footer appear on those three pages and nowhere else.
Can I omit the header on landscape pages in a mixed-orientation document?
Mixed orientations are supported via page.WithPageSize(...) on individual pages, but the header/footer closure is the same for all pages regardless of orientation. The right call here is usually to make a header that looks reasonable in both orientations, e.g. centered text rather than fixed-width logos.
Does this work with the JSON template input?
Yes. The JSON schema has header, footer, and element types {"type": "pageNumber"} and {"type": "totalPages"}. The gpdf/_examples/json/26_page_number_test.go test runs the same scenario through JSON instead of the builder API and compares against the same golden PDF.
Does this work with Go's text/template input?
Yes. The gpdf/_examples/gotemplate/26_page_number_test.go does the equivalent. Whichever entry point you use — builder, JSON, or Go template — the same two-pass paginator runs underneath.
Next steps
Headers, footers, and page numbers are the boring part of a report — but they're also the part that makes a report feel finished. If you've been hand-rolling these on top of a lower-level PDF library, the four lines in this post are the whole thing. Take the example, change the strings, ship.
The unresolved bits — c.PageOf(...) for single-string formatting, different header on the first page, "last page" detection — are on the list. If any of them block you, drop a note on the GitHub issue tracker. Concrete use cases shape the shape of the API more than abstract requests do.
gpdf を使ってみる
gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、CJK 対応。
go get github.com/gpdf-dev/gpdf