HTML to PDF in Go (Golang)

HTML to PDF in Go (Golang)

HTML to PDF in Go with chromedp, wkhtmltopdf wrappers, self-hosted Gotenberg, or a plain net/http API call. Runnable code for each.

Quick answer. Post JSON to https://html-pdf.com/v1/pdf with a Bearer key and write the response body to a file. This works from Go's standard library or any shell.

curl https://html-pdf.com/v1/pdf -H "Authorization: Bearer $HTML_PDF_KEY" \
  -d '{"html": "<h1>Hello</h1>"}' -o hello.pdf

Go has no native HTML layout engine. Every HTML to PDF option in Go drives something else, such as headless Chrome, the old wkhtmltopdf binary, or a rendering service over HTTP.

Below are the realistic options, what each costs you to run, and a version that needs nothing but the standard library.

Which Go HTML to PDF option?

This table compares the four options at a glance.

Option Renders with Modern CSS You run Status
chromedp Headless Chrome via DevTools protocol Yes Chrome on every server Active
go-wkhtmltopdf wkhtmltopdf binary (QtWebKit) Partial, no grid The wkhtmltopdf binary Wrapper maintained, binary archived
Gotenberg (self-hosted) Headless Chromium in Docker Yes A Gotenberg container Active
html-pdf.com API Headless Chromium (Gotenberg), hosted Yes Nothing Hosted

Option 1: chromedp

chromedp gives you Chrome-quality PDFs from pure Go code. It controls Chrome over the DevTools protocol with no extra Go dependencies, but Chrome itself must be installed (or use the chromedp/headless-shell image).

package main

import (
	"context"
	"log"
	"os"

	"github.com/chromedp/cdproto/cdp"
	"github.com/chromedp/cdproto/page"
	"github.com/chromedp/chromedp"
)

func main() {
	html := `<html><body style="display:flex;gap:2rem"><h1>Invoice #042</h1><p>$120.00</p></body></html>`

	ctx, cancel := chromedp.NewContext(context.Background())
	defer cancel()

	// Start the browser on a blank page, then replace its content.
	if err := chromedp.Do(ctx, chromedp.Navigate("about:blank")); err != nil {
		log.Fatal(err)
	}
	tree, err := chromedp.Call(ctx, page.GetFrameTree, cdp.Empty{})
	if err != nil {
		log.Fatal(err)
	}
	if _, err := chromedp.Call(ctx, page.SetDocumentContent, page.SetDocumentContentParams{
		FrameID: tree.FrameTree.Frame.ID,
		HTML:    html,
	}); err != nil {
		log.Fatal(err)
	}

	printBackground := true
	res, err := chromedp.Call(ctx, page.PrintToPDF, page.PrintToPDFParams{
		PrintBackground: &printBackground,
		PaperWidth:      8.27, // A4, in inches
		PaperHeight:     11.69,
	})
	if err != nil {
		log.Fatal(err)
	}
	os.WriteFile("invoice.pdf", res.Data, 0o644)
}

This uses the generic API that chromedp adopted in v0.17 (tested with v0.20). Older tutorials pass chromedp.ActionFunc steps to chromedp.Run, which no longer compiles on current versions.

Output matches Chrome's print preview, and paper sizes and margins are in inches. The cost is operations. Chrome adds a few hundred MB to your image, and each render needs a browser tab and its memory.

Long-running services also need to reuse a browser, cap concurrency and recover from crashed processes.

Option 2: wkhtmltopdf wrappers

This option is only worth it for legacy templates. github.com/SebastiaanKlippert/go-wkhtmltopdf wraps the wkhtmltopdf command line with typed options, and it still works.

But wkhtmltopdf itself is archived and no longer receives fixes, and its QtWebKit engine predates flexbox and grid support. The wrapper's own README recommends looking at alternatives for new projects.

Our wkhtmltopdf alternative guide covers migration.

Option 3: self-hosted Gotenberg

Gotenberg gives you Chromium output without Chrome inside your Go service. It packages Chromium and LibreOffice behind an HTTP API in one Docker image.

Your Go code just posts a multipart form, which you can do with the standard library instead of a client package.

docker run --rm -p 3000:3000 gotenberg/gotenberg:8
var body bytes.Buffer
w := multipart.NewWriter(&body)
part, _ := w.CreateFormFile("files", "index.html") // must be named index.html
part.Write([]byte("<h1>Invoice #042</h1>"))
w.WriteField("paperWidth", "8.27")
w.WriteField("paperHeight", "11.69")
w.Close()

resp, err := http.Post("http://localhost:3000/forms/chromium/convert/html",
	w.FormDataContentType(), &body)

The trade-off is that you now run, scale, secure and update the container yourself. See self-host Gotenberg with Docker.

Or skip the server: html-pdf.com

This section gives you a complete Go client with no server to run. html-pdf.com is hosted Gotenberg with an API key. You send JSON and get PDF bytes back, using only net/http and encoding/json.

Get a key, then open the link we email you to confirm your address. That adds 100 free credits.

curl -X POST https://html-pdf.com/v1/signup -H "Content-Type: application/json" -d '{"email": "you@example.com"}'
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

type apiError struct {
	Error struct {
		Code    string `json:"code"`
		Message string `json:"message"`
		Hint    string `json:"hint"`
	} `json:"error"`
}

var client = &http.Client{Timeout: 60 * time.Second}

func RenderPDF(html string) ([]byte, error) {
	payload, _ := json.Marshal(map[string]any{
		"html":      html,
		"format":    "A4",
		"margin":    "15mm",
		"max_pages": 20, // fail without charge if it runs longer
		"footer_html": `<div style="font-size:9px;width:100%;text-align:center">` +
			`Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
	})

	req, err := http.NewRequest(http.MethodPost, "https://html-pdf.com/v1/pdf", bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("HTML_PDF_KEY"))
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		var e apiError
		json.Unmarshal(data, &e)
		return nil, fmt.Errorf("%d %s: %s", resp.StatusCode, e.Error.Code, e.Error.Message)
	}
	fmt.Println("pages:", resp.Header.Get("X-Pages"), "remaining:", resp.Header.Get("X-Credits-Remaining"))
	return data, nil
}

func main() {
	pdf, err := RenderPDF(`<h1>Invoice #042</h1><p>Total: $120.00</p>`)
	if err != nil {
		panic(err)
	}
	os.WriteFile("invoice.pdf", pdf, 0o644)
}

Margins take CSS units ("15mm", "0.5in") instead of inches as floats. To convert a public page, send "url": "https://example.com" instead of html.

A 402 with code insufficient_credits means you need to top up.

Serve the PDF from an HTTP handler

Here is how to return the PDF directly from a net/http handler.

http.HandleFunc("/invoice.pdf", func(w http.ResponseWriter, r *http.Request) {
	pdf, err := RenderPDF(buildInvoiceHTML(r.URL.Query().Get("id")))
	if err != nil {
		http.Error(w, "could not render PDF", http.StatusBadGateway)
		return
	}
	w.Header().Set("Content-Type", "application/pdf")
	w.Header().Set("Content-Disposition", `inline; filename="invoice.pdf"`)
	w.Write(pdf)
})

When you build the HTML from user data, escape it with html.EscapeString or the html/template package.

Reference images and fonts by absolute https:// URLs or inline them, since the renderer can't reach localhost or private networks. You can also send index.html plus asset files as multipart, with your options in an options JSON field.

Pricing

Pricing is per page, with no subscription. 1 credit = 1 PDF page, and you start with 100 free credits.

After that, packs go from $10 for 1,000 pages down to $0.005 per page at $250 for 50,000. Purchased credits never expire and failed renders are free.

More: page breaks · headers, footers and page numbers · API docs · sign in

FAQ

How do I convert HTML to PDF in Go?

Use chromedp to drive headless Chrome, post a form to a self-hosted Gotenberg container, or send JSON to https://html-pdf.com/v1/pdf with net/http and save the response bytes.

Is there a pure Go HTML to PDF library?

No. Go has no native HTML layout engine, so every reliable option drives Chromium (or the archived wkhtmltopdf binary) somewhere.

Is wkhtmltopdf still a good choice in Go?

Not for new projects. wkhtmltopdf is archived and its QtWebKit engine predates flexbox and grid, so keep it only for legacy templates.

Do credits expire?

No. Purchased credits never expire, 1 credit is 1 PDF page, and failed renders cost nothing.

This page as markdown · text