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.
| Library | URI and prefix | Tags | What it is for |
|---|---|---|---|
SQL |
|
|
Run JDBC queries and updates from a page, show the rows, group updates into a transaction (spec chapter 10) |
XML core |
|
|
Parse an XML document and evaluate XPath 1.0 expressions against it (spec chapter 11) |
XML flow control |
|
|
Decide and loop on XPath results (spec chapter 12) |
XML transform |
|
|
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.
| 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 |
|
A |
Injection |
A statement built with |
Parameters always; better, no SQL in pages |
Transactions |
|
A transaction owned by a service |
Error handling and leaks |
Every failure becomes a |
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 |
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.
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.
<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-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>
Source: H2 — in-memory databases
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.
<%@ 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.
The value can therefore be given in five ways. The page datasource.jsp uses all of them in turn against the same database.
| Form | Where it is written | Notes |
|---|---|---|
Configuration variable |
|
Used when a tag has no |
Attribute with a |
|
Needs a servlet, a listener or |
Attribute with a JNDI relative path |
|
|
Attribute with the JDBC parameter string |
|
A comma inside a parameter is written |
|
Page or servlet code |
Stores a |
| Attribute | Type | Meaning |
|---|---|---|
|
|
Alternative to the four JDBC attributes: a |
|
|
JDBC URL ( |
|
|
Driver class name, loaded when given |
|
|
Credentials passed to |
|
|
Name of the exported variable (not dynamic) |
|
|
Scope of |
The tag is empty (no body), and a null dataSource is a JspException.
<%@ 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.
| Attribute | Type (dynamic) | Meaning |
|---|---|---|
|
|
The statement; alternatively it is the body of the tag |
|
|
See Choosing the Data Source; must not be given when the tag is nested in |
|
|
Largest number of rows kept; |
|
|
Index of the first row kept; row 0 is the first row of the result set; default 0 |
|
|
Required: the exported |
|
|
Scope of |
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:
| EL property | Java type | Meaning |
|---|---|---|
|
|
One map per row, column name to value; the map uses |
|
|
One array per row in column order; the generic way to print a table without knowing the columns |
|
|
Column labels as the database reports them (H2 reports upper case: |
|
|
Number of rows kept in the result (after |
|
|
|
Source: Jakarta Tags 3.0 API — 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.
<%@ 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.
<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.
| Tag | Attributes | Behaviour |
|---|---|---|
|
|
A |
|
|
Converts the value to |
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.
<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>
Source: Jakarta Standard Tag Library 3.0 — 10.3 sql:query (syntax 3, query with optional parameters)
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.
<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.
| Attribute | Type (dynamic) | Meaning |
|---|---|---|
|
|
The statement; alternatively the body of the tag |
|
|
As for |
|
|
Optional: exported as a |
|
|
Scope of |
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.
<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.
isolation value |
JDBC constant it selects |
|---|---|
|
|
|
|
|
|
|
|
(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.
<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:
<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.
<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: <script>alert(1)</script>
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.
| Variable (context parameter name) | Config constant |
Type | Use |
|---|---|---|---|
|
|
|
The data source for tags without a |
|
|
|
Cap for every |
Source: Jakarta Tags 3.0 API — Config
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.
@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);
}
}
<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:
| Tag | Replacement |
|---|---|
|
A pooled |
|
|
|
|
|
A service method with the same SQL (or an entity update) behind a |
|
A transaction owned by the service layer ( |
|
|
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.
| Rule | What it means |
|---|---|
The |
XPath is written only in |
Default context node |
Outside an |
Context in |
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 ( |
Namespaces |
Prefixes used in an XPath expression must resolve to a namespace (see Namespaces) |
Result types |
XPath boolean, number and string become |
Source: W3C — XPath 1.0
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.
| Expression | Value |
|---|---|
|
|
|
|
|
|
|
The value of the cookie named |
|
|
|
|
| Java type of the variable | XPath type |
|---|---|
|
boolean |
|
number |
|
string |
An object exported by |
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.
<?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 & 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.
| Source | Works for | Notes |
|---|---|---|
Body of |
Inline XML, |
|
|
Absolute URLs, dynamic resources (a JSP or servlet), not static files |
Verified: for |
|
As above |
Same effect as |
A servlet |
Anything |
Reads and parses with a hardened parser, puts a |
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.
<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.
| Attribute | Type | Meaning |
|---|---|---|
|
|
The XML source. Alternatively the body of the tag |
|
|
Deprecated: use |
|
|
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 |
|
|
A SAX filter applied before the tree is built |
|
|
Scoped variable for the tree; its type is implementation dependent; usable in |
|
|
Scoped variable for the tree as an |
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).
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);
}
}
}
<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.
<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.
| Tag | Attributes | Behaviour |
|---|---|---|
|
|
Evaluates the expression, converts the result as the XPath |
|
|
Stores the result in the scoped variable; its type is whatever the expression evaluates to (see the type table) |
Source: Jakarta Tags 3.0 tagdocs — x:out
Source: Jakarta Tags 3.0 tagdocs — x:set
The page xml-basic.jsp shows both, plus the flow tags of the next section. This is its 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 & 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).
| Tag | Attributes | Notes |
|---|---|---|
|
|
Renders the body when the expression is true; |
|
none |
Body only |
|
|
Child of |
|
none |
Last child of |
|
|
Iterates over a node-set; |
The second half of xml-basic.jsp uses all of them, with a request parameter as an XPath variable.
<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 & 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 & 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:
<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 & Empire | 4
Source: Jakarta Standard Tag Library 3.0 — 12.6 x:forEach (the context node is the node exposed by var)
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.
<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.
<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.
<?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>
<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
Source: W3C — XPath 1.0
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.
| Attribute | Type | Meaning |
|---|---|---|
|
See above (dynamic) |
The source document; |
|
|
The stylesheet |
|
|
System identifiers used to resolve references in the document and in the stylesheet; |
|
|
Store the result as an |
|
|
An object that receives the result (alternative to |
|
|
A stylesheet parameter; |
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.
<?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>
<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 & 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 & Empire (1951)
-- varDom
<ul class="classic">
<li>The Great Gatsby (1925)</li>
</ul>
Source: W3C — XSLT 1.0
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).
<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
| Risk | What was verified | What to do |
|---|---|---|
External entities (XXE) |
A document with |
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 |
A stylesheet that calls |
Stylesheets are code: take them only from the application, never from a request |
XPath injection |
XPath built by concatenating a request value into |
Always use variables for visitor values, as with |
Output escaping |
|
Leave it on for any text that did not come from you; XML attribute values and element text can carry markup |
Server-side fetch |
|
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 |
Size limits and caching in a servlet; the |
Source: W3C — XPath 1.0
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.
<?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>
<%@ 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.
<%@ 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
| Symptom or error | Cause | Fix |
|---|---|---|
|
No |
Declare the context parameter in |
"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 |
Everything returns another database’s tables |
A |
Give it a |
All rows come back for a visitor’s text |
The value was concatenated into the SQL with |
Use |
"It is illegal to specify a DataSource when nested within a <transaction>" |
|
Remove the attribute; set it on the transaction |
Half of a group of updates is saved |
The updates were not inside one |
Wrap them; check the result with |
|
The tag handler commits and rolls back itself |
Do not issue transaction statements through |
Page 2 repeats a row of page 1 |
|
Always sort on a unique key |
Database error text in the HTML |
The |
Catch it, log it, show a reference number |
A |
|
Pass a number object ( |
Memory leak warning at undeploy about the JDBC driver |
A driver in |
Put the driver in the container’s |
|
The text was empty: |
Put |
XPath finds nothing in a document with |
Unprefixed names mean "no namespace" |
|
"resolveVariable for variable … returning null" |
An XPath variable ( |
Test and default it before the tag |
|
|
Do not read cookies in XPath |
Wrong title inside |
The relative context node is off by one with |
Use |
"Expected ], but found: <" |
|
Write |
"Prefix must resolve to a namespace" |
A prefix in XPath that no declaration resolves |
|
|
XPath numbers are |
|
|
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 |
Pass |
What Changed Recently
| Version | Change for sql: and x: |
|---|---|
JSTL 1.0 (2002) |
Two libraries per area ( |
JSTL 1.1 and 1.2 |
One library per area ( |
JSTL 2.0 (Jakarta EE 9) |
Package rename: |
JSTL 3.0 (Jakarta EE 10, current) |
The tag URIs are renamed to |
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:
| In the books | Today |
|---|---|
|
One library per area with |
|
|
|
|
|
A container-managed |
JDBC from scriptlets, |
A |
The XML engine of the 2002 reference implementation |
The 3.0.1 implementation embeds a shaded Xalan XPath engine ( |
No mention of XXE, secure processing or |
A current concern: see XML Security: XXE, XSLT, XPath Injection and Escaping |
|
Unchanged |
|
Same API under |
Source: Manning — JSTL in Action
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
-
Jakarta Standard Tag Library 3.0 — 10.1.2 Querying a Database
-
Jakarta Standard Tag Library 3.0 — 10.1.3 Updating a Database
-
Jakarta Standard Tag Library 3.0 — 10.1.4 SQL Statement Parameters
-
Jakarta Standard Tag Library 3.0 — 11.1.2 XPath Variable Bindings
-
Jakarta Standard Tag Library 3.0 — 11.1.3 Java to XPath Type Mappings
-
Jakarta Standard Tag Library 3.0 — 11.1.4 XPath to Java Type Mappings
-
Jakarta Standard Tag Library 3.0 — 11.1.5 The select Attribute
-
Jakarta Standard Tag Library 3.0 — 11.1.6 Default Context Node
-
Jakarta Standard Tag Library 3.0 — 14 Tag Library Validators
-
Jakarta Standard Tag Library 3.0 — Appendix A: Compatibility & Migration
-
Apache Tomcat 11 — The Context Container, resource definitions
-
Manning — Web Development with JavaServer Pages, Second Edition