Templates & Modifying Workbooks

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Baseline date: 2026-10-09. This page was written against Apache POI 5.5.1 (the latest release on that date; 6.0 is announced but has not been released, so everything about 6.0 below is expected, not shipped), Java 21 and later, Spring Boot 4.x on Spring Framework 7 and Jakarta EE 11. Every example in this page was compiled and run against POI 5.5.1; the observations marked as such come from those runs, not from the documentation.

The earlier pages of this section build workbooks cell by cell: styles, formats, validations, charts and print setup are all created in code. That is the right choice for a generic export, but it is a poor fit for a report that an analyst or a designer wants to restyle every month. The alternative is to let a person design the layout in Excel once, save it as a template, and let the code do only what code is good at: put the data in. This page explains how to open such a template, fill it, reshape it (add rows, copy or remove sheets), keep its macros, and write the result, using the Bookshelf scenario of the section landing page. It builds on Spreadsheet Usermodel Basics, reuses the style rules of Cell Styles, Fonts & Formats, and relies on Tables, Named Ranges & Pivot Tables for the objects it fills. Word templates are a separate topic, covered in the Word pages of this section.

Why start from a template

A template is an ordinary workbook that already contains everything that is not data. The division of labour is the whole point:

  • The designer owns the look. Fonts, colours, column widths, number formats, the table style, frozen panes, the print area and page setup, conditional formatting, data-validation lists, charts and the company logo all live in the file. A change of brand colour is a new template, not a code change and a release.

  • The code owns the data. It finds the places to fill, writes values and formulas, grows tables and writes the file.

  • The contract between them is a handful of names. Defined names (ReportTitle, GeneratedOn), a table name (Books) and a prototype row. If the designer keeps those, the code keeps working whatever else changes.

The figure shows the life of one report:

flowchart LR T["Template .xlsx
(designed in Excel, kept in the repository)"] --> L["Load
bytes to a private XSSFWorkbook"] D[("Bookshelf data
books, loans")] --> F L --> F["Fill
named cells, table rows,
placeholders, shiftRows"] F --> R["Reshape (optional)
cloneSheet, removeSheetAt"] R --> C["Recalculate flag
setForceFormulaRecalculation"] C --> W["write(OutputStream)"] W --> X["close()
the workbook"] X --> O["books-inventory.xlsx
HTTP response or file"]

The cost of the approach is that a template is code in disguise: it can be edited by someone who does not run the tests. Treat it like a source file: keep it in version control next to the code, write a test that opens it and checks that every name and table the code needs is present, and fail with a clear message when one is missing (the examples below do).

What POI offers for templates, and what it does not

POI has no template engine. There is no fill(template, model) call, no ${...} language, no loop construct and no mail-merge. What it gives you are the primitives that a hand-written filler is made of:

Task POI API Where it is covered

Open a workbook that already exists

new XSSFWorkbook(InputStream) or (File), WorkbookFactory.create(...)

Opening a template safely

Find the cell to fill

Workbook.getNames(String), Name.getRefersToFormula(), CellReference, AreaReference

Filling named cells

Fill a table and make it grow

XSSFWorkbook.getTable(String), XSSFTable.setCellReferences(AreaReference)

Filling a table

Replace text markers

Your own loop over cells; POI offers no search-and-replace

Replacing placeholders in cells

Make room, or repeat a block

Sheet.shiftRows(...), XSSFSheet.copyRows(...), CellCopyPolicy

Moving and copying rows

Produce one sheet per branch, member or month

Workbook.cloneSheet(int), XSSFWorkbook.cloneSheet(int, String), setSheetName, removeSheetAt

Cloning and removing sheets

Keep or inspect VBA macros

XSSFWorkbook.isMacroEnabled(), XSSFWorkbookType, VBAMacroReader, VBAMacroExtractor

Macro-enabled templates (.xlsm)

Stream a large report on top of a template

new SXSSFWorkbook(XSSFWorkbook, int)

Streaming on top of a template: SXSSFWorkbook(XSSFWorkbook)

Opening a template safely

Where the template lives

Keep the template next to the code (src/main/resources/templates/) or, when the business edits it, in object storage or a configuration directory. A Spring Boot fat jar cannot give you a File for a classpath resource, so the template is read as a stream in the common case. That is also the safe case, as the next subsection shows.

The File constructor can overwrite your template

The official guide recommends a File over an InputStream because it needs less memory. For a read-only job that is right. For a template it is a trap. Observed with POI 5.5.1:

How the workbook is opened What happens after you change it and call write(otherStream) then close() Verdict for templates

new XSSFWorkbook(File) or WorkbookFactory.create(File)

The package is opened read-write on the original file, and close() saves the changes into it. The template on disk was changed in the test even though the report was written to a different stream. The close() Javadoc says the same: for writable packages it saves.

Dangerous: use only on a private copy

WorkbookFactory.create(File, null, true) (read-only)

Opening works, but write(...) throws InvalidOperationException ("document open in read only mode"). The same happens with OPCPackage.open(file, PackageAccess.READ).

Cannot be filled and written

new XSSFWorkbook(InputStream)

The whole file is buffered in memory, nothing keeps a link to the original file, close() does not touch it.

Safe; costs memory proportional to the template

new XSSFWorkbook(File) on a copy made with Files.copy into a temporary file

Changes go to the copy.

Safe and lighter on memory, at the price of a file copy and cleaning up the copy

Source: POI Quick Guide, Files vs InputStreams, XSSFWorkbook Javadoc and WorkbookFactory Javadoc. The behaviour table is the result of running these cases against POI 5.5.1.

For a service that produces reports all day, read the template bytes once and open a fresh workbook from them for every report. A workbook is not thread safe and must never be shared between requests, but the immutable byte[] can be:

package com.bookshelf.office;

import java.io.ByteArrayInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

/** Reads the template once and hands out a fresh, private workbook for every report. */
public final class InventoryTemplate {

    private static final String RESOURCE = "/templates/books-inventory-template.xlsx";

    private final byte[] bytes;

    public InventoryTemplate() throws IOException {
        try (InputStream in = InventoryTemplate.class.getResourceAsStream(RESOURCE)) {
            if (in == null) {
                throw new FileNotFoundException("Missing template " + RESOURCE);
            }
            this.bytes = in.readAllBytes();
        }
    }

    /** The caller owns the returned workbook and must close it. */
    public XSSFWorkbook open() throws IOException {
        return new XSSFWorkbook(new ByteArrayInputStream(bytes));
    }
}

Opening from an InputStream here is a deliberate exception to the "prefer a File`" advice of the rest of this section: the `File constructor would write the changes back into the template on close(), and a read-only package cannot be written at all (see the table above). If memory matters more than a file copy, copy the template to a temporary file and open that File instead.

The template is then about as large in memory as the file, per concurrent report. For a template of a few hundred kilobytes that is irrelevant; for a template that carries a large embedded image, multiply it by the number of simultaneous exports when sizing the heap.

Template file types

  • Use an ordinary .xlsx (or .xlsm, see Macro-enabled templates (.xlsm)) as the template. Excel also saves templates as .xltx (and .xltm), which differ only by the main part’s content type. In a quick test, POI 5.5.1 opened a package re-labelled with the .xltx content type without complaint, but the POI documentation does not promise it, and the written file keeps the template content type, so Excel would offer to create a new workbook from it instead of opening it. Keep the template as .xlsx unless you have a reason not to.

  • A legacy .xls template works the same way through the ss.usermodel interfaces (Legacy .xls with HSSF), with the limits of that format.

  • An encrypted template needs the password, WorkbookFactory.create(InputStream, String); the template should not be encrypted in the first place, since it contains no data.

A template for the Bookshelf inventory

The examples that follow use books-inventory-template.xlsx. In real life a person builds it in Excel; the helper below builds the same file so that the page is reproducible. It has a title cell and a date cell with defined names, a table named Books made of a header row and one prototype data row that carries the column styles, and a footer two rows below the table.

package com.bookshelf.office;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.poi.ss.SpreadsheetVersion;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormat;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Name;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.util.AreaReference;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFTable;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

/** One-off helper: in real life the designer builds this file in Excel. */
public final class BuildInventoryTemplate {

    public static void main(String[] args) throws IOException {
        Path target = Path.of(args.length > 0 ? args[0] : "books-inventory-template.xlsx");
        try (XSSFWorkbook wb = new XSSFWorkbook(); OutputStream out = Files.newOutputStream(target)) {
            XSSFSheet sheet = wb.createSheet("Inventory");
            DataFormat formats = wb.createDataFormat();

            Font big = wb.createFont();
            big.setBold(true);
            big.setFontHeightInPoints((short) 16);
            CellStyle titleStyle = wb.createCellStyle();
            titleStyle.setFont(big);
            CellStyle dateStyle = wb.createCellStyle();
            dateStyle.setDataFormat(formats.getFormat("yyyy-mm-dd"));
            CellStyle moneyStyle = wb.createCellStyle();
            moneyStyle.setDataFormat(formats.getFormat("#,##0.00"));

            // Named cells that the code will fill
            sheet.createRow(0).createCell(1).setCellStyle(titleStyle);
            sheet.createRow(1).createCell(1).setCellStyle(dateStyle);
            defineName(wb, "ReportTitle", "Inventory!$B$1");
            defineName(wb, "GeneratedOn", "Inventory!$B$2");

            // Header row (index 3) and one prototype data row (index 4) that carries the styles
            String[] headers = {"Title", "Author", "Year", "Price", "Stock"};
            Row header = sheet.createRow(3);
            Row prototype = sheet.createRow(4);
            for (int c = 0; c < headers.length; c++) {
                header.createCell(c).setCellValue(headers[c]);
                prototype.createCell(c).setCellStyle(c == 3 ? moneyStyle : wb.createCellStyle());
            }
            XSSFTable table = sheet.createTable(new AreaReference("A4:E5", SpreadsheetVersion.EXCEL2007));
            table.setName("Books");
            table.setDisplayName("Books");
            table.getCTTable().addNewTableStyleInfo().setName("TableStyleMedium2");

            // Footer, two rows under the table: it must move down when the table grows
            sheet.createRow(6).createCell(0).setCellValue("Total stock");
            sheet.getRow(6).createCell(4).setCellFormula("SUM(E5:E5)");
            sheet.createRow(7).createCell(0).setCellValue("Prices are in EUR");
            wb.write(out);
        }
    }

    private static void defineName(XSSFWorkbook wb, String name, String reference) {
        Name n = wb.createName();
        n.setNameName(name);
        n.setRefersToFormula(reference);
    }
}

Filling named cells

A defined name is the most robust address a template can offer. Cell references (B2) break when the designer inserts a row above them; a name moves with its cell. The lookup has two steps: ask the workbook for the name, then turn the formula it refers to into a sheet and a cell.

Call Notes

wb.getNames("ReportTitle")

Returns every name with that text: the same name can exist once per sheet scope and once for the workbook. Prefer it to getName(String), which returns only one of them.

name.getRefersToFormula()

The text of the reference, for example Inventory!$B$1. A name can also refer to a range (Inventory!$A$5:$E$20), a constant or a formula; check before you parse.

name.getSheetName(), name.getSheetIndex()

The sheet name of a sheet-scoped name; getSheetIndex() is -1 for a workbook-scoped one.

new CellReference(String)

Parses Inventory!$B$1 into getSheetName(), getRow() and getCol(). It is meant for a single cell; use AreaReference for a range.

new AreaReference(String, SpreadsheetVersion)

Parses a range such as Inventory!$A$5:$E$20; getFirstCell() and getLastCell() give the corners.

The helper below does the lookup, fails with a message that names the missing name, and creates the row or cell when the template left it out (a styled but empty cell may not exist in the file at all):

    /** Resolves a workbook-scoped name that points at one cell and returns that cell. */
    private static Cell setNamedCell(XSSFWorkbook wb, String name) {
        List<? extends Name> names = wb.getNames(name);
        if (names.isEmpty()) {
            throw new IllegalStateException("The template defines no name '" + name + "'");
        }
        CellReference ref = new CellReference(names.get(0).getRefersToFormula());
        Sheet sheet = wb.getSheet(ref.getSheetName());
        Row row = sheet.getRow(ref.getRow());
        if (row == null) {
            row = sheet.createRow(ref.getRow());
        }
        return row.getCell(ref.getCol(), Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
    }

Setting the value keeps the cell style that the designer applied (the 16 point bold title, the yyyy-mm-dd date format): setCellValue changes the content, never the style. Writing a LocalDate with setCellValue(LocalDate) stores a date serial number and relies on that format to display it; if the template cell is a plain General cell the report shows a number such as 46304.

Filling a table

An Excel table (XSSFTable) is a named range with a header row, banded styling and, optionally, an auto-filter and a totals row. It is the best anchor for data that grows: a designer can point a chart, a pivot table or a formula at Books[Price] and the reference follows the data. The catch is that the table is a separate object in the file. Writing cells below the last row does not enlarge it; you must update its reference yourself.

Call Notes

wb.getTable("Books")

Finds a table by name across all sheets; null if absent.

table.getXSSFSheet(), table.getSheetName()

The sheet that holds it.

table.getStartRowIndex(), getEndRowIndex(), getStartColIndex(), getEndColIndex()

The current area, including the header row (and the totals row, if there is one).

table.getArea()

The same as an AreaReference.

table.setCellReferences(AreaReference) (alias setArea)

Changes the area. This is how a table grows or shrinks. Verified in the tests: the ref attribute of the table part is rewritten.

table.getRowCount(), table.updateReferences()

Number of rows from the table definition, and a refresh of the internal cell-reference cache after you change the area.

The recipe has four steps, and the example below follows them in order:

  1. Prototype row. The template contains one data row (A5:E5 here) whose cells hold the number formats and styles. Read the style from it; do not hard-code styles in the filler.

  2. Make room. If rows below the table must stay below it (a footer, a signature block), move them down with shiftRows before writing the data, as explained in Moving and copying rows.

  3. Write the data, row by row, applying the prototype’s cell style to each new cell.

  4. Resize the table and rewrite any formula that referred to the old data range.

The fillers on this page take the rows as a small Book record:

package com.bookshelf.office;

public record Book(String title, String author, int year, double price, int stock) {
}
package com.bookshelf.office;

import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDate;
import java.util.List;
import org.apache.poi.ss.SpreadsheetVersion;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Name;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.util.AreaReference;
import org.apache.poi.ss.util.CellReference;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFTable;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

public final class InventoryFiller {

    private final InventoryTemplate template;

    public InventoryFiller(InventoryTemplate template) {
        this.template = template;
    }

    public void write(String title, List<Book> books, OutputStream out) throws IOException {
        try (XSSFWorkbook wb = template.open()) {
            setNamedCell(wb, "ReportTitle").setCellValue(title);
            setNamedCell(wb, "GeneratedOn").setCellValue(LocalDate.now());

            XSSFTable table = wb.getTable("Books");
            XSSFSheet sheet = table.getXSSFSheet();
            int firstData = table.getStartRowIndex() + 1;
            int lastTemplateData = table.getEndRowIndex();
            int footerStart = lastTemplateData + 2;            // the template leaves one blank row
            int extraRows = Math.max(books.size(), 1) - (lastTemplateData - firstData + 1);

            // 1. make room: move the footer (and everything below it) down
            if (extraRows > 0) {
                sheet.shiftRows(footerStart, sheet.getLastRowNum(), extraRows);
            }

            // 2. write the data, reusing the styles of the prototype row
            Row prototype = sheet.getRow(firstData);
            int rowIndex = firstData;
            for (Book b : books) {
                Row row = sheet.getRow(rowIndex);
                if (row == null) {
                    row = sheet.createRow(rowIndex);
                }
                put(row, prototype, 0).setCellValue(b.title());
                put(row, prototype, 1).setCellValue(b.author());
                put(row, prototype, 2).setCellValue(b.year());
                put(row, prototype, 3).setCellValue(b.price());
                put(row, prototype, 4).setCellValue(b.stock());
                rowIndex++;
            }
            int lastData = Math.max(rowIndex - 1, firstData);

            // 3. resize the table: shiftRows and setCellValue never touch it
            table.setCellReferences(new AreaReference(
                    new CellReference(table.getStartRowIndex(), table.getStartColIndex()),
                    new CellReference(lastData, table.getEndColIndex()),
                    SpreadsheetVersion.EXCEL2007));

            // 4. rewrite the footer formula: its range still points at the template rows
            Cell total = sheet.getRow(lastData + 2).getCell(4);
            total.setCellFormula("SUM(E" + (firstData + 1) + ":E" + (lastData + 1) + ")");

            wb.setForceFormulaRecalculation(true);   // Excel recalculates when the file is opened
            wb.write(out);
        }
    }

    /** Resolves a workbook-scoped name that points at one cell and returns that cell. */
    private static Cell setNamedCell(XSSFWorkbook wb, String name) {
        List<? extends Name> names = wb.getNames(name);
        if (names.isEmpty()) {
            throw new IllegalStateException("The template defines no name '" + name + "'");
        }
        CellReference ref = new CellReference(names.get(0).getRefersToFormula());
        Sheet sheet = wb.getSheet(ref.getSheetName());
        Row row = sheet.getRow(ref.getRow());
        if (row == null) {
            row = sheet.createRow(ref.getRow());
        }
        return row.getCell(ref.getCol(), Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
    }

    private static Cell put(Row row, Row prototype, int column) {
        Cell cell = row.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK);
        cell.setCellStyle(prototype.getCell(column).getCellStyle());
        return cell;
    }
}

Calling it from a batch job or a test:

InventoryFiller filler = new InventoryFiller(new InventoryTemplate());
try (OutputStream out = Files.newOutputStream(Path.of("books-inventory.xlsx"))) {
    filler.write("Bookshelf inventory", books, out);
}

Run against four books, the output had the table at A4:E8, the footer pushed from rows 7-8 to rows 10-11 and its formula rewritten to SUM(E5:E8). Run against an empty list, the table stayed A4:E5 (a table needs at least one data row) and the footer did not move.

$ unzip -p books-inventory.xlsx xl/tables/table1.xml | grep -o 'ref="[^"]*"'
ref="A4:E8"

Rules for tables

  • Header text and table columns must agree. On write POI refreshes the column names from the header cells. Do not put formulas or duplicate texts in the header row: Excel may report a corrupt file and repair it on open.

  • Do not leave data outside the table when it should be inside. Cells written below the old area but not covered by the new reference are plain cells next to the table.

  • Totals rows. A table with a totals row has the totals row inside its area. If your template uses one, the data rows go between the header and the totals row, so the arithmetic of the example changes: shift from the totals row.

  • Structured references in formulas. A formula such as SUBTOTAL(109,Books[Stock]) is stored as text and Excel evaluates it; POI’s own FormulaEvaluator supports only part of the structured-reference syntax, so do not depend on evaluating it in Java (see Formulas & Evaluation).

  • A table in an HSSF .xls template does not exist: tables are an OOXML feature.

Replacing placeholders in cells

Some templates are not tables at all: a delivery note or a letter-like sheet with text such as Printed by ${user} on ${when}. POI has no search-and-replace, so you write the loop. The version below visits every text cell of every sheet, leaves formulas, numbers and blanks alone, keeps unknown markers visible (a typo in the template should be seen, not silently swallowed) and uses Matcher.quoteReplacement so that a value containing $ or a backslash is inserted literally.

package com.bookshelf.office;

import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;

/** Replaces ${key} markers inside text cells. Formula cells and non-text cells are left alone. */
public final class PlaceholderReplacer {

    private static final Pattern MARKER = Pattern.compile("\\$\\{([A-Za-z0-9_.]+)}");

    private PlaceholderReplacer() {
    }

    public static int replace(Workbook wb, Map<String, String> values) {
        int replaced = 0;
        for (Sheet sheet : wb) {
            for (Row row : sheet) {
                for (Cell cell : row) {
                    if (cell.getCellType() != CellType.STRING) {
                        continue;                       // formulas, numbers, blanks
                    }
                    String text = cell.getStringCellValue();
                    if (!text.contains("${")) {
                        continue;
                    }
                    Matcher m = MARKER.matcher(text);
                    StringBuilder out = new StringBuilder();
                    while (m.find()) {
                        String value = values.get(m.group(1));
                        // unknown keys stay visible instead of silently disappearing
                        m.appendReplacement(out, Matcher.quoteReplacement(value != null ? value : m.group()));
                        replaced++;
                    }
                    m.appendTail(out);
                    cell.setCellValue(out.toString());  // plain string: rich-text runs are lost
                }
            }
        }
        return replaced;
    }
}

Running it on a copy of the Bookshelf template with a footer cell Printed by ${user} on ${when} ${unknown} and the values user=Ada and when=today$1 replaced three markers and produced Printed by Ada on today$1 ${unknown}: the $1 survived because of quoteReplacement.

Things to know about placeholder replacement:

  • Rich text is flattened. A cell whose characters have different fonts is a rich-text string; getStringCellValue() returns the text without the formatting, and setCellValue(String) writes plain text in the cell style. If the designer formats part of a placeholder cell, work on the RichTextString runs instead, or avoid mixed formatting in cells that contain markers.

  • Headers and footers are not cells. Page headers and footers (&P, &D codes included) are reached with sheet.getHeader() and sheet.getFooter() and their getLeft(), getCenter(), getRight() setters; the loop above does not see them. See Sheet Layout & Printing.

  • Formulas that build text from other cells are better than markers when the value is a number or a date: the format stays under the designer’s control.

  • Do not put user-provided text in a marker position without thinking about formula injection. The replacement writes a string cell, which Excel never evaluates, so a value starting with an equals sign is shown as text. That is the safe behaviour; keep it by never calling setCellFormula with user data. The Security page of this section, in a later group, covers the export-side rules.

Moving and copying rows

shiftRows

Sheet.shiftRows(startRow, endRow, n) moves rows by n (negative moves up). It is the tool to make room for data that the template does not have rows for. A signature with two more flags exists, shiftRows(startRow, endRow, n, copyRowHeight, resetOriginalRowHeight); the three-argument call shifts the heights too.

What actually moves is the question that matters, because the answer differs between objects. The probe below was run on an XSSF sheet with a name, formulas, a merged region, a conditional-format range, a data validation, a hyperlink, a comment and a table, after shiftRows(5, 29, 3) on a sheet whose tracked cell was A11:

Object that referred to the shifted cell A11 Result in POI 5.5.1 Moved with the rows?

Defined name N = S!$A$11

S!$A$14

Yes

Formula in a shifted row (A11)

A14

Yes

Formula in a row that did not move (row 3, A11)

A14

Yes

Merged region A11:B11

A14:B14

Yes

Conditional formatting range A11:A13

A14:A16

Yes

Hyperlink on the cell

Now at row 14

Yes

Cell comment

Now at row 14, none at the old position

Yes

Data validation on A11

Still A11

No

Table A11:A13

Still A11:A13

No

Source: POI Quick Guide, Shift rows, Sheet.shiftRows Javadoc and the official example ShiftRows. The table is the result of running the probe against POI 5.5.1; it is a regression check for your own workbook, not a contract. Pictures and charts anchored in the shifted area were not part of the probe: check them in your own template and, if needed, move their anchors with the ClientAnchor API of Images, Charts, Comments & Hyperlinks.

Pitfalls:

  • Data validations and tables must be fixed by hand, as in the filler above (setCellReferences). For a validation, recreate it with the new CellRangeAddressList; see Data Validation & Conditional Formatting.

  • Shift before you write. Shifting after writing is fine for the cells, but a formula you wrote that points at the new data range is not rewritten for you, and the formulas you rewrite after the shift must use the final coordinates.

  • Shifting past the end is not an error; shifting rows over existing rows overwrites them silently.

  • Cost. shiftRows touches every cell in the range and every formula in the workbook that might refer to it; with tens of thousands of rows below the insertion point it is slow. Put variable-length blocks at the bottom of the sheet, or in their own sheet, and avoid shifting big ranges.

copyRows

The template can only contain so many example rows. When a block has to repeat (one block per loan, a subtotal line after each author), copy a prototype block with XSSFSheet.copyRows. It is declared on XSSFSheet only, not on the Sheet interface or on HSSFSheet, so cast (an .xls template has no copyRows). CellCopyPolicy says what is copied; its builder methods are cellValue, cellStyle, cellFormula, copyHyperlink, mergeHyperlink, rowHeight, condenseRows and mergedRegions.

package com.bookshelf.office;

import org.apache.poi.ss.usermodel.CellCopyPolicy;
import org.apache.poi.xssf.usermodel.XSSFSheet;

public final class InsertCopies {

    private InsertCopies() {
    }

    /** Inserts {@code count} copies of {@code prototypeRow} right under it, pushing the rest down. */
    public static void insertCopies(XSSFSheet sheet, int prototypeRow, int count) {
        int last = sheet.getLastRowNum();
        if (count <= 0) {
            return;
        }
        if (prototypeRow < last) {
            sheet.shiftRows(prototypeRow + 1, last, count);
        }
        CellCopyPolicy policy = new CellCopyPolicy.Builder()
                .cellValue(false)          // keep only style (and formulas below)
                .cellStyle(true)
                .cellFormula(true)
                .rowHeight(true)
                .mergedRegions(false)
                .build();
        for (int i = 1; i <= count; i++) {
            sheet.copyRows(prototypeRow, prototypeRow, prototypeRow + i, policy);
        }
    }
}

Source: XSSFSheet Javadoc (copyRows) and CellCopyPolicy Javadoc.

On the Bookshelf template, insertCopies(sheet, 4, 3) took the last row from 7 to 10 and gave the three new rows the prototype’s styles. copyRows copies rows inside one sheet; to copy between sheets you copy cell by cell (value, style with cloneStyleFrom when the workbooks differ) or clone the whole sheet, as in the next section.

Cloning and removing sheets

When the output needs one sheet per branch, per member or per month, design one sheet in the template, clone it once for every group and delete the original.

Call Notes

wb.cloneSheet(int) and, on XSSFWorkbook, cloneSheet(int, String)

Deep copy of the sheet with an automatic name such as Inventory (2), or the name you give. In POI 5.5.1 the copy carried over cells, styles, the table (renamed, here to Table2, so the names stay unique) and the picture of a test sheet. Verify charts and comments in your own template before relying on them: this page did not test them.

wb.setSheetName(int, String)

A sheet name has at most 31 characters, may not contain \ / ? * [ ] :, may not start or end with a quote, and must be unique ignoring case. WorkbookUtil.createSafeSheetName(String) replaces forbidden characters with a space and trims.

wb.removeSheetAt(int)

Removes the sheet and, per its Javadoc, makes another sheet active and selected when the removed one was.

wb.setSheetVisibility(int, SheetVisibility.VERY_HIDDEN)

Hides a template sheet instead of deleting it; VERY_HIDDEN cannot be unhidden from the Excel user interface.

wb.setSheetOrder(String, int), wb.setActiveSheet(int)

Reorder, and choose the tab that is shown when the file is opened.

package com.bookshelf.office;

import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.poi.ss.usermodel.Name;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.SheetVisibility;
import org.apache.poi.ss.util.WorkbookUtil;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

public final class BranchSheets {

    private BranchSheets() {
    }

    /** One sheet per branch, cloned from the template sheet "Inventory", which is then removed. */
    public static void write(InventoryTemplate template, Map<String, List<Book>> booksByBranch,
                             OutputStream out) throws IOException {
        try (XSSFWorkbook wb = template.open()) {
            int templateIndex = wb.getSheetIndex("Inventory");

            for (Map.Entry<String, List<Book>> branch : booksByBranch.entrySet()) {
                // sheet names: at most 31 characters, none of  \ / ? * [ ] :
                String name = WorkbookUtil.createSafeSheetName(branch.getKey());
                Sheet copy = wb.cloneSheet(templateIndex);
                wb.setSheetName(wb.getSheetIndex(copy), name);
                // ... fill the copy here (cells, table rows) ...
            }

            // Names are NOT re-pointed or invalidated when a sheet disappears: delete the ones
            // that still refer to the template sheet before removing it.
            List<Name> stale = new ArrayList<>();
            for (Name n : wb.getAllNames()) {
                if ("Inventory".equals(n.getSheetName())
                        || n.getRefersToFormula().startsWith("Inventory!")) {
                    stale.add(n);
                }
            }
            stale.forEach(wb::removeName);

            wb.removeSheetAt(templateIndex);       // or: wb.setSheetVisibility(i, SheetVisibility.VERY_HIDDEN)
            wb.setActiveSheet(0);                   // the active tab may now point past the end
            wb.write(out);
        }
    }
}

Observed in the test: after removeSheetAt a workbook-scoped name that pointed at the removed sheet was not invalidated; it kept referring to Inventory!$B$1, a sheet that no longer existed, which Excel reports as an error on open. That is why the example removes such names first. A formula in another sheet that referred to the removed sheet has the same problem and must be rewritten.

Cloned sheets share the workbook’s style table, which is good: the styles are reused and the file does not grow with the number of sheets. They also share the workbook-scoped names, which therefore point at one sheet only. When every cloned sheet needs its own ReportTitle, create sheet-scoped names after cloning (createName(), setNameName, setSheetIndex, setRefersToFormula) or address the cells by position instead of by name.

Formulas and cached values after filling

A formula cell in an .xlsx stores its text and a cached result. POI does not recalculate when you change an input, so the cached values of the template’s formulas (the footer total, a share-of-total column) are the values from the last time someone saved the file in Excel. Two safe choices:

  • Ask Excel to recalculate with wb.setForceFormulaRecalculation(true), as the filler does. The file is flagged (fullCalcOnLoad="true" in workbook.xml, which the test confirmed) and Excel and LibreOffice recompute everything when the file is opened. This is the right default for a download the user opens in a spreadsheet application.

  • Evaluate in Java with a FormulaEvaluator when the cached value must be right without opening Excel (the file is read by another program, previewed in a browser, or imported by a tool that reads cached values). Call evaluator.evaluateAll() after you finish filling, understanding that not every Excel function is supported.

Both are described in Formulas & Evaluation. If you do neither, a viewer that does not recalculate shows the template’s old numbers next to the new data, which is the worst outcome because it looks plausible.

Charts and pivot tables built on the data are also cached: a chart keeps the cached points of the series it plots, and a pivot table keeps its cache. POI does not refresh either. A chart that points at a fixed range ($A$5:$A$5) keeps plotting that range after you add rows; point the chart at the table or at a dynamic defined name, or rebuild it with the XDDF API (Images, Charts, Comments & Hyperlinks). For a pivot table, the pivot cache definition can be marked to refresh when the file is opened; see Tables, Named Ranges & Pivot Tables.

Macro-enabled templates (.xlsm)

An .xlsm file is an .xlsx package that also contains a xl/vbaProject.bin part (an OLE2 file holding the VBA modules) and a different main content type. What POI does with it:

Need What POI supports

Open an .xlsm and write it back with the macros intact

Yes. XSSFWorkbook keeps parts it does not interpret. In a test with a stand-in vbaProject.bin part, POI 5.5.1 wrote it back byte for byte and kept the macro-enabled content type. isMacroEnabled() tells whether the workbook is an .xlsm.

Turn a plain .xlsx into an .xlsm

XSSFWorkbook.setWorkbookType(XSSFWorkbookType.XLSM) changes the content type, but it does not add any VBA: the result has no macros. There is no API to create a vbaProject.bin.

Read the macro source code

Yes, VBAMacroReader (readMacros() returns module name to source, readMacroModules() returns module objects). It accepts .xlsm, .xls, .docm and raw OLE2 files. VBAMacroExtractor is the command-line form that writes every module to a file.

Edit, run, sign or remove a macro

No. POI never executes VBA. Removing the macros means writing the data into a macro-free workbook.

Keep macros when the workbook goes through SXSSFWorkbook

Not documented and not tested here; plan to keep the macro-enabled report on the XSSF path.

The output file name must end in .xlsm, and the HTTP content type must be application/vnd.ms-excel.sheet.macroEnabled.12; an .xlsm package saved under an .xlsx name makes Excel refuse to open it.

package com.bookshelf.office;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.apache.poi.poifs.macros.VBAMacroReader;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

public final class MacroTemplate {

    public static void main(String[] args) throws IOException {
        byte[] template = Files.readAllBytes(Path.of("books-inventory-macros.xlsm"));

        // 1. Reading: list the VBA modules (POI only reads them, it cannot create or edit them)
        try (VBAMacroReader reader = new VBAMacroReader(new ByteArrayInputStream(template))) {
            Map<String, String> macros = reader.readMacros();
            macros.forEach((module, code) ->
                    System.out.println(module + ": " + code.lines().count() + " lines of VBA"));
        }

        // 2. Filling: the vbaProject.bin part travels through the open/write cycle untouched.
        //    Opened from bytes, not from the File, so close() cannot write into the template.
        try (InputStream in = new ByteArrayInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in);
             OutputStream out = Files.newOutputStream(Path.of("books-inventory-filled.xlsm"))) {
            if (!wb.isMacroEnabled()) {
                throw new IllegalStateException("Not a macro-enabled workbook");
            }
            wb.getSheetAt(0).getRow(0).createCell(1).setCellValue("Filled by POI");
            wb.write(out);                          // the target file name MUST end in .xlsm
        }
    }
}

Source: VBAMacroReader Javadoc, VBAMacroExtractor Javadoc and XSSFWorkbook.isMacroEnabled. This example compiles against POI 5.5.1; the macro-preservation claim above was checked with a stand-in part, and VBAMacroReader was not run against a real VBA project in this test.

Macros are also the reason to be careful with uploads: a macro-enabled workbook sent by a user is executable content. Reading its data with POI is safe, because POI never runs it, but storing and re-serving the file is not. The upload pages of this section (a later group) cover the allow-list and the content checks.

Streaming on top of a template: SXSSFWorkbook(XSSFWorkbook)

A template can also start a streaming export. The designer provides the title, the header row, the styles and the column widths; the code appends hundreds of thousands of rows through SXSSF without holding them in memory. The wrapper is new SXSSFWorkbook(XSSFWorkbook template) or new SXSSFWorkbook(XSSFWorkbook, int rowAccessWindowSize).

The restriction is the one the constructor’s Javadoc states, and it was reproduced on a template whose rows 0-7 were filled:

Operation after wrapping Result in POI 5.5.1

sheet.getRow(0) or sheet.getRow(4) on a template row

null. The template rows exist in the file but are not visible through SXSSF.

sheet.getLastRowNum()

-1. SXSSF does not know where the template ends; you must remember it from the XSSF sheet.

sheet.createRow(0) or createRow(5) (inside the template)

IllegalArgumentException ("Attempting to write a row[0] in the range [0,7] that is already written to disk"). The Javadoc says the API may silently allow overwriting and then produce a file that Excel cannot read; do not rely on the exception.

sheet.createRow(8) or any index after the template’s last row

Works. The output file contains the template rows followed by the new rows (verified by re-reading the file).

Styles, fonts and formats of the template

Reusable: read the CellStyle from the template before wrapping and apply it to the new cells.

Template tables, auto-filters, print areas and charts

Kept as they are but not resized: a table in the template still ends where the template said.

package com.bookshelf.office;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDate;
import java.util.Iterator;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

public final class LoansReportFromTemplate {

    public record Loan(long id, String member, String title, LocalDate loaned, LocalDate due) {
    }

    /**
     * The template holds the title, the header row (index 2) and a prototype body row (index 3)
     * that only carries styles. All of it is written as is; the loans start after it.
     */
    public static void write(byte[] template, Iterator<Loan> loans, OutputStream out) throws IOException {
        try (XSSFWorkbook xssf = new XSSFWorkbook(new ByteArrayInputStream(template))) {
            XSSFSheet templateSheet = xssf.getSheetAt(0);
            int firstFreeRow = templateSheet.getLastRowNum() + 1;
            // Read the styles NOW: once wrapped, SXSSF cannot give the template rows back
            CellStyle text = templateSheet.getRow(3).getCell(1).getCellStyle();
            CellStyle date = templateSheet.getRow(3).getCell(3).getCellStyle();

            // window of 200 rows; close() also deletes the temp files (POI 5.3+)
            try (SXSSFWorkbook wb = new SXSSFWorkbook(xssf, 200)) {
                Sheet sheet = wb.getSheetAt(0);
                // sheet.getRow(0) == null here, and createRow(i) with i < firstFreeRow throws
                int r = firstFreeRow;
                while (loans.hasNext()) {
                    Loan loan = loans.next();
                    Row row = sheet.createRow(r++);
                    row.createCell(0).setCellValue(loan.id());
                    row.createCell(1).setCellValue(loan.member());
                    row.createCell(2).setCellValue(loan.title());
                    row.createCell(3).setCellValue(loan.loaned());
                    row.createCell(4).setCellValue(loan.due());
                    row.getCell(1).setCellStyle(text);
                    row.getCell(3).setCellStyle(date);
                    row.getCell(4).setCellStyle(date);
                }
                wb.write(out);
            }
        }
    }
}

Source: SXSSFWorkbook Javadoc (constructor SXSSFWorkbook(XSSFWorkbook)), POI How-To, SXSSF and HybridStreaming example.

A run with 200,000 loans completed in about one second on a laptop, and re-reading the result showed the last row at index 200003, the header row intact and the date format of the prototype row applied to the new date cells. The streaming internals (window, flushing, temp files) are the subject of Streaming Writes with SXSSF; close() also deletes the temporary files, which since POI 5.3 is the call to make rather than dispose() (deprecated in 5.5.1).

When the design needs data above the template’s content (for example, rows inserted between the header and a footer), SXSSF cannot do it. Choose between a smaller XSSF report, a layout that puts the variable rows last, or building the fixed parts after the stream in a second pass.

Memory and performance

Factor Effect

Template size

An XSSF workbook is fully in memory, so a template with large embedded images costs that much for every concurrent report. Keep images in the template small; compressing them in Excel is cheaper than any code change. Read the bytes once and share the byte[], not the workbook.

Loading time

Parsing the template (unzipping, building the XML trees) is a fixed cost per report; measure it with your own template before introducing a cache of loaded workbooks, which you cannot share across threads anyway.

Data volume

XSSF holds every cell as objects. The usermodel path is comfortable up to tens of thousands of rows; beyond that use Streaming on top of a template: SXSSFWorkbook(XSSFWorkbook) or the pure streaming export of the SXSSF page.

shiftRows

Linear in the number of cells below the insertion point, plus the formula adjustment. Prefer layouts where the variable part comes last.

Styles

Use the prototype row’s CellStyle object for every new cell. Calling createCellStyle in a loop creates one style per call and soon hits the limit of about 64,000 styles per workbook.

cloneSheet

Copies the sheet XML in memory; fifty branch sheets from a template with a thousand rows means fifty thousand rows in memory. Fill them in a loop and write once, or stream.

Pitfalls and security

Pitfall Why and what to do

Overwritten template on disk

new XSSFWorkbook(File) plus close() writes your changes into the template. Open from bytes or from a copy, as shown.

A shared workbook across requests

Workbooks are not thread safe. Share the bytes; open one workbook per report.

Missing names after a template edit

The designer renamed ReportTitle. Fail fast with the message from setNamedCell, and put a test on every template.

Table not resized or not even a table

Writing rows below a table leaves it at the old size. Call setCellReferences. An empty list still needs one data row.

Stale formulas and cached values

Rewrite the ranges you changed and set setForceFormulaRecalculation(true) (or evaluate in Java).

Stale names and references after removeSheetAt or shiftRows

Remove or rewrite the names, formulas and validations that POI does not adjust.

Data validation left behind

shiftRows does not move data validations. Recreate them.

User data in a template position

Always setCellValue(String); never build formulas from user text. A value starting with an equals sign, a plus, a minus or an at sign is still a risk when the file is later saved as CSV; see the security guidance of this section in a later group.

Macro-enabled files from users

Do not use a user-supplied .xlsm as a template and do not re-serve it. The template must come from your repository or from an administrator-only location.

Template as an attack surface

If the business can upload templates, treat the upload as an untrusted file (size limits, zip-bomb limits, no macros), because it is parsed by the same code path as any other upload.

Date and locale

setCellValue(LocalDate) needs a date format on the cell. Take the style from the prototype cell; do not build one per call.

Wrong content type or extension on download

.xlsx: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; .xlsm: application/vnd.ms-excel.sheet.macroEnabled.12.

What changed across versions

Version Relevant changes

POI 3.x

HSSFWorkbook was the default; templates were .xls and went through POIFSFileSystem. Code from this era uses integer cell types and the no-argument createTable(), which are removed or deprecated now.

POI 4.0 to 4.1

SemVer starts (4.0). XSSFTable.setCellReferences exists in the 4.1 line (4.1.2 fixed a bug for single-cell tables, per the changes page), and copyRows with CellCopyPolicy is the supported way to repeat blocks.

POI 5.2.3

The changes page lists "`XSSFWorkbook.cloneSheet` does not clone XSSFTable linked from the sheet". In the 5.5.1 test the table was cloned and renamed, so test your own template if you rely on tables in cloned sheets.

POI 5.3

SXSSFWorkbook removes its temp files when closed, so a separate dispose() call is no longer needed (5.3.0, 2024-07-02).

POI 5.4 to 5.5

Stricter zip and XML handling for untrusted input, which applies to template uploads too. 5.5.0 added an API for reading .xlsb files; it is read only, so an .xlsb cannot serve as a writable template. 5.5.1 (2025-11-30) is the latest release on the baseline date.

POI 6.0 (in development, not released)

Per the POI site: JDK 17 or later, removal of the APIs deprecated in 5.x (the Workbook Javadoc already marks members such as getNumberOfFontsAsInt with @Removal(version="6.0.0")), DataFormatter reading cached formula values by default, and hardened zip-bomb and path-traversal handling. The changes page lists, under 6.0.0 (TBD), a faster XSSFSheet.shiftRows (it no longer rebuilds every row from the XML on each shift) and fixes to VBAMacroReader. Re-run the template tests when you upgrade; nothing on this page is known to depend on a removed API, but only a build against 6.0 can confirm that once it ships.

What POI does not provide here

  • No template engine. No ${...} expression language, no loops, no conditionals, no mail-merge. The filler is code you write, as above. Third-party projects exist for Excel (for example JXLS, built on POI) and for Word; the introduction page lists related projects.

  • No automatic table growth. setCellReferences is your job, and so is the footer arithmetic.

  • No full reference rewriting. shiftRows adjusts several kinds of reference but not tables or data validations, and removeSheetAt leaves stale names.

  • No macro creation, editing or execution. Read and preserve only.

  • No conversion of the result to PDF. A filled workbook stays a workbook; PDF generation needs another tool (see the PDF Generation section of this site).

  • No pivot table or chart refresh. Both keep cached data until a spreadsheet application recalculates them.

  • No .xlsb writing. The binary format is read only.

References