Spring Boot Integration

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.

This page connects the JasperReports engine to a Spring Boot 4 service on Java 21: how the engine is wired as beans, how compiled templates are cached, how a database connection reaches a report without leaking, and how a very large report is returned without holding the whole file in memory. It uses the Bookshelf Billing service of this section. Versions are stated as of 2026-10-09: JasperReports 7.0.8 and Spring Boot 4.1.1. The complete project below (about 150 lines of Java plus two JRXML files) was compiled and run with those versions on Java 21 language level, against an in-memory H2 database with 20,000 invoice lines: the invoice endpoint returned a 6-page PDF with a Content-Length, and the streaming endpoint returned a chunked response of about 540 pages while the JVM ran with -Xmx256m.

The Maven coordinates, a first "Hello PDF" and the generic ResponseEntity and Content-Disposition handling are on Getting Started with Spring Boot; the pipeline from JRXML to PDF is explained in JasperReports Architecture & Lifecycle, and the data inputs in Data Sources & Query Executers. Here only the Spring-specific parts are added.

What older books describe and what replaced it

The two JasperReports books consulted for this section (B2 and B3) were written for the Servlet, JSP and applet era, and their web chapters no longer match current practice (outdated finding O23). Three patterns are gone or discouraged:

  • The applet viewer and JRViewer in a browser. Java applets no longer run in browsers, and a Swing viewer is a desktop component, not something a web service returns. A web client receives a PDF (or HTML) and the browser’s own viewer shows it.

  • Servlet and JSP code that writes to HttpServletResponse by hand, with image servlets and browser-specific header workarounds. A controller now returns a ResponseEntity, and the framework sets status, type and length.

  • Spring’s JasperReportsPdfView and the other classes of org.springframework.web.servlet.view.jasperreports. The Spring Framework 5.0 release notes list JasperReports among the dropped integrations, so code using these views does not compile against Spring Framework 5 or later, which includes Spring Boot 4. Applications either stayed on Spring 4.3 or wrote their own adapter, which is what this page does in a few lines.

Official documentation: the Spring Framework 5.0 release notes (section "Dropped support").

The replacement on the JasperReports side is the official Spring Boot sample, which ships with the library.

The official Spring Boot sample

JasperReports 7.0.8 includes a sample named springboot that "demonstrates running JasperReports Library in a Spring Boot 4 application". A REST controller compiles several report templates and serves them as PDF at /rest/pdf/{reportName}; the sample needs JDK 17 or later and Maven, and is started from demo/samples/springboot of the library sources with mvn clean compile spring-boot:run. The report templates are copies of other samples (stretching text fields, images, data adapters over Excel files and charts), so the sample shows that the engine works under Spring Boot rather than a layered design. The webapp sample, which the documentation index lists for web applications, is marked "under construction" in 7.0.8, so the Spring Boot sample is the reference to follow.

The rest of this page takes the same shape (a controller that returns PDF) and adds what a real service needs.

Official documentation: Spring Boot sample and Webapp sample.

Request flow

sequenceDiagram participant C as Client participant K as ReportController participant S as ReportService participant R as CompiledReports participant P as DataSource pool participant J as JasperFillManager and JasperExportManager C->>K: GET /invoices/42/pdf K->>S: invoice(42) S->>R: get("invoice") R-->>S: JasperReport, compiled once and cached S->>P: getConnection() P-->>S: Connection S->>J: fill(report, parameters, connection) J-->>S: JasperPrint S->>J: exportToPdf(print) J-->>S: PDF bytes S->>P: connection.close(), returned to the pool S-->>K: byte array K-->>C: 200, application/pdf, Content-Length

The sections below follow the diagram: the beans first, then the cache, the connection, and finally the streaming variant.

Project setup

The dependencies are those of the Getting Started with Spring Boot page (jasperreports, jasperreports-pdf, jasperreports-fonts and jasperreports-jdt, all 7.0.8), plus spring-boot-starter-webmvc and spring-boot-starter-jdbc with a database driver. The example uses H2 so that it runs without any server.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
</parent>

<properties>
    <java.version>21</java.version>
    <jasperreports.version>7.0.8</jasperreports.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>net.sf.jasperreports</groupId>
        <artifactId>jasperreports</artifactId>
        <version>${jasperreports.version}</version>
    </dependency>
    <dependency>
        <groupId>net.sf.jasperreports</groupId>
        <artifactId>jasperreports-pdf</artifactId>
        <version>${jasperreports.version}</version>
    </dependency>
    <dependency>
        <groupId>net.sf.jasperreports</groupId>
        <artifactId>jasperreports-fonts</artifactId>
        <version>${jasperreports.version}</version>
    </dependency>
    <dependency>
        <groupId>net.sf.jasperreports</groupId>
        <artifactId>jasperreports-jdt</artifactId>
        <version>${jasperreports.version}</version>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

The table and the application settings are plain Spring Boot. The async request timeout matters for the streaming endpoint described below, because the default is shorter than a large report may need.

-- src/main/resources/schema.sql
CREATE TABLE invoice_line (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    invoice_id  BIGINT         NOT NULL,
    title       VARCHAR(120)   NOT NULL,
    quantity    INT            NOT NULL,
    unit_price  DECIMAL(10, 2) NOT NULL
);
# src/main/resources/application.properties
spring.datasource.url=jdbc:h2:mem:billing
spring.sql.init.mode=always
spring.mvc.async.request-timeout=120s

For a real database, supply the URL, user and password through environment variables or the platform’s secret store (SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD), never in the repository.

Official documentation: Spring Boot sample.

Wiring JasperReportsContext as a bean

Every JasperReports manager (JasperCompileManager, JasperFillManager, JasperExportManager, the exporters) works against a JasperReportsContext: the object that supplies configuration properties, the extension registry and the repository used to find resources. The static methods such as JasperFillManager.fillReport(...) use the process-wide DefaultJasperReportsContext, which is fine for a first example. A service is better off with its own context as a Spring bean, for three reasons: the settings are visible in one place, tests can build a different context, and two reports that need different settings can use different contexts.

SimpleJasperReportsContext is the ready-made implementation. Its no-argument constructor creates a context on top of the default one, so jasperreports.properties files on the classpath and the extensions found there still apply, and the setProperty calls override them. The constructor that takes a JasperReportsContext creates a child context, which is the way to give a single request its own overrides without changing the shared bean.

package com.example.billing;

import net.sf.jasperreports.engine.JasperReportsContext;
import net.sf.jasperreports.engine.SimpleJasperReportsContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JasperConfig {

    /**
     * One context for the whole service. It wraps the default context, so the
     * jasperreports.properties files and the extension registry still apply,
     * and the properties set here override them for every report.
     */
    @Bean
    JasperReportsContext jasperReportsContext() {
        SimpleJasperReportsContext context = new SimpleJasperReportsContext();
        context.setProperty("net.sf.jasperreports.jdbc.fetch.size", "500");
        return context;
    }
}

One property is set as an example: net.sf.jasperreports.jdbc.fetch.size asks the JDBC driver to read rows in batches of 500, which keeps memory flat for large result sets with drivers that honour the fetch size. Leave net.sf.jasperreports.awt.ignore.missing.font at its default false, so a missing font fails the report instead of silently changing the document, as explained in Fonts & Internationalization. Set the context once at start-up; treat it as read-only afterwards, because it is shared by all request threads.

Official documentation: the JasperReportsContext API and the configuration reference.

A compiled-report cache

Compiling a template parses the JRXML and compiles every expression to bytecode, which takes noticeable time, while the resulting JasperReport is immutable and safe to share between threads. A web service must therefore compile each template once, not once per request. The component below does it lazily with a ConcurrentHashMap, and uses the JasperCompileManager of the shared context. computeIfAbsent guarantees one compilation per template even when two requests arrive at the same time.

package com.example.billing;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperReport;
import net.sf.jasperreports.engine.JasperReportsContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;

/** Compiles each template once and hands the same thread-safe JasperReport to every request. */
@Component
public class CompiledReports {

    private final JasperCompileManager compiler;
    private final ConcurrentMap<String, JasperReport> cache = new ConcurrentHashMap<>();

    public CompiledReports(JasperReportsContext context) {
        this.compiler = JasperCompileManager.getInstance(context);
    }

    public JasperReport get(String name) {
        return cache.computeIfAbsent(name, this::compile);
    }

    private JasperReport compile(String name) {
        try (InputStream jrxml = new ClassPathResource("reports/" + name + ".jrxml").getInputStream()) {
            return compiler.compile(jrxml);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        } catch (JRException e) {
            throw new IllegalStateException("Cannot compile report " + name, e);
        }
    }
}

Variations that keep the same shape:

  • Compile at build time. The JasperReports Maven plugin compiles .jrxml files to .jasper files during the build. The cache then loads the serialised object with JRLoader.loadObject(InputStream) instead of compiling, which removes the compiler (and the jasperreports-jdt dependency) from the running service. A .jasper file must be produced by the same JasperReports version that loads it, so recompile on every upgrade.

  • Warm up at start-up. Call get(...) for every known template from an ApplicationRunner, so that a broken template fails the deployment instead of the first user request.

  • Spring’s cache abstraction. A @Cacheable method returning JasperReport works too and adds eviction and metrics, but a compiled report is not serialisable to remote caches in a useful way, so keep it in a local cache.

  • Hot reload in development. Clear the map when a template changes. Production should not recompile.

The templates are standard v7 JRXML. The invoice template of the example has one parameter used by the query and a page footer; in v7 the title and pageFooter elements contain the elements directly, without a band wrapper, while detail still contains bands.

<?xml version="1.0" encoding="UTF-8"?>
<jasperReport name="invoice" language="java" pageWidth="595" pageHeight="842"
              columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
    <parameter name="INVOICE_ID" class="java.lang.Long"/>
    <query language="sql"><![CDATA[
        SELECT title, quantity, unit_price FROM invoice_line
        WHERE invoice_id = $P{INVOICE_ID} ORDER BY id
    ]]></query>
    <field name="title" class="java.lang.String"/>
    <field name="quantity" class="java.lang.Integer"/>
    <field name="unit_price" class="java.math.BigDecimal"/>
    <title height="40">
        <element kind="textField" x="0" y="0" width="515" height="30" fontSize="18.0" bold="true">
            <expression><![CDATA["Bookshelf invoice " + $P{INVOICE_ID}]]></expression>
        </element>
    </title>
    <detail>
        <band height="20">
            <element kind="textField" x="0" y="0" width="320" height="18">
                <expression><![CDATA[$F{title}]]></expression>
            </element>
            <element kind="textField" x="320" y="0" width="60" height="18" hTextAlign="Right">
                <expression><![CDATA[$F{quantity}]]></expression>
            </element>
            <element kind="textField" x="380" y="0" width="135" height="18" hTextAlign="Right" pattern="#,##0.00">
                <expression><![CDATA[$F{unit_price}]]></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 second template, all-lines.jrxml, is the same file without the parameter, without the WHERE clause and with the fixed title "All invoice lines"; it is used by the streaming endpoint below.

Official documentation: the JasperCompileManager API and the Maven sample, which compiles templates at build time.

Passing a DataSource and Connection safely

A report with a <query language="sql"> runs its query on a java.sql.Connection that the caller passes to fill. The rules below keep that safe in a pooled service.

  • Borrow late and return early. Take the connection from the Spring DataSource immediately before the fill and close it in a try-with-resources block that wraps only the fill. The rows are read during the fill, so the connection must stay open until fill returns, but the export reads the filled JasperPrint and does not need it. The invoice method below exports after the block; the streaming allLines method keeps the export inside it for brevity, so for a long export move it after the block so the pooled connection is not held while the bytes are written.

  • Never store the connection in a field or a report parameter that outlives the request. A Connection is not thread-safe and belongs to one request. Pass it as an argument, as fill expects.

  • Do not give the report more privileges than it needs. The query is designer-written SQL. Use a connection from a role that can only read the tables of the report, ideally a separate read-only DataSource for reports.

  • Bind request values as parameters. $P{INVOICE_ID} in the query becomes a bound PreparedStatement parameter, so a request value cannot change the SQL. Do not use the $P!{...} form with request input, because it pastes text into the query.

  • Pass a mutable map. fill adds entries to the parameters map, so Map.of(...) is rejected with an UnsupportedOperationException; use a HashMap.

  • Prefer a JRDataSource when the data comes from the service’s own code. A JRBeanCollectionDataSource or a small adapter over a repository stream avoids giving the template a connection at all (see the Data Sources page). Beans must be classes with getters: a Java record is rejected as a bean data source in 7.0.8, so use a class with getters or a Map.

package com.example.billing;

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.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReportsContext;
import org.springframework.stereotype.Service;

@Service
public class InvoiceService {

    private final DataSource dataSource;
    private final CompiledReports reports;
    private final JasperFillManager filler;
    private final JasperExportManager exporter;

    public InvoiceService(DataSource dataSource, CompiledReports reports, JasperReportsContext context) {
        this.dataSource = dataSource;
        this.reports = reports;
        this.filler = JasperFillManager.getInstance(context);
        this.exporter = JasperExportManager.getInstance(context);
    }

    public byte[] invoice(long invoiceId) throws JRException, SQLException {
        Map<String, Object> parameters = new HashMap<>();   // mutable: the engine adds entries
        parameters.put("INVOICE_ID", invoiceId);
        JasperPrint print;
        try (Connection connection = dataSource.getConnection()) {
            print = filler.fill(reports.get("invoice"), parameters, connection);
        }
        return exporter.exportToPdf(print);
    }
}

The controller returns the bytes with a known length, so a failure while building the document becomes a clean HTTP 500 before the first byte is sent.

package com.example.billing;

import java.sql.SQLException;
import net.sf.jasperreports.engine.JRException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class InvoiceController {

    private final InvoiceService service;

    public InvoiceController(InvoiceService service) {
        this.service = service;
    }

    @GetMapping(value = "/invoices/{id}/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
    public ResponseEntity<byte[]> invoice(@PathVariable long id) throws JRException, SQLException {
        byte[] pdf = service.invoice(id);
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.inline().filename("invoice-" + id + ".pdf").build().toString())
                .contentLength(pdf.length)
                .body(pdf);
    }
}

Request curl -o invoice.pdf http://localhost:8080/invoices/7/pdf to try it. Spring’s data access and controller basics are in REST APIs with Spring Boot; using the same bean for scheduled or batch generation is shown in Generating Reports from Databases & Services.

Official documentation: the JasperFillManager API and the query sample.

Streaming large PDFs

A report with tens of thousands of rows raises two separate memory questions, and a streaming endpoint alone solves only one of them.

  1. The fill. fill builds a JasperPrint with every page of the report. For a 540-page report that is a lot of objects on the heap. The engine’s answer is a virtualizer: pass a JRVirtualizer in the REPORT_VIRTUALIZER parameter and it swaps filled pages to disk when more than a configured number are in memory. JRSwapFileVirtualizer keeps the pages in one swap file. It must be cleaned up with cleanup() when the report is no longer needed, otherwise the swap file stays on disk.

  2. The export. Exporting to a byte[] holds the whole PDF in memory a second time. Exporting directly to the response OutputStream with exportToPdfStream avoids that copy, and StreamingResponseBody is how Spring MVC gives the callback that stream, on an async task thread.

The service method below uses both, and the controller returns a ResponseEntity<StreamingResponseBody>. The connection and the virtualizer are created inside the callback, so they belong to the async thread that runs the report and are released by it. The first bytes only reach the client after the fill has completed, because the export needs the finished JasperPrint; what streaming saves is memory and the second copy, not time to first byte.

package com.example.billing;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
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.JRParameter;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReportsContext;
import net.sf.jasperreports.engine.fill.JRSwapFileVirtualizer;
import net.sf.jasperreports.engine.util.JRSwapFile;
import org.springframework.stereotype.Service;

@Service
public class BigReportService {

    private final DataSource dataSource;
    private final CompiledReports reports;
    private final JasperFillManager filler;
    private final JasperExportManager exporter;

    public BigReportService(DataSource dataSource, CompiledReports reports, JasperReportsContext context) {
        this.dataSource = dataSource;
        this.reports = reports;
        this.filler = JasperFillManager.getInstance(context);
        this.exporter = JasperExportManager.getInstance(context);
    }

    /** Virtualize the filled pages to disk, then export straight to the response stream. */
    public void allLines(OutputStream out) throws JRException, SQLException, IOException {
        Path swapDir = Files.createTempDirectory("billing-swap");
        // 50 pages stay in memory; older ones go to a swap file of 4096-byte blocks.
        JRSwapFileVirtualizer virtualizer =
                new JRSwapFileVirtualizer(50, new JRSwapFile(swapDir.toString(), 4096, 1024));
        try {
            Map<String, Object> parameters = new HashMap<>();
            parameters.put(JRParameter.REPORT_VIRTUALIZER, virtualizer);
            try (Connection connection = dataSource.getConnection()) {
                JasperPrint print = filler.fill(reports.get("all-lines"), parameters, connection);
                exporter.exportToPdfStream(print, out);
            }
        } finally {
            virtualizer.cleanup();
            Files.deleteIfExists(swapDir);
        }
    }
}
package com.example.billing;

import java.io.IOException;
import java.sql.SQLException;
import net.sf.jasperreports.engine.JRException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

@RestController
public class BigReportController {

    private final BigReportService service;

    public BigReportController(BigReportService service) {
        this.service = service;
    }

    @GetMapping(value = "/reports/all-lines/pdf", produces = MediaType.APPLICATION_PDF_VALUE)
    public ResponseEntity<StreamingResponseBody> allLines() {
        StreamingResponseBody body = out -> {
            try {
                service.allLines(out);
            } catch (JRException | SQLException e) {
                throw new IOException("Report generation failed", e);
            }
        };
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment().filename("all-lines.pdf").build().toString())
                .body(body);
    }
}

Request it with curl -OJ http://localhost:8080/reports/all-lines/pdf. Points to keep in mind:

  • Errors after the first byte. Once the response is committed, a failure leaves a truncated file and a closed connection instead of an HTTP error status. Validate the request and check that the template compiles (the cache warm-up above) before returning the ResponseEntity.

  • Time limits. spring.mvc.async.request-timeout must be longer than the report. For reports that run for minutes, do not hold an HTTP request at all: start a job, store the PDF (see File storage and object stores) and return a link.

  • Threads. The fill and the export are CPU-bound and synchronous. Limit how many reports run at once, with a bounded task executor or a semaphore, so that a burst of requests cannot exhaust the heap or the connection pool.

  • Page limits. A formatted report of hundreds of pages is rarely read on screen; consider whether the user needs a CSV or Excel export instead, which the engine can also produce.

  • Export settings. PDF metadata, compression, encryption and conformance are configured on the exporter, see PDF Export Configuration.