Writing Excel Files in Go With Excelize Step by Step
Write and read Excel files in Go with Excelize: create a workbook, typed cells and styles, the stream writer for big exports and a row iterator.

Go services produce Excel files mostly as exports: a downloadable report, a nightly file for a partner, a CSV that someone insisted should be an xlsx. Excelize is the commonly used library for this. It is written in Go with no cgo and no Excel dependency, reads and writes xlsx, and has a streaming writer for big outputs. This post covers the pieces most exports need: creating and filling a sheet, typed values, styles, the stream writer, reading, and the Go-specific habits of error handling and closing. It is a spoke of reading and writing Excel from JavaScript, Java and C Sharp.
Install
go get github.com/xuri/excelize/v2Import it as "github.com/xuri/excelize/v2". The package name is excelize.
Create a workbook and set cells
package main
import (
"log"
"time"
"github.com/xuri/excelize/v2"
)
func main() {
f := excelize.NewFile()
defer func() {
if err := f.Close(); err != nil {
log.Println(err)
}
}()
sheet := "Report"
if _, err := f.NewSheet(sheet); err != nil {
log.Fatal(err)
}
f.DeleteSheet("Sheet1") // remove the default sheet
f.SetSheetRow(sheet, "A1", &[]interface{}{"Order", "Date", "Amount"})
f.SetCellStr(sheet, "A2", "0007") // string, keeps the leading zeros
f.SetCellValue(sheet, "B2", time.Date(2025, 3, 14, 0, 0, 0, 0, time.UTC))
f.SetCellFloat(sheet, "C2", 1234.5, 2, 64)
if err := f.SaveAs("report.xlsx"); err != nil {
log.Fatal(err)
}
}NewFile starts a workbook with a sheet named Sheet1. NewSheet adds one (the return value is the sheet index; in some versions it returns only the index, so check your version's signature). Cell addresses are strings like "B2". For code that loops, convert row and column numbers with excelize.CoordinatesToCellName(col, row).
SetCellValue accepts a Go value and writes the matching type: ints and floats as numbers, time.Time as a date, bool as boolean, strings as text. SetCellStr forces text, which is what an identifier needs. The explicit setters (SetCellInt, SetCellFloat, SetCellBool) give control. Many methods return an error: assign and check it.
Styles and number formats
Dates and decimals need number formats, set through styles. Create a style once and apply it to a range:
dateStyle, err := f.NewStyle(&excelize.Style{CustomNumFmt: ptr("yyyy-mm-dd")})
if err != nil { log.Fatal(err) }
moneyStyle, _ := f.NewStyle(&excelize.Style{NumFmt: 4}) // built-in #,##0.00
headStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true},
Fill: excelize.Fill{Type: "pattern", Color: []string{"#DDEBF7"}, Pattern: 1},
})
f.SetCellStyle(sheet, "B2", "B1000", dateStyle)
f.SetCellStyle(sheet, "C2", "C1000", moneyStyle)
f.SetCellStyle(sheet, "A1", "C1", headStyle)
f.SetColWidth(sheet, "A", "C", 14)
f.SetPanes(sheet, &excelize.Panes{Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"})
f.AutoFilter(sheet, "A1:C1000", nil)(ptr is a small helper returning a pointer to a string, since CustomNumFmt is a *string.) Create each style once and reuse the index. A style per cell makes the file large and slow. Built-in number format IDs exist for common formats (for instance 2 for 0.00, 4 for #,##0.00, 14 for the short date), and a custom format string covers the rest.
Stream large outputs
The normal API holds every cell in memory. For a few hundred thousand rows, use the stream writer, which writes rows in order and flushes them:
f := excelize.NewFile()
defer f.Close()
sw, err := f.NewStreamWriter("Sheet1")
if err != nil { log.Fatal(err) }
head, _ := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
if err := sw.SetRow("A1", []interface{}{
excelize.Cell{Value: "Order", StyleID: head},
excelize.Cell{Value: "Amount", StyleID: head},
}); err != nil { log.Fatal(err) }
for i := 2; i <= 500000; i++ {
cell, _ := excelize.CoordinatesToCellName(1, i)
if err := sw.SetRow(cell, []interface{}{fmt.Sprintf("%07d", i), float64(i) * 1.5}); err != nil {
log.Fatal(err)
}
}
if err := sw.Flush(); err != nil { log.Fatal(err) }
if err := f.SaveAs("big.xlsx"); err != nil { log.Fatal(err) }Rules for the stream writer: rows must be written in increasing order, each row only once; styles must be created before the rows that use them; Flush must be called at the end; and operations that need random access to earlier rows (merging, some formatting) are limited or must be set before streaming. Column widths can be set with sw.SetColWidth before the first row. A sheet holds at most 1,048,576 rows, and the stream writer does not lift that.
Read a sheet
For small and medium sheets:
f, err := excelize.OpenFile("orders.xlsx")
if err != nil { log.Fatal(err) }
defer f.Close()
rows, err := f.GetRows("Orders") // [][]string
for i, row := range rows {
if i == 0 { continue }
// row[0], row[1] ... as strings
}GetRows returns every cell as a string, formatted as Excel displays it, and rows can have different lengths (trailing empty cells are omitted), so check len(row) before indexing. To control formatting use f.GetCellValue(sheet, "B2", excelize.Options{RawCellValue: true}), which returns the raw value: a date is a serial number as text. Convert with excelize.ExcelDateToTime(serial, false); the second argument selects the 1904 date system.
For big sheets, use the iterator:
it, err := f.Rows("Orders")
if err != nil { log.Fatal(err) }
defer it.Close()
for it.Next() {
cols, err := it.Columns(excelize.Options{RawCellValue: true})
if err != nil { log.Fatal(err) }
process(cols)
}Rows streams the sheet XML, so memory is much lower than GetRows. The workbook is still opened as a zip and its shared strings table is loaded. Each cell comes back as a string; parse numbers and dates yourself, with explicit error handling for values that do not convert.
Formulas
f.SetCellFormula(sheet, "D2", "=B2*C2") writes a formula. Excelize does not calculate on save, so the file has no cached results until Excel opens it. f.CalcCellValue(sheet, "D2") calculates a cell with Excelize's engine, which supports a subset of functions. For files read by programs that do not calculate, write values.
Security and reliability
Treat uploaded workbooks as untrusted: limit the size, set a context timeout around the work, open with excelize.Options{UnzipSizeLimit: ..., UnzipXMLSizeLimit: ...} to cap decompressed sizes, and handle errors without panicking. The library has options for these limits; use them for files from outside. Neutralize text that starts with =, +, - or @ before writing user data into cells, since Excel evaluates it when the file is opened (formula injection in Excel exports describes the CSV variant of the same risk).
Always Close the file: it releases temporary files and file handles. In a web handler, write the workbook to the response with f.Write(w), set the content type to application/vnd.openxmlformats-officedocument.spreadsheetml.sheet and a Content-Disposition header.
Check the result
Open the file in Excel and not only in Go. Compare the number of rows written with the number in the source, and a sum with a database total. Look at an identifier column for zeros, a date column for the format and a decimal column for rounding. Test with an empty result set, a very long string (the 32,767-character cell limit applies) and a value with a newline or a quote (Excel cell contains more than 32767 characters).
Concurrency and context
Excelize's File type is not safe for concurrent use from several goroutines without your own locking. Build a workbook in one goroutine, or guard it with a mutex. A common pattern for a report endpoint is to fetch the data concurrently (database queries in goroutines with an errgroup), collect the results, and then write the workbook sequentially in one place. The writing is CPU work and fast compared with the queries.
Pass a context.Context through the code that fetches data, so a canceled request stops the queries. Excelize itself does not take a context, which means a long write cannot be interrupted from outside; keep the data volume bounded, and use the stream writer with checks between batches (ctx.Err()) so a canceled request stops writing early.
Memory is the other concern in a service. A handler that builds a 200,000-row workbook in memory for every request can exhaust a small container under load. Limit the concurrency of export requests with a semaphore or a worker pool, put large exports on a queue and email or store the result, and prefer the stream writer for anything large. Writing to a temporary file and streaming it back keeps the handler's memory small, and cleaning up the file afterwards is part of the job (watching a folder for new Excel files and importing them shows the same file-handling care on the import side).
When Excelize is more than the task needs
The consumer reads CSV. The standard library's encoding/csv is enough (reading and writing CSV files in Go).
You process huge inputs routinely. Convert once to Parquet or CSV, and avoid reading xlsx repeatedly.
You need charts and pivots. Excelize supports charts and some pivot features, and complex reports are easier with a template that you fill.
The workbook must calculate in front of the user. Write the formulas and let Excel calculate, and test in Excel.
See the Excelize documentation and Go's page on error handling. To convert a small result to JSON, use the CSV to JSON converter.


