XLSX is a zipped XML format with a lot of features (formulas, styles, charts, pivot tables). Converting to CSV mostly means: pick a sheet, walk rows, write CSV. The catches are formulas (do you want the formula or the value?) and multi-sheet workbooks (do you want one CSV per sheet?).

Method 1: xuri/excelize (full-featured, canonical)

excelize is the most-used Go library for XLSX. Reads, writes, edits, evaluates formulas. Pure Go — no native deps.

go get github.com/xuri/excelize/v2
package main

import (
    "encoding/csv"
    "fmt"
    "os"

    "github.com/xuri/excelize/v2"
)

func xlsxToCSV(inPath, outPath, sheetName string) error {
    xl, err := excelize.OpenFile(inPath)
    if err != nil {
        return fmt.Errorf("open: %w", err)
    }
    defer xl.Close()

    if sheetName == "" {
        sheetName = xl.GetSheetName(0) // first sheet
    }

    rows, err := xl.GetRows(sheetName)
    if err != nil {
        return fmt.Errorf("rows: %w", err)
    }

    out, err := os.Create(outPath)
    if err != nil {
        return err
    }
    defer out.Close()

    w := csv.NewWriter(out)
    defer w.Flush()

    for _, row := range rows {
        if err := w.Write(row); err != nil {
            return fmt.Errorf("write row: %w", err)
        }
    }
    return nil
}

func main() {
    if err := xlsxToCSV("sales.xlsx", "sales.csv", ""); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Three things to know:

  • GetRows returns formula values, not formula text. Excelize evaluates simple formulas (SUM, AVERAGE, etc.) as part of read. For complex Excel-only formulas, the cell may be empty.
  • Empty trailing cells are dropped. A row with values ["a", "b", "", ""] returns ["a", "b"]. If you need fixed-width output, pad to the column count.
  • Streams for huge files. Use xl.Rows(sheetName) for an iterator-style API that doesn't load the whole sheet — important for files over ~100MB.

Method 2: Multi-sheet workbooks (one CSV per sheet)

Most XLSX files have multiple sheets. CSV is single-sheet, so the convention is one CSV per sheet, named by sheet.

package main

import (
    "encoding/csv"
    "fmt"
    "os"
    "path/filepath"
    "strings"

    "github.com/xuri/excelize/v2"
)

func xlsxToCSVs(inPath, outDir string) error {
    xl, err := excelize.OpenFile(inPath)
    if err != nil {
        return err
    }
    defer xl.Close()

    if err := os.MkdirAll(outDir, 0o755); err != nil {
        return err
    }

    for _, sheet := range xl.GetSheetList() {
        rows, err := xl.GetRows(sheet)
        if err != nil {
            return fmt.Errorf("%s: %w", sheet, err)
        }

        // sanitize sheet name for filename
        safe := strings.ReplaceAll(sheet, "/", "_")
        safe = strings.ReplaceAll(safe, " ", "_")
        outPath := filepath.Join(outDir, safe+".csv")

        out, err := os.Create(outPath)
        if err != nil {
            return err
        }
        w := csv.NewWriter(out)
        for _, row := range rows {
            if err := w.Write(row); err != nil {
                out.Close()
                return err
            }
        }
        w.Flush()
        out.Close()
    }
    return nil
}

func main() {
    if err := xlsxToCSVs("workbook.xlsx", "./out"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Sanitize sheet names for filesystem safety — sheet names can contain slashes, colons, and other characters that break paths.

Method 3: ChangeThisFile API (LibreOffice fidelity)

For broken or exotic XLSX files (corrupt zip, unusual encryption, custom XML namespaces), the API runs LibreOffice server-side which is more tolerant than excelize. 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 xlsxToCSV(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.xlsx")
    if err != nil {
        return err
    }
    if _, err := io.Copy(fw, f); err != nil {
        return err
    }
    _ = w.WriteField("source", "xlsx")
    _ = w.WriteField("target", "csv")
    _ = 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() {
    if err := xlsxToCSV("messy.xlsx", "clean.csv"); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

By default the API converts the first sheet only. Pass sheet=Sheet2 in form data for a specific sheet, or all_sheets=true to receive a zip with one CSV per sheet.

When to use each

ApproachBest forTradeoff
excelizeMost cases — pure Go, full XLSX support, fastSome Excel-only formulas not evaluated
tealeg/xlsxRead-only workflows, simpler APILess actively maintained than excelize
ChangeThisFile APIBroken XLSX, no Go library installNetwork call, file size limit (25MB free)

CLI alternatives: ssconvert, xlsx2csv

For shell pipelines:

# gnumeric's ssconvert (best fidelity)
apt install gnumeric
ssconvert sales.xlsx sales.csv

# Python's xlsx2csv (fast, scriptable)
pip install xlsx2csv
xlsx2csv sales.xlsx sales.csv
xlsx2csv -a sales.xlsx ./out_dir/  # one CSV per sheet

# LibreOffice headless (highest compat)
soffice --headless --convert-to csv sales.xlsx --outdir ./out
// From Go, ssconvert is the smallest dep:
cmd := exec.Command("ssconvert", "sales.xlsx", "sales.csv")
out, err := cmd.CombinedOutput()

For scripted batch jobs, xlsx2csv is hard to beat. For long-running services, excelize is more efficient (no subprocess overhead).

Production tips

  • Use the streaming API for big files. xl.Rows(sheet) returns an iterator — process rows one at a time without loading the whole sheet. Required for files above ~100MB.
  • Decide on formula handling explicitly. excelize's GetRows returns the cached value (whatever was visible in Excel last time the file was saved). For text-of-formula, use GetCellFormula. For freshly-evaluated formulas, call CalcCellValue first.
  • Handle empty trailing cells. If you need fixed-width CSV (header has 10 columns, every row should have 10 fields), pad short rows with empty strings before writing.
  • UTF-8 BOM for Excel compatibility. If your CSV will be opened in Excel on Windows, write a UTF-8 BOM at the start (0xEF 0xBB 0xBF) so non-ASCII characters render correctly.
  • Quote dates carefully. Excel stores dates as numbers internally. excelize converts to strings using the cell's display format — but inconsistent date formats across cells can produce mixed CSV output. Normalize at extraction time.

For nearly all cases, excelize is the right Go answer — pure Go, fast, well-maintained. The API is the right call for messy input or when you don't want a parser dep. Free tier covers 1,000 conversions/month.