Generating Reports from Databases & Services

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. Almost every real report starts as a query: sales by category from PostgreSQL, a customer list from another team’s REST service, a price list that finance keeps in a spreadsheet. This page is about the part between the data and the PDF, using the monthly sales report of the Bookshelf Billing service as the running example. It shows five ways to feed JasperReports 7.0.8 (a JDBC query in the template, a JRDataSource over Spring Data JPA, REST and JSON, CSV and Excel, and subreports that combine them), one way to feed iText 9.8.0 without keeping the result set in memory, and how to run both on a schedule and store the result. The design of the template itself, the Maven setup and the HTTP endpoint are covered by other pages of this section, see Getting Started with Spring Boot.

The examples were compiled and run with Java 21, Spring Boot 4.1.0, Hibernate on H2, JasperReports 7.0.8, iText 9.8.0 and ShedLock 7.10.1. A few results below (what does not work, and why) come from that run and are marked as such.

Where report data comes from

Whatever the source, the engine receives either a java.sql.Connection or a JRDataSource. Everything else (a REST call, a CSV file, a JPA query) is a way to produce one of those two.

flowchart LR DB[("PostgreSQL")] -->|"JDBC query in the JRXML"| JR["JasperReports fill"] DB -->|"Spring Data JPA stream"| DS["JRDataSource adapter"] REST["REST service"] -->|"JSON body"| JS["JsonDataSource or JSONQL"] FILES["CSV or Excel upload"] --> FS["CSV or Excel data source"] DS --> JR JS --> JR FS --> JR DB -->|"paged repository stream"| IT["iText table"] JR --> PDF["PDF bytes"] IT --> PDF PDF --> ST[("Object storage")] PDF --> HTTP["HTTP response"] SCH["Scheduler or Spring Batch"] -->|"triggers"| JR SCH -->|"triggers"| IT

The choice is mostly about who owns the query. Putting the SQL in the template keeps report design and data selection in one file that a report designer can change without a deployment. Building the data in Java keeps the query in the repository layer, under the same tests, mapping and security rules as the rest of the service, and it is the only option when the data does not come from a database at all.

Source How it reaches the engine Use it when

JDBC query inside the JRXML

<query language="sql"> and a Connection passed to fillReport

The report designer owns the query, the data is relational, and the query is not shared with other code.

Spring Data JPA or any Java code

A JRDataSource, either a stock one or a small adapter

The data needs the service’s mapping, authorisation or business rules, or comes from several repositories.

REST or JSON

JsonDataSource, or a jsonql query over a JSON_INPUT_STREAM parameter

Another service owns the data and returns JSON.

CSV or Excel

JRCsvDataSource, ExcelDataSource

Users upload or finance publishes the data as files.

Several of the above

Subreports, each with its own data source or connection

One document needs data from different places.

A JDBC query inside the template

The simplest report is a template that carries its own query. The v7 JRXML below groups the sale rows by category, totals each group and takes a minimum amount as a parameter. Its element syntax is the JasperReports 7 one: namespace-free, with kind on each element. Templates written for 6.x do not load in 7.0, and Jaspersoft Studio 7 converts them.

<jasperReport name="sales-by-category" language="java" pageWidth="595" pageHeight="842"
              columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
  <parameter name="MIN_AMOUNT" class="java.math.BigDecimal"/>
  <query language="sql"><![CDATA[
    SELECT category, title, quantity, amount
    FROM sale
    WHERE amount >= $P{MIN_AMOUNT}
    ORDER BY category, title
  ]]></query>
  <field name="category" class="java.lang.String"/>
  <field name="title" class="java.lang.String"/>
  <field name="quantity" class="java.lang.Integer"/>
  <field name="amount" class="java.math.BigDecimal"/>
  <variable name="categoryTotal" calculation="Sum" resetType="Group" resetGroup="category" class="java.math.BigDecimal">
    <expression><![CDATA[$F{amount}]]></expression>
  </variable>
  <group name="category">
    <expression><![CDATA[$F{category}]]></expression>
    <groupHeader>
      <band height="24">
        <element kind="textField" x="0" y="2" width="515" height="20" bold="true" fontSize="12">
          <expression><![CDATA[$F{category}]]></expression>
        </element>
      </band>
    </groupHeader>
    <groupFooter>
      <band height="24">
        <element kind="textField" x="365" y="2" width="150" height="20" bold="true" hTextAlign="Right" pattern="#,##0.00">
          <expression><![CDATA[$V{categoryTotal}]]></expression>
        </element>
      </band>
    </groupFooter>
  </group>
  <title height="40">
      <element kind="staticText" x="0" y="0" width="515" height="30" fontSize="18" bold="true">
        <text><![CDATA[Bookshelf sales by category]]></text>
      </element>
    </title>
  <detail>
    <band height="20">
      <element kind="textField" x="0" y="0" width="300" height="18">
        <expression><![CDATA[$F{title}]]></expression>
      </element>
      <element kind="textField" x="300" y="0" width="60" height="18" hTextAlign="Right">
        <expression><![CDATA[$F{quantity}]]></expression>
      </element>
      <element kind="textField" x="365" y="0" width="150" height="18" hTextAlign="Right" pattern="#,##0.00">
        <expression><![CDATA[$F{amount}]]></expression>
      </element>
    </band>
  </detail>
  <pageFooter height="20">
      <element kind="textField" x="0" y="0" width="515" height="18" hTextAlign="Right">
        <expression><![CDATA["Page " + $V{PAGE_NUMBER}]]></expression>
      </element>
    </pageFooter>
</jasperReport>

The $P{MIN_AMOUNT} reference in the query is not text pasted into the SQL. The engine turns it into a bound parameter of a PreparedStatement, so a value that comes from a request cannot change the shape of the query. The $P!{NAME} form is different: it pastes the parameter’s text into the query, which is how a report can change its ORDER BY or table name at run time and also how a SQL injection is written. Use it only with values the service chose itself, never with request input.

The Java side compiles the template once, because compilation is slow and the compiled JasperReport can be shared by threads, then borrows a connection from the pool for the duration of the fill.

package bookshelf;

import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import javax.sql.DataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperReport;
import org.springframework.stereotype.Service;

@Service
public class JdbcSalesReport {

    private final DataSource dataSource;
    private final JasperReport report;

    public JdbcSalesReport(DataSource dataSource) throws JRException {
        this.dataSource = dataSource;
        try (var jrxml = getClass().getResourceAsStream("/reports/sales-by-category.jrxml")) {
            this.report = JasperCompileManager.compileReport(jrxml);
        } catch (java.io.IOException e) {
            throw new JRException(e);
        }
    }

    public byte[] render(BigDecimal minAmount) throws JRException, SQLException {
        Map<String, Object> parameters = new HashMap<>();
        parameters.put("MIN_AMOUNT", minAmount);
        try (Connection connection = dataSource.getConnection()) {
            // The query inside the JRXML runs on this connection while the report is filled.
            var print = JasperFillManager.fillReport(report, parameters, connection);
            return JasperExportManager.exportReportToPdf(print);
        }
    }
}

Two details matter. fillReport writes its own entries into the parameter map, so an immutable map such as Map.of(...) fails with UnsupportedOperationException (checked with 7.0.8): always pass a HashMap. And the connection must stay open until the fill returns, because the query is executed and its rows are read during the fill. Derived from the JasperReports query sample and the JasperFillManager API.

The JPA table here is the default one for a Sale entity (sale, with columns such as sold_on); in PostgreSQL the same query runs unchanged. A query written in the template bypasses the JPA mapping, so column renames in the entity are not seen by the report. Keep such a report next to a test that fills it against the real schema, for example with Testcontainers, so a migration that renames a column fails the build and not the nightly job.

A JRDataSource over Spring Data JPA

When the data should come from the repository layer, the template declares only its fields and parameters, and Java supplies the rows. The Bookshelf entity and a repository method that returns a Stream of records:

package bookshelf;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import java.time.LocalDate;

@Entity
public class Sale {

    @Id
    @GeneratedValue
    private Long id;
    private String category;
    private String title;
    private int quantity;
    private BigDecimal amount;
    private LocalDate soldOn;

    protected Sale() {
    }

    public Sale(String category, String title, int quantity, BigDecimal amount, LocalDate soldOn) {
        this.category = category;
        this.title = title;
        this.quantity = quantity;
        this.amount = amount;
        this.soldOn = soldOn;
    }
}
package bookshelf;

import java.math.BigDecimal;

public record SalesRow(String category, String title, int quantity, BigDecimal amount) {
}
package bookshelf;

import java.time.LocalDate;
import java.util.stream.Stream;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import jakarta.persistence.QueryHint;

public interface SaleRepository extends JpaRepository<Sale, Long> {

    @Query("""
            select new bookshelf.SalesRow(s.category, s.title, s.quantity, s.amount)
            from Sale s
            where s.soldOn >= :from and s.soldOn < :to
            order by s.category, s.title
            """)
    @QueryHints(@QueryHint(name = "org.hibernate.fetchSize", value = "500"))
    Stream<SalesRow> streamSales(LocalDate from, LocalDate to);
}

A repository method that returns a Stream keeps the result set open and reads rows as they are consumed, so it must be called inside a transaction and the stream must be closed. The org.hibernate.fetchSize hint asks the JDBC driver to fetch rows in batches. With the PostgreSQL driver a fetch size only takes effect when auto-commit is off, which a Spring transaction guarantees, see the driver’s cursor documentation. Derived from the Spring Data JPA reference on query return types.

The template for this report declares the same fields as the query but has no <query> element:

<jasperReport name="sales-list" language="java" pageWidth="595" pageHeight="842"
              columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
  <parameter name="REPORT_TITLE" class="java.lang.String"/>
  <field name="category" class="java.lang.String"/>
  <field name="title" class="java.lang.String"/>
  <field name="quantity" class="java.lang.Integer"/>
  <field name="amount" class="java.math.BigDecimal"/>
  <title height="36">
      <element kind="textField" x="0" y="0" width="515" height="30" fontSize="16" bold="true">
        <expression><![CDATA[$P{REPORT_TITLE}]]></expression>
      </element>
    </title>
  <detail>
    <band height="20">
      <element kind="textField" x="0" y="0" width="120" height="18">
        <expression><![CDATA[$F{category}]]></expression>
      </element>
      <element kind="textField" x="120" y="0" width="240" height="18">
        <expression><![CDATA[$F{title}]]></expression>
      </element>
      <element kind="textField" x="360" y="0" width="40" height="18" hTextAlign="Right">
        <expression><![CDATA[$F{quantity}]]></expression>
      </element>
      <element kind="textField" x="400" y="0" width="115" height="18" hTextAlign="Right" pattern="#,##0.00">
        <expression><![CDATA[$F{amount}]]></expression>
      </element>
    </band>
  </detail>
</jasperReport>

JasperReports ships JRBeanCollectionDataSource for lists of JavaBeans, and it is the usual answer in older examples. It does not work with Java records, though. A record’s accessors are named category(), not getCategory(), and the bean data source looks for getters: filling the template above from a list of SalesRow records fails with Error retrieving field value from bean: category (checked with 7.0.8). There are three ways out: write classes with getters, convert each record to a Map and use JRMapCollectionDataSource, or write the small adapter below, which is the best fit for Java 21 because it also avoids loading the whole result into a list.

package bookshelf;

import java.util.Iterator;
import java.util.stream.Stream;
import net.sf.jasperreports.engine.JRDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JRField;

/** Adapts a stream of records to JasperReports without copying it into a list. */
public final class SalesRowDataSource implements JRDataSource, AutoCloseable {

    private final Stream<SalesRow> stream;
    private final Iterator<SalesRow> rows;
    private SalesRow current;

    public SalesRowDataSource(Stream<SalesRow> stream) {
        this.stream = stream;
        this.rows = stream.iterator();
    }

    @Override
    public boolean next() {
        if (!rows.hasNext()) {
            return false;
        }
        current = rows.next();
        return true;
    }

    @Override
    public Object getFieldValue(JRField field) throws JRException {
        return switch (field.getName()) {
            case "category" -> current.category();
            case "title" -> current.title();
            case "quantity" -> current.quantity();
            case "amount" -> current.amount();
            default -> throw new JRException("Unknown field: " + field.getName());
        };
    }

    @Override
    public void close() {
        stream.close();
    }
}

JRDataSource has two methods, next() and getFieldValue(JRField), and the switch expression maps field names to record components. The service that uses it owns the transaction and the report lifecycle:

package bookshelf;

import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class SalesReportService {

    private final SaleRepository sales;
    private final JasperReport report;

    public SalesReportService(SaleRepository sales) throws IOException, JRException {
        this.sales = sales;
        try (InputStream jrxml = getClass().getResourceAsStream("/reports/sales-list.jrxml")) {
            this.report = JasperCompileManager.compileReport(jrxml); // compile once, reuse for every request
        }
    }

    @Transactional(readOnly = true) // the stream needs an open transaction while it is consumed
    public byte[] monthlyReport(LocalDate from, LocalDate to) throws JRException {
        Map<String, Object> parameters = new HashMap<>(); // JasperReports writes into this map
        parameters.put("REPORT_TITLE", "Sales " + from + " to " + to);
        try (var rows = new SalesRowDataSource(sales.streamSales(from, to))) {
            JasperPrint print = JasperFillManager.fillReport(report, parameters, rows);
            return JasperExportManager.exportReportToPdf(print);
        }
    }
}

The adapter is a record-friendly use of Java 21: a record carries the row, a switch expression maps it to fields, and try-with-resources closes the stream. Derived from the JasperReports data source sample and the JRDataSource API.

Everything the engine needs for grouping and totals (the categoryTotal variable above) works the same whichever way the rows arrive, but the rows must already be in group order, because the engine breaks a group when the value changes and does not sort. The repository method therefore orders by category and then by title. For data sets that are too large to hold the finished document in memory, JasperReports offers virtualizers that swap filled pages to disk, see the virtualizer sample.

The Spring MVC view classes that rendered JasperReports (JasperReportsPdfView and its relatives) were removed in Spring Framework 5, and the applet and servlet viewers described by earlier books are obsolete. Rendering to a byte[] in a service and returning it from a controller, as the next pages of this section do, is the supported shape.

REST and JSON sources

A report that reads another service’s data fetches the JSON first and fills second. Fetching inside the fill, for example through a data adapter, ties a slow upstream call to the lifetime of the engine, so the service below completes the HTTP exchange before JasperReports starts. The JSON module is a separate artifact:

<dependency>
    <groupId>net.sf.jasperreports</groupId>
    <artifactId>jasperreports-json</artifactId>
    <version>7.0.8</version>
</dependency>
package bookshelf;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperReport;
import net.sf.jasperreports.json.data.JsonDataSource;
import org.springframework.web.client.RestClient;

/** Report whose rows come from a REST endpoint instead of the database. */
public class CatalogReport {

    private final RestClient client;
    private final JasperReport report; // compiled from sales-list.jrxml or a JSON-based template

    public CatalogReport(RestClient.Builder builder, String baseUrl, JasperReport report) {
        this.client = builder.baseUrl(baseUrl).build();
        this.report = report;
    }

    public byte[] render() throws JRException {
        // Fetch first, then fill: the HTTP connection is closed before JasperReports starts working.
        byte[] json = client.get().uri("/api/sales").retrieve().body(byte[].class);
        try (InputStream in = new ByteArrayInputStream(json)) {
            var dataSource = new JsonDataSource(in, "items");
            Map<String, Object> parameters = new HashMap<>();
            parameters.put("REPORT_TITLE", "Sales from the catalog service");
            return JasperExportManager.exportReportToPdf(JasperFillManager.fillReport(report, parameters, dataSource));
        } catch (java.io.IOException e) {
            throw new JRException(e);
        }
    }
}

JsonDataSource(InputStream, String) takes a selection expression, here items, that names the array whose elements become rows; a field’s name is the path of the property inside each element unless the field carries a description that says otherwise. Derived from the JSON data source sample.

The sample documentation recommends the newer JSONQL query language over the original JSON one, because it can traverse nested structures and filter them. A template opts in with <query language="jsonql">, the JSON arrives as the JSON_INPUT_STREAM parameter, and each field’s description is its expression:

<jasperReport name="sales-from-json" language="java" pageWidth="595" pageHeight="842"
              columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
  <query language="jsonql"><![CDATA[data.items]]></query>
  <field name="category" class="java.lang.String">
    <description><![CDATA[category]]></description>
  </field>
  <field name="title" class="java.lang.String">
    <description><![CDATA[title]]></description>
  </field>
  <field name="amount" class="java.math.BigDecimal">
    <description><![CDATA[amount]]></description>
  </field>
  <detail>
    <band height="20">
      <element kind="textField" x="0" y="0" width="150" height="18">
        <expression><![CDATA[$F{category}]]></expression>
      </element>
      <element kind="textField" x="150" y="0" width="250" height="18">
        <expression><![CDATA[$F{title}]]></expression>
      </element>
      <element kind="textField" x="400" y="0" width="115" height="18" hTextAlign="Right" pattern="#,##0.00">
        <expression><![CDATA[$F{amount}]]></expression>
      </element>
    </band>
  </detail>
</jasperReport>

and the fill needs no data source object at all:

Map<String, Object> parameters = new HashMap<>();
parameters.put("JSON_INPUT_STREAM", new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)));
JasperPrint print = JasperFillManager.fillReport(report, parameters);

The query data.items selected the array in a body of the form {"data":{"items":[...]}}, and the run produced one row per element (checked with 7.0.8). Derived from the JSONQL data source sample.

JasperReports also has HTTP data adapters: a JSON or XML data adapter whose data file is declared as an HTTP location, with optional basic authentication, so Jaspersoft Studio can preview a report against a live endpoint. They are a good design-time tool. In a service, calling the upstream with Spring’s RestClient, with the timeouts, retries and token handling the rest of the application already has, is easier to secure and test; see the HTTP data adapter sample for the adapter route. The RestClient API is described in the Spring Framework reference.

CSV and Excel inputs

Files that people hand to the service follow the same pattern: parse into a data source and fill. The CSV data source is part of the core artifact; the Excel one is in jasperreports-excel-poi:

<dependency>
    <groupId>net.sf.jasperreports</groupId>
    <artifactId>jasperreports-excel-poi</artifactId>
    <version>7.0.8</version>
</dependency>
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.text.NumberFormat;
import java.util.Locale;
import net.sf.jasperreports.data.excel.ExcelFormatEnum;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.data.JRCsvDataSource;
import net.sf.jasperreports.poi.data.ExcelDataSource;

class FileSources {

    static JRCsvDataSource csv(byte[] content) throws JRException {
        var dataSource = new JRCsvDataSource(new ByteArrayInputStream(content), StandardCharsets.UTF_8.name());
        dataSource.setUseFirstRowAsHeader(true); // the header row names the fields
        dataSource.setNumberFormat(NumberFormat.getInstance(Locale.ROOT)); // 1234.50, not 1.234,50
        return dataSource;
    }

    static ExcelDataSource excel(InputStream workbook) throws Exception {
        var dataSource = new ExcelDataSource(workbook, ExcelFormatEnum.XLSX);
        dataSource.setUseFirstRowAsHeader(true);
        return dataSource;
    }
}

Both data sources were filled into the sales-list template above in the test run, with the header row category,title,quantity,amount. The number and date formats are the usual trap: set the locale of the file explicitly instead of relying on the server default, because the same file parses differently on a machine with another locale. Derived from the CSV data source sample and the Excel data source sample. Apache POI, which the Excel data source uses underneath, is outside the scope of this section.

Combining sources with subreports

A document sometimes needs data from two places: a list of categories from a REST service, and for each one the sales rows from the database. A subreport is a report embedded in a band, with its own data source or connection, and parameters passed from the parent. The parent here is fed from a list and passes each category name down:

<jasperReport name="categories" language="java" pageWidth="595" pageHeight="842"
              columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
  <parameter name="TITLES_REPORT" class="net.sf.jasperreports.engine.JasperReport"/>
  <field name="name" class="java.lang.String"/>
  <detail>
    <band height="60">
      <element kind="textField" x="0" y="0" width="515" height="20" bold="true">
        <expression><![CDATA[$F{name}]]></expression>
      </element>
      <element kind="subreport" x="0" y="22" width="515" height="30">
        <expression><![CDATA[$P{TITLES_REPORT}]]></expression>
        <connectionExpression><![CDATA[$P{REPORT_CONNECTION}]]></connectionExpression>
        <parameter name="CATEGORY">
          <expression><![CDATA[$F{name}]]></expression>
        </parameter>
      </element>
    </band>
  </detail>
</jasperReport>
<jasperReport name="category-titles" language="java" pageWidth="515" pageHeight="842"
              columnWidth="515" leftMargin="0" rightMargin="0" topMargin="0" bottomMargin="0">
  <parameter name="CATEGORY" class="java.lang.String"/>
  <query language="sql"><![CDATA[
    SELECT title, amount FROM sale WHERE category = $P{CATEGORY} ORDER BY title
  ]]></query>
  <field name="title" class="java.lang.String"/>
  <field name="amount" class="java.math.BigDecimal"/>
  <detail>
    <band height="18">
      <element kind="textField" x="20" y="0" width="300" height="16">
        <expression><![CDATA[$F{title}]]></expression>
      </element>
      <element kind="textField" x="400" y="0" width="115" height="16" hTextAlign="Right" pattern="#,##0.00">
        <expression><![CDATA[$F{amount}]]></expression>
      </element>
    </band>
  </detail>
</jasperReport>
JasperReport titles = JasperCompileManager.compileReport(titlesJrxml);
JasperReport main = JasperCompileManager.compileReport(categoriesJrxml);

Map<String, Object> parameters = new HashMap<>();
parameters.put("TITLES_REPORT", titles);
parameters.put(JRParameter.REPORT_CONNECTION, connection); // used by the subreport's SQL query

var categories = new JRMapCollectionDataSource(
        List.<Map<String, ?>>of(Map.of("name", "Fiction"), Map.of("name", "Science")));
JasperPrint print = JasperFillManager.fillReport(main, parameters, categories);

In the test run the two categories produced the group rows and, for each, the sale rows queried by the subreport. The subreport’s connectionExpression could be replaced by a dataSourceExpression to give each subreport a different JRDataSource, which is how a report mixes a REST list on the outside with database rows on the inside, or the other way round. A subreport executes once per parent row, so it multiplies queries: fine for a few dozen categories, wrong for a hundred thousand customers. For that, join in SQL or in the repository and group in the parent. Derived from the subreport sample.

iText tables from a repository stream

With iText 9.8.0 the layout is built in Java, so the data source is simply whatever the code iterates. The dependency is the itext-core aggregator POM, with the Bouncy Castle adapter added only when the document is also signed or encrypted:

<dependency>
    <groupId>com.itextpdf</groupId>
    <artifactId>itext-core</artifactId>
    <version>9.8.0</version>
    <type>pom</type>
</dependency>

iText is distributed under the AGPLv3 or a commercial licence, which matters for a service that is offered to others; the licensing page of this section explains the consequences. A table with tens of thousands of rows should not be built whole and then added: the default is to keep every cell until the table is laid out. A large table is created with the largeTable flag, added to the document empty, and filled while being flushed, so memory stays flat:

package bookshelf;

import com.itextpdf.io.font.constants.StandardFonts;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Cell;
import com.itextpdf.layout.element.Paragraph;
import com.itextpdf.layout.element.Table;
import com.itextpdf.layout.properties.TextAlignment;
import com.itextpdf.layout.properties.UnitValue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ItextSalesPdf {

    private final SaleRepository sales;

    public ItextSalesPdf(SaleRepository sales) {
        this.sales = sales;
    }

    @Transactional(readOnly = true)
    public byte[] render(LocalDate from, LocalDate to) throws IOException {
        var out = new ByteArrayOutputStream();
        try (var pdf = new PdfDocument(new PdfWriter(out));
             var document = new Document(pdf);
             Stream<SalesRow> rows = sales.streamSales(from, to)) {

            PdfFont bold = PdfFontFactory.createFont(StandardFonts.HELVETICA_BOLD);
            document.add(new Paragraph("Sales " + from + " to " + to).setFont(bold).setFontSize(16));

            // A large table is flushed to the PDF every few hundred rows instead of being kept in memory.
            Table table = new Table(UnitValue.createPercentArray(new float[] {2, 4, 1, 2}), true)
                    .useAllAvailableWidth();
            table.addHeaderCell(header("Category", bold));
            table.addHeaderCell(header("Title", bold));
            table.addHeaderCell(header("Qty", bold));
            table.addHeaderCell(header("Amount", bold));
            document.add(table); // added empty: rows are appended below and written as they complete

            int[] count = {0};
            rows.forEach(row -> {
                table.addCell(new Cell().add(new Paragraph(row.category())));
                table.addCell(new Cell().add(new Paragraph(row.title())));
                table.addCell(new Cell().add(new Paragraph(String.valueOf(row.quantity()))
                        .setTextAlignment(TextAlignment.RIGHT)));
                table.addCell(new Cell().add(new Paragraph(row.amount().toPlainString())
                        .setTextAlignment(TextAlignment.RIGHT)));
                if (++count[0] % 200 == 0) {
                    table.flush();
                }
            });
            table.complete();
        }
        return out.toByteArray();
    }

    private static Cell header(String text, PdfFont bold) {
        return new Cell().add(new Paragraph(text).setFont(bold));
    }
}

The three moves are: new Table(widths, true) marks it as large, document.add(table) happens before the rows are added, and table.flush() writes the completed rows, with table.complete() closing it. The font is created once per document and shared. The stream is consumed row by row, so the repository’s fetch size, not the table, bounds the memory use. The 1000-row sample produced a multi-page PDF. Derived from the iText knowledge base article on large tables and the Table API.

Compared with JasperReports, nothing here declares groups or totals for you. Grouping is a loop with a "previous category" variable and totals are sums kept in local variables, which is more code and more control. For a report whose layout changes often, or that a non-programmer must edit, use a template.

Generating reports in batch and on a schedule

The monthly report is nobody’s request, so something must start it. Two Spring mechanisms fit, and the site already documents both in depth: simple in-process scheduling in Scheduling and ShedLock, and jobs with restart and metadata in Spring Batch.

A scheduled method with a lock

@Scheduled runs on every instance of the service, so with three replicas the report is generated three times. ShedLock puts a lock row in a shared store so only one instance runs each tick. Add net.javacrumbs.shedlock:shedlock-spring 7.10.1 and a lock provider (the JDBC one, for example) to the project:

package bookshelf;

import java.time.LocalDate;
import java.time.YearMonth;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
class SchedulingConfig {
}

@Component
class MonthlyReportJob {

    private final SalesReportService reports;
    private final ReportStore store;

    MonthlyReportJob(SalesReportService reports, ReportStore store) {
        this.reports = reports;
        this.store = store;
    }

    // 02:00 on the first day of every month; only one instance runs it thanks to ShedLock
    @Scheduled(cron = "0 0 2 1 * *", zone = "Europe/Madrid")
    @SchedulerLock(name = "monthlySalesReport", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M")
    void generatePreviousMonth() throws Exception {
        YearMonth previous = YearMonth.now().minusMonths(1);
        LocalDate from = previous.atDay(1);
        byte[] pdf = reports.monthlyReport(from, from.plusMonths(1));
        store.put("sales-" + previous + ".pdf", pdf);
    }
}

lockAtMostFor should be longer than any run can take, so a crashed instance does not hold the lock forever, and lockAtLeastFor stops a fast run on one node from being repeated by a node whose clock is slightly behind. The cron expression has six fields, with seconds first. Derived from the Spring Framework scheduling reference and the ShedLock documentation.

A Spring Batch job

Use a batch job when the run must be restartable, have an audit trail, take parameters, or process many reports (one per customer, say). A single report is naturally a tasklet step: one unit of work, with the month as a job parameter, so the job repository refuses to run the same month twice successfully.

package bookshelf;

import java.time.LocalDate;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.infrastructure.repeat.RepeatStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

@Configuration
class MonthlyReportBatch {

    @Bean
    Job monthlySalesJob(JobRepository jobRepository, Step renderStep) {
        return new JobBuilder("monthlySalesJob", jobRepository).start(renderStep).build();
    }

    @Bean
    Step renderStep(JobRepository jobRepository, PlatformTransactionManager tx,
                    SalesReportService reports, ReportStore store) {
        return new StepBuilder("renderStep", jobRepository)
                .tasklet((contribution, chunkContext) -> {
                    var parameters = contribution.getStepExecution().getJobParameters();
                    LocalDate from = LocalDate.parse(parameters.getString("from"));
                    byte[] pdf = reports.monthlyReport(from, from.plusMonths(1));
                    store.put("sales-" + from + ".pdf", pdf);
                    return RepeatStatus.FINISHED;
                }, tx)
                .build();
    }
}

The job is started through the JobOperator:

JobExecution execution = jobOperator.start(
        monthlySalesJob,
        new JobParametersBuilder().addString("from", "2026-09-01").toJobParameters());

This is the Spring Batch 6 API that ships with Spring Boot 4: Job and Step live in org.springframework.batch.core.job and .step, RepeatStatus moved to org.springframework.batch.infrastructure.repeat, and JobOperator, which now extends JobLauncher, is the launching interface. Examples that import org.springframework.batch.core.Job and call JobLauncher.run target version 5 and earlier. The test run completed this job against the same service as the scheduled method.

When the work is one report per customer, the chunk-oriented model fits better: a reader pages through customers (see database item readers), a processor renders each PDF, and a writer stores it. The related Spring Batch pages are Running a job for launching and parameters and file item writers for writing the output. Cron-style triggering with calendars, misfire handling and clustering is the subject of the Scheduling section, and its Spring Boot page shows a Quartz job in the same Bookshelf scenario. Derived from the Spring Batch reference on running a job.

Storing the generated PDFs

A scheduled job has no HTTP response to write to, so the PDF has to go somewhere that survives the container and is readable by the instances that serve it. The container’s local disk is not that place. The code above depends on a two-line interface, and the implementation is the only thing that changes between environments:

package bookshelf;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.stereotype.Component;

public interface ReportStore {

    /** Stores a PDF under a key and returns the key. */
    String put(String key, byte[] pdf);
}

@Component
class LocalReportStore implements ReportStore {

    private final Path root = Path.of(System.getProperty("java.io.tmpdir"), "bookshelf-reports");

    @Override
    public String put(String key, byte[] pdf) {
        try {
            Files.createDirectories(root);
            Files.write(root.resolve(key), pdf);
            return key;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
}

The local implementation is enough for a development run. In production the implementation writes to S3, Google Cloud Storage or Azure Blob Storage, and the page File Storage & Object Stores shows each client, the cloud-agnostic abstraction layer, presigned URLs for handing the finished PDF to a user, and storage tiers for old reports. Store the PDF under a key that includes the period (sales-2026-09.pdf) so that regenerating a month overwrites its own file, and record the key and generation time in a table. The generated PDFs are the record of what was sent to a customer, so decide the retention period before choosing the bucket’s lifecycle rules.

Choosing between the approaches

  • Use the JDBC query in the template when a report designer owns both the layout and the query and the data is relational. Test it against the real schema.

  • Use a JRDataSource over the repository when the query needs the service’s own mapping, security or business rules, or when the rows combine several sources. Order the rows to match the groups.

  • Use JsonDataSource or JSONQL for REST data, fetching first and filling second.

  • Use JRCsvDataSource or ExcelDataSource for uploaded files, with the locale set explicitly.

  • Use subreports sparingly, because each one runs once per parent row.

  • Use an iText large table when the layout is code and the table is very long.

  • Trigger a single periodic report with @Scheduled plus a lock, and use Spring Batch when restart, parameters or many reports matter.

  • Hide the storage behind an interface and keep the PDF out of the container’s disk.

Java 21 idioms used in this page

The earlier books on iText and JasperReports show Java 1.3 to 5 code, with raw collections, casts, anonymous classes and explicit close() in finally. The examples here use records for rows, text blocks for the JPQL, a switch expression inside the data source, var, generics throughout and try-with-resources for connections, streams and documents. The two places where older habits break are the ones found while running the examples: bean data sources need getters, so records need an adapter or a map, and the parameter map passed to fillReport must be mutable. See the language reference for records and switch expressions.