HTML to PDF in Go (Golang)

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.

Quick answer

OptionPick 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).

The test template

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>

Heads-up: old chromedp examples no longer compile

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.

1. chromedp (v0.21, new API)

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.

2. go-rod

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.

The defaults that go wrong (both libraries)

chromedp's PrintToPDF() with no options and rod's &proto.PagePrintToPDF{} both send Chrome's print defaults. What we measured on the invoice above:

"No usable sandbox!" on Ubuntu and in Docker

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.

3. An API (no Chrome, standard library only)

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.

Which one to pick

Related: page numbers and footers, CSS page breaks, background colors in PDFs, Puppeteer PDF, Playwright PDF.

What it costs

One PDF = one credit. €5 buys 2,000 renders, credits never expire and there's no subscription.

Try it with 25 free renders a month

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).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons