← All guides

How to convert HTML to PDF in Go

Go has no native library that lays out modern HTML and CSS. The realistic options are driving a headless Chrome over the DevTools protocol, usually with chromedp, or sending the HTML to a rendering API. This guide shows both, with working code and the trade-offs that matter once it runs in production.

A note on wkhtmltopdf: Go wrappers such as go-wkhtmltopdf still appear in search results, but wkhtmltopdf itself is archived and uses an old WebKit with no flexbox or grid. See the wkhtmltopdf migration guide if you are on it today.

Option 1: chromedp

chromedp talks to Chrome through the DevTools protocol with no external dependencies on the Go side. It does need a Chrome or Chromium binary on the machine.

go get github.com/chromedp/chromedp
package main

import (
	"context"
	"log"
	"os"
	"time"

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

func htmlToPDF(parent context.Context, html string) ([]byte, error) {
	ctx, cancel := chromedp.NewContext(parent)
	defer cancel()
	ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
	defer cancel()

	var pdf []byte
	err := chromedp.Run(ctx,
		chromedp.Navigate("about:blank"),
		chromedp.ActionFunc(func(ctx context.Context) error {
			tree, err := page.GetFrameTree().Do(ctx)
			if err != nil {
				return err
			}
			return page.SetDocumentContent(tree.Frame.ID, html).Do(ctx)
		}),
		chromedp.WaitReady("body"),
		chromedp.ActionFunc(func(ctx context.Context) error {
			buf, _, err := page.PrintToPDF().
				WithPrintBackground(true).
				WithPaperWidth(8.27).
				WithPaperHeight(11.69).
				WithMarginTop(0.5).
				WithMarginBottom(0.5).
				WithMarginLeft(0.5).
				WithMarginRight(0.5).
				Do(ctx)
			if err != nil {
				return err
			}
			pdf = buf
			return nil
		}),
	)
	return pdf, err
}

func main() {
	pdf, err := htmlToPDF(context.Background(), "<h1>Invoice 1042</h1><p>Total: $1,200.00</p>")
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile("invoice.pdf", pdf, 0o644); err != nil {
		log.Fatal(err)
	}
}

Paper size and margins are in inches, so A4 is 8.27 by 11.69. PrintToPDF also supports WithDisplayHeaderFooter, WithHeaderTemplate and WithFooterTemplate for page numbers.

What to know:

  • Chrome must be installed. Your static Go binary now has a runtime dependency of several hundred megabytes. In Docker, the usual approach is basing the image on chromedp/headless-shell or installing Chromium plus fonts in your own image.
  • Do not start a browser per request. chromedp.NewContext(context.Background()) launches a new Chrome. In a server, create one allocator with chromedp.NewExecAllocator and one browser context at startup, then derive a new tab context from it per request. You then have to handle the browser crashing and restart it.
  • Waiting is your job. SetDocumentContent returns before web fonts and images have necessarily loaded. For pages with remote assets or JavaScript charts, navigate to a URL and wait for a specific selector with chromedp.WaitVisible, or you will get PDFs with missing images and fallback fonts.
  • Memory. Each tab uses tens to hundreds of megabytes. Limit concurrency with a semaphore, or a burst of requests will exhaust the container.
  • Fonts and sandboxing. Fonts must be installed in the image. Running as root in a container often requires disabling Chrome's sandbox, which matters if you render untrusted HTML.
  • SSRF. Chrome will fetch any URL the HTML references, including internal metadata endpoints. Block or proxy outbound requests if users control the input.

chromedp is a solid, well-maintained library. Most of the work is operating Chrome, not writing Go.

The PDFOven way: net/http

PDFOven runs the Chromium fleet for you. Your Go service sends JSON and gets PDF bytes back, using only the standard library.

package pdfoven

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

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

type apiError struct {
	Error struct {
		Code    string
		Message string
	}
}

func RenderPDF(ctx context.Context, html string) ([]byte, error) {
	body, err := json.Marshal(map[string]any{
		"html": html,
		"options": map[string]any{
			"format":           "A4",
			"margin":           "0.5in",
			"print_background": true,
			"footer_template":  `<div style="font-size:9px;width:100%;text-align:center">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>`,
		},
	})
	if err != nil {
		return nil, err
	}

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

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

	if resp.StatusCode != http.StatusOK {
		var e apiError
		if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
			return nil, fmt.Errorf("pdfoven: HTTP %d", resp.StatusCode)
		}
		return nil, fmt.Errorf("pdfoven: %s: %s", e.Error.Code, e.Error.Message)
	}
	return io.ReadAll(resp.Body)
}

encoding/json matches field names case-insensitively, so the apiError struct decodes { "error": { "code", "message" } } without tags.

Serving it from an HTTP handler

func invoiceHandler(w http.ResponseWriter, r *http.Request) {
	html, err := renderInvoiceTemplate(r.PathValue("id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	pdf, err := pdfoven.RenderPDF(r.Context(), html)
	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", `attachment; filename="invoice.pdf"`)
	w.Write(pdf)
}

renderInvoiceTemplate stands in for your own html/template code. Passing r.Context() means the upstream request is cancelled if the client disconnects.

Other inputs

Instead of html, send exactly one of markdown, url, template with data, or template_id with data. For a page that loads data with JavaScript, add "wait_for_selector" and "timeout_ms". The same body posted to /v1/image returns a PNG, or a JPEG with ?type=jpeg.

Comparison

chromedp PDFOven
Rendering engine Chromium Chromium
Dependencies Chrome binary, fonts Standard library only
Browser lifecycle and crashes You manage Managed
Concurrency limits Your memory Your plan quota
Works offline Yes No

PDFOven's free plan includes 100 renders per month; paid plans are $12, $39 and $99 for 2,000, 15,000 and 60,000 renders. A 429 with code quota_exceeded means the monthly quota is used up. Generated files are not stored.

Skip the browser plumbing

PDFOven runs Chromium for you. Send HTML, Markdown, a URL or JSON to a template and get a PDF back. 100 free renders every month.

Get a free API key