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:
| Client | Avg total latency | Client overhead | Throughput (10 concurrent) |
|---|---|---|---|
| Rust (reqwest) | 342 ms | 1.8 ms | 512 docs/min |
| Go (net/http) | 345 ms | 4.5 ms | 495 docs/min |
| Node.js (undici) | 348 ms | 7.2 ms | 478 docs/min |
| Python (httpx) | 355 ms | 14.1 ms | 423 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:
| Category | Formats |
|---|---|
| Documents | PDF, DOCX, ODT, RTF, EPUB, CBR/CBZ |
| Presentations | PPTX, ODP |
| Spreadsheets | XLSX, ODS, CSV (rendered as tables) |
| Web | HTML, Markdown (pass-through) |
| Images | OCR extraction from JPG, PNG, TIFF |
Pricing
| Plan | Pages/month | Max file size | Price |
|---|---|---|---|
| Free | 100 | 10 MB | $0 |
| Starter | 500 | 25 MB | $19 |
| Pro | 5,000 | 50 MB | $99 |
| Enterprise | Custom | Custom | Custom |
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.