Go has no native PDF renderer. The two viable options: shell out to Poppler (pdftoppm), or link MuPDF via cgo. Both produce high-quality output. Choose based on whether you'd rather have a Poppler install or a cgo dep.
Method 1: pdftoppm via os/exec (Poppler)
Poppler's pdftoppm is the canonical PDF rasterizer. Same engine PDF readers and pdf2image use.
apt install poppler-utils # provides pdftoppm
# macOS: brew install poppler
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"time"
)
func pdfToJPG(ctx context.Context, inPath, outDir string, dpi int) ([]string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return nil, err
}
prefix := filepath.Join(outDir, "page")
cmd := exec.CommandContext(ctx,
"pdftoppm",
"-jpeg",
"-r", strconv.Itoa(dpi),
"-jpegopt", "quality=90",
inPath, prefix,
)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("pdftoppm: %w (%s)", err, out)
}
matches, err := filepath.Glob(prefix + "-*.jpg")
if err != nil {
return nil, err
}
sort.Strings(matches)
return matches, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pages, err := pdfToJPG(ctx, "document.pdf", "./pages", 200)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("wrote %d pages\n", len(pages))
}
Useful flags:
- -r DPI — resolution. 150 for web, 200-300 for print, 600 for archival.
- -jpegopt quality=N — JPEG quality (1-100, default 90).
- -f / -l — first/last page (e.g., -f 5 -l 10 for pages 5-10).
- -singlefile — output one file (only for single-page PDFs).
Method 2: gen2brain/go-fitz (MuPDF via cgo)
go-fitz wraps MuPDF — a fast, lightweight PDF renderer. cgo dep, but the binary is much smaller than Poppler.
go get github.com/gen2brain/go-fitz
package main
import (
"fmt"
"image/jpeg"
"os"
"path/filepath"
"github.com/gen2brain/go-fitz"
)
func pdfToJPG(inPath, outDir string, dpi int) ([]string, error) {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return nil, err
}
doc, err := fitz.New(inPath)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
defer doc.Close()
var paths []string
for n := 0; n < doc.NumPage(); n++ {
img, err := doc.ImageDPI(n, float64(dpi))
if err != nil {
return nil, fmt.Errorf("render page %d: %w", n+1, err)
}
outPath := filepath.Join(outDir, fmt.Sprintf("page-%03d.jpg", n+1))
f, err := os.Create(outPath)
if err != nil {
return nil, err
}
if err := jpeg.Encode(f, img, &jpeg.Options{Quality: 90}); err != nil {
f.Close()
return nil, err
}
f.Close()
paths = append(paths, outPath)
}
return paths, nil
}
func main() {
pages, err := pdfToJPG("document.pdf", "./pages", 200)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("wrote %d pages\n", len(pages))
}
go-fitz statically links MuPDF — the resulting binary is portable but ~30MB larger than a non-cgo Go binary. License is AGPL (same as MuPDF); buy a commercial license from Artifex if needed.
Method 3: ChangeThisFile API (no installs)
If you don't want Poppler installed or cgo in your build, the API runs Poppler server-side. Free tier covers 1,000 conversions/month.
package main
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"time"
)
const apiKey = "ctf_sk_your_key_here"
func pdfToJPG(inPath, outPath string) error {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
f, err := os.Open(inPath)
if err != nil {
return err
}
defer f.Close()
fw, err := w.CreateFormFile("file", "input.pdf")
if err != nil {
return err
}
if _, err := io.Copy(fw, f); err != nil {
return err
}
_ = w.WriteField("source", "pdf")
_ = w.WriteField("target", "jpg")
_ = w.Close()
req, err := http.NewRequest("POST", "https://changethisfile.com/v1/convert", body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", w.FormDataContentType())
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api %d: %s", resp.StatusCode, msg)
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func main() {
// outPath gets a single JPG (page 1) or a zip of pages depending on input
if err := pdfToJPG("document.pdf", "output"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The API renders at 150 DPI by default. Pass dpi=300 in form data for higher quality. Multi-page PDFs return as a zip with Content-Type: application/zip — check the response header.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| pdftoppm via os/exec | Self-hosted services, no cgo, full control | ~50MB Poppler install |
| go-fitz | Single-binary deploys, no system deps | cgo + AGPL license + ~30MB binary growth |
| ChangeThisFile API | Lambda, distroless, no cgo, no Poppler | Network call, file size limit (25MB free) |
Production tips
- Always use CommandContext. Stuck PDF rendering hangs forever. 60s is the typical bound for PDFs under 100 pages.
- Bound concurrency. Page rendering is CPU-bound. For batch jobs, use a worker pool of size numCPU; more than that thrashes the cache.
- For OCR pipelines, render at 300 DPI minimum. Tesseract accuracy drops noticeably below 300 DPI on small text. JPG is fine; PNG avoids JPEG artifacts that can confuse OCR on tiny fonts.
- Watch for password-protected PDFs. pdftoppm: -upw / -opw flags. go-fitz: doc.Authenticate(password). The API: password= form field.
- Stream pdftoppm output for low memory. By default pdftoppm writes files; for in-memory use, redirect stdout:
cmd := exec.Command("pdftoppm", "-jpeg", "-", "-")with a single-page PDF gives you the JPG bytes on stdout.
For most Go services, pdftoppm via os/exec is the right answer — fast, well-tested, easy to deploy. For single-binary distribution, go-fitz. For environments without either, the API. Free tier covers 1,000 conversions/month.