Go has no HTML renderer of its own, so a Go program that turns HTML into a PDF drives Chrome. We tested the two common libraries, chromedp v0.21 and go-rod v0.116 on Go 1.26, plus a plain net/http call to an API, on the same invoice. Every snippet below was run as shown, and we measured each default that came out wrong.
| Option | Pick it when |
|---|---|
| chromedp Pure Go CDP client, no driver | You already ship Chrome (or the chromedp/headless-shell image) and want the most widely used library. |
| go-rod CDP client that can download its own Chromium | You want a simpler API and an automatic browser download in development. |
| An API Chrome on someone else's server | Your binary should stay a small static file (scratch or distroless images, Lambda) with no Chrome to install, patch and keep alive. |
Skip these: go-wkhtmltopdf wraps wkhtmltopdf, whose project is archived and which has no flexbox or grid (wkhtmltopdf alternative). gofpdf, its fork fpdf and maroto build PDFs from drawing calls, not from HTML. Gotenberg is Chrome in a separate container (Gotenberg compared).
Save this as invoice.html next to main.go. It asks for A4 in CSS and has a dark header bar:
<!doctype html>
<html><head><meta charset="utf-8"><style>
@page { size: A4; margin: 20mm 15mm; }
body { font-family: sans-serif; }
.top { display: flex; justify-content: space-between; align-items: center;
background: #1e3a8a; color: #fff; padding: 12px; }
table { width: 100%; border-collapse: collapse; }
td, th { border-bottom: 1px solid #ddd; padding: 6px; text-align: left; }
</style></head><body>
<div class="top"><h1>Invoice #1042</h1><p>Due: 2026-11-01</p></div>
<table><tr><th>Item</th><th>Price</th></tr>
<tr><td>Consulting (3h)</td><td>€450.00</td></tr>
<tr><td>Hosting, Oct.</td><td>€19.00</td></tr></table>
</body></html>
chromedp v0.20 replaced its API with generics: an action now returns a value, chromedp.Run(ctx, action) takes a single action and returns its result, and chromedp.Do(ctx, actions...) runs actions that return nothing. Most chromedp PDF examples online use chromedp.Run(ctx, a, b), chromedp.ActionFunc and page.PrintToPDF().WithPrintBackground(true).Do(ctx). On v0.21 they fail with:
./main.go:19:3: too many arguments in call to chromedp.Run
have (context.Context, chromedp.Action[chromedp.Void], unknown type)
want (context.Context, chromedp.Action[T])
./main.go:19:12: undefined: chromedp.ActionFunc
./main.go:21:18: invalid operation: cannot call page.PrintToPDF (variable of struct type cdp.Command[page.PrintToPDFParams, page.PrintToPDFResult]): cdp.Command[page.PrintToPDFParams, page.PrintToPDFResult] is not a function
Either pin a release older than v0.20 in go.mod or use the new API, as below.
go get github.com/chromedp/chromedp. It needs Chrome or Chromium installed; it looks for headless-shell, chromium, google-chrome and chrome on the PATH. main.go:
package main
import (
"context"
"log"
"os"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
func main() {
html, err := os.ReadFile("invoice.html")
if err != nil {
log.Fatal(err)
}
// NoSandbox: needed in Docker and on Ubuntu 23.10+, see below
opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.NoSandbox)
actx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancelAlloc()
ctx, cancel := chromedp.NewContext(actx)
defer cancel()
// Load an HTML string: open a blank page, then replace its document
setHTML := chromedp.Func(func(ctx context.Context, t *chromedp.Target) error {
tree, err := chromedp.Call(ctx, page.GetFrameTree, cdp.Empty{})
if err != nil {
return err
}
_, err = chromedp.Call(ctx, page.SetDocumentContent, page.SetDocumentContentParams{
FrameID: tree.FrameTree.Frame.ID,
HTML: string(html),
})
return err
})
if err := chromedp.Do(ctx, chromedp.Navigate("about:blank"), setHTML); err != nil {
log.Fatal(err)
}
pdf, err := chromedp.Run(ctx, chromedp.PrintToPDF(
chromedp.PDFPaper(chromedp.PaperA4),
chromedp.PDFPreferCSSPageSize(), // use @page size/margins from the CSS
chromedp.PDFPrintBackground(), // keep background colors
chromedp.PDFHeaderTemplate(`<span></span>`),
chromedp.PDFFooterTemplate(`<div style="font-size:9px;width:100%;text-align:center">
Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`),
))
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("invoice.pdf", pdf, 0o644); err != nil {
log.Fatal(err)
}
}
Result: a 1-page A4 PDF with the blue header bar and "Page 1 of 1" at the bottom, in 1.3 s including Chrome's start. chromedp has no "set HTML" helper, so the setHTML step above sends the DevTools command Page.setDocumentContent itself. For a live page, use chromedp.Navigate(url) instead.
go get github.com/go-rod/rod. The launcher uses a Chrome it finds on the system, or downloads a Chromium build the first time. main.go:
package main
import (
"io"
"log"
"os"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
)
func f(v float64) *float64 { return &v }
func main() {
html, err := os.ReadFile("invoice.html")
if err != nil {
log.Fatal(err)
}
// Uses Chrome/Chromium if one is installed, otherwise downloads one.
// NoSandbox: needed in Docker and on Ubuntu 23.10+, see below
u := launcher.New().NoSandbox(true).MustLaunch()
browser := rod.New().ControlURL(u).MustConnect()
defer browser.MustClose()
page := browser.MustPage("about:blank")
if err := page.SetDocumentContent(string(html)); err != nil {
log.Fatal(err)
}
page.MustWaitLoad()
r, err := page.PDF(&proto.PagePrintToPDF{
PaperWidth: f(8.27), // A4, in inches
PaperHeight: f(11.69),
PreferCSSPageSize: true, // use @page size/margins from the CSS
PrintBackground: true, // keep background colors
DisplayHeaderFooter: true,
HeaderTemplate: `<span></span>`,
FooterTemplate: `<div style="font-size:9px;width:100%;text-align:center">
Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
})
if err != nil {
log.Fatal(err)
}
pdf, err := io.ReadAll(r)
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("invoice.pdf", pdf, 0o644); err != nil {
log.Fatal(err)
}
}
Result: the same 1-page A4 PDF with the header bar and footer, in 1.8 s. page.PDF() returns a stream reader rather than bytes, hence the io.ReadAll. Every field of proto.PagePrintToPDF that you leave unset gets Chrome's default, which leads to the problems below.
chromedp's PrintToPDF() with no options and rod's &proto.PagePrintToPDF{} both send Chrome's print defaults. What we measured on the invoice above:
@page { size: A4 } unless you set PreferCSSPageSize. Set the paper explicitly (PDFPaper(chromedp.PaperA4), or 8.27 × 11.69 inches in rod), or turn on PreferCSSPageSize, or both, as above.PDFPrintBackground() / PrintBackground: true.font-size prints tiny glyphs in the corner. Always set font-size and width:100% in the template. If you set only a footer, Chrome prints its own default header (date and title), so pass <span></span> as the header, as above. The footer sits inside the bottom margin, so a margin of 0 hides it.font-family: Arial falls back silently. Install the fonts in the image or load them with @font-face (custom fonts in PDFs).With a downloaded Chrome (Chrome for Testing, or rod's download) on Ubuntu 23.10+, Chrome exits at start with FATAL:zygote_host_impl_linux.cc(126)] No usable sandbox!, because AppArmor blocks the unprivileged user namespaces its sandbox uses. Both libraries showed it here. That's why the snippets pass chromedp.NoSandbox and NoSandbox(true). chromedp adds --no-sandbox on its own only when running as root, and rod only when it detects a container. Ubuntu's snap Chromium has its own AppArmor profile and starts without the flag. Only render HTML you trust when the sandbox is off.
Shotmint renders in the same Chrome on its servers, with print backgrounds on and A4 by default, so your Go binary stays a static file with no browser in the image. Run it with SHOTMINT_KEY=... go run .:
package main
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
html, err := os.ReadFile("invoice.html")
if err != nil {
log.Fatal(err)
}
body, _ := json.Marshal(map[string]any{
"html": string(html), // or "url": "https://example.com"
"format": "A4",
"margin": "20mm",
"page_numbers": true, // "1 / 3" footer
})
req, _ := http.NewRequest("POST", "https://shotmint.creasework.com/v1/pdf", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("SHOTMINT_KEY"))
client := &http.Client{Timeout: 60 * time.Second}
res, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
if res.StatusCode != http.StatusOK {
log.Fatalf("render failed: %s %s", res.Status, data) // JSON error message
}
if err := os.WriteFile("invoice.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
log.Printf("saved %d bytes, %s credits left", len(data), res.Header.Get("X-Credits-Remaining"))
}
Result: the same 1-page A4 invoice with the header bar and a "1/1" footer, in 0.66 s end to end. One PDF is one credit, however many pages it has. A 24-page Wikipedia article took 2.7 s. Other options: landscape, format (A3, A4, A5, Letter, Legal, Tabloid), header_template/footer_template, wait_until, screen_media and delay; see the API reference. For screenshots from Go, see Screenshot API in Go.
pdftoppm -r 50 -png out.pdf p), not just by opening it in a viewer.Related: page numbers and footers, CSS page breaks, background colors in PDFs, Puppeteer PDF, Playwright PDF.
One PDF = one credit. €5 buys 2,000 renders, credits never expire and there's no subscription.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons