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
| Approach | Best for | Tradeoff |
|---|---|---|
| excelize | Most cases — pure Go, full XLSX support, fast | Some Excel-only formulas not evaluated |
| tealeg/xlsx | Read-only workflows, simpler API | Less actively maintained than excelize |
| ChangeThisFile API | Broken XLSX, no Go library install | Network 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.