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=/tmpin containers and ephemeral environments. - It's single-threaded per process. LibreOffice headless serializes conversions internally — running multiple
libreofficeprocesses does NOT parallelize cleanly. For batch jobs, run one process at a time per host or useunoconv. - 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
| Approach | Best for | Tradeoff |
|---|---|---|
| LibreOffice + os/exec | High-fidelity batch conversion on a server you control | 1GB+ install, single-threaded per host, profile setup |
| unioffice | Single-binary Go services, Lambda, simple documents | Layout fidelity gap on complex docs, commercial license |
| ChangeThisFile API | Edge runtimes, multi-tenant SaaS, no infra ownership | Network 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.