Blog

Go document to Markdown conversion

19 Aug 2026 · 6 min read


Go's net/http standard library and io primitives make it a natural fit for document conversion pipelines — lightweight goroutines, zero-cost abstractions, and a single compiled binary. When you pair Go with AnyMD's REST API, you get fast, reliable document-to-Markdown conversion without external dependencies.

This post walks you through a clean Go package for converting office documents to Markdown — context-aware, retry-able, with shared HTTP client patterns. See also our Rust, Node.js, Python, and CLI tutorials.

Why Go + AnyMD?

Go excels at network-bound I/O workloads — exactly what an API client does. Every goroutine is a potential concurrent conversion, and Go's http.Client handles connection pooling and keep-alive out of the box. You don't need a third-party HTTP library or an async runtime; the standard library is all you need.

In benchmarks, a Go client adds ~4.5 ms of client-side overhead per conversion — between Rust (1.8 ms) and Node.js (7.2 ms). At 10 concurrent conversions, Go achieves 495 docs/minute, very close to Rust's 512. For most production workloads, the difference is negligible.

Single document conversion

Here's a minimal anymd Go function that converts any supported document to Markdown:

package anymd

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

// Convert sends a file to AnyMD and returns the Markdown output.
func Convert(apiKey, filePath string) (string, error) {
    body := &bytes.Buffer{}
    writer := multipart.NewWriter(body)

    part, err := writer.CreateFormFile("file", filepath.Base(filePath))
    if err != nil {
        return "", fmt.Errorf("create form file: %w", err)
    }

    // In production, read the file with os.Open
    if _, err := io.WriteString(part, "(file contents)"); err != nil {
        return "", fmt.Errorf("write file part: %w", err)
    }
    writer.Close()

    req, err := http.NewRequest(
        "POST",
        "https://anymd.net/api/convert",
        body,
    )
    if err != nil {
        return "", fmt.Errorf("create request: %w", err)
    }

    req.Header.Set("Content-Type", writer.FormDataContentType())
    req.Header.Set("Authorization", "Bearer "+apiKey)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", fmt.Errorf("send request: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("API returned %s", resp.Status)
    }

    out, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("read response: %w", err)
    }

    return string(out), nil
}

The API auto-detects the input format from the filename — PDF, DOCX, PPTX, EPUB, ODT, RTF, and more. Everything happens in memory on the server, and the response body is your clean GitHub-Flavored Markdown.

Context-aware conversion with cancellation

Production code needs context propagation — timeouts, cancellation, and request tracing. Go's context package makes this trivial:

package anymd

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "mime/multipart"
    "net/http"
    "path/filepath"
    "time"
)

type Client struct {
    apiKey  string
    baseURL string
    http    *http.Client
}

// NewClient creates an AnyMD client with a configurable timeout.
func NewClient(apiKey string, timeout time.Duration) *Client {
    return &Client{
        apiKey:  apiKey,
        baseURL: "https://anymd.net",
        http: &http.Client{
            Timeout: timeout,
        },
    }
}

// ConvertWithContext sends a file for conversion with context support.
func (c *Client) ConvertWithContext(
    ctx context.Context,
    filePath string,
    fileData []byte,
) (string, error) {
    body := &bytes.Buffer{}
    writer := multipart.NewWriter(body)

    part, err := writer.CreateFormFile("file", filepath.Base(filePath))
    if err != nil {
        return "", fmt.Errorf("create form: %w", err)
    }
    if _, err := part.Write(fileData); err != nil {
        return "", fmt.Errorf("write file: %w", err)
    }
    writer.Close()

    req, err := http.NewRequestWithContext(
        ctx,
        "POST",
        c.baseURL+"/api/convert",
        body,
    )
    if err != nil {
        return "", fmt.Errorf("create req: %w", err)
    }

    req.Header.Set("Content-Type", writer.FormDataContentType())
    req.Header.Set("Authorization", "Bearer "+c.apiKey)

    resp, err := c.http.Do(req)
    if err != nil {
        return "", fmt.Errorf("do req: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return "", fmt.Errorf("API: %s", resp.Status)
    }

    out, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("read body: %w", err)
    }

    return string(out), nil
}

Usage with a 30-second timeout and context cancellation:

func main() {
    client := anymd.NewClient("your-api-key", 30*time.Second)

    ctx := context.Background()
    data, _ := os.ReadFile("report.docx")

    md, err := client.ConvertWithContext(ctx, "report.docx", data)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(md)
}

Retry with exponential backoff

Network blips happen. Here's a retry wrapper that fits into the pattern above:

import "math/rand"

// ConvertWithRetry wraps ConvertWithContext with exponential backoff.
func (c *Client) ConvertWithRetry(
    ctx context.Context,
    filePath string,
    fileData []byte,
    maxRetries int,
) (string, error) {
    var lastErr error

    for attempt := 0; attempt <= maxRetries; attempt++ {
        if attempt > 0 {
            // Exponential backoff with jitter
            wait := time.Duration(100*(1<<attempt)+rand.Intn(100)) * time.Millisecond
            select {
            case <-ctx.Done():
                return "", ctx.Err()
            case <-time.After(wait):
            }
        }

        result, err := c.ConvertWithContext(ctx, filePath, fileData)
        if err == nil {
            return result, nil
        }
        lastErr = err

        // Don't retry client errors (bad auth, bad file)
        if strings.Contains(err.Error(), "401") ||
            strings.Contains(err.Error(), "413") {
            break
        }
    }

    return "", fmt.Errorf("all %d retries failed: %w", maxRetries+1, lastErr)
}

Batch conversion with goroutines

Process an entire directory of documents concurrently with a bounded worker pool:

func BatchConvert(ctx context.Context, client *Client,
    files []string, workers int) map[string]string {

    type job struct {
        path string
        data []byte
    }

    jobs := make(chan job, len(files))
    results := make(chan struct{ path, md string }, len(files))

    // Start bounded workers
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := range jobs {
                md, err := client.ConvertWithRetry(ctx, j.path, j.data, 2)
                if err != nil {
                    log.Printf("FAIL %s: %v", j.path, err)
                    continue
                }
                results <- struct{ path, md string }{j.path, md}
            }
        }()
    }

    // Feed jobs
    go func() {
        for _, f := range files {
            data, err := os.ReadFile(f)
            if err != nil {
                log.Printf("SKIP %s: %v", f, err)
                continue
            }
            jobs <- job{f, data}
        }
        close(jobs)
    }()

    // Close results when workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    out := make(map[string]string, len(files))
    for r := range results {
        out[r.path] = r.md
    }
    return out
}

This converts 50 concurrent documents using a worker pool of 10 goroutines, with retry and context cancellation built in. The bounded channel pattern keeps memory flat even with thousands of files in the queue.

Benchmark: Go vs Rust vs Node.js vs Python

Same benchmark setup as our Rust post — a 12-page, 2.8 MB PDF, 100 warm runs with HTTP keep-alive:

ClientAvg total latencyClient overheadThroughput (10 concurrent)
Rust (reqwest)342 ms1.8 ms512 docs/min
Go (net/http)345 ms4.5 ms495 docs/min
Node.js (undici)348 ms7.2 ms478 docs/min
Python (httpx)355 ms14.1 ms423 docs/min

Go's overhead is a fraction of Python's, and its throughput at 10 concurrent workers is within 3% of Rust. For most teams, Go offers the best trade-off: C-like performance with fast compilation, a world-class standard library, and trivial deployment as a single static binary.

Supported formats

AnyMD accepts all of these from your Go client — just change the filename extension:

CategoryFormats
DocumentsPDF, DOCX, ODT, RTF, EPUB, CBR/CBZ
PresentationsPPTX, ODP
SpreadsheetsXLSX, ODS, CSV (rendered as tables)
WebHTML, Markdown (pass-through)
ImagesOCR extraction from JPG, PNG, TIFF

Pricing

PlanPages/monthMax file sizePrice
Free10010 MB$0
Starter50025 MB$19
Pro5,00050 MB$99
EnterpriseCustomCustomCustom

Go conversions benefit from the same zero-retention policy and sub-second latency as every other language pair. See the API docs for the full Go-friendly specification, and pricing for plan details.

Get your free API key — 100 pages/month, no credit card required.


← Read more →