report

Excel report generator for Go. Accepts any tabular data source, auto-sizes columns, and supports per-report customisation via embedded Starlark scripts.
Requires Go 1.26+
go get github.com/abagile/tokyo3-report
Design
Generate(outputPath, worksheets...) ──► .xlsx file
│
└── WorksheetConf[]
├── Rows (data source: StructRows or SqlxRows)
├── Script (optional Starlark, controls layout & style)
├── Parsers (optional named field-value parsers)
└── Translator (optional, localises headers and enum values)
Generate either creates a new workbook or opens an existing one. Each worksheet with a RowsReader replaces its named sheet with fresh data; a nil reader preserves an existing named sheet while applying script-defined layout. All row data is streamed into one staged workbook, whose column widths, and the styles of sheets without a RowsReader, are patched in before the final atomic replacement. If the target file does not exist it is created from scratch; if it exists, unrelated sheets are left untouched.
Quick start
type Order struct {
Region string
Product string
Amount float64
}
rows := []any{
Order{"North", "Widget A", 1200.00},
Order{"North", "Widget B", 450.00},
Order{"South", "Widget A", 980.00},
}
err := report.Generate(
"orders.xlsx",
report.WorksheetConf{
SheetName: "Orders",
Rows: report.NewStructRows(rows),
},
)
Produces a sheet with auto-titled headers (Region, Product, Amount) and auto-sized columns.
See the example/ directory for a complete runnable program.
WorksheetConf
| Field |
Type |
Description |
SheetName |
string |
Name of the worksheet to write or style. |
Rows |
RowsReader |
Optional data source; nil preserves an existing worksheet. |
Script |
string |
Starlark script for layout customisation (see below). |
Translator |
Translator |
Optional interface for header and value localisation. |
Parsers |
map[string]Parser |
Optional named parsers referenced by column metadata. |
RowsReader
RowsReader is the interface that decouples Generate from any particular data source.
type RowsReader interface {
Read() error
Headers() ([]string, error)
Values() ([]any, error)
Next() bool
Err() error
}
A reader that also implements io.Closer is closed by Generate once the
worksheet is written, including when generation fails partway through.
Two implementations are provided:
StructRows
Wraps an in-memory []any of structs. Struct field names become column headers.
reader := report.NewStructRows([]any{
MyStruct{...},
MyStruct{...},
})
Pointer fields are dereferenced; nil pointers render as an empty string.
SqlxRows
Streams directly from a live *sqlx.DB query.
reader := report.NewSqlxRows("SELECT region, product, amount FROM orders", db)
NUMERIC database columns are automatically converted to float64. Close
releases the result set; Generate calls it, so only direct users of the
reader need to.
Translator
Implement Translator to localise header names and enum-coded values.
type Translator interface {
Header(key string) string // raw field/column name → display header
Lookup(key string, code any) string // lookup key + raw code → display value
}
Header is called once per header with the raw field or SQL column name.
Lookup is called per cell for columns that set lookup in the script (see below).
type MyTranslator struct{}
func (MyTranslator) Header(key string) string {
return map[string]string{
"region": "Region",
"product": "Product Name",
"amount": "Amount (USD)",
}[key]
}
func (MyTranslator) Lookup(key string, code any) string {
if key == "status" {
return map[any]string{1: "Active", 2: "Closed"}[code]
}
return fmt.Sprintf("%v", code)
}
When no Translator is set, headers are produced by flect.Titleize (order_id → Order Id).
Parsers
A Parser converts a raw field value before it is written to Excel:
type Parser func(any) (any, error)
Register parsers by name in WorksheetConf, then reference those names from the
script's col metadata:
worksheet := report.WorksheetConf{
Parsers: map[string]report.Parser{
"trim": func(value any) (any, error) {
return strings.TrimSpace(value.(string)), nil
},
},
}
An unknown parser name or a parser error stops report generation. When a column
specifies both parser and lookup, the parser runs first and the lookup
receives the parsed value.
Starlark script
The Script field accepts a Starlark program. Starlark is a deterministic, sandboxed Python dialect — no I/O, no imports, no side-effects outside the declared variables below.
The script may define any combination of the four top-level variables. Unrecognised variables are silently ignored.
groupFields
groupFields = ["Region", "Category"]
A list of raw column names. Whenever the value in any listed column changes compared to the previous row, a blank separator row is inserted above the new group. Multiple fields are checked in order.
width
width = {
"A": 14,
"B:D": 22,
}
Explicit column widths. Keys are column letters or letter ranges ("B:D"). Values are numbers (integer or float). Overrides auto-sizing for those columns. All other columns remain auto-sized.
Column widths are clamped to the range [10, 100].
style
style = {
"1": '{"font":{"bold":true}}', # entire row 1 (header)
"A": '{"font":{"color":"FF0000"}}', # entire column A
"A:C": '{"fill":{"type":"pattern","color":["FFFFCC"],"pattern":1}}', # columns A–C
"B2": '{"font":{"italic":true}}', # single cell
"B2:D10": '{"font":{"italic":true}}', # cell range
}
Each key selects a target; each value is the JSON serialisation of an excelize Style. Target type is inferred from the key format:
| Key pattern |
Example |
Target |
| Column letter(s) |
"A", "A:C" |
Whole column, plus every cell already in it |
| Integer |
"1", "3" |
Whole row, plus every cell already in it |
| Cell reference |
"B2" |
Single cell |
| Cell range |
"B2:D10" |
Every cell in the rectangle |
Styles of a sheet with a RowsReader are applied while its rows are streamed.
Sheets without one are patched directly in the staged worksheet XML. Neither
path loads sheet data into memory.
Precedence (first match wins): cell reference → cell range → row → column.
A style replaces the target's existing style rather than merging with it, so a
column or row style also overrides the automatic date format on time.Time
cells in that column or row.
Row, cell and range targets create what they need. Rows and cells that hold
no data are written as empty styled elements so the style is visible in Excel,
exactly as SetCellStyle would do. The cost is therefore proportional to the
targeted area, not to the amount of data: "B2:D10" adds at most 27 cells, but
"A1:B1048576" adds two million of them. To style a whole column, use a column
target ("A", "A:B"), which is a single <col> definition, instead of a range
that spans every row.
A style that resolves to no formatting clears the target. '{}', and any
style JSON excelize maps to its default style ID 0, removes the style from the
target instead of adding one.
col
Per-column settings keyed by the raw field/column name (before any Translator.Header rename).
col = {
"amount": {
"width": 18,
"style": '{"NumFmt":4}', # #,##0.00
},
"status": {
"parser": "normalize_status", # resolves WorksheetConf.Parsers entry
"lookup": "order_status", # receives the parsed value
},
}
| Sub-key |
Type |
Description |
width |
number |
Column width (same semantics as width dict above). |
style |
string |
JSON style applied to the entire column. |
parser |
string |
Runs the named WorksheetConf.Parsers entry. Unknown names and parser errors stop generation. |
lookup |
string |
Passes the parsed (or raw) value through Translator.Lookup(key, value). No-op when no Translator is set. |
col style and width entries are merged into the style and width dicts after the script runs, so they can coexist with direct style/width entries.
Style JSON reference
Style values are JSON representations of excelize.Style. Common fields:
{
"font": { "bold": true, "italic": true, "size": 11, "color": "FF0000" },
"fill": { "type": "pattern", "pattern": 1, "color": ["FFFFCC"] },
"alignment": { "horizontal": "center", "WrapText": true },
"NumFmt": 4 // built-in number format ID (4 = #,##0.00)
}
Common NumFmt IDs: 3 = #,##0, 4 = #,##0.00, 9 = 0%, 10 = 0.00%.
Auto column sizing
Column widths are calculated automatically from content width, where ASCII characters count as 1 unit and CJK/multibyte characters count as 2 units, multiplied by a 1.123 scale factor. The result is clamped to [10, 100]. Script-defined widths always override auto-sizing.
time.Time values receive Excel's built-in short-date format (format ID 14, e.g. 01/15/24) to prevent Excel from auto-converting dates to MMM-YY display format on open.