Template
The .xlsx file you design in Excel. It contains placeholder text like ${e.Name} in some cells and special instructions in cell comments. It’s the design.
This is the slow, friendly walkthrough. It assumes you’ve never used a template engine before, only know basic Go, and want to understand XLFill — not just copy/paste an example. If you already know the concepts and just want a 5-minute install-and-run, jump to Getting Started.
XLFill takes an .xlsx file that you designed in Excel (fonts, borders, colors, number formats), reads a few special markers from cell comments, then writes a new .xlsx file with your data filled in. The output looks exactly like your template — same fonts, same widths, same chart styling — but where the template said ${employee.Name} the output says Alice. That’s the whole product.
Think of it as the same idea as a Word mail merge, but for spreadsheets and with a lot more power (loops, conditionals, charts, tables, etc.).
Before you write a single line of code, fix four words in your head:
Template
The .xlsx file you design in Excel. It contains placeholder text like ${e.Name} in some cells and special instructions in cell comments. It’s the design.
Data
A map[string]any you pass from Go. Keys are the variable names your template references; values are the actual content.
Expression
The text inside ${...} in a cell value. XLFill evaluates it (using a small expression language) and writes the result into the output cell.
Command
A jx:something(...) instruction in a cell comment (not the cell value). Commands do bigger things than expressions — looping, conditionals, charts, tables.
That’s it. Everything else is just specific commands and helper functions.
go mod init my-reportgo get github.com/javajack/xlfillRequires Go 1.24 or newer. You also need any spreadsheet editor (Excel, LibreOffice Calc, Google Sheets, or OnlyOffice).
Open your spreadsheet editor. Make a new file and design it like any report you’d build by hand:
| A | B | C | |
|---|---|---|---|
| 1 | Employee Report — ${reportDate} | ||
| 2 | Name | Department | Salary |
| 3 | ${e.Name} | ${e.Department} | ${e.Salary} |
Format it however you like. Bold row 2. Add a border. Set column widths. Pick a font.
The text inside ${...} is a placeholder. We’ll tell XLFill what to substitute for it.
Now add the markers. Right-click cell A1 → Insert Comment. Type:
jx:area(lastCell="C3")This says “the template region is A1 through C3.” Anything outside that rectangle is left alone in the output.
Right-click cell A3 → Insert Comment. Type:
jx:each(items="employees" var="e" lastCell="C3")This says “starting at A3, repeat the row C3 (the single data row) once per employee. Inside the loop, refer to the current employee as e.”
Save as template.xlsx.
package main
import ( "log" "github.com/javajack/xlfill")
func main() { data := map[string]any{ "reportDate": "2026-05-21", "employees": []map[string]any{ {"Name": "Alice", "Department": "Engineering", "Salary": 95000}, {"Name": "Bob", "Department": "Marketing", "Salary": 72000}, {"Name": "Carol", "Department": "Engineering", "Salary": 88000}, }, } if err := xlfill.Fill("template.xlsx", "report.xlsx", data); err != nil { log.Fatal(err) } log.Println("Wrote report.xlsx")}Run it:
go run main.goOpen report.xlsx in any spreadsheet program. You should see three employee rows with all your formatting preserved. Done.
data map whose keys (reportDate, employees) match the names your template references.xlfill.Fill opened your template.jx:area comment on A1, and knew rows 1-3 / columns A-C were the live region.${reportDate} in cell A1’s value → wrote “Employee Report — 2026-05-21”.jx:each on A3, looked up employees in your data, and looped: for each element, set e to that element and re-rendered row 3.${e.Name} and ${e.Salary} resolved against the current iteration’s e.Now that you have a working first report, here’s the order I’d learn the rest:
Anything inside ${...} is an expression evaluated by expr-lang. You’re not limited to field access:
${e.Salary * 1.1} // arithmetic${e.Age >= 18 ? "adult" : "minor"} // ternary${e.Skills[0]} // indexing${upper(e.Name)} // function calls${"Hello, " + e.Name + "!"} // string concatenationSee the Expressions guide for the full grammar.
There are 16 ready-to-use functions inside ${...}: upper, lower, title, join, formatNumber, formatDate, coalesce, ifEmpty, sumBy, avgBy, countBy, minBy, maxBy, t (i18n), hyperlink, comment.
${formatNumber(e.Salary, 2)} → "95,000.00"${sumBy(employees, "Salary")} → 255000${coalesce(e.Nickname, e.Name)} → nickname if set, else nameFull reference: Built-in Functions.
You already know jx:each. There are three siblings worth learning early:
jx:if — conditionally include/exclude an areajx:repeat — repeat N times without needing a collection (e.g., blank invoice lines)jx:grid — render a 2D matrix from a slice of slicesThese work on the output rather than placing data:
jx:freezePanes — pin headers while users scrolljx:autoRowHeight / jx:autoColWidth — adapt to data lengthjx:mergeCells — span headers across columnsjx:pageBreak — section per page for printingFor real Excel features:
jx:table — structured Excel table with auto-filterjx:chart — embedded chart driven by your data rangejx:sparkline — in-cell mini chart per rowjx:conditionalFormat — data bars, color scales, icon setsjx:dataValidation — dropdowns, integer ranges, etc.jx:definedName — named range that resizes with your datajx:group — collapsible outline groupsjx:protect — sheet protection with passwordLet’s go from the toy report above to something more realistic. Same Go program, but the template now adds:
Template additions:
| Cell | Value | Comment |
|---|---|---|
| A4 | ”Total” | |
| C4 | =SUM(C3:C3) | (no comment — XLFill auto-expands this) |
| C3 | ${e.Salary} | jx:conditionalFormat(type="dataBar" color="#638EC6" lastCell="C3") |
| F1 | jx:chart(type="bar" title="Salaries by Department" catRange="A3:A3" valRange="C3:C3" lastCell="K10") |
When XLFill processes 3 employees, the formula =SUM(C3:C3) automatically becomes =SUM(C3:C5) because the range was inside the iteration template. The chart’s catRange and valRange get similarly expanded. The conditional format covers the expanded range.
That’s the entire payoff: you write the template once at “1 row”, and XLFill adjusts everything when rows multiply.
Every command has a lastCell attribute. It’s the bottom-right corner of the rectangle this command applies to, measured in the template (not the output).
jx:area, it’s the bottom-right of the live template region.jx:each, it’s the bottom-right of the one-iteration template — usually a single row.jx:chart, it’s the bottom-right of the chart anchor area.If you’ve ever wondered “why is my chart tiny” — it’s because you set lastCell to a small range. Make it bigger.
Commands nest automatically based on cell position. If jx:each on A3 has lastCell="C5" (covering rows 3-5), and you put another jx:each on A4 with lastCell="C4" (just row 4), the inner each is detected as a child of the outer each. You don’t write anything to declare the nesting.
This is how you do “departments → employees” reports.
For collections (used with jx:each, jx:grid, etc.):
[]map[string]any — most flexible, what you saw above[]any where each element is a struct (XLFill uses reflection)[]SomeStruct — fields are accessed by nameFor scalars: any Go value (int, float, string, time.Time, bool, etc.). XLFill picks the right Excel cell type automatically.
By default (WithClearTemplateCells(true)), any cell that still contains an unfilled ${...} after processing — e.g., because your jx:each iterated zero times — is blanked. The cell formatting is kept; only the value is cleared.
If you set WithClearTemplateCells(false), those cells keep their template text. Useful when debugging.
Cause: no jx:area comment, or it’s on the wrong cell. Every template needs exactly one jx:area on its top-left cell.
${e.Name} in the output”Cause: there’s no surrounding jx:each defining e, or e doesn’t have a Name field. Try the Describe helper to see what XLFill parsed.
Cause: catRange and valRange are template-relative (the one-iteration positions). When the loop expands, XLFill stretches them automatically. If you wrote the expanded range yourself, that confuses the engine.
Cause: the formula references the unexpanded template range. Put the formula in a cell that’s inside the same area as your jx:each, and write the template range (=SUM(C3:C3) for a one-row template). XLFill will expand it.
Cause: you have a data key named e and a jx:each with var="e". The loop variable wins inside the loop, but anywhere outside it sees your data key. Just use distinct names.
Browse all 21 commands
The 16 built-in functions
Debug templates without filling
When things go wrong