JSTL SQL & XML (Legacy)

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 is written against Jakarta Standard Tag Library 3.0 (API jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2, implementation org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1, the latest release of both), Jakarta Pages 4.0, Jakarta Expression Language 6.0 and Jakarta Servlet 6.1, running on Apache Tomcat 11.0.26 with Java 21. Everything was verified on 2026-10-08: the rules against chapters 10 to 13 of the Tags 3.0 specification (and chapter 14 for the tag library validators), the tag documentation (tagdocs) and the API Javadoc, and every example by building it into a Maven WAR (context path /g5jstlsqlxml) with an in-memory H2 2.5.252 database, deploying it on Tomcat 11.0.26 and calling it with curl; the outputs and error messages shown are the ones that run produced (blank lines produced by templates are left out). The two libraries are the part of JSTL that the Jakarta EE Tutorial calls archived: a subset of Jakarta Tags has been archived in favor of Jakarta XML Binding, Jakarta Faces and Jakarta Persistence. They still exist in the specification and in the implementation, they still work on Tomcat 11, and the specification itself says that database work belongs in the business logic of an MVC application and that DriverManager access is "for prototyping purposes only". This page documents every tag with an example, shows the safe way to use each one, and says plainly when to stop using them. The running scenario is the Bookshelf catalogue: the book and shelf tables on the SQL side, and a catalog.xml file and a feed of new books on the XML side. The core tags used in the examples (c:forEach, c:out, c:catch, c:import, c:set) have a page each in this section, written together with this one; the expression language is on the expression language basics page.

What the Two Libraries Are and Why They Are Discouraged

The Standard Tag Library has five libraries. Three of them (core, formatting and functions) are the everyday tools of a page author; the other two reach outside the view layer. The table lists them as the specification and the tag library descriptors of the implementation define them.

Table 1. The two legacy libraries of Jakarta Tags 3.0
Library URI and prefix Tags What it is for

SQL

jakarta.tags.sql, prefix sql

sql:setDataSource, sql:query, sql:update, sql:transaction, sql:param, sql:dateParam

Run JDBC queries and updates from a page, show the rows, group updates into a transaction (spec chapter 10)

XML core

jakarta.tags.xml, prefix x

x:parse, x:out, x:set

Parse an XML document and evaluate XPath 1.0 expressions against it (spec chapter 11)

XML flow control

jakarta.tags.xml, prefix x

x:if, x:choose, x:when, x:otherwise, x:forEach

Decide and loop on XPath results (spec chapter 12)

XML transform

jakarta.tags.xml, prefix x

x:transform, x:param

Apply an XSLT 1.0 stylesheet to a document (spec chapter 13)

The old Sun URIs http://java.sun.com/jsp/jstl/sql and http://java.sun.com/jsp/jstl/xml are still accepted by the 3.0.1 implementation on Tomcat 11 (a page that declares them runs; checked), but the URIs of JSTL 3.0 are the jakarta.tags.* ones, and only those are used on this page. The JSTL 1.0 URIs http://java.sun.com/jstl/sql and the _rt twin libraries of the older books are another matter, covered in Where the Books Are Outdated.

Why they are discouraged

The sentence that opens chapter 10 of the specification is the whole argument: it is generally preferred to have database operations handled in the business logic of an MVC application, but there are situations where a page author needs them within the page: prototyping and testing, small and simple applications, lack of developer resources. Nothing in the libraries is wrong for those cases, and nothing in them scales beyond them.

Table 2. Why sql:* and x:* stay at the prototype end of a project
Concern What happens What to do instead

Layering

SQL (or XPath and XSLT) lives in the view, so the view is coupled to table and column names, cannot be unit tested without a container and cannot be reused by a REST endpoint or a batch job

Query in a servlet or a service, put the result in a request attribute, let the page only print it (see Doing It Properly: Replacing the SQL Tags)

Connection management

sql:setDataSource with url, driver, user and password uses DriverManager; the specification says it should not be used other than for prototyping because it provides no connection management

A javax.sql.DataSource from the container (JNDI) or from the application, with a pool

Injection

A statement built with ${...} inside the body of sql:query is plain string concatenation (shown in sql:param and sql:dateParam: Injection-Safe Values); the safe form needs a ? marker and a nested sql:param for every value

Parameters always; better, no SQL in pages

Transactions

sql:transaction is a JDBC Connection transaction; its behaviour is undefined inside a larger JTA user transaction, and a rollback cannot be forced from SQL

A transaction owned by a service

Error handling and leaks

Every failure becomes a JspException whose message contains the SQL statement, which must not reach a visitor

Catch and log in Java, show a reference number (see the error handling page)

XML parsing

Whole documents become trees in memory inside a page, with parser security that depends on the implementation (see XML Security: XXE, XSLT, XPath Injection and Escaping)

Parse in Java with Jakarta XML Binding or StAX and a hardened factory, and hand the page beans

Testing and tooling

XPath and SQL errors surface at run time as JspException messages with a page line number

Plain Java classes with real tests

The Jakarta EE Tutorial page for Jakarta Tags lists the XML, internationalisation and SQL libraries under a sentence saying that a subset has been archived, and says the core library and the JSTL functions are still available in Facelets. The issue that planned this section paraphrased the sentence as "the SQL, XML and i18n parts are archived"; this page uses the tutorial’s own wording, which does not name the three libraries as removed from the specification (they are in the 3.0 specification and in Jakarta EE 11), only as no longer the recommended route.

The Bookshelf Used on This Page

The examples use one scratch web application with Maven coordinates as follows. The Jakarta APIs are provided (Tomcat supplies them), the JSTL API and implementation travel in WEB-INF/lib, and the database driver is a runtime dependency. H2 in memory stands in for a real database; nothing on this page depends on it except the exact wording of its error messages.

pom.xml (dependencies only; a WAR project with the maven-war-plugin, as built on the getting started page)
<dependencies>
    <dependency>
        <groupId>jakarta.servlet</groupId>
        <artifactId>jakarta.servlet-api</artifactId>
        <version>6.1.0</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>jakarta.servlet.jsp</groupId>
        <artifactId>jakarta.servlet.jsp-api</artifactId>
        <version>4.0.0</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>jakarta.servlet.jsp.jstl</groupId>
        <artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
        <version>3.0.2</version>
    </dependency>
    <dependency>
        <groupId>org.glassfish.web</groupId>
        <artifactId>jakarta.servlet.jsp.jstl</artifactId>
        <version>3.0.1</version>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <version>2.5.252</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

The data source is declared the way a real deployment would declare it: a Resource in META-INF/context.xml (Tomcat creates a pooled javax.sql.DataSource and binds it in JNDI as java:comp/env/jdbc/bookshelf), and two context parameters in web.xml that are the JSTL configuration variables of the SQL library. The user name and password below are placeholders; a real application takes them from the environment or a secret store, never from a page.

META-INF/context.xml
<Context>
    <Resource name="jdbc/bookshelf" auth="Container" type="javax.sql.DataSource"
              driverClassName="org.h2.Driver"
              url="jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1"
              username="sa" password=""
              maxTotal="8" maxIdle="2" maxWaitMillis="5000"/>
</Context>
WEB-INF/web.xml
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee" version="6.1">
    <context-param>
        <param-name>jakarta.servlet.jsp.jstl.sql.dataSource</param-name>
        <param-value>jdbc/bookshelf</param-value>
    </context-param>
    <context-param>
        <param-name>jakarta.servlet.jsp.jstl.sql.maxRows</param-name>
        <param-value>100</param-value>
    </context-param>
    <resource-ref>
        <res-ref-name>jdbc/bookshelf</res-ref-name>
        <res-type>javax.sql.DataSource</res-type>
        <res-auth>Container</res-auth>
    </resource-ref>
</web-app>

A throwaway setup.jsp creates and fills the two tables (a book and the number of copies on each of two shelves). It already uses most of the tags described below, which is why it comes first: DDL through sql:update, a transaction around several statements, parameters for every value and sql:dateParam for a date. The fmt: tags only produce the date and are described on the JSTL Formatting page of this section.

setup.jsp (abridged: the five inserts are one loop)
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="sql" uri="jakarta.tags.sql" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<sql:transaction>
    <sql:update>DROP TABLE IF EXISTS shelf</sql:update>
    <sql:update>DROP TABLE IF EXISTS book</sql:update>
    <sql:update>
        CREATE TABLE book (
            id BIGINT PRIMARY KEY,
            title VARCHAR(200) NOT NULL,
            author VARCHAR(100) NOT NULL,
            published_year INT NOT NULL,
            added_on DATE NOT NULL)
    </sql:update>
    <sql:update>
        CREATE TABLE shelf (
            book_id BIGINT NOT NULL REFERENCES book(id),
            location VARCHAR(20) NOT NULL,
            copies INT NOT NULL CHECK (copies >= 0),
            PRIMARY KEY (book_id, location))
    </sql:update>
</sql:transaction>
<fmt:setLocale value="en"/>
<fmt:parseDate var="d" value="2026-10-08" pattern="yyyy-MM-dd"/>
<c:set var="titles" value="${['Dune','Neuromancer','Foundation','Solaris','Hyperion']}"/>
<c:set var="authors" value="${['Frank Herbert','William Gibson','Isaac Asimov','Stanislaw Lem','Dan Simmons']}"/>
<c:set var="years" value="${[1965,1984,1951,1961,1989]}"/>
<sql:transaction>
<c:forEach var="t" items="${titles}" varStatus="s">
    <sql:update var="n">
        INSERT INTO book (id, title, author, published_year, added_on) VALUES (?, ?, ?, ?, ?)
        <sql:param value="${s.count}"/>
        <sql:param value="${t}"/>
        <sql:param value="${authors[s.index]}"/>
        <sql:param value="${years[s.index]}"/>
        <sql:dateParam value="${d}" type="date"/>
    </sql:update>
inserted ${s.count}: ${t} (${n} row)
</c:forEach>
<sql:update>INSERT INTO shelf VALUES (1, 'MAIN', 3)</sql:update>
<sql:update>INSERT INTO shelf VALUES (1, 'ANNEX', 0)</sql:update>
</sql:transaction>
done

curl http://localhost:18080/g5jstlsqlxml/setup.jsp prints inserted 1: Dune (1 row) through inserted 5: Hyperion (1 row) and done. The page is a setup script, not a design: it may be called by anyone who can reach the URL and it drops the tables, which is the first reason it must never ship.

Choosing the Data Source

Every SQL tag that talks to a database needs a javax.sql.DataSource, and the same algorithm finds it for sql:query, sql:update and sql:transaction. The specification (10.2) gives the steps, and the diagram follows them.

flowchart TD start["sql:query, sql:update or sql:transaction runs"] --> tx{"query or update nested in sql:transaction?"} tx -->|"yes"| parent["Use the connection of the parent transaction"] tx -->|"no"| attr{"dataSource attribute set?"} attr -->|"yes"| val["Use its value"] attr -->|"no"| cfg{"Configuration variable jakarta.servlet.jsp.jstl.sql.dataSource found by Config.find?"} cfg -->|"yes"| val cfg -->|"no"| err1["JspException: dataSource is null"] val --> kind{"Type of the value?"} kind -->|"javax.sql.DataSource object"| conn["getConnection()"] kind -->|"String"| jndi{"Lookup of java:comp/env/ plus the string succeeds?"} jndi -->|"yes"| conn jndi -->|"no"| dm["Read the string as url, driver, user, password and use DriverManager"] dm -->|"fails"| err2["JspException: Unable to get connection"] dm -->|"works"| conn

The value can therefore be given in five ways. The page datasource.jsp uses all of them in turn against the same database.

Table 3. The ways to name a data source
Form Where it is written Notes

Configuration variable

jakarta.servlet.jsp.jstl.sql.dataSource: a context parameter in web.xml, sql:setDataSource without var, or Config.set in Java (Config.SQL_DATA_SOURCE)

Used when a tag has no dataSource attribute; the page needs no JDBC knowledge. A String here is read like the attribute below

Attribute with a DataSource object

dataSource="${applicationScope.ds}"

Needs a servlet, a listener or sql:setDataSource var to put the object in a scope

Attribute with a JNDI relative path

dataSource="jdbc/bookshelf"

java:comp/env/ is prepended; the Resource is declared in context.xml or the server

Attribute with the JDBC parameter string

dataSource="url,driver,user,password"

A comma inside a parameter is written \, and a backslash \\; for prototypes only

sql:setDataSource with url, driver, user, password

Page or servlet code

Stores a DataSource (or the string) in var, or sets the configuration variable when there is no var; the four attributes are a simpler spelling of the parameter string

Table 4. sql:setDataSource attributes
Attribute Type Meaning

dataSource

String or javax.sql.DataSource

Alternative to the four JDBC attributes: a DataSource, a JNDI relative path or a parameter string

url

String

JDBC URL (jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1 in the examples)

driver

String

Driver class name, loaded when given

user, password

String

Credentials passed to DriverManager; an empty string when omitted

var

String

Name of the exported variable (not dynamic)

scope

String

Scope of var, or of the configuration variable when there is no var (default page)

The tag is empty (no body), and a null dataSource is a JspException.

datasource.jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="sql" uri="jakarta.tags.sql" %>
<%-- 1. DriverManager parameters in a scoped variable (prototyping only; placeholder credentials) --%>
<sql:setDataSource var="proto" url="jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1" driver="org.h2.Driver" user="sa" password=""/>
<sql:query var="a" dataSource="${proto}">SELECT COUNT(*) AS n FROM book</sql:query>
A: ${a.rows[0].n} (setDataSource var)
<%-- 2. the "url,driver,user,password" string form --%>
<sql:query var="b" dataSource="jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1,org.h2.Driver,sa,">SELECT COUNT(*) AS n FROM book</sql:query>
B: ${b.rows[0].n} (parameter string)
<%-- 3. a JNDI relative path, java:comp/env/ is prepended --%>
<sql:query var="c1" dataSource="jdbc/bookshelf">SELECT COUNT(*) AS n FROM book</sql:query>
C: ${c1.rows[0].n} (JNDI relative path)
<%-- 4. the configuration variable set by the deployment descriptor (no dataSource attribute) --%>
<sql:query var="d" sql="SELECT COUNT(*) AS n FROM book"/>
D: ${d.rows[0].n} (web.xml context-param)
<%-- 5. setDataSource without var sets the configuration variable for the rest of the page scope --%>
<sql:setDataSource url="jdbc:h2:mem:other" driver="org.h2.Driver" user="sa" password=""/>
<c:catch var="err"><sql:query var="e">SELECT COUNT(*) AS n FROM book</sql:query></c:catch>
E: <c:out value="${empty err ? e.rows[0].n : err.cause.message}"/>
<%-- 6. missing JNDI name and no configuration --%>
<c:catch var="err2"><sql:query var="f" dataSource="jdbc/missing">SELECT 1</sql:query></c:catch>
F: <c:out value="${err2.message}"/>

The output shows what each form does, including the surprising last two cases (line 5 is another empty in-memory database, and line 6 shows the message for a name that is neither a JNDI resource nor a JDBC string):

A: 5 (setDataSource var)
B: 5 (parameter string)
C: 5 (JNDI relative path)
D: 5 (web.xml context-param)
E: Table "BOOK" not found (this database is empty); SQL statement:
SELECT COUNT(*) AS n FROM book [42104-252]
F: Unable to get connection, DataSource invalid: "java.sql.SQLException: No suitable driver found for jdbc/missing"

Case 5 is the pitfall: sql:setDataSource without var replaces the configuration variable for the page scope, so every later tag of the page that has no dataSource attribute uses the new database, whatever web.xml says. A JNDI name that is not bound is not reported as "name not found": the algorithm falls through to the JDBC parameter string, and the error is the one from DriverManager. The helper tags are also scoped by scope: a sql:setDataSource with scope="application" in a page changes the data source of every page that runs afterwards (verified: after one request to such a page, an unrelated page that has no dataSource attribute failed with the other database’s "Table not found"; the application had to be redeployed), which is another reason to configure it once, in web.xml or in Java.

Two rules close the section. First, the parameter string and the four JDBC attributes put a user name and a password into a page: use them only on a laptop with a throwaway database and a placeholder account, and never commit a real password into a JSP, a web.xml or a context.xml that is under version control. Second, a JDBC driver in WEB-INF/lib of a web application is registered with DriverManager and not unregistered by the container on undeploy: Tomcat logs a warning that the application "registered the JDBC driver [org.h2.Driver] but failed to unregister it" when the scratch WAR is redeployed, and the Tomcat JNDI how-to has a section on the service provider mechanism and memory leaks that recommends putting the driver in the server’s lib directory for container-managed pools.

sql:query: Reading Rows

sql:query runs a SELECT and stores a jakarta.servlet.jsp.jstl.sql.Result in var. It has three syntaxes, which differ only in where the statement and the parameters come from.

Table 5. sql:query attributes
Attribute Type (dynamic) Meaning

sql

String (yes)

The statement; alternatively it is the body of the tag

dataSource

DataSource or String (yes)

See Choosing the Data Source; must not be given when the tag is nested in sql:transaction

maxRows

int (yes)

Largest number of rows kept; -1 or absent means no limit; must be at least -1. Beats the configuration variable jakarta.servlet.jsp.jstl.sql.maxRows (Config.SQL_MAX_ROWS)

startRow

int (yes)

Index of the first row kept; row 0 is the first row of the result set; default 0

var

String (no)

Required: the exported Result

scope

String (no)

Scope of var; default page

Syntax 1 is <sql:query sql="..." var="r"/> (no body); syntax 2 puts only sql:param actions in the body; syntax 3 puts the statement text and then the sql:param actions in the body, which the container evaluates and the tag trims. If the query returns nothing, var holds an empty Result, not null. Any SQLException is rethrown as a JspException whose message includes the SQL statement and whose cause is the original exception.

The Result interface has five properties, all readable from EL:

Table 6. The jakarta.servlet.jsp.jstl.sql.Result properties
EL property Java type Meaning

rows

SortedMap[]

One map per row, column name to value; the map uses String.CASE_INSENSITIVE_ORDER, so row.title, row.TITLE and row['Title'] all work whatever case the database reports

rowsByIndex

Object[][]

One array per row in column order; the generic way to print a table without knowing the columns

columnNames

String[]

Column labels as the database reports them (H2 reports upper case: ID, TITLE)

rowCount

int

Number of rows kept in the result (after startRow and maxRows)

limitedByMaxRows

boolean

true when maxRows (or the configuration variable) cut the result

The page query.jsp lists the books ordered by year, skipping the first row and keeping three. It prints the same rows three ways: the counters, a table built from columnNames and rowsByIndex (no column is named in the page), and a list that uses rows with the case-insensitive keys.

query.jsp
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="sql" uri="jakarta.tags.sql" %>
<sql:query var="books" startRow="1" maxRows="3">
    SELECT id, title, author, published_year FROM book ORDER BY published_year
</sql:query>
<p>rowCount=${books.rowCount} limitedByMaxRows=${books.limitedByMaxRows} columns=${books.columnNames[0]},${books.columnNames[1]}</p>
<table>
    <tr>
        <c:forEach var="name" items="${books.columnNames}"><th><c:out value="${name}"/></th></c:forEach>
    </tr>
    <c:forEach var="row" items="${books.rowsByIndex}">
        <tr><c:forEach var="cell" items="${row}"><td><c:out value="${cell}"/></td></c:forEach></tr>
    </c:forEach>
</table>
<ul>
    <c:forEach var="row" items="${books.rows}">
        <li><c:out value="${row.title}"/> by <c:out value="${row.AUTHOR}"/> (${row['published_year']})</li>
    </c:forEach>
</ul>
<p>rowCount=3 limitedByMaxRows=true columns=ID,TITLE</p>
<table>
    <tr>
        <th>ID</th><th>TITLE</th><th>AUTHOR</th><th>PUBLISHED_YEAR</th></tr>
    <tr><td>4</td><td>Solaris</td><td>Stanislaw Lem</td><td>1961</td></tr>
    <tr><td>1</td><td>Dune</td><td>Frank Herbert</td><td>1965</td></tr>
    <tr><td>2</td><td>Neuromancer</td><td>William Gibson</td><td>1984</td></tr>
    </table>
<ul>
    <li>Solaris by Stanislaw Lem (1961)</li>
    <li>Dune by Frank Herbert (1965)</li>
    <li>Neuromancer by William Gibson (1984)</li>
    </ul>

The books sorted by year are Foundation (1951), Solaris, Dune, Neuromancer and Hyperion; startRow="1" dropped Foundation, so the first row shown is Solaris. Every value is printed through c:out, because a database column is untrusted text (see Escaping What Comes from the Database).

Paging with startRow and maxRows

The two attributes are, in the words of the specification, a "poor man’s way" of paging: maxRows protects against runaway queries and startRow skips the pages already shown. With both set, the database is asked for startRow + maxRows rows and the first startRow of them are discarded, so deep pages get slower. Without an ORDER BY the order is not guaranteed, so a page can repeat or skip a row between requests. limitedByMaxRows tells the page whether a "next" link makes sense.

pager.jsp
<c:set var="size" value="2"/>
<c:set var="page" value="${empty param.page ? 0 : param.page}"/>
<sql:query var="books" startRow="${page * size}" maxRows="${size}">
    SELECT id, title FROM book ORDER BY id
</sql:query>
<ol start="${page * size + 1}">
    <c:forEach var="row" items="${books.rows}"><li><c:out value="${row.title}"/></li></c:forEach>
</ol>
<c:if test="${books.limitedByMaxRows}"><a href="?page=${page + 1}">next</a></c:if>
$ curl 'http://localhost:18080/g5jstlsqlxml/pager.jsp'
<ol start="1">
    <li>Dune</li><li>Neuromancer</li></ol>
<a href="?page=1">next</a>
$ curl 'http://localhost:18080/g5jstlsqlxml/pager.jsp?page=2'
<ol start="5">
    <li>Hyperion</li></ol>

The flag is exact: limitedByMaxRows is true only when the query had more rows than were kept. On the five-row table maxRows="4" reports true with four rows, while maxRows="5" (an exact fit) reports false with five (verified), so a "next" link is shown only when there really is a next page, except for rows that appear between two requests. The web.xml above sets jakarta.servlet.jsp.jstl.sql.maxRows to 100 as a safety net for every query in the application. Real paging belongs in the query itself or in the data layer: the tag still reads and discards every row before startRow.

sql:param and sql:dateParam: Injection-Safe Values

A statement may contain ? markers; every marker is filled, in order, by a nested sql:param or sql:dateParam. The values travel as JDBC PreparedStatement parameters, so the database never parses them as SQL. Both tags locate their nearest ancestor that implements jakarta.servlet.jsp.jstl.sql.SQLExecutionTag (sql:query and sql:update do) and call its addSQLParameter() method, which is also the extension point for custom parameter tags.

Table 7. sql:param and sql:dateParam
Tag Attributes Behaviour

sql:param

value (Object, dynamic); or the value as the body

A null value sets the SQL NULL. A String value is documented to work only for text columns (CHAR, VARCHAR, LONGVARCHAR); other types need a number or date object, for example one produced by fmt:parseNumber or fmt:parseDate

sql:dateParam

value (java.util.Date), type (date, time or timestamp)

Converts the value to java.sql.Date, java.sql.Time or java.sql.Timestamp; null gives NULL

The page param.jsp runs the same search twice, once with a parameter and once with the request value concatenated into the statement by EL. The second form is the vulnerable "before" snippet; it is shown so the difference can be seen, and it is never to be used.

param.jsp
<c:set var="q" value="${empty param.q ? 'Dune' : param.q}"/>
<h2>sql:param</h2>
<sql:query var="safe">
    SELECT title FROM book WHERE title = ?
    <sql:param value="${q}"/>
</sql:query>
<p>safe: ${safe.rowCount} row(s)</p>
<h2>EL concatenated into the statement</h2>
<c:catch var="err">
    <sql:query var="unsafe">
        SELECT title FROM book WHERE title = '${q}'
    </sql:query>
</c:catch>
<p>unsafe: <c:out value="${empty err ? unsafe.rowCount : 'error'}"/> row(s)</p>
<c:if test="${not empty err}"><p>cause: <c:out value="${err.cause.message}"/></p></c:if>

With a normal value both forms agree. With q set to x' OR '1'='1 (sent as a URL-encoded query parameter) the result differs:

<h2>sql:param</h2>
<p>safe: 0 row(s)</p>
<h2>EL concatenated into the statement</h2>
<p>unsafe: 5 row(s)</p>

The parameter form found nothing, because no book has that literal title; the concatenated form returned every book, because the EL expression was replaced by text before the statement reached the driver and the OR '1'='1' became part of the SQL. (A second attempt with x' OR 'a shows the other symptom, a JspException whose cause is the database error with the statement in it: Data conversion error converting "CHARACTER VARYING to BOOLEAN".) The specification’s examples are safe: its money transfer example uses markers. The rule is mechanical: no ${...} inside the SQL text, only in value attributes. A value that cannot be a parameter, such as a table or column name chosen by the visitor, is never taken from the request: it is looked up in a fixed list of allowed names in a servlet.

Dates

sql:dateParam takes a java.util.Date, which the formatting library produces from text (fmt:parseDate). It is also the only way to bind a time-of-day or a timestamp without a custom tag, because sql:param binds the object it is given as it is.

dateparam.jsp
<fmt:setLocale value="en"/>
<fmt:parseDate var="since" value="2026-01-01" pattern="yyyy-MM-dd"/>
<sql:query var="recent">
    SELECT title, added_on FROM book WHERE added_on >= ? ORDER BY id
    <sql:dateParam value="${since}" type="date"/>
</sql:query>
<p>${recent.rowCount} books added since 2026-01-01; first: ${recent.rows[0].title} on ${recent.rows[0].added_on}</p>
<p>5 books added since 2026-01-01; first: Dune on 2026-10-08</p>

The fmt:setLocale line matters for curl: without an Accept-Language header and without a configured locale, fmt:parseDate cannot establish a parse locale and fails with a JspException ("In <parseDate>, a parse locale can not be established"), a behaviour of the formatting library described on its own page. Whether a String can be bound to a numeric column depends on the driver: the specification documents text columns only, and H2 happens to coerce "1965" for an INT column (WHERE published_year = ? with <sql:param value="1965"/> returned the book), which another database may refuse.

sql:update: Changing Data and Running DDL

sql:update runs an INSERT, UPDATE or DELETE, and also any statement that returns nothing, such as DDL. Its syntaxes mirror sql:query: the statement in sql, or in the body, with sql:param actions.

Table 8. sql:update attributes
Attribute Type (dynamic) Meaning

sql

String (yes)

The statement; alternatively the body of the tag

dataSource

DataSource or String (yes)

As for sql:query; forbidden inside sql:transaction

var

String (no)

Optional: exported as a java.lang.Integer, the number of rows affected

scope

String (no)

Scope of var; if given, var is required

The result is the same number Statement.executeUpdate() returns: zero for a statement that matched nothing and for every DDL statement. The page errors.jsp checks this and some other edges in one place.

errors.jsp (abridged)
<c:catch var="err3"><sql:query var="x" maxRows="-2">SELECT 1</sql:query></c:catch>
maxRows -2: <c:out value="${err3.message}"/>
<c:catch var="err4"><sql:query var="x">SELECT * FROM nope</sql:query></c:catch>
bad SQL: <c:out value="${err4.message}"/>
<sql:update var="ddl">CREATE TABLE IF NOT EXISTS tmp_probe (id INT)</sql:update>
ddl result: ${ddl}
<sql:update var="n">DELETE FROM tmp_probe</sql:update>
delete result: ${n}
<sql:query var="nul">SELECT ? AS v<sql:param value="${null}"/></sql:query>
null param: ${empty nul.rows[0].v}
<sql:query var="big" maxRows="2">SELECT id FROM book ORDER BY id</sql:query>
limited: ${big.limitedByMaxRows} rowCount: ${big.rowCount}
<sql:query var="cfg">SELECT id FROM book</sql:query>
config maxRows 100: limited=${cfg.limitedByMaxRows} rows=${cfg.rowCount}
maxRows -2: Invalid or out of bounds value specified in parameter.
bad SQL: SELECT * FROM nope: Table "NOPE" not found; SQL statement:
SELECT * FROM nope [42102-252]
ddl result: 0
delete result: 0
null param: true
limited: true rowCount: 2
config maxRows 100: limited=false rows=5

Three details are worth remembering. A null parameter value became SQL NULL (the page printed true for empty nul.rows[0].v). A maxRows below -1 is rejected with the tag’s own message, and a failing statement reports its text first, which is why the message of a JspException from these tags must never be shown to a visitor. And each sql:update that is not inside sql:transaction obtains its own connection, runs, and releases it before the tag ends (the specification requires every database action to close what it opened), so on an auto-commit connection each statement is its own transaction: two separate updates are not atomic.

sql:transaction: Grouping Statements

sql:transaction provides one connection to the sql:query and sql:update actions nested in its body, turns auto-commit off, and commits when the body ends or rolls back if an exception escapes. Its attributes are dataSource (as before) and isolation.

Table 9. sql:transaction isolation levels
isolation value JDBC constant it selects

read_uncommitted

Connection.TRANSACTION_READ_UNCOMMITTED

read_committed

Connection.TRANSACTION_READ_COMMITTED

repeatable_read

Connection.TRANSACTION_REPEATABLE_READ

serializable

Connection.TRANSACTION_SERIALIZABLE

(not given)

The isolation level the data source is configured with

The life cycle given by the specification is: at the start, read the current isolation level (a value of TRANSACTION_NONE means transactions are not supported and the action fails), save and disable auto-commit, and set the requested isolation level if it differs; at the end call commit(); on an exception call rollback() and rethrow; and in every case restore the saved isolation level and auto-commit mode and close the connection. Three rules follow from it: the nested sql:query and sql:update must not specify dataSource; transactions cannot be nested to share a connection; and executing a SQL statement such as rollback with sql:update inside the body is not supported (the result is unpredictable). Its behaviour inside a larger JTA user transaction is undefined.

The Bookshelf transfers copies of Dune from the main shelf to the annex. The two updates are one transaction; the first one adds copies to the annex and the second removes them from the main shelf, where the CHECK (copies >= 0) constraint rejects an impossible quantity. The output of the second request shows that the first update, which had succeeded, was rolled back.

transaction.jsp
<c:set var="n" value="${empty param.n ? 1 : param.n}"/>
<c:catch var="err">
    <sql:transaction isolation="read_committed">
        <sql:update>
            UPDATE shelf SET copies = copies + ? WHERE book_id = 1 AND location = 'ANNEX'
            <sql:param value="${n}"/>
        </sql:update>
        <sql:update>
            UPDATE shelf SET copies = copies - ? WHERE book_id = 1 AND location = 'MAIN'
            <sql:param value="${n}"/>
        </sql:update>
    </sql:transaction>
</c:catch>
<c:choose>
    <c:when test="${empty err}">moved ${n} copies</c:when>
    <c:otherwise>rolled back: <c:out value="${err.cause.message}"/></c:otherwise>
</c:choose>
<sql:query var="now">SELECT location, copies FROM shelf WHERE book_id = 1 ORDER BY location</sql:query>
<c:forEach var="r" items="${now.rows}">${r.location}=${r.copies} </c:forEach>
$ curl 'http://localhost:18080/g5jstlsqlxml/transaction.jsp?n=2'
moved 2 copies
ANNEX=2 MAIN=1
$ curl 'http://localhost:18080/g5jstlsqlxml/transaction.jsp?n=5'
rolled back: Check constraint violation: "CONSTRAINT_4B: "; SQL statement:
UPDATE shelf SET copies = copies - ? WHERE book_id = 1 AND loca [23513-252]
ANNEX=2 MAIN=1

After the failed request ANNEX is still 2, not 7: the + 5 on the annex was rolled back with the failed - 5. The JspException that c:catch stored has the SQL exception as its cause, and the SQL text is part of the message, again.

Giving a dataSource to a tag inside the transaction is not a run-time error: the tag library validator of the SQL library rejects the page when it is translated, which is better:

nested.jsp
<sql:transaction>
    <sql:query var="x" dataSource="jdbc/bookshelf">SELECT 1</sql:query>
</sql:transaction>
Validation error messages from TagLibraryValidator for [sql] in [/nested.jsp]
8: It is illegal to specify a DataSource when nested within a <transaction>

A transaction made of tags in a page also lives only as long as the page; it cannot span two requests, so the classic "read, show a form, write" cycle with a version check needs the optimistic locking of a real data layer (the Hibernate pages in this repository have a transactions page and the SQL transactions page describes the isolation levels themselves).

Escaping What Comes from the Database

Everything in a Result is data to be treated as untrusted. A title that contains markup is printed unchanged by a bare ${...} and escaped by c:out. The page xss.jsp inserts such a title through a parameter and prints it both ways.

xss.jsp
<sql:update>
    INSERT INTO book (id, title, author, published_year, added_on) VALUES (99, ?, 'x', 2000, CURRENT_DATE)
    <sql:param value="<script>alert(1)</script>"/>
</sql:update>
<sql:query var="r">SELECT title FROM book WHERE id = 99</sql:query>
raw: ${r.rows[0].title}
escaped: <c:out value="${r.rows[0].title}"/>
raw: <script>alert(1)</script>
escaped: &lt;script&gt;alert(1)&lt;/script&gt;

The injection safety of sql:param and the output safety of c:out are two different protections, and a page needs both: the first keeps the visitor’s text from becoming SQL, the second keeps the database’s text from becoming HTML.

The SQL Configuration Variables

The SQL library reads two configuration settings (spec 10.9). Each can be set as a context parameter in web.xml (String values only) and overridden for one scope (page, request, session or application) by a configuration variable, which sql:setDataSource and the Config class write from a page or from Java; Config.find looks a setting up by its context-parameter name.

Table 10. SQL configuration variables
Variable (context parameter name) Config constant Type Use

jakarta.servlet.jsp.jstl.sql.dataSource

SQL_DATA_SOURCE

String or DataSource

The data source for tags without a dataSource attribute; set by sql:setDataSource, web.xml or Config

jakarta.servlet.jsp.jstl.sql.maxRows

SQL_MAX_ROWS

Integer (at least -1)

Cap for every sql:query that has no maxRows; -1 means no cap

The web.xml of the scratch application sets both: the first line of datasource.jsp case D worked without any attribute because of the first, and the cfg query of errors.jsp is bounded by the second. The jakarta.servlet.jsp.jstl.fmt.* variables of the formatting library follow the same rules and are described with it.

Doing It Properly: Replacing the SQL Tags

The replacement for sql:* is the structure the specification recommends: the page prints, a servlet (or a service behind it) queries. The Bookshelf servlet BooksServlet gets the container’s data source through @Resource, runs a prepared statement, puts a list of records in the request and forwards to a page that has no SQL.

BooksServlet.java
@WebServlet("/books-mvc")
public class BooksServlet extends HttpServlet {

    @Resource(name = "jdbc/bookshelf")
    private DataSource dataSource;

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String author = req.getParameter("author");
        String like = "%" + (author == null ? "" : author) + "%";
        List<Book> books = new ArrayList<>();
        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(
                     "SELECT id, title, author, published_year FROM book WHERE author LIKE ? ORDER BY id")) {
            statement.setString(1, like);
            try (ResultSet rs = statement.executeQuery()) {
                while (rs.next()) {
                    books.add(new Book(rs.getLong("id"), rs.getString("title"), rs.getString("author"),
                            rs.getInt("published_year")));
                }
            }
        } catch (SQLException e) {
            throw new ServletException("Cannot read the books", e);
        }
        req.setAttribute("books", books);
        req.getRequestDispatcher("/WEB-INF/views/books-mvc.jsp").forward(req, resp);
    }
}
WEB-INF/views/books-mvc.jsp
<ul>
    <c:forEach var="book" items="${books}">
        <li><c:out value="${book.title}"/> by <c:out value="${book.author}"/> (${book.year})</li>
    </c:forEach>
</ul>
$ curl 'http://localhost:18080/g5jstlsqlxml/books-mvc?author=Gib'
<ul>
    <li>Neuromancer by William Gibson (1984)</li>
    </ul>

The servlet is longer than the tag, and that is the trade: it can be tested without a container, reused by a REST endpoint, traced, given a read-only account, and changed without touching the page. Serious projects go one step further and replace the hand-written JDBC with a data layer: the Hibernate pages of this repository (getting started with Hibernate) and the Spring Data JPA page show the options. The mapping from the old tags to the modern code is straightforward:

Table 11. From sql tags to code
Tag Replacement

sql:setDataSource, dataSource

A pooled DataSource bound by the container (Resource in context.xml) or created once by the application

sql:query plus Result

PreparedStatement or a repository in a servlet or service; a list of records in a request attribute

sql:param, sql:dateParam

setString, setObject with java.time types (JDBC 4.2), or named parameters in the data layer

sql:update

A service method with the same SQL (or an entity update) behind a POST

sql:transaction

A transaction owned by the service layer (@Transactional in Spring, a JPA transaction, a JTA user transaction)

maxRows, startRow

LIMIT/OFFSET or keyset pagination in the query, as on the pagination strategies page

The XML Library: XPath as a Local Language

The XML tags are built on XPath 1.0, and the specification is careful about how XPath coexists with EL. XPath is a separate language used only in the select attribute of the XML tags; everything else on the page is EL and JSP.

flowchart LR src["XML text: c:import, x:parse body, String, Reader"] --> parse["x:parse"] filter["Optional SAX filter"] --> parse parse --> tree["Parsed tree in var (implementation type) or varDom (DOM Document)"] tree --> xp["XPath 1.0 in select attributes"] xp --> out["x:out: text"] xp --> set["x:set: scoped variable"] xp --> flow["x:if, x:choose, x:forEach"] tree --> xt["x:transform with an XSLT 1.0 stylesheet"] xt --> res["Page output, a Document in var, or a Result"]
Table 12. How XPath is integrated
Rule What it means

The select attribute

XPath is written only in select; it is not dynamic, it is a literal string for the XPath engine, and EL is not evaluated in it (select="${x}" is not an XPath variable)

Default context node

Outside an x:forEach body the context node is the root of an empty document, so a page must name its document explicitly: $catalog/catalog/book

Context in x:forEach

Inside the body the context node is the current item, the context position is the iteration count and the context size is the number of nodes iterated

Function library

Only the XPath 1.0 core functions (count(), sum(), string(), number(), local-name(), starts-with() and so on); no extension functions are promised

Namespaces

Prefixes used in an XPath expression must resolve to a namespace (see Namespaces)

Result types

XPath boolean, number and string become Boolean, Number and String; a node-set is an implementation type that the other XML tags understand

XPath variables

Web application data enters an XPath expression through variables whose prefixes mirror the EL implicit objects. A variable that is not bound is an exception, following the XPath specification.

Table 13. XPath variable bindings
Expression Value

$foo

pageContext.findAttribute("foo"): the first scope that has it

$param:foo

request.getParameter("foo")

$header:foo

request.getHeader("foo")

$cookie:foo

The value of the cookie named foo

$initParam:foo

application.getInitParameter("foo")

$pageScope:foo, $requestScope:foo, $sessionScope:foo, $applicationScope:foo

pageContext.getAttribute("foo", scope) for that scope

Table 14. Java to XPath types
Java type of the variable XPath type

java.lang.Boolean

boolean

java.lang.Number

number

java.lang.String

string

An object exported by x:parse, x:set or x:forEach (or a DOM node)

node-set

The mapping makes values of the request, session and application visible to XPath without any string concatenation: /foo/bar[@x=$param:name] selects the bar whose attribute equals the request parameter. This is also the safe way to build a filter from user input; it is the XPath counterpart of sql:param (see XML Security: XXE, XSLT, XPath Injection and Escaping).

The Bookshelf’s catalog.xml, used by the examples, is a small file in WEB-INF/data/ (so that it cannot be fetched directly): a catalog element with four book elements, each with id, genre and year attributes and title, author and price children.

WEB-INF/data/catalog.xml
<?xml version="1.0" encoding="UTF-8"?>
<catalog xmlns:bk="https://example.org/bookshelf" updated="2026-10-08">
    <book id="1" genre="scifi" year="1965">
        <title>Dune</title>
        <author>Frank Herbert</author>
        <price currency="EUR">12.50</price>
    </book>
    <book id="2" genre="scifi" year="1984">
        <title>Neuromancer</title>
        <author>William Gibson</author>
        <price currency="EUR">9.90</price>
    </book>
    <book id="3" genre="scifi" year="1951">
        <title>Foundation &amp; Empire</title>
        <author>Isaac Asimov</author>
        <price currency="EUR">8.00</price>
    </book>
    <book id="4" genre="classic" year="1925">
        <title>The Great Gatsby</title>
        <author>F. Scott Fitzgerald</author>
        <price currency="EUR">7.25</price>
    </book>
</catalog>

Getting the XML In

The XML tags take documents as a String or a Reader; they do not open URLs. The specification routes URL access through c:import (11.1.7), and relative systems identifiers through the systemId attribute. There are four ways to get text to x:parse, and on Tomcat 11 with JSTL 3.0.1 one of the obvious ones does not work for static files.

Table 15. Where the XML text can come from
Source Works for Notes

Body of x:parse (or x:transform)

Inline XML, c:import, jsp:include

<x:parse var="d"><c:import url="/WEB-INF/data/catalog.xml"/></x:parse> works for a static file: the import writes into the tag’s body

c:import url=... var=... then doc="${text}"

Absolute URLs, dynamic resources (a JSP or servlet), not static files

Verified: for /WEB-INF/data/catalog.xml (and for a static file in the web application root) var stays empty and the file’s text is written into the page output instead, so x:parse fails with "Premature end of file"

c:import ... varReader with x:parse in the body

As above

Same effect as var for static files

A servlet

Anything

Reads and parses with a hardened parser, puts a String (or beans) in a request attribute

The cause is in the implementation: the response wrapper that c:import hands to the included resource has an output stream whose flush() copies what was written into the page’s JspWriter and then clears it; a static file served by Tomcat’s default servlet is written to that stream and flushed, which is consistent with what was observed: the text goes to the page and var is empty. A JSP or a servlet that uses a Writer is not affected, which is why c:import of a dynamic URL works, and why the examples on this page use the body form. This was verified on 2026-10-08 on Tomcat 11.0.26 with JSTL 3.0.1 for var, for varReader and for a file outside WEB-INF; it is recorded as an inconsistency with the specification, which says c:import "works seamlessly with the XML tags". The XSLT stylesheet below (a static file too) is imported through a one-line JSP wrapper, catalog-xsl.jsp, that includes it.

imp3.jsp (the four ways, each wrapped in c:catch)
<c:catch var="e1"><x:parse var="d"><jsp:include page="/WEB-INF/data/catalog.xml"/></x:parse></c:catch>
A jsp:include in body: <c:out value="${empty e1 ? 'ok' : e1.message}"/>
<c:catch var="e2"><c:import url="/WEB-INF/data/catalog.xml" varReader="r"><x:parse var="d2" doc="${r}"/></c:import></c:catch>
B varReader: <c:out value="${empty e2 ? 'ok' : e2.message}"/>
<c:catch var="e3"><x:parse var="d3"><c:import url="/WEB-INF/data/catalog.xml"/></x:parse></c:catch>
C c:import in body: <c:out value="${empty e3 ? 'ok' : e3.message}"/>
<c:catch var="e4"><c:import url="/WEB-INF/data/catalog.xml" var="s"/><x:parse var="d4" doc="${s}"/></c:catch>
D c:import var: <c:out value="${empty e4 ? 'ok' : e4.message}"/>
A jsp:include in body: ok
B varReader: org.xml.sax.SAXParseException; systemId: jstl:; lineNumber: 1; columnNumber: 1; Premature end of file.
C c:import in body: ok
D c:import var: org.xml.sax.SAXParseException; systemId: jstl:; lineNumber: 1; columnNumber: 1; Premature end of file.

(The failing cases also write the XML declaration of the file into the page, which is the leak described above; it is not shown in the output.) For remote documents, c:import of an absolute URL with var works, because the text is read with a reader (see A Remote Feed, the Classic Use).

x:parse: Building the Tree

x:parse parses a document and saves the result. It does not validate against a DTD or schema.

Table 16. x:parse attributes
Attribute Type Meaning

doc

String or Reader (dynamic)

The XML source. Alternatively the body of the tag

xml

String or Reader (dynamic)

Deprecated: use doc; still accepted by the 3.0.1 implementation (verified)

systemId

String (dynamic)

The system identifier (URI) used to resolve external references: an absolute URL is passed to the parser, a relative URL is read as a web application resource

filter

org.xml.sax.XMLFilter (dynamic)

A SAX filter applied before the tree is built

var, scope

String

Scoped variable for the tree; its type is implementation dependent; usable in select and in doc of x:transform

varDom, scopeDom

String

Scoped variable for the tree as an org.w3c.dom.Document, for collaboration with custom tags

An empty or null source is an exception. var and varDom are alternatives: one of them is required.

Filtering large documents

The filter attribute exists for performance: a DOM-like tree of a large document is expensive, so a filter can drop what the page does not need before the tree is built. The specification’s example prunes non-European customers. The Bookshelf filter drops every book whose year attribute is older than a threshold; it is an ordinary XMLFilterImpl that skips the subtree, and the page creates it with jsp:useBean (no scriptlet).

YearFilter.java
public class YearFilter extends XMLFilterImpl {
    private int minYear = 1960;
    private int skipDepth;

    public void setMinYear(int minYear) {
        this.minYear = minYear;
    }

    @Override
    public void startElement(String uri, String local, String qName, Attributes atts) throws SAXException {
        if (skipDepth > 0) {
            skipDepth++;
            return;
        }
        if ("book".equals(qName) && Integer.parseInt(atts.getValue("year")) < minYear) {
            skipDepth = 1;
            return;
        }
        super.startElement(uri, local, qName, atts);
    }

    @Override
    public void endElement(String uri, String local, String qName) throws SAXException {
        if (skipDepth > 0) {
            skipDepth--;
            return;
        }
        super.endElement(uri, local, qName);
    }

    @Override
    public void characters(char[] ch, int start, int length) throws SAXException {
        if (skipDepth == 0) {
            super.characters(ch, start, length);
        }
    }
}
The filter in a page (from xml-transform.jsp)
<jsp:useBean id="yearFilter" class="org.example.bookshelf.YearFilter"/>
<jsp:setProperty name="yearFilter" property="minYear" value="1960"/>
<x:parse var="young" filter="${yearFilter}"><c:import url="/WEB-INF/data/catalog.xml"/></x:parse>
after filter: <x:out select="count($young//book)"/> books
after filter: 2 books

The filter is a bean in page scope, shared by nothing, and it keeps state (skipDepth) while it runs, so one instance must not be shared between requests: put it in page or request scope (the default of jsp:useBean is page), never in application scope.

systemId

systemId matters when the document refers to an external DTD or entity by a relative name. The specification says relative URLs are treated as references to web application resources. The page below parses a file whose DOCTYPE points to catalog.dtd, with and without systemId.

xml-systemid.jsp
<c:catch var="e1">
  <x:parse var="a" systemId="/WEB-INF/data/withdtd.xml"><c:import url="/WEB-INF/data/withdtd.xml"/></x:parse>
  with systemId: <x:out select="$a//title"/>
</c:catch>
<c:out value="${e1.message}"/>
<c:catch var="e2">
  <x:parse var="b"><c:import url="/WEB-INF/data/withdtd.xml"/></x:parse>
  without: <x:out select="$b//title"/>
</c:catch>
<c:out value="${e2.message}"/>
with systemId: Teaser from the publisher
com.sun.org.apache.xerces.internal.util.URI$MalformedURIException: Scheme specific part cannot be empty.

The file declares an entity &pub; in catalog.dtd (<!ENTITY pub "from the publisher">); with systemId the entity is resolved from the web application and the title is Teaser from the publisher. Without it the parser has no base URI and fails with an error that does not mention systemId at all. Entities defined this way are resolved by a web-application resolver; external references to files or network locations are another matter and are blocked (XML Security: XXE, XSLT, XPath Injection and Escaping).

x:out and x:set: Printing and Storing XPath Results

x:out writes the string value of an XPath expression to the page, and x:set stores the result in a scoped variable. They are the XPath counterparts of c:out and c:set.

Table 17. x:out and x:set attributes
Tag Attributes Behaviour

x:out

select (XPath, required, not dynamic); escapeXml (boolean, default true)

Evaluates the expression, converts the result as the XPath string() function does, and writes it. With escapeXml true the five characters <, >, &, ' and " become <, >, &, ' and "

x:set

select, var (required), scope

Stores the result in the scoped variable; its type is whatever the expression evaluates to (see the type table)

The page xml-basic.jsp shows both, plus the flow tags of the next section. This is its first half.

xml-basic.jsp (first half)
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="x" uri="jakarta.tags.xml" %>
<x:parse var="catalog"><c:import url="/WEB-INF/data/catalog.xml"/></x:parse>
<p>first: <x:out select="$catalog/catalog/book[1]/title"/></p>
<p>third: <x:out select="$catalog//book[@id='3']/title"/> | raw: <x:out select="$catalog//book[@id='3']/title" escapeXml="false"/></p>
<p>count: <x:out select="count($catalog//book)"/>, sum: <x:out select="sum($catalog//price)"/>, updated: <x:out select="$catalog/catalog/@updated"/></p>
<x:set var="dune" select="$catalog//book[title='Dune']"/>
<p>set: <x:out select="$dune/author"/></p>
<x:set var="n" scope="request" select="count($catalog//book[@genre='scifi'])"/>
<p>n=${n}</p>
<p>first: Dune</p>
<p>third: Foundation &amp; Empire | raw: Foundation & Empire</p>
<p>count: 4, sum: 37.65, updated: 2026-10-08</p>
<p>set: Frank Herbert</p>
<p>n=3.0</p>

Three observations follow from the output. x:out escapes by default (the title Foundation & Empire appears as & in the HTML) and escapeXml="false" prints it raw, which is only safe for trusted XML. A number result is a Double, so count() prints 3.0 through EL; use number() and a formatting tag, or an integer conversion, where an integer is wanted. And a node-set stored by x:set (dune) can be used as the start of later paths ($dune/author).

x:if, x:choose and x:forEach: Control Flow on XPath

The flow tags are the XPath versions of c:if, c:choose and c:forEach; the test expression is evaluated by XPath and converted to a boolean as the boolean() function does (a number is true unless zero or NaN; a node-set is true if it is non-empty; a string is true if it is not empty).

Table 18. The XML flow tags
Tag Attributes Notes

x:if

select (required); var, scope (optional; var required if scope is given)

Renders the body when the expression is true; var receives the Boolean

x:choose

none

Body only x:when (one or more) and then at most one x:otherwise; white space is allowed around them

x:when

select

Child of x:choose, before any x:otherwise; the first true one is rendered

x:otherwise

none

Last child of x:choose

x:forEach

select (required); var, varStatus (a LoopTagStatus), begin, end, step (dynamic int)

Iterates over a node-set; var and varStatus have nested visibility; an empty select is a JspException; begin at least 0, step at least 1; end less than begin runs nothing

The second half of xml-basic.jsp uses all of them, with a request parameter as an XPath variable.

xml-basic.jsp (second half)
<ul>
<x:forEach select="$catalog//book" var="b" varStatus="s" begin="1" end="3" step="2">
    <li>${s.count}/${s.index}: <x:out select="title"/> (<x:out select="@year"/>) price <x:out select="$b/price"/></li>
</x:forEach>
</ul>
<x:if select="$catalog//book[@year < 1930]" var="hasOld">old books present</x:if> (${hasOld})
<x:choose>
    <x:when select="$catalog//book[@genre='fantasy']">fantasy</x:when>
    <x:when select="$catalog//book[@genre='classic']">classic available</x:when>
    <x:otherwise>nothing</x:otherwise>
</x:choose>
<p>param: <x:forEach select="$catalog//book[@genre=$param:genre]"><x:out select="title"/>; </x:forEach></p>
<ul>
<li>1/1: Foundation &amp; Empire (1951) price 9.90</li>
<li>2/3: The Great Gatsby (1925) price 7.25</li>
</ul>
old books present (true)
classic available<p>param: Dune; Neuromancer; Foundation &amp; Empire; </p>

The first line of the output is wrong, and it is the most useful lesson of this page. The loop has begin="1" end="3" step="2", so it visits the books at index 1 (Neuromancer) and 3 (The Great Gatsby); varStatus reports index 1 and 3 correctly, and $b/price (path from the loop variable) is right, but the relative path title printed Foundation & Empire for the first iteration, the book at index 2. The relative XPath is evaluated against a context node that is one step ahead of var whenever step is greater than one. A second page, xml-loop.jsp, reproduced it without begin and end:

xml-loop.jsp (the step case)
<x:forEach select="$catalog//book" var="b" varStatus="s" step="2">${s.index}: <x:out select="title"/> | <x:out select="$b/title"/> | <x:out select="@id"/>
</x:forEach>
0: Neuromancer | Dune | 2
2: The Great Gatsby | Foundation &amp; Empire | 4

The specification says the context node is "the node whose representation would be exposed by var (whether or not the var attribute is specified)". On Tomcat 11.0.26 with JSTL 3.0.1 this holds without step (the plain loop printed Dune | Dune | 1 and so on) and not with step above 1, so the workaround is to name the loop variable and write paths from it ($b/title, $b/@id) in any loop that uses step or begin; it is also the style that stays readable.

The other flow-control results: x:if select="$catalog//book[@year < 1930]" is true (the 1925 book) and its var holds true; x:choose skipped the empty fantasy test and took the second x:when; the last loop used $param:genre and a request with ?genre=scifi listed three books. Two things about that XPath: the < in the first select is written unescaped, because in the standard (non-XML) JSP syntax an attribute value is not an XML text and < would be passed to XPath as four literal characters and fail ("Expected ], but found: <"); in a JSP document (XML syntax) it has to be escaped (see the JSP documents page). And a request without the genre parameter is an exception, not an empty list:

resolveVariable for variable {http://java.sun.com/jstl/xpath/param}genre returning null

The variable namespaces still use the old java.sun.com URIs inside the XPath engine; that is an implementation detail and not something a page writes.

The specification’s x:if example

The x:if example in the specification, <x:if select="$customer/[location='UK']">, is not valid XPath 1.0 (a location step cannot follow / with a predicate alone) and fails on Tomcat 11.0.26 with "A relative location path was expected following the '/' or '//' token". The valid spelling is $customer[location='UK'] or $customer/*[location='UK']. It is recorded among the inconsistencies found while writing this section.

XPath Variables from the Request

The scope variables of the table in The XML Library: XPath as a Local Language were each tried by xml-edge.jsp, with the cookie theme=dark set by curl -b.

xml-edge.jsp (the variable part)
<c:set var="genre" value="scifi" scope="request"/>
<c:set var="minYear" value="1960" scope="session"/>
<x:parse var="cat"><c:import url="/WEB-INF/data/catalog.xml"/></x:parse>
scopes: <x:out select="count($cat//book[@genre=$requestScope:genre and @year > $sessionScope:minYear])"/>
header: <x:out select="string($header:accept)"/>
cookie: <x:out select="string($cookie:theme)"/>
initParam: <x:out select="string($initParam:jakarta.servlet.jsp.jstl.sql.maxRows)"/>
<c:catch var="e1"><x:out select="$undefinedVar/book"/></c:catch>
undefined variable: <c:out value="${e1.message}"/>
scopes: 2
header: */*
cookie: dark
initParam: 100
undefined variable: javax.xml.xpath.XPathExpressionException: javax.xml.transform.TransformerException: resolveVariable for variable undefinedVar returning null

Two sharp edges. A request that has no Cookie header at all makes $cookie:name fail with a NullPointerException ("Cannot read the array length because "c" is null") in the 3.0.1 implementation, instead of an unbound-variable error, so a page that reads a cookie guards it (a request-scoped value set by a filter or servlet is better). And an unbound variable is always an exception: there is no way to test for existence from inside XPath, so a page tests the request first (c:if test="${not empty param.genre}") and sets a default.

Then the rest of the second page: the results of x:set and the old attribute and empty documents.

xml-edge.jsp (the rest)
<c:catch var="e2"><x:parse var="p" doc="${''}"/></c:catch>
empty doc: <c:out value="${e2.message}"/>
<c:catch var="e3"><x:parse var="p3" xml="<a/>"/></c:catch>
old xml attr: <c:out value="${empty e3 ? 'accepted' : e3.message}"/>
<x:set var="books" select="$cat//book[@genre='scifi']"/>
set then forEach: <x:forEach select="$books" var="b"><x:out select="$b/@id"/>,</x:forEach>
set then out: <x:out select="$books/title"/>
number: <x:out select="$cat//book[1]/@year + 1"/> bool: <x:out select="$cat//book[1]/@year > 1900"/>
empty doc: org.xml.sax.SAXParseException; systemId: jstl:; lineNumber: 1; columnNumber: 1; Premature end of file.
old xml attr: accepted
set then forEach: 1,2,3,
set then out: Dune
number: 1966 bool: true

An empty document is reported as the parser’s own "Premature end of file", not as the specification’s "source XML document is null or empty"; x:out of a node-set prints the string value of the first node (Dune); and the deprecated xml attribute is still accepted.

Namespaces

An element name without a prefix in XPath means "no namespace". A document whose elements are in a default namespace is therefore invisible to //entry, a classic source of "my XPath finds nothing". The Bookshelf feed feed.xml declares a default namespace and a prefixed one.

WEB-INF/data/feed.xml
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="https://example.org/ns/shelf" xmlns:bk="https://example.org/ns/books">
    <entry bk:isbn="X1"><title>Dune</title></entry>
    <entry bk:isbn="X2"><title>Solaris</title></entry>
</feed>
xml-more.jsp
<x:parse var="feed"><c:import url="/WEB-INF/data/feed.xml"/></x:parse>
<c:catch var="e1">ns prefix: <x:out select="count($feed//entry)"/></c:catch>
<c:catch var="e2">local-name: <x:out select="count($feed//*[local-name()='entry'])"/> / <x:out select="$feed//*[local-name()='entry'][2]/*[local-name()='title']"/></c:catch>
<c:catch var="e3">declared prefix: <x:out select="count($feed//s:entry)"/></c:catch> ${empty e3 ? '' : e3.message}
<c:catch var="e4">attr: <x:out select="$feed//*[local-name()='entry'][1]/@*[local-name()='isbn']"/></c:catch>
ns prefix: 0
local-name: 2 / Solaris
declared prefix: javax.xml.xpath.XPathExpressionException: org.eclipse.tags.shaded.org.apache.xpath.domapi.XPathStylesheetDOM3Exception: Prefix must resolve to a namespace: s
attr: X1

The specification promises "namespace prefix definitions" in the XPath context but gives no way to declare one from a page (the tags have no attribute for it), and a prefix that is not resolvable is an error (Prefix must resolve to a namespace). The portable answer is local-name() (and, for a namespace check, namespace-uri()), as in the page. The stack trace shows the implementation: XPath is evaluated by an embedded copy of Apache Xalan’s XPath engine that the implementation bundles under org.eclipse.tags.shaded, which is why only XPath 1.0 is available.

x:transform and x:param: XSLT

x:transform applies an XSLT stylesheet to a document. The document comes from doc (a String, Reader, javax.xml.transform.Source, DOM Document, or an object exported by x:parse or x:set) or from the body; the stylesheet from xslt (a String, Reader or Source). Parameters are nested x:param tags.

Table 19. x:transform and x:param attributes
Attribute Type Meaning

doc

See above (dynamic)

The source document; xml is the deprecated name

xslt

String, Reader, Source (dynamic)

The stylesheet

docSystemId, xsltSystemId

String (dynamic)

System identifiers used to resolve references in the document and in the stylesheet; xmlSystemId is the deprecated name of docSystemId

var, scope

String

Store the result as an org.w3c.dom.Document instead of writing it

result

javax.xml.transform.Result (dynamic)

An object that receives the result (alternative to var)

x:param name, value

String and Object (dynamic)

A stylesheet parameter; value may be the body of the tag

By default the result is written to the page. An empty source document or stylesheet is a JspException. An implementation may cache compiled stylesheets, and the specification warns that a cache must key on both xslt and xsltSystemId.

The Bookshelf stylesheet turns the catalog into a list of the books of one genre, sorted by year; the genre is a stylesheet parameter with a default.

WEB-INF/data/catalog.xsl
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <xsl:output method="html" omit-xml-declaration="yes"/>
    <xsl:param name="genre" select="'scifi'"/>
    <xsl:template match="/catalog">
        <ul class="{$genre}">
            <xsl:for-each select="book[@genre = $genre]">
                <xsl:sort select="@year" data-type="number"/>
                <li><xsl:value-of select="title"/> (<xsl:value-of select="@year"/>)</li>
            </xsl:for-each>
        </ul>
    </xsl:template>
</xsl:stylesheet>
xml-transform.jsp
<c:set var="genre" value="${empty param.genre ? 'scifi' : param.genre}"/>
<c:import url="/WEB-INF/data/catalog-xsl.jsp" var="xsl"/>
<x:transform xslt="${xsl}">
    <x:param name="genre" value="${genre}"/>
    <c:import url="/WEB-INF/data/catalog.xml"/>
</x:transform>
-- captured in a variable (org.w3c.dom.Document), parsed again
<x:transform var="out" xslt="${xsl}"><c:import url="/WEB-INF/data/catalog.xml"/></x:transform>
<x:out select="count($out//li)"/> items, first: <x:out select="$out//li[1]"/>
-- varDom
<x:parse varDom="dom"><c:import url="/WEB-INF/data/catalog.xml"/></x:parse>
<x:transform doc="${dom}" xslt="${xsl}"><x:param name="genre" value="classic"/></x:transform>
<ul class="scifi">
    <li>Foundation &amp; Empire (1951)</li>
    <li>Dune (1965)</li>
    <li>Neuromancer (1984)</li>
</ul>

-- captured in a variable (org.w3c.dom.Document), parsed again
3 items, first: Foundation &amp; Empire (1951)
-- varDom
<ul class="classic">
    <li>The Great Gatsby (1925)</li>
</ul>

x:transform accepted a document in the body, a varDom document, and a stylesheet imported as a string (through the one-line wrapper JSP, because a static .xsl would hit the c:import problem of Getting the XML In). The HTML produced by the stylesheet is written as it is, so a stylesheet is trusted code that must escape the data it prints; xsl:value-of escapes text by default, which is why Foundation & Empire is & in the output. XSLT 1.0 only: xsl:sort, xsl:for-each, parameters and the common instructions work, but nothing from XSLT 2 or 3 (no xsl:for-each-group, no regular expressions).

A Remote Feed, the Classic Use

The JSTL in Action book builds an RSS reader; the same shape is still the classic use of these tags, and still the one that needs the most care. The page rss.jsp imports a feed over HTTP, parses it and lists the items. The scratch application serves its own feed from new-books.jsp; a real page would use the publisher’s URL (the host publisher.example.com appears inside the feed only as data).

rss.jsp
<c:set var="feedUrl" value="http://localhost:18080/g5jstlsqlxml/new-books.jsp"/>
<c:catch var="e">
    <c:import url="${feedUrl}" var="rssText"/>
    <x:parse var="rss" doc="${rssText}"/>
    <x:forEach select="$rss/rss/channel/item" var="item">
        <x:out select="$item/title"/> -> <x:out select="$item/link"/>
    </x:forEach>
</c:catch>
<c:out value="${e.message}"/>
Hyperion -> https://publisher.example.com/b/5
Rendezvous with Rama -> https://publisher.example.com/b/6

The risks are the ones of any server-side fetch: c:import of an absolute URL has no timeout attribute, so a slow host holds a request thread; the URL must never come from the request (a visitor-chosen URL makes the server fetch internal addresses, a server-side request forgery); the document is parsed in memory on every request, so a page that serves many visitors caches it; and the remote text is untrusted (see XML Security: XXE, XSLT, XPath Injection and Escaping). A servlet with a client that has timeouts and a cache is the right place, and the page then reads a bean.

XML Security: XXE, XSLT, XPath Injection and Escaping

Table 20. Security of the XML tags
Risk What was verified What to do

External entities (XXE)

A document with <!DOCTYPE c [<!ENTITY s SYSTEM "file:///etc/hostname">]> is refused by x:parse on Tomcat 11.0.26 with JSTL 3.0.1: "'file' access is not allowed due to restriction set by the accessExternalDTD property". The implementation enables the JAXP feature XMLConstants.FEATURE_SECURE_PROCESSING on its parser factory and its transformer factories (found in the class files and shown by the error). A document taken from a request parameter behaves the same

Keep the 3.0.1 implementation or newer, never turn the restrictions off with system properties, and parse documents from users in Java with a factory that disallows DOCTYPE declarations entirely

XSLT document() and xsl:include

A stylesheet that calls document('file:///etc/hosts') fails with "'file' access is not allowed due to restriction set by the accessExternalStylesheet property"

Stylesheets are code: take them only from the application, never from a request

XPath injection

XPath built by concatenating a request value into select would be injectable. The specification gives the safe form: $param:name and the scope variables bind the value as data ([@genre=$param:genre] worked above). Note that select is not an EL attribute, so there is no concatenation to do

Always use variables for visitor values, as with sql:param

Output escaping

x:out escapes by default; escapeXml="false" does not

Leave it on for any text that did not come from you; XML attribute values and element text can carry markup

Server-side fetch

c:import of a URL built from input

Use an allow-list of feed addresses in the servlet

Denial of service

The whole document becomes a tree in memory on every request, and XPath // is a full scan

Size limits and caching in a servlet; the filter attribute prunes early

The test page xml-xxe.jsp imports the entity document from a file and from a request parameter, and the output for both was the same error text about accessExternalDTD (the file read is blocked). The entity attack is the one that makes XML tags in a page worth a security review: the restriction is a default of the implementation, not a promise of the specification (the chapter only says x:parse does not validate), and a container or a different implementation on the class path may behave otherwise. A project that keeps these tags runs the check above once per implementation upgrade.

Forbidding the Libraries: PermittedTaglibsTLV

The best defence for a team that wants nothing of this page in its code is to forbid the libraries. The specification’s chapter 14 provides two tag library validators for that. jakarta.servlet.jsp.jstl.tlv.PermittedTaglibsTLV lists the tag libraries a page may import, and ScriptFreeTLV forbids scripting elements. A validator is attached to a tag library descriptor; every page that imports that library is then checked at translation time. The TLD below allows the core, formatting and functions libraries and (a requirement of the TLV) itself, and nothing else.

WEB-INF/tlds/no-sql-xml.tld
<?xml version="1.0" encoding="UTF-8"?>
<taglib xmlns="https://jakarta.ee/xml/ns/jakartaee"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-jsptaglibrary_3_0.xsd"
        version="3.0">
    <description>Allows only the core, formatting and function libraries on a page.</description>
    <tlib-version>1.0</tlib-version>
    <short-name>nosqlxml</short-name>
    <uri>https://example.org/bookshelf/no-sql-xml</uri>
    <validator>
        <validator-class>jakarta.servlet.jsp.jstl.tlv.PermittedTaglibsTLV</validator-class>
        <init-param>
            <param-name>permittedTaglibs</param-name>
            <param-value>
                jakarta.tags.core
                jakarta.tags.fmt
                jakarta.tags.functions
                https://example.org/bookshelf/no-sql-xml
            </param-value>
        </init-param>
    </validator>
</taglib>
guarded.jsp
<%@ taglib prefix="guard" uri="https://example.org/bookshelf/no-sql-xml" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="sql" uri="jakarta.tags.sql" %>
<c:out value="never reached"/>
Validation error messages from TagLibraryValidator for [nosqlxml] in [/guarded.jsp]
null: taglib guard (https://example.org/bookshelf/no-sql-xml) allows only the following taglibs to be imported: [https://example.org/bookshelf/no-sql-xml, jakarta.tags.fmt, jakarta.tags.functions, jakarta.tags.core]

A page that imports only the guard and the core library (guarded-ok.jsp) runs normally. The guard has to be imported by the page to take effect, so teams enforce it with a shared header include (an include-prelude in a jsp-property-group of web.xml, described on the configuration page). The specification’s own example TLDs in chapter 14 contain markup errors (a <jsp-version> element closed with </jsp.version> and a <short-name> closed with </scriptfree>) that make them unusable as printed; the TLD above is the corrected, tested form.

Worked Example: Importing the Catalog File into the Database

The two libraries meet in a one-off administrative task of the Bookshelf: load catalog.xml into the book table in one transaction. It is a prototype page, exactly what these tags are meant for, and it shows the libraries working together: x:forEach inside sql:transaction, XPath results turned into typed parameters with x:set, and a count at the end. Each book is stored with an offset of 100 on its id so that it does not collide with the rows of setup.jsp.

xml-import-db.jsp
<%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="sql" uri="jakarta.tags.sql" %>
<%@ taglib prefix="x" uri="jakarta.tags.xml" %>
<x:parse var="catalog"><c:import url="/WEB-INF/data/catalog.xml"/></x:parse>
<sql:transaction>
    <sql:update>DELETE FROM shelf WHERE book_id > 100</sql:update>
    <sql:update>DELETE FROM book WHERE id > 100</sql:update>
    <x:forEach select="$catalog/catalog/book" var="b">
        <x:set var="id" select="number($b/@id) + 100"/>
        <x:set var="title" select="string($b/title)"/>
        <x:set var="author" select="string($b/author)"/>
        <x:set var="year" select="number($b/@year)"/>
        <sql:update var="n">
            INSERT INTO book (id, title, author, published_year, added_on) VALUES (?, ?, ?, ?, CURRENT_DATE)
            <sql:param value="${id}"/>
            <sql:param value="${title}"/>
            <sql:param value="${author}"/>
            <sql:param value="${year}"/>
        </sql:update>
imported ${title}: ${n} row
    </x:forEach>
</sql:transaction>
<sql:query var="r">SELECT COUNT(*) AS n FROM book WHERE id > 100</sql:query>
rows now: ${r.rows[0].n}
imported Dune: 1 row
imported Neuromancer: 1 row
imported Foundation & Empire: 1 row
imported The Great Gatsby: 1 row
rows now: 4

The number() results are Double objects (id 101.0), which sql:param bound to a BIGINT and an INT without complaint on H2; the string() results are String objects for the text columns, which is the combination the specification documents. The page ran twice with the same result because the first statements clear the previous load. As a design it has every smell listed at the beginning of this page (a write that a GET can trigger, a file and a database in a view, no authentication), and in a real project it becomes a servlet or a batch job that uses JAXB or StAX and a repository.

Pitfalls and Error Names

Table 21. Pitfalls of the SQL and XML tags
Symptom or error Cause Fix

JspException "dataSource is null"

No dataSource attribute and no configuration variable (jakarta.servlet.jsp.jstl.sql.dataSource)

Declare the context parameter in web.xml or use sql:setDataSource

"Unable to get connection, DataSource invalid …​ No suitable driver found for jdbc/missing"

A JNDI name that is not bound falls through to the JDBC parameter string

Check the Resource name in context.xml; the name is relative to java:comp/env/

Everything returns another database’s tables

A sql:setDataSource without var replaced the page’s configuration variable

Give it a var and pass dataSource explicitly, or configure once in web.xml

All rows come back for a visitor’s text

The value was concatenated into the SQL with ${...}

Use ? and sql:param

"It is illegal to specify a DataSource when nested within a <transaction>"

dataSource on a tag inside sql:transaction (translation-time error)

Remove the attribute; set it on the transaction

Half of a group of updates is saved

The updates were not inside one sql:transaction; each sql:update commits on its own

Wrap them; check the result with c:catch

rollback or commit as a statement does nothing useful

The tag handler commits and rolls back itself

Do not issue transaction statements through sql:update

Page 2 repeats a row of page 1

startRow/maxRows without ORDER BY

Always sort on a unique key

Database error text in the HTML

The JspException message holds the SQL

Catch it, log it, show a reference number

A String bound to a number column fails (or works only on some databases)

sql:param is documented for text columns

Pass a number object (fmt:parseNumber, x:set with number())

Memory leak warning at undeploy about the JDBC driver

A driver in WEB-INF/lib is registered with DriverManager

Put the driver in the container’s lib for container pools

x:parse fails with "Premature end of file"

The text was empty: c:import of a static file with var or varReader on Tomcat 11, or an empty response

Put c:import in the body of x:parse, or serve the file through a JSP/servlet

XPath finds nothing in a document with xmlns

Unprefixed names mean "no namespace"

*[local-name()='name']

"resolveVariable for variable …​ returning null"

An XPath variable ($param:x) is not bound

Test and default it before the tag

NullPointerException ... "c" is null

$cookie: in a request with no cookies

Do not read cookies in XPath

Wrong title inside x:forEach step=

The relative context node is off by one with step above 1

Use $var/path

"Expected ], but found: <"

< written in select of a standard-syntax JSP

Write < there; escape only in a JSP document

"Prefix must resolve to a namespace"

A prefix in XPath that no declaration resolves

local-name()

count() prints 3.0

XPath numbers are Double

number() and a formatting tag

SAXParseException with "accessExternalDTD"

An external entity or DTD was refused by secure processing

Remove the DOCTYPE; do not weaken the restriction

"Scheme specific part cannot be empty"

A relative DTD reference without systemId

Pass systemId, or drop the DOCTYPE

What Changed Recently

Table 22. History of the two libraries
Version Change for sql: and x:

JSTL 1.0 (2002)

Two libraries per area (http://java.sun.com/jstl/sql for expression language attributes and _rt for rtexprvalues), x:parse takes xml=

JSTL 1.1 and 1.2

One library per area (http://java.sun.com/jsp/jstl/sql and .../xml), EL in attributes through JSP 2.0, x:parse xml= replaced by doc= (deprecated names kept), fn: functions added

JSTL 2.0 (Jakarta EE 9)

Package rename: javax.servlet.jsp.jstl becomes jakarta.servlet.jsp.jstl (spec Appendix A.1)

JSTL 3.0 (Jakarta EE 10, current)

The tag URIs are renamed to jakarta.tags.* (spec issue 144), the API needs Java 11, a JPMS module descriptor is added, and the XML Binding dependency leaves the API POM (Appendix B.1). The old java.sun.com URIs are still accepted by the 3.0.1 implementation. The implementation enables secure XML processing on its parser and transformer factories. The Jakarta EE Tutorial calls a subset of Jakarta Tags archived

JSTL 3.1 (under development)

Not released; the 3.1 specification page marks it as under development. Nothing on this page depends on it

The rows for JSTL 1.0, 1.1 and 1.2 come from the book sections of the appendix of the issue and from the spec’s own B.2 and the deprecation footnotes; the rest were checked. The JSTL 3.0.1 implementation is also the last release of org.glassfish.web:jakarta.servlet.jsp.jstl on Maven Central (published 2023-01-19), and the JSTL API 3.0.2 the latest of the API; both projects live in the Jakarta EE repositories jakartaee/tags and eclipse-ee4j/jstl-api.

Where the Books Are Outdated

The two books that cover this page’s tags are JSTL in Action (JSTL 1.0; chapters 7 and 8 for XML and 9 for databases, with the SQL primer in the appendices) and Web Development with JavaServer Pages (JSP 1.2; chapter 9 for databases). They remain good for the ideas (XPath data model, location paths and predicates, XPath variables, result paging, transactions and isolation) and out of date in the details:

Table 23. Outdated in the books
In the books Today

http://java.sun.com/jstl/sql, http://java.sun.com/jstl/xml and the sql_rt, x_rt libraries

One library per area with jakarta.tags.sql and jakarta.tags.xml; the _rt twins do not exist since JSTL 1.1 (EL is part of JSP 2.0)

javax.servlet.jsp.jstl.sql.*, configuration variables javax.servlet.jsp.jstl.sql.dataSource

jakarta.servlet.jsp.jstl.sql.* and jakarta.servlet.jsp.jstl.sql.dataSource

<x:parse xml="...">, <x:transform xml=...> and xmlSystemId

doc and docSystemId; the old names are deprecated (still accepted by 3.0.1)

sql:setDataSource with a literal password, MySQL and Oracle driver class names of 2002 (the specification’s own example still names org.gjt.mm.mysql.Driver)

A container-managed DataSource with the credentials outside the page, a current driver class

JDBC from scriptlets, Class.forName, a hand-written pool, sun.jdbc.rowset.CachedRowSet

A DataSource, try-with-resources, RowSetProvider.newFactory().createCachedRowSet() if a disconnected result is needed (JDK 7 and later), or a data layer (see the Hibernate pages)

The XML engine of the 2002 reference implementation

The 3.0.1 implementation embeds a shaded Xalan XPath engine (org.eclipse.tags.shaded in its stack traces) and uses the JDK’s JAXP parser and transformer, with secure processing enabled

No mention of XXE, secure processing or accessExternalDTD

A current concern: see XML Security: XXE, XSLT, XPath Injection and Escaping

$cookie, $param, $header XPath variables

Unchanged

RowSet and the Result classes in javax.servlet.jsp.jstl.sql

Same API under jakarta.servlet.jsp.jstl.sql (Result, ResultSupport, SQLExecutionTag)

The specification examples carry old habits too. The chapter 10 sample jdbc:mysql://localhost/,org.gjt.mm.mysql.Driver uses a driver class of the year 2000, <x:if select="$customer/[location='UK']"> is not valid XPath, the syntax of x:forEach in 12.6 has a stray > after select, and the two example TLDs of chapter 14 do not parse. They are listed with the other inconsistencies found in this section. What the specification is right about, and the books agree: these tags are for a prototype, a small internal tool or a stop-gap, and the day the page needs a second consumer, a test, a pool setting or a security review, the query moves to Java.

References