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-shellor 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 withchromedp.NewExecAllocatorand 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.
SetDocumentContentreturns 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 withchromedp.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