The Print Button Problem
You built a dashboard. The client loves the charts. Then the client asks for a "Print to PDF" button. You stare at the screen. Go doesn't have a pdf package in the standard library. You need a library, and you need to pick one that doesn't turn your binary into a megabyte monster or require a license key for basic text.
Go's standard library focuses on networking, concurrency, and system primitives. Document generation falls outside that scope. The ecosystem fills the gap with libraries that range from lightweight pure-Go implementations to heavy enterprise suites. The right choice depends on whether you need a simple invoice, a complex form with digital signatures, or a pixel-perfect render of HTML.
PDFs are snapshots, not documents
A PDF is not a word processor file. It is a snapshot of a page. The format stores drawing commands: "draw a line here," "place this character at these coordinates," "fill this rectangle with blue." There is no flow layout by default. If you want text to wrap, you have to calculate the width of every word and move the cursor manually.
Think of PDF generation like painting on a canvas. You define the canvas size, pick a brush, and place strokes. The library maintains a cursor position. Every drawing call moves the cursor or uses the current position. If you forget to move the cursor, the next line of text overwrites the previous one.
This model gives you precise control. It also means you handle the math. Go libraries wrap this complexity, but you still need to understand coordinates, fonts, and streams.
Minimal example
Here's the simplest PDF: create the object, add a page, draw text, and save. The community standard for open-source PDF generation is gofpdf. It's pure Go, fast, and handles the binary format details.
package main
import (
"log"
"os"
"github.com/jung-kurt/gofpdf"
)
func main() {
// P = portrait, mm = millimeters, A4 = page size, empty string = no custom font dir
pdf := gofpdf.New("P", "mm", "A4", "")
// AddPage initializes the page and resets the cursor to the top-left
pdf.AddPage()
// SetFont loads a built-in font. Times is serif, B is bold, 16 is point size
pdf.SetFont("Times", "B", 16)
// Cell draws a rectangle and text. Width 0 means stretch to right margin.
// Height 10 sets line height. "Hello, PDF!" is the content.
pdf.Cell(0, 10, "Hello, PDF!")
// OutputFileAndClose writes the PDF bytes to disk and closes the file
err := pdf.OutputFileAndClose("output.pdf")
if err != nil {
log.Fatal(err)
}
}
The code creates a gofpdf.Fpdf instance, which holds the internal state of the document. AddPage prepares a new page. SetFont selects the typeface. Cell draws a cell with text. OutputFileAndClose serializes the state to a file.
Go convention favors explicit error handling. The if err != nil block is verbose by design. It makes the failure path visible. In a real application, you would return the error to the caller instead of calling log.Fatal.
How the state machine works
PDF libraries in Go act as state machines. The Fpdf struct tracks the current page, cursor position, font, colors, and a buffer of drawing commands. Every method mutates this state or appends to the buffer.
When you call AddPage, the library flushes the current page buffer, writes the page dictionary to the PDF structure, and resets the cursor to (0, 0). The coordinate system starts at the top-left corner. X increases to the right. Y increases downward. This matches screen coordinates, not Cartesian math.
Cell draws a rectangle and places text inside it. The width parameter controls the horizontal span. A width of 0 tells the library to stretch the cell to the right margin. The height parameter sets the vertical space. If the text is taller than the height, it gets clipped.
Output writes the accumulated bytes to an io.Writer. This pattern is idiomatic Go. By accepting a writer, the library lets you pipe output to a file, a buffer, or an HTTP response without changing the generation logic.
Goroutines are cheap, but PDF generation is usually CPU-bound. You don't need concurrency for a single document. If you generate many PDFs, use a worker pool to bound concurrency and protect your CPU.
Realistic example: Streaming an invoice
Real code rarely writes to a file. It streams to an HTTP response or a cloud storage bucket. Here's a function that generates an invoice and writes to any io.Writer.
package main
import (
"io"
"log"
"os"
"github.com/jung-kurt/gofpdf"
)
// Item represents a line item on the invoice.
type Item struct {
Name string
Price float64
}
// GenerateInvoice writes a PDF invoice to w.
// The function signature follows Go convention: io.Writer for output, error return.
func GenerateInvoice(w io.Writer, items []Item) error {
// Initialize PDF with A4 size and millimeter units
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// Header: bold Helvetica, 16pt
pdf.SetFont("Helvetica", "B", 16)
pdf.Cell(0, 10, "Invoice")
pdf.Ln(10) // Move cursor down 10mm to start new line
// Items: regular Helvetica, 12pt
pdf.SetFont("Helvetica", "", 12)
for _, item := range items {
// Cell width 50 for name, 20 for price. Ln(10) moves to next row.
pdf.Cell(50, 10, item.Name)
pdf.Cell(20, 10, fmt.Sprintf("$%.2f", item.Price))
pdf.Ln(10)
}
// Output streams bytes to w. Returns error if write fails.
return pdf.Output(w)
}
func main() {
items := []Item{
{Name: "Web Design", Price: 1500.00},
{Name: "Hosting", Price: 200.00},
}
// Open file for writing. os.Create truncates if file exists.
f, err := os.Create("invoice.pdf")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Pass the file as io.Writer. GenerateInvoice handles the PDF logic.
if err := GenerateInvoice(f, items); err != nil {
log.Fatal(err)
}
}
The function accepts io.Writer. This decouples PDF generation from storage. You can pass os.Stdout, a bytes.Buffer, or an HTTP response writer. The loop iterates over items and draws cells. Ln advances the cursor vertically.
The receiver name in methods is usually one or two letters matching the type. If you were adding a method to Fpdf, you'd write (p *Fpdf) Write(...), not (this *Fpdf). Go style favors brevity in receivers.
The font trap
Standard PDFs include 14 built-in fonts: Helvetica, Times, Courier, and their variants. These fonts support basic Latin characters. They do not support emojis, accented characters, or non-Latin scripts. If your text contains "café" or "日本語", the built-in fonts will render garbage or missing glyphs.
To support Unicode, you must embed a TrueType font (TTF). gofpdf can load TTF files and compress them into the PDF. This increases file size but enables full Unicode support.
// LoadTTFFont parses a TTF file and caches a compressed .z font file.
// The first argument is the font family name used in SetFont.
// The second argument is the style (empty for regular).
// The third argument is the path to the TTF file.
err := pdf.AddTTFFont("NotoSans", "", "NotoSans-Regular.ttf")
if err != nil {
log.Fatal(err)
}
// SetFont now uses the embedded font
pdf.SetFont("NotoSans", "", 12)
pdf.Cell(0, 10, "Café and 日本語")
AddTTFFont reads the TTF file, extracts the glyphs, and creates a compressed .z file in the font directory. The .z file is cached for reuse. The first run is slow because the library parses the font. Subsequent runs load the cache instantly.
If you forget to add the font, the compiler won't catch it. The runtime will fail with a font-not-found error, or the PDF will render with missing characters. Always test PDFs with your actual data, not just ASCII strings.
Pitfalls and compiler errors
PDF generation has specific gotchas. Missing imports trigger immediate compiler errors. If you reference gofpdf without importing it, the compiler rejects the program with undefined: gofpdf. Fix this by adding the import path.
If you pass the wrong type to a function, the compiler complains. For example, Cell expects a string for text. Passing an integer results in cannot use x (untyped int constant) as string value in argument. Use fmt.Sprintf to format numbers.
Runtime panics happen when you access a nil receiver or divide by zero in coordinate calculations. gofpdf guards against many errors, but malformed font paths or invalid page sizes can cause issues. Always check errors from AddTTFFont and Output.
Memory usage grows with document size. The library buffers drawing commands in memory until you call Output. A PDF with thousands of pages can consume significant RAM. If you generate large reports, consider splitting the document or streaming pages.
The worst PDF bug is the one that generates a valid file with missing text. The file opens, but the content is wrong. Test your PDFs by opening them in a viewer, not just checking the file size.
Choosing a library
The Go ecosystem offers several PDF libraries. Each targets a different use case.
Use gofpdf when you need a zero-dependency, open-source library for simple reports, invoices, and documents with basic layout. It supports TTF fonts, images, and tables. The API is straightforward, and the community is active.
Use unidoc when you require advanced features like form filling, digital signatures, PDF merging, or enterprise support with a budget. The library is commercial, but it offers robust functionality and dedicated support.
Use chromedp when you need to render HTML and CSS as PDF and don't mind the overhead. This approach lets you use web technologies for layout. It requires a headless Chrome instance, which adds complexity and resource usage.
Use a dedicated backend service when your PDF logic is complex and better suited for a microservice. Offload generation to a separate process to keep your main application lightweight.
PDFs are binary. Fonts are the enemy. Embed them or stick to ASCII.