Page numbers, headers, and footers that just work in Go PDFs
Add headers, footers, and page numbers to Go PDFs with gpdf: two builder methods, the same 12-column grid as the body, and a paginator that fills the totals.
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 what it still can't do.
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.
- Want
Page 3 of 12as one string? Since v1.0.14 you can put both placeholders inside a singlec.Text. See How do I show "Page X of Y" in a gpdf footer?.
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.
Putting the numbers inside a sentence
Updated 2026-11-12. This section used to describe a four-column workaround for printing
Page 3 of 12on one line. gpdf v1.0.14 removed the need for it.
c.PageNumber() and c.TotalPages() are thin wrappers around two exported constants, document.PageNumberPlaceholder and document.TotalPagesPlaceholder. The second pass replaces them wherever they appear, including the middle of an ordinary string:
c.Text("Page "+document.PageNumberPlaceholder+" of "+document.TotalPagesPlaceholder,
template.AlignRight())
On v1.0.14 and later the line is re-measured after the swap, so right- and center-aligned footers stay put when the page count goes from one digit to two. The full recipe, including what goes wrong on v1.0.13 and earlier, is in How do I show "Page X of Y" in a gpdf footer?.
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 — a different header on the first page and "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.
Related recipes:
- How do I show "Page X of Y" in a gpdf footer? — the number and the total in one
c.Textstring (v1.0.14+) - How do I make a table span multiple pages? — where overflow pages come from
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