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.
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 |
|
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 |
The data needs the service’s mapping, authorisation or business rules, or comes from several repositories. |
REST or JSON |
|
Another service owns the data and returns JSON. |
CSV or Excel |
|
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
JRDataSourceover 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
JsonDataSourceor JSONQL for REST data, fetching first and filling second. -
Use
JRCsvDataSourceorExcelDataSourcefor 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
@Scheduledplus 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.
References
-
PostgreSQL JDBC driver: issuing a query and processing the result
-
Spring Data JPA on this site
-
Spring Batch on this site