xlsx is a dependency-free Go library for creating and reading Excel .xlsx
workbooks incrementally. Writing buffers a batch of rows and streams them to
temporary files; reading streams worksheet rows with xml.Decoder. Neither
direction needs to hold a whole workbook in memory.
- Streaming writer with configurable batching, temp directory, buffer size and ZIP compression, plus a zero-temp streaming mode for HTTP and pipes.
- Streaming reader with row-bounded memory and in-memory, disk-backed or automatic shared-string storage.
- Keyed columns, typed cell helpers, formulas, styles, hyperlinks, merged cells, freeze panes, auto-filters, row heights and column widths.
- Strings, booleans, integers, unsigned integers, floats,
time.Time,[]byte, anddatabase/sql/driver.Valuervalues (includingsql.Null*). - Structural validation for packages, relationships, content types, cell references, styles and shared strings.
- Context cancellation, Excel limits, ZIP-bomb protection and clear sentinel errors.
- No third-party runtime dependencies.
- Tested on Linux, macOS, and Windows with Go 1.24, 1.25, and 1.26.
go get github.com/zignd/xlsx@latestRequires Go 1.24 or newer.
Runnable programs live in examples/. Each has its own README.md
with instructions and expected output:
# Small keyed workbook, then read it back.
go run ./examples/basic -out /tmp/people.xlsx
go run ./examples/read -in /tmp/people.xlsx
# A feature-rich dashboard with styles, formulas, merges and 10k rows.
go run ./examples/complex -out /tmp/dashboard.xlsx -rows 10000
# Serve buffered and zero-temp streamed downloads.
go run ./examples/http -addr 127.0.0.1:8080The Example* functions in the root tests are documentation examples for
go test; the programs under examples/ are standalone runnable commands.
package main
import (
"log"
"github.com/zignd/xlsx"
)
func main() {
workbook := xlsx.NewWorkbook("report.xlsx")
worksheet := workbook.AddWorksheet(&xlsx.WorksheetOptions{Name: "Sales"})
for i := 0; i < 1_000_000; i++ {
row, err := worksheet.AddRow()
if err != nil {
log.Fatal(err)
}
product, err := row.AddCell()
if err != nil {
log.Fatal(err)
}
product.SetString("Keyboard")
amount, err := row.AddCell()
if err != nil {
log.Fatal(err)
}
amount.SetFloat(49.90)
// Flush this batch to disk. Repeat CommitRows to keep memory flat.
if err := worksheet.CommitRows(); err != nil {
log.Fatal(err)
}
}
// Worksheets without pending rows are finalized automatically.
if err := workbook.Commit(); err != nil {
log.Fatal(err)
}
}CommitRows can be called once per row or once per batch. Batching is usually
faster:
const batchSize = 10_000
for i := 0; i < total; i++ {
row, _ := worksheet.AddRow()
if err := row.Append("Keyboard", 49.90); err != nil {
log.Fatal(err)
}
if (i+1)%batchSize == 0 {
if err := worksheet.CommitRows(); err != nil {
log.Fatal(err)
}
}
}
if err := worksheet.CommitRows(); err != nil { // flush the final partial batch
log.Fatal(err)
}
if err := workbook.Commit(); err != nil {
log.Fatal(err)
}worksheet := workbook.AddWorksheet(&xlsx.WorksheetOptions{Name: "Sales"})
err := worksheet.DefineColumns([]*xlsx.WorksheetColumn{
{Key: "product", Value: "Product", Width: 30},
{Key: "amount", Value: "Amount"},
{Key: "sold_at", Value: "Sold at", Width: 22},
})
if err != nil {
log.Fatal(err)
}
row, err := worksheet.AddRow()
if err != nil {
log.Fatal(err)
}
product, _ := row.AddCellWithKey("product")
product.SetString("Keyboard")
amount, _ := row.AddCellWithKey("amount")
amount.SetFloat(49.90)
if err := worksheet.CommitRows(); err != nil {
log.Fatal(err)
}style, err := workbook.AddStyle(xlsx.Style{
Bold: true,
NumberFormat: "#,##0.00",
FillColor: "FFF2CC",
Horizontal: "center",
})
if err != nil {
log.Fatal(err)
}
worksheet := workbook.AddWorksheet(&xlsx.WorksheetOptions{Name: "Data"})
if err := worksheet.FreezePanes(1, 0); err != nil { // freeze the header row
log.Fatal(err)
}
if err := worksheet.SetAutoFilter("A1:D3"); err != nil {
log.Fatal(err)
}
if err := worksheet.MergeCells("A4:B4"); err != nil {
log.Fatal(err)
}
row, _ := worksheet.AddRow()
amount, _ := row.WriteFloat(1234.5)
amount.SetStyle(style)
amount.SetHyperlink("https://example.com")
formula, _ := row.AddCell()
formula.SetFormula("=SUM(B1:B2)")Formulas are written without the leading = and the workbook asks Excel to
recalculate on open.
reader, err := xlsx.Open("report.xlsx")
if err != nil {
log.Fatal(err)
}
defer reader.Close()
for _, info := range reader.Sheets() {
fmt.Println(info.Name, info.Hidden)
}
sheet, err := reader.OpenSheet("Sales")
if err != nil {
log.Fatal(err)
}
defer sheet.Close()
for sheet.Next() {
row := sheet.Row()
name, ok := row.CellAt(0)
if !ok {
continue
}
value, err := name.String()
if err != nil {
log.Fatal(err)
}
fmt.Println(row.Index, value)
}
if err := sheet.Err(); err != nil {
log.Fatal(err)
}OpenReaderAt(io.ReaderAt, size) supports embedding and object storage.
Shared strings use SharedStringsAuto by default: small tables stay in memory
and large tables are stored in temporary files and read on demand.
if err := xlsx.Validate("report.xlsx"); err != nil {
log.Fatal(err)
}Validate checks ZIP integrity, unsafe entry names, content types, root and
worksheet relationships, required parts, XML well-formedness, sheet names, cell
references, row/column ordering, style IDs and shared-string indexes. It streams
worksheets and therefore stays memory-flat.
workbook, err := xlsx.NewWorkbookWithOptions("report.xlsx", xlsx.Options{
TempDir: "/var/tmp/xlsx",
BufferSize: 128 * 1024,
Compression: xlsx.CompressionBestSpeed,
TempDirMode: 0o700,
FileMode: 0o600,
SharedStrings: true,
FlushEveryRows: 1000, // streaming writer flush cadence
Context: ctx,
})TempDirmust exist and be writable. The library creates a privatexlsx-*subdirectory inside it and never deletes the parent directory.KeepTempFilesleaves the private directory in place for debugging.NewWriter(io.Writer, Options)writes the final ZIP to an arbitrary writer.NewStreamWriter(io.Writer, Options)writes ZIP entries directly to the writer without temporary worksheet files. See Streaming over HTTP.Workbook.Flushflushes buffered data and calls the underlying writer'sFlushmethod when it has one, which works withhttp.Flusher.Options.FlushEveryRowssets how often the streaming writer flushes compressed bytes to the underlying writer.Workbook.Close/Cleanupare idempotent and safe todefer.
There are two options for serving an XLSX download.
NewWriter writes worksheets to temporary files and streams the completed ZIP
to the response when Commit runs:
func export(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", `attachment; filename="report.xlsx"`)
workbook, err := xlsx.NewWriter(w, xlsx.Options{
Context: r.Context(),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sheet := workbook.AddWorksheet(&xlsx.WorksheetOptions{Name: "Data"})
// ... add rows and CommitRows ...
if err := workbook.Commit(); err != nil {
// Headers are already sent; the connection must be aborted.
return
}
}This is the safest option: it uses local temp disk, but generation errors before
Commit can still be reported with a proper HTTP status.
NewStreamWriter writes worksheet XML and ZIP entries directly to the response
as rows are committed. It never creates temporary worksheet files:
workbook, err := xlsx.NewStreamWriter(w, xlsx.Options{
Context: r.Context(),
FlushEveryRows: 1000,
SharedStrings: true,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sheet := workbook.AddWorksheet(&xlsx.WorksheetOptions{Name: "Data"})
// ... add rows and CommitRows ...
if err := workbook.Flush(); err != nil {
return
}
if err := workbook.Commit(); err != nil {
// The response has already started; it will be truncated.
return
}Important constraints:
- Only one worksheet may be open at a time. Finish a worksheet with
Commitbefore writing rows to another sheet. - The ZIP is not complete until
Commitwrites the central directory. Clients can download progressively, but readers cannot fully open the file until the response ends. - Do not set
Content-Length; use chunked transfer or HTTP/2. - A failure after the first byte cannot change the HTTP status. The client gets a truncated download.
FlushEveryRowscontrols how often compressed bytes are pushed to the response.Workbook.Flushalso callshttp.Flusherwhen available.- For reverse proxies, disable response buffering (for example nginx
X-Accel-Buffering: no). - For object storage, use the provider's multipart/streaming upload API; a ZIP cannot be retried with range requests while it is being generated.
SharedStringsstays in memory in streaming mode. Disable it when memory is tighter than CPU/network budget.
See examples/http for a runnable server exposing both modes.
| Go type | Written as |
|---|---|
string, []byte, named string and byte-slice types |
Inline or shared string |
bool, named bool types |
Excel boolean |
| Signed and unsigned integers | Number |
float32, float64 |
Number |
time.Time |
Excel date serial with a yyyy-mm-dd hh:mm:ss style |
database/sql/driver.Valuer |
The value returned by Value() |
nil |
Blank cell |
Unsupported values (structs other than time.Time, maps, NaN, infinities,
and so on) return an error from CommitRows, wrapped with
ErrUnsupportedType.
Dates are written using their wall-clock components (the location is ignored), which is how Excel stores date-times. Both 1900 and 1904 date systems are read.
- 1,048,576 rows and 16,384 columns per worksheet.
- 32,767 characters per text cell and 8,192 characters per formula.
- 2,079 characters per hyperlink.
- Configurable limits for temp space, ZIP entries, uncompressed size, rows, columns and cell length on the reader.
- Reader rejects path traversal, duplicate ZIP entries, encrypted workbooks and XLSB packages with clear sentinel errors.
Workbook,Worksheet,Rowand the reader are not safe for concurrent use; use one workbook/reader per goroutine.
The writer targets ECMA-376 5th edition / ISO/IEC 29500 Transitional SpreadsheetML, which is what Excel writes by default. The test suite includes:
- structural validation of every generated part,
- round-trip reader/writer tests,
- an openpyxl interoperability job that verifies values, dates, styles, hyperlinks, frozen panes, auto-filters, merged cells and formulas,
- fuzzing of the reader and column conversion.
The OOXML standard continues to evolve through ISO/IEC and Microsoft implementation notes. When changing OOXML output, compare generated files with Excel, LibreOffice, openpyxl and Excelize and update the compatibility tests.
func NewWorkbook(filePath string) *Workbook
func NewWorkbookWithOptions(filePath string, opts Options) (*Workbook, error)
func NewWriter(w io.Writer, opts Options) (*Workbook, error)
func NewStreamWriter(w io.Writer, opts Options) (*Workbook, error)
func (wb *Workbook) AddWorksheet(opts *WorksheetOptions) *Worksheet
func (wb *Workbook) AddStyle(style Style) (int, error)
func (wb *Workbook) Commit() error
func (wb *Workbook) Flush() error
func (wb *Workbook) Close() error
func (wb *Workbook) Cleanup() error
func (wb *Workbook) TempDir() string
func (wb *Workbook) Worksheets() []*Worksheet
func (ws *Worksheet) DefineColumns(columns []*WorksheetColumn) error
func (ws *Worksheet) AddRow() (*Row, error)
func (ws *Worksheet) CommitRows() error
func (ws *Worksheet) Commit() error
func (ws *Worksheet) MergeCells(ranges ...string) error
func (ws *Worksheet) FreezePanes(rows, columns int) error
func (ws *Worksheet) SetAutoFilter(ref string) error
func (r *Row) AddCell() (*Cell, error)
func (r *Row) AddCellWithKey(key string) (*Cell, error)
func (r *Row) Append(values ...interface{}) error
func (r *Row) WriteString(value string) (*Cell, error)
func (r *Row) WriteInt(value int64) (*Cell, error)
func (r *Row) WriteFloat(value float64) (*Cell, error)
func (r *Row) WriteBool(value bool) (*Cell, error)
func (r *Row) WriteTime(value time.Time) (*Cell, error)
func Open(path string, opts ...ReadOption) (*Reader, error)
func OpenReaderAt(r io.ReaderAt, size int64, opts ...ReadOption) (*Reader, error)
func (r *Reader) Sheets() []SheetInfo
func (r *Reader) OpenSheet(name string) (*SheetReader, error)
func (r *Reader) Close() error
func (s *SheetReader) Next() bool
func (s *SheetReader) Row() *RowData
func (s *SheetReader) Err() error
func (s *SheetReader) Close() error
func Validate(path string, opts ...ReadOption) error
func ValidateReaderAt(r io.ReaderAt, size int64, opts ...ReadOption) errorErrors intended for callers are exported as sentinels and can be checked with
errors.Is, for example ErrPendingRows, ErrReadLimit, ErrCorruptFile,
ErrEncrypted, ErrUnsupportedFormat, ErrCellTooLong and ErrInvalidStyle.
See the package documentation for the complete API and runnable examples.
make bench
# or
go test -run '^$' -bench . -benchmem ./...The suite covers typed writes, shared strings, styles, row buffering,
CommitRows, full workbook commits, reading, validation, XML escaping and a
side-by-side comparison of buffered and streaming HTTP exports
(BenchmarkWriteModes and BenchmarkHTTPExport). Results depend on the machine
and storage; CI runs a smoke test for every benchmark.
- Batch
CommitRowscalls. Flushing once per 1,000–50,000 rows is usually faster than flushing every row and still keeps memory bounded. - Use
CompressionBestSpeedwhen CPU is the bottleneck andCompressionBestCompressionwhen file size matters more. - Enable
SharedStringswhen many cells repeat the same text. It reduces file size but keeps a string table in memory. - For reading large files, leave shared strings on
SharedStringsAuto; disk mode keeps memory flat at the cost of extra temporary I/O. - Keep
TempDiron a fast local disk. Network filesystems can dominate commit time. - Reuse one workbook/reader per goroutine and parallelize across independent files.
The v0.1.x API remains available. New code can opt into:
NewWorkbookWithOptionsfor temp-dir, context and compression control.Row.Append/typedWrite*methods andCell.Set*helpers.Workbook.AddStyle,Cell.SetFormula,Cell.SetHyperlink,Worksheet.MergeCells,Worksheet.FreezePanesandWorksheet.SetAutoFilter.Open,ValidateandNewWriter.
Existing NewWorkbook, AddWorksheet, AddCell, AddCellWithKey,
CommitRows, Commit and Cleanup calls continue to work.
make test # go test ./...
make test-race # race detector
make cover # coverage profile + function report
make cover-check # coverage gate
make interop # openpyxl interoperability check
make examples # run the file-based examples
make bench # benchmarks
make lint # golangci-lint
make check # tidy + format + vet + race tests + lintCI runs the test matrix, lint, module tidiness, coverage gate, benchmark, fuzz, gosec, CodeQL, govulncheck and openpyxl interoperability jobs. Dependabot keeps the Go module and GitHub Actions up to date.
The project follows Semantic Versioning. Versions are regular Go module tags, so they are published to pkg.go.dev automatically.
To release from the GitHub UI, run the Tag release workflow and choose
patch, minor, or major (optionally with a prerelease suffix), or provide an
exact tag such as v1.2.3. The workflow runs the tests, computes or validates
the tag, creates an annotated tag, and pushes it. The same workflow calls the
reusable Release workflow, which validates the SemVer tag, builds, tests,
scans for vulnerabilities, and creates the GitHub release with generated notes.
The same flow can be driven locally:
git tag -a v1.2.3 -m "Release v1.2.3"
git push origin v1.2.3MIT. See LICENSE.