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

ApproachBest forTradeoff
pdftoppm via os/execSelf-hosted services, no cgo, full control~50MB Poppler install
go-fitzSingle-binary deploys, no system depscgo + AGPL license + ~30MB binary growth
ChangeThisFile APILambda, distroless, no cgo, no PopplerNetwork 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.