DOCX is a zipped XML format with hundreds of layout features. Pure-Go libraries handle the basics but miss complex layouts. The two reliable options are LibreOffice (called from Go via os/exec) and the API. The choice mostly comes down to deployment: do you want LibreOffice on your host or not?

Method 1: LibreOffice headless via os/exec (highest fidelity)

LibreOffice's --convert-to flag does DOCX-to-PDF with the same engine that powers LibreOffice Writer. Layout, fonts, headers/footers, tables — everything renders correctly.

# macOS
brew install --cask libreoffice

# Debian/Ubuntu
apt install libreoffice --no-install-recommends
package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "time"
)

func docxToPDF(ctx context.Context, inPath, outDir string) (string, error) {
    if err := os.MkdirAll(outDir, 0o755); err != nil {
        return "", err
    }

    cmd := exec.CommandContext(ctx,
        "libreoffice", "--headless",
        "--convert-to", "pdf",
        "--outdir", outDir,
        inPath,
    )
    cmd.Env = append(os.Environ(), "HOME=/tmp") // LibreOffice needs a writable HOME

    out, err := cmd.CombinedOutput()
    if err != nil {
        return "", fmt.Errorf("libreoffice: %w (%s)", err, out)
    }

    base := filepath.Base(inPath)
    pdfName := base[:len(base)-len(filepath.Ext(base))] + ".pdf"
    return filepath.Join(outDir, pdfName), nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
    defer cancel()

    pdf, err := docxToPDF(ctx, "report.docx", "./out")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println("wrote:", pdf)
}

Three things to know:

  • HOME must be writable. LibreOffice creates a profile dir on first run. Set HOME=/tmp in containers and ephemeral environments.
  • It's single-threaded per process. LibreOffice headless serializes conversions internally — running multiple libreoffice processes does NOT parallelize cleanly. For batch jobs, run one process at a time per host or use unoconv.
  • Always use CommandContext. A stuck conversion will hang forever without a timeout.

Method 2: unioffice (pure Go, limited fidelity)

unioffice (formerly gooxml) is a commercial pure-Go library that reads/writes DOCX and exports to PDF. The free tier has limits and the rendering doesn't match Word as closely as LibreOffice.

go get github.com/unidoc/unioffice/v2
package main

import (
    "fmt"
    "os"

    "github.com/unidoc/unioffice/v2/document"
)

func docxToPDF(inPath, outPath string) error {
    doc, err := document.Open(inPath)
    if err != nil {
        return fmt.Errorf("open: %w", err)
    }
    defer doc.Close()

    // unioffice writes through its own renderer
    return doc.SaveToFile(outPath) // PDF if outPath ends in .pdf
}

func main() {
    if err := docxToPDF("report.docx", "report.pdf"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The pure-Go approach avoids a native dep, which is useful for Lambda or single-binary deploys. The tradeoff: complex tables, equations, embedded objects, and exotic fonts often render incorrectly. For documents authored in Word, LibreOffice is the safer choice.

Method 3: ChangeThisFile API (LibreOffice fidelity, no install)

If you don't want LibreOffice on your hosts (1GB+ install, profile management, dependency on Java for some features), the API runs it server-side. Free tier covers 1,000 conversions/month.

package main

import (
    "bytes"
    "fmt"
    "io"
    "mime/multipart"
    "net/http"
    "os"
)

const apiKey = "ctf_sk_your_key_here"

func docxToPDF(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.docx")
    if err != nil {
        return err
    }
    if _, err := io.Copy(fw, f); err != nil {
        return err
    }
    _ = w.WriteField("source", "docx")
    _ = w.WriteField("target", "pdf")
    _ = 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 * 1e9} // 120s for slow renders
    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() {
    if err := docxToPDF("report.docx", "report.pdf"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The API runs the same LibreOffice headless workflow as method 1, so output fidelity is identical. You skip the install and the single-instance bottleneck — concurrent API calls are queued server-side without blocking your service.

When to use each

ApproachBest forTradeoff
LibreOffice + os/execHigh-fidelity batch conversion on a server you control1GB+ install, single-threaded per host, profile setup
uniofficeSingle-binary Go services, Lambda, simple documentsLayout fidelity gap on complex docs, commercial license
ChangeThisFile APIEdge runtimes, multi-tenant SaaS, no infra ownershipNetwork call, file size limit (25MB free)

CLI alternative: pandoc

For DOCX-to-PDF with a Markdown-style intermediate, pandoc + a LaTeX engine produces beautiful typeset PDFs. Useful when you control the source DOCX and want LaTeX-quality output.

apt install pandoc texlive-xetex
pandoc report.docx -o report.pdf --pdf-engine=xelatex
cmd := exec.CommandContext(ctx, "pandoc", "report.docx", "-o", "report.pdf", "--pdf-engine=xelatex")
out, err := cmd.CombinedOutput()

Pandoc + LaTeX produces the cleanest output for academic-style documents but loses Word's exact visual fidelity. LibreOffice is the right choice when WYSIWYG match matters; pandoc is the right choice when typography quality matters more than exact layout.

Production tips

  • Pre-warm LibreOffice in containers. First-run profile creation adds ~5s to the first conversion. Run a throwaway conversion at container start so user-facing requests don't pay this cost.
  • Always use CommandContext with a timeout. A stuck LibreOffice conversion will hold a goroutine forever. 60-120s is a reasonable bound for typical documents.
  • One LibreOffice process per host, queued. LibreOffice's headless mode serializes conversions internally. Building your own queue (e.g., with a buffered channel) is more reliable than relying on the OS to schedule competing processes.
  • Watch for embedded fonts. If the DOCX uses fonts not installed on the host, LibreOffice substitutes — and substitutions can shift line breaks. Install Microsoft core fonts (msttcorefonts) or the API equivalent.

If you can install LibreOffice on your host, that's the fidelity-best option. If you can't (Lambda, edge, single-binary), use the API for the same fidelity without the install. Free tier covers 1,000 conversions/month — try it without committing.