DEV Community

Yongho Hwang
Yongho Hwang

Posted on

Excel report generation in Java and Kotlin: Apache POI vs JXLS vs TBEG

When a single line - "Excel download" - lands in a spec, the work that reaches the developer is far larger than that line suggests.

If dumping data into a grid were all that was needed, CSV would be enough. Real requirements rarely stop there: a header with the company logo, merged title cells, per-category background colors, a formula in the totals row, a chart at the bottom. And the layout has usually already been drawn in Excel by a planner or designer and handed over as a file.

This post compares three ways of meeting that requirement on the JVM, along with the conditions under which each one fits and the conditions under which it does not.

Disclosure: I am the developer of TBEG, one of the three options compared here. The feature comparison is based on running JXLS 3.1.0 directly and inspecting its output, and the performance figures were measured on the same machine under the same workload. The raw measurements and reproduction conditions are linked at the end.

Two Approaches

Generating by code, and binding data into a template.

The first builds cells one at a time with Apache POI and assigns styles in code. The second takes a finished Excel file as a template and binds data into the positions marked inside it. Among the template-based options, JXLS is the oldest and TBEG is a recent arrival.

Apache POI, Written Directly

The most fundamental approach. The other two libraries both use POI internally.

Workbook wb = new XSSFWorkbook();
Sheet sheet = wb.createSheet("Employees");

CellStyle header = wb.createCellStyle();
Font bold = wb.createFont();
bold.setBold(true);
header.setFont(bold);
header.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
header.setFillPattern(FillPatternType.SOLID_FOREGROUND);
header.setBorderBottom(BorderStyle.THIN);

Row r0 = sheet.createRow(0);
String[] titles = {"Department", "Name", "Position"};
for (int i = 0; i < titles.length; i++) {
    Cell c = r0.createCell(i);
    c.setCellValue(titles[i]);
    c.setCellStyle(header);
}
Enter fullscreen mode Exit fullscreen mode

Six lines go into setting a single background color on the header. Add merging, borders, number formats, column widths, images, and charts, and the code grows to hundreds of lines. And when the designer changes a color, the code has to change.

There are conditions where POI is the right answer: the layout is simple and unlikely to change, the data matters more than the formatting, or dependencies must be kept to a minimum. Writing thirty lines instead of adding another dependency is often the better trade.

Large volumes are handled with SXSSFWorkbook. It keeps a fixed number of rows in memory and flushes the rest to disk, so hundreds of thousands of rows are fine. Rows already written, however, can no longer be modified.

JXLS, Commands in Cell Comments

JXLS is the oldest and most widely used library in this space. Currently 3.1.0, Apache 2.0 licensed.

Expressions such as ${e.name} go into cell values. Commands such as repetition go into Excel cell comments (notes).

Cell A1 value:   ${e.department}
Cell A1 comment: jx:each(items="employees" var="e" lastCell="C2")
Enter fullscreen mode Exit fullscreen mode

The call looks like this.

Map<String, Object> data = new HashMap<>();
data.put("employees", employees);

JxlsPoiTemplateFillerBuilder.newInstance()
    .withTemplate("template.xlsx")
    .build()
    .fill(data, new File("report.xlsx"));
Enter fullscreen mode Exit fullscreen mode

JXLS has two strengths.

The first is maturity. It has been in use for over a decade, and usage examples and troubleshooting material have accumulated. When something breaks, there is prior art to consult. On a large team, or on a project where long-term maintenance matters, that is worth a great deal.

The second is the expressive range of its data-transformation syntax. jx:if shows or hides a region based on a condition, groupBy repeats data in groups, jx:grid builds a table at runtime that was not in the template, jx:each can generate entire sheets, and nested master-detail repetition is available declaratively. On the formula side there is also jointed reference (U_()), which combines separated ranges, and parameterized formulas ($[...]), which inject context variables.

The weakness is that commands are invisible. A cell comment only appears when you hover over it. Opening the template does not tell you at a glance which commands are attached where, and when a lastCell range is off, tracing the cause takes time. The developer has to write comments into a file the designer produced - and if the designer later edits that file again, the comments sometimes disappear.

TBEG, Markers in Cell Text

TBEG is a recent library written in Kotlin. Currently 1.2.5, published to Maven Central as io.github.jogakdal:tbeg.

The approach differs: commands go directly into cell text rather than into comments.

Cell A3: ${emp.department}
Cell B3: ${emp.name}
Cell C3: ${emp.position}
Cell A5: ${repeat(employees, A3:C3, emp)}
Enter fullscreen mode Exit fullscreen mode
ExcelGenerator().use { generator ->
    val bytes = generator.generate(template, data)
}
Enter fullscreen mode Exit fullscreen mode

In Spring Boot, ExcelGenerator is registered as a bean automatically.

The strength is template readability. Open the file and you see what goes where. It is easy to tell a designer "please write this in that cell," and because the markers are plain text, editing the file again does not remove them. Other features include image insertion (${image(...)}), automatic cell merging during repetition (${merge(...)}), conditional field hiding, and automatic range adjustment for charts and pivot tables.

The weakness is plain: it is a new library. Almost no production usage has been published, and searching for a problem turns up little. On a project where long-term maintenance comes first, that should weigh heavily. On a new project where reducing template maintenance cost matters, it is worth evaluating.

Where the Three Diverge - The Location of Commands

The difference among the three comes down to one thing.

Where formatting is defined Where repetition and conditions are defined
Apache POI Code Code
JXLS Template file Cell comments in the template file
TBEG Template file Cell text in the template file

It looks minor, but it matters in operation. On a project where the layout changes often, whether the structure is legible the moment you open the template is what determines maintenance cost.

Feature Comparison - Shared Support and Divergent Items

Running both libraries against the same template makes the supported and unsupported items clear.

Start with what both do, which removes most of the confusion. Vertical and horizontal repeat expansion; layout preservation including column widths and row heights; shifting elements below by the expansion amount; automatic formula range adjustment (=SUM(C3) becoming =SUM(C3:C5)); variable injection into formulas; adjustment of formulas referencing another sheet; conditional formatting and data validation range expansion; image insertion; merged cells; SXSSF streaming. All of that works on both sides.

The divergent items are these. Every judgment below was verified by running the libraries.

Feature JXLS 3.1.0 TBEG 1.2.5
Declarative conditional branching (jx:if) Supported Filter the data in code instead
Group repeat (groupBy) Supported No declarative syntax
Nested (master-detail) repeat Supported Not supported
Dynamic grid / dynamic sheet generation Supported Not supported (by design)
Variable-field tables (hiding columns) Header row only, so columns misalign Header and totals removed together
Automatic merging of consecutive identical values Not supported Supported
Replacement content for an empty collection Not supported (rows are deleted) Supported
Automatic chart range adjustment Manual (dynamic named range) Automatic
Pivot tables Refresh on file open Cache regenerated
Forward-reference formulas during streaming Not supported Supported
Asynchronous generation None Coroutines and Future
Spring Boot auto-configuration None Available
Document metadata / open password None Available

The two aim at different things. JXLS is strong on data-transformation syntax; TBEG is strong on automatic adjustment of native Excel elements such as charts and pivot tables, and on large-scale processing and integration.

Dynamic grids and dynamic sheet generation are marked "by design" because they conflict with the template-based premise. If the goal is binding data into a fixed layout drawn by a designer, there is no need to create a table at runtime that the template does not contain. If that capability is in the requirements, JXLS is the right fit.

Large Data and Streaming

Once row counts pass tens of thousands, memory becomes the constraint. All three rely on Apache POI's SXSSF, but they apply it differently.

POI drives SXSSFWorkbook directly. It offers the widest control, but rows already written cannot be modified.

In JXLS, streaming is optional and off by default. The official documentation states the constraint explicitly: a formula in row 1 cannot reference data in row 2, because row 2 does not exist yet. Streaming is therefore usable only for a simple data list with no forward-reference formulas, and the documentation advises testing each template to see whether streaming is possible. The common layout with a totals row beneath the data falls under this constraint.

TBEG always streams, and performs formula adjustment in a step after rendering. Because final positions are computed before cells are written, formulas that reference rows below - rows not yet written - still resolve correctly. Without a pivot table, it processes up to one million rows.

This is a difference in structure rather than in speed. For a large template that contains formulas, there are cases where streaming cannot be enabled in JXLS at all, and the full in-memory mode becomes the only option.

Performance Measured Under Identical Conditions

A performance comparison means nothing unless the conditions match. Numbers taken on different hardware, different JDKs, and different data shapes are not comparable. Only results measured on the same machine, the same POI version, the same template, and the same workload are given here.

The conditions: Apple Silicon 12-core, OpenJDK 21, 8GB max heap, Apache POI 5.5.1, JMH (fork 1 / warmup 1 / measurement 3), with a 3-column repeat plus a SUM formula as the workload. Since TBEG always streams, the direct comparison is JXLS STREAMING_ON, which shares the same output condition; STREAMING_OFF, the full in-memory mode, is included for reference. All figures below are measured.

Data Configuration Duration Heap allocation
10,000 rows TBEG 47ms 31MB
10,000 rows JXLS STREAMING_ON 78ms 67MB
10,000 rows (ref.) JXLS STREAMING_OFF 393ms 457MB
50,000 rows TBEG 187ms 134MB
50,000 rows JXLS STREAMING_ON 378ms 328MB
50,000 rows (ref.) JXLS STREAMING_OFF 1,306ms 2,244MB
100,000 rows TBEG 354ms 256MB
100,000 rows JXLS STREAMING_ON 728ms 641MB
100,000 rows (ref.) JXLS STREAMING_OFF 2,626ms 4,490MB

Under the same output condition, TBEG is roughly 2x faster. One point in that table is easy to misread, so it is worth spelling out.

Heap allocation is not memory footprint. The heap allocation column is the total volume of temporary objects created and discarded during processing. Peak heap - how much is in use at any single moment - is 353MB for TBEG and 383MB for JXLS STREAMING_ON at 100,000 rows, which is essentially the same. Both stream, so the peak stays bounded as data grows. JXLS is not holding more memory; it creates more temporary objects, so GC runs more often.

The allocation profile shows where the difference comes from. Most of the temporary objects in JXLS originate in the per-cell expression evaluation engine (Apache Commons JEXL) and its own cell-reference objects. That is the cost of building on a general-purpose expression engine. TBEG parses once with a dedicated marker parser and writes straight to POI, so those intermediate objects barely appear. In exchange, that same engine is what gives JXLS the expressive range seen earlier - jx:if, groupBy, and the rest. The slower path is not slower for nothing; it is traded for expressiveness.

Where peak heap does become a real constraint is STREAMING_OFF. It loads all data into the workbook, so the peak climbs to 1,621MB at 100,000 rows. As noted in the previous section, templates containing formulas can force that mode, which makes it the first thing to check for large volumes.

One caveat belongs here. These are our own measurements for a single workload. Column count, formula complexity, and the presence of charts or pivot tables all change the result. If performance is the deciding factor, measuring with your own template and data is the reliable answer.

Selection Criteria

Condition Choice
Layout is simple and unlikely to change Apache POI
Dependencies must be kept to a minimum Apache POI
Hundreds of thousands of rows, minimal formatting Apache POI (SXSSF) or CSV
Maturity, documentation, and prior art matter JXLS
Conditional branching, grouping, or nested repeats needed JXLS
Tables or sheets generated at runtime, not in the template JXLS
Already running fine on JXLS Keep it
Designer-authored layouts updated frequently TBEG
Layouts containing charts or pivot tables TBEG
Tables whose visible columns depend on permissions TBEG
Large volumes with formulas, streamed TBEG
Asynchronous generation or Spring Boot integration TBEG

There is no reason to replace an implementation that runs fine. If a report built on JXLS is operating without trouble, leaving it alone is the right call. The comparison above is for new work, or for when the current approach has started to hurt.

References


Want to see TBEG in practice? The 6-part TBEG series walks through it from your first template-bound report to production troubleshooting.

⭐ Found TBEG useful? Star it on GitHub - it really helps the project grow.

Top comments (0)