Expression Language Basics
|
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 Expression Language 6.0, Jakarta Pages 4.0 (chapter 2, "Expression Language"),
Jakarta Standard Tag Library 3.0 (chapter 3, "Expression Language Overview") and Jakarta Servlet 6.1, running on Apache
Tomcat 11.0.26 and Java 21. Everything was verified on 2026-10-08: the facts against the three specifications and the
Javadoc, and every example by building it into a Maven WAR and running it on Tomcat 11.0.26 with curl; the outputs and
error messages shown are the ones that run produced. The Expression Language (EL) is the small language that lives between
the ${ and } of a JSP page. It reads data that a servlet or a tag left in a scope, walks into beans, maps, lists and
arrays, compares and calculates, and turns the result into text. Since JSP 2.0 it is the normal way to read data in a view,
and scriptlets are legacy. It is also the part of JSP most often misused: an unknown name prints nothing instead of failing,
a missing property fails with a stack trace, #{} is not an alias for ${}, and nothing in it escapes HTML. This page is the
foundation for the other Expression Language pages of this section: it covers the two delimiters, where an expression may
appear, how data is reached, what happens when it is missing, and how the container decides which object answers a
lookup. Operators and type coercion, lambdas and streams, static access, records and Optional, and standalone use with
ELProcessor each have their own page. The running scenario is the Bookshelf, a small catalogue of books; the scopes
and implicit objects that EL reads are explained on the
Implicit Objects & Scopes page, and the servlet that fills them on the
Servlet Fundamentals page.
The Bookshelf Used on This Page
All the examples below are views of one WAR, deployed as g4elbasics.war under the context path /g4elbasics. A servlet
mapped to /view/* puts the same small object graph in the request for every view and forwards to
/WEB-INF/views/el/<name>.jsp; a web.xml declares the context parameter shelfName and one jsp-property-group
for a strict area. Every listing below is one of those views (the page directive and the taglib directives are left
out when they only repeat what the caption says), and every output is what curl printed.
public record Book(long id, String title, String author, int year) { }
public class Address {
private final String city;
public Address(String city) { this.city = city; }
public String getCity() { return city; }
}
public class Reader {
private final String name;
private final Address address;
public Reader(String name, Address address) { this.name = name; this.address = address; }
public String getName() { return name; }
public Address getAddress() { return address; }
}
@WebServlet("/view/*")
public class ViewServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String view = req.getPathInfo() == null ? "" : req.getPathInfo().substring(1);
if (!view.matches("[a-z0-9/-]+")) { // never forward to a path built from raw input
resp.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
Book dune = new Book(1, "Dune", "Frank Herbert", 1965);
List<Book> books = List.of(dune,
new Book(2, "Neuromancer", "William Gibson", 1984),
new Book(3, "Solaris", "Stanislaw Lem", 1961));
Map<String, Object> info = new LinkedHashMap<>();
info.put("title", "Map title");
info.put("my-key", "hyphenated key");
req.setAttribute("book", dune);
req.setAttribute("books", books);
req.setAttribute("info", info);
req.setAttribute("reader", new Reader("Ada", null)); // no address on file
req.setAttribute("bean", new Reader("Grace", new Address("Lisbon")));
// ... a Map<String, Book> named catalog, and a session attribute named user.name, are set the same way
req.getRequestDispatcher("/WEB-INF/views/el/" + view + ".jsp").forward(req, resp);
}
}
Two of the objects are chosen on purpose. Book is a Java record, which EL 6.0 reads without any getter (see
the resolver chain), and reader has no address, which is the everyday way a null reaches a page.
What the Expression Language Is
EL is an independent specification (Jakarta Expression Language 6.0, part of Jakarta EE 11) that Jakarta Pages, Jakarta
Tags (JSTL), Jakarta Faces and Jakarta Servlet applications all embed. The specification describes it as a language for
the presentation layer: a simple syntax restricted to the evaluation of expressions, variables and nested properties,
relational, logical, arithmetic, conditional and empty operators, functions implemented as static methods, and
lenient semantics in which defaults and conversions keep errors away from the reader of the page. Around the language it
defines a pluggable API (ELResolver, ELContext, ExpressionFactory, ValueExpression, MethodExpression) that the
container implements. Status: current and stable, shipped with Tomcat 11.0.26 and Jakarta EE 11; the 6.1 version is in
development (milestone 6.1.0-M2).
Jakarta Pages adds three things on top of the language. It defines where an expression may be written (template text and
attribute values, chapter 2 of the Pages specification), which objects are visible to it (the implicit objects and the
four scopes), and which resolvers answer a lookup (section 2.10). It also keeps three switches in the
configuration (isELIgnored, errorOnELNotFound, deferredSyntaxAllowedAsLiteral), which the
Configuration and Property Groups page describes.
| Piece | What it is | Where to read about it |
|---|---|---|
The language |
Expressions, literals, operators, the |
Jakarta Expression Language 6.0, chapters 1 and 2; this page and the four sibling pages of this section (operators, lambdas and streams, static access and configuration) |
The integration with JSP |
Where expressions may appear, the implicit objects, the resolver chain, the configuration switches |
Jakarta Pages 4.0, chapter 2 and section 3.3; this page |
The use in tag libraries |
|
Jakarta Tags 3.0, chapter 3 (an overview, not normative) and the tag documentation; this page |
The Java API |
|
Jakarta Expression Language 6.0 Javadoc |
Two Delimiters: Immediate and Deferred Evaluation
An EL expression is written between ${ and } or between #{ and }. The language itself parses and evaluates
both in exactly the same way; the difference is a convention of the technology that embeds the language. In the Jakarta
EE web tier ${expr} means immediate evaluation and #{expr} means deferred evaluation:
-
an immediate expression is compiled when the page is compiled and executed when the page runs, once, at the place where it is written, and the result is what the attribute or the text receives;
-
a deferred expression is parsed but not run: the tag handler receives the expression object (
jakarta.el.ValueExpressionorjakarta.el.MethodExpression) and decides when, and how often, to evaluate it. Jakarta Faces is the classic user, because a form field needs the expression both to read the value now and to write it later.
JSP template text only ever uses the immediate form. The deferred form belongs to tag attributes, and the specification states the rules as a table. The number of places is small, but each of them fails differently, so the rules are worth seeing run.
| Place | ${expr} |
#{expr} |
What happens otherwise |
|---|---|---|---|
Template text of a page, a tag file or the body of a custom action |
evaluated, result converted to a |
translation error |
|
Attribute that accepts a run-time expression (TLD |
evaluated now, value coerced to the attribute type |
translation error |
a deferred expression in a dynamic attribute is rejected at translation |
Attribute declared as |
translation error |
parsed and passed to the tag as a |
an immediate expression in a deferred attribute is rejected at translation |
Attribute declared both |
dynamic: evaluated now |
deferred: passed unevaluated |
the setter must accept |
Static attribute ( |
translation error |
translation error |
the value must be a literal |
Body of a tag whose |
not evaluated, passed as written |
not evaluated |
the tag receives the text unchanged |
A JSP comment ( |
ignored |
ignored |
nothing is translated |
An HTML comment ( |
evaluated, and sent to the client |
translation error |
JSP does not know it is inside a comment or a script |
Java code in a scriptlet, a declaration or a scripting expression |
not available |
not available |
use the |
The Specification’s Table, Run on Tomcat 11
The specification’s Table JSP.2-1 (section 2.3.6) takes a tag with a static, a dynamic and a deferred attribute and lists
nine uses. The tag below is a tag file, the shortest way to declare the three attribute categories; rtexprvalue="false"
makes an attribute static, a plain attribute is dynamic, and deferredValue="true" makes it deferred. The
implicit.tld file next to it is needed for one reason that the specification states and the error message does not: the
JSP version of an implicit tag library defaults to 2.0, and deferred attributes need 2.1 or later.
<%@ tag body-content="empty" trimDirectiveWhitespaces="true" %>
<%@ attribute name="stat" required="false" rtexprvalue="false" %>
<%@ attribute name="dyn" required="false" type="java.lang.String" %>
<%@ attribute name="def" required="false" deferredValue="true" rtexprvalue="false" deferredValueType="java.lang.String" %>
static=[${stat}] dynamic=[${dyn}] deferred=[${def}]
<?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_4_0.xsd"
version="4.0">
<tlib-version>1.0</tlib-version>
<short-name>bookshelf</short-name>
</taglib>
Each row of the table is a one-line view, such as <x:probe dyn="${book.title}"/> (the page declares the tag directory
with <%@ taglib prefix="x" tagdir="/WEB-INF/tags" %>). The observed result of each row is below; the status is the
HTTP status of the page.
| Use | Result | Observed |
|---|---|---|
|
OK |
|
|
ERROR |
HTTP 500, translation error |
|
ERROR |
the same message for |
|
OK |
|
|
OK |
|
|
ERROR |
the same message for |
|
OK |
|
|
ERROR |
the same message for |
|
OK |
|
All nine rows match the specification. The same error appears for a tag from a library: c:out has only a dynamic
value attribute, so <c:out value="#{book.title}"/> fails with attribute [value] does not accept any expressions
(observed). This is also the whole of what JSTL says about the delimiter: the Jakarta Tags specification states that the
expression language is invoked exclusively through ${expr}, because no standard JSTL attribute is deferred.
Without the implicit.tld, the first request for any page that uses the tag fails with Invalid JSP version defined for
tag file at [/WEB-INF/tags/probe.tag] (observed), including requests for the attributes that are not deferred. The
specification explains it in section 8.4.3: "the JSP version of an implicit tag library defaults to 2.0", and an
implicit.tld may set it.
Immediate Reads Now, Deferred Reads Later
The table says when an expression is evaluated, not what that means. The next tag shows it. It receives the same
request attribute twice, once as a dynamic attribute written with ${} and once as a deferred attribute written with
#{}, and between the two it changes the attribute.
<%@ tag body-content="empty" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ attribute name="value" required="true" deferredValue="true" rtexprvalue="false" deferredValueType="java.lang.String" %>
<%@ attribute name="now" required="true" %>
<c:set var="when" value="after" scope="request"/>
deferred=[${value}] immediate=[${now}]
<c:set var="when" value="before" scope="request"/>
<x:lazy value="#{when}" now="${when}"/>
deferred=[after] immediate=[before]
The immediate expression was evaluated when the page passed the attribute, so it holds the value from before the tag ran.
The deferred expression was evaluated by the tag, at the moment the tag file used ${value}, so it sees the value
after the change. In a tag file the deferred attribute is consumed as ${value} (Tomcat evaluates it there); a Java tag
handler receives the ValueExpression itself and calls getValue with its ELContext, which is how Faces components
and any custom tag that wants late binding work.
What the Page Author Must Remember About Mixing
-
A composite expression mixing the two delimiters in one attribute value is illegal. Tomcat reports
Cannot use both ${} and #{} EL expressions in the same attribute valuefordef="${book.title} #{book.year}"(observed). The specification gives the reason: a developer must not believe that the delimiter chooses when each part is evaluated. -
A nested expression such as
${books[${param.i}].title}is illegal. Tomcat reportsFailed to parse the expression [${books[${param.i}].title}](observed). The index needs no inner expression:${books[param.i].title}. -
A deferred attribute that is also dynamic needs care. A tag-file attribute written with
deferredValue="true"and the defaultrtexprvalue(true) is handled by Tomcat 11.0.26 as a deferred attribute only:#{...}works, while${...}and a plain literal fail at run time withClassCastException: class java.lang.String cannot be cast to class jakarta.el.ValueExpression(observed). Section 2.3.5 of the specification describes such an attribute as accepting all three, with a setter of typeObject, so writertexprvalue="false"(as above) unless a Java tag handler needs both.
Writing a Literal #{ or ${
Since JSP 2.1 the character sequence #{ is reserved. A page that merely contains it in template text is not a page that
prints it: it is a translation error, and ${ in a page that was written before JSP 2.0 is evaluated. Three ways out
exist, in order of preference: escape it, quote it, or switch the interpretation off.
escaped: \#{book.title} and \${book.title}
dollar-quoted: ${'${'}book.title}
escaped: #{book.title} and ${book.title}
dollar-quoted: ${book.title}
#{ as plain text (the page directive is shown)<%@ page contentType="text/plain;charset=UTF-8" deferredSyntaxAllowedAsLiteral="true" %>
literal hash: #{not evaluated} and ${book.title}
literal hash: #{not evaluated} and Dune
<%@ page contentType="text/plain;charset=UTF-8" isELIgnored="true" %>
ignored: ${book.title} #{book.title}
ignored: ${book.title} #{book.title}
The quoting form ${'${'}book.title} comes from the language specification (a string literal inside an expression);
the backslash form \${ and \#{ is the escape that the specification also defines for literal text, and it is the
one to use in a page. The language specification adds that other technologies embedding EL may not integrate literal
expressions at all, and that JSP then defines its own escaping; the three lines above are what Tomcat 11.0.26 does. A
template-text #{...} that is not escaped fails with the message already quoted, for example for
Title: #{book.title} in a page (observed):
org.apache.jasper.JasperException: /WEB-INF/views/el/dtext.jsp (line: [4], column: [9]) #{...} is not allowed in template text
Which form to use is a question of how much of the page is affected. A single literal uses the backslash. A page full of
text from another template language (a JavaScript template literal, for instance, see below) is a candidate for
deferredSyntaxAllowedAsLiteral or for isELIgnored, and a whole directory of old pages is a candidate for the
jsp-property-group elements of the same names. Both switches, their defaults and the page-versus-group precedence are
on the Configuration and Property Groups page, and the
Expression Language Configuration page of this section covers the deferred syntax further.
EL in Template Text
Anywhere that plain text is allowed in a page, in the body of a standard or custom action or outside any action, ${expr}
is evaluated by the container when the response is rendered. The result is converted to a String by the EL coercion
rules and inserted into the current output, with the same semantics as a Java expression (section 2.2 of the Pages
specification). Only ${} is allowed here. An expression
may appear several times in a text, and an attribute value may mix text and expressions: the specification calls this a
composite expression. The parts are evaluated from left to right, each converted to a String, and concatenated with the
text between them.
template text : ${book.title} by ${book.author} (${book.year})
composite : <c:out value="Price of ${book.title} is ${book.year}"/>
attribute : <c:out value="${book.title}"/>
escaped : \${book.title} | ${'${'}book.title}
quotes : ${'it\'s'} | ${"say \"hi\""} | ${'say "hi"'}
template text : Dune by Frank Herbert (1965)
composite : Price of Dune is 1965
attribute : Dune
escaped : ${book.title} | ${book.title}
quotes : it's | say "hi" | say "hi"
The fifth line shows the rules for string literals, which are the same as in the language specification. A string is
enclosed in single or double quotes; the backslash must be escaped as \\; the enclosing quote must be escaped, and the
other quote may be; and no other escape sequence exists, so \n is not a newline in EL. There are literals for
true, false, integers, floating-point numbers, strings and null.
A few more consequences of being evaluated as a String are worth knowing, and the next view shows them.
object (toString) : ${book}
number : ${book.year + 1}
missing parameter : ${param.start + 10}
boolean : ${book.year gt 1900 and not empty book.title}
object (toString) : Book[id=1, title=Dune, author=Frank Herbert, year=1965]
number : 1966
missing parameter : 10
boolean : true
An object is printed through its toString, which for the record Book shows every component. A number is printed
without any formatting (no grouping, no locale); output that needs formatting goes through the JSTL fmt library. A
parameter that is not present is null, and null in arithmetic is 0, which is why ${param.start + 10} printed 10. The
operators and the coercion rules are on the Operators and Type Coercion page of this section.
Template Text That Is Not Really Template Text
The container does not know what language the surrounding text is written in. Three places catch pages out, and one view shows all of them.
<%-- JSP comment: ${book.title} never reaches the client --%>
<!-- HTML comment: ${book.title} is evaluated and sent -->
<script>
const evaluated = `Hello ${book.title}`;
const kept = `Hello \${name}`;
</script>
<!-- HTML comment: Dune is evaluated and sent -->
<script>
const evaluated = `Hello Dune`;
const kept = `Hello ${name}`;
</script>
A JSP comment disappears at translation time, so nothing inside it is evaluated and nothing is sent. An HTML comment is
ordinary template text: the expression is evaluated, and its value is sent to every visitor, including data the page
author thought was hidden. And a JavaScript template literal uses the same ${...} as EL, so the container tries to
evaluate it: the first literal was filled in with the book title on the server, and the second needed the backslash to
reach the browser untouched. A page with much client-side script is better served by moving the script to a static file.
The fourth case is the body of a custom action. Template text in the body of a tag is evaluated, with one exception: a
tag declared with body-content="tagdependent" receives the body exactly as written.
<%-- raw.tag --%>
<%@ tag body-content="tagdependent" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<jsp:doBody var="body" scope="page"/>
body=[<c:out value="${body}"/>]
<%-- tagdep.jsp --%>
<x:raw>Title: ${book.title}</x:raw>
body=[Title: ${book.title}]
EL in Attribute Values
Any attribute of a standard or custom action that accepts a run-time expression value accepts an EL expression. The value
is parsed by ExpressionFactory.createValueExpression with the expected type from the TLD, evaluated immediately, coerced
to that type and handed to the tag. The JSTL specification shows the typical uses: a condition in c:if, text that mixes
literals and expressions in c:out, and numeric attributes such as begin and end in c:forEach that receive an
Integer or even a request parameter.
<c:if test="${book.year lt 1970}">${book.title} is a classic | </c:if><c:forEach var="b" items="${books}" begin="${param.start}" end="${param.start + 1}">${b.title}; </c:forEach>| <c:out value="${reader.address.city}" default="no address on file"/>
curl "http://localhost:18080/g4elbasics/view/jstl?start=1"Dune is a classic | Neuromancer; Solaris; | no address on file
Three things happened. The condition was a boolean expression, and lt is the word form of < that avoids entity
references in XML syntax. begin="${param.start}" received the string 1 from the request and the tag received an
int: the container coerced the value to the attribute’s type, and ${param.start + 10} earlier showed the same rule
for arithmetic. And c:out supplied its own default when its expression evaluated to null, because reader has no
address. A request parameter is user input, so start=abc would fail with ELException: Cannot convert [abc] of type [class java.lang.String] to [int] (observed; see the pitfalls).
The attribute type is declared in the TLD for a tag library or in the attribute directive of a tag file. The category
(static, dynamic or deferred) decides what the attribute accepts, as in the table above; the type decides the coercion.
Expressions are also accepted in the attributes of standard actions that allow request-time values, such as the page
attribute of jsp:include and the value attribute of jsp:setProperty
(see the Standard Actions and JavaBeans page). That is exactly
why an expression there must never come from the request unchecked.
The Names Every Expression Starts From
An EL expression starts with a name, and the name is resolved in a fixed order. The eleven EL implicit objects come
first (they cannot be hidden by an attribute of the same name); then every other name is looked up with
PageContext.findAttribute, that is in the page, request, session and application scopes in that order. The table is a
summary, with a different example from the one on the
Implicit Objects & Scopes page, where their
types, sources and traps are explained in full.
| Name | What it gives you | Bookshelf use |
|---|---|---|
|
the |
|
|
one scope each, as a |
|
|
request parameters: the first value, all values |
|
|
request headers: the first value, all values |
|
|
cookies by name, each a |
|
|
context initialization parameters of |
|
Other than the implicit objects, request and session are not EL names: they exist in scriptlets, and
${request.method} therefore prints nothing. The scope lookup is easiest to see with one name set in two scopes.
<c:set var="shelf" value="page level"/>
<c:set var="shelf" value="request level" scope="request"/>
shelf : ${shelf}
pageScope.shelf : ${pageScope.shelf}
requestScope.shelf : ${requestScope.shelf}
sessionScope.shelf : [${sessionScope.shelf}]
<c:remove var="shelf" scope="page"/>after c:remove : ${shelf}
sessionScope['user.name'] : ${sessionScope['user.name']}
tempdir in application scope : ${not empty applicationScope['jakarta.servlet.context.tempdir']}
shelf : page level
pageScope.shelf : page level
requestScope.shelf : request level
sessionScope.shelf : []
after c:remove : request level
sessionScope['user.name'] : ada
tempdir in application scope : true
The bare name found the page-level value first. c:remove removed the page-level attribute, so the same bare name then
found the request-level one: a search always stops at the first scope that has the name, and which scope a variable lives
in decides what hides what. A scope that does not exist is not an error: sessionScope is just empty. The two
JSTL tags used here, c:set and c:remove, belong to the JSTL Core page; for EL purposes they are the way a page
creates and drops the scoped variables that EL reads.
Reaching Data: the Dot and the Bracket
The language has two ways to go from an object to something inside it, and they are the same operator. The specification
says that expr-a.identifier-b is equivalent to expr-a["identifier-b"]: the dot is shorthand for a bracket with a string
literal, usable when the name is a valid Java identifier. Use the bracket when the name is not (a hyphen, a dot, a
space), when it comes from a variable, or when the base is a list or an array and the key is an index.
| Base | Expression | What the operator does |
|---|---|---|
JavaBean |
|
calls |
Record (EL 6.0) |
|
calls the component accessor |
|
|
|
|
|
|
Array |
|
element access; |
A name chosen at run time |
|
the expression in the brackets is evaluated and used as the property name |
A method |
|
calls the method; the arguments are expressions |
dot property : ${book.title}
bracket property : ${book['title']}
dynamic property : <c:set var="prop" value="author"/>${book[prop]}
list index : ${books[0].title} / ${books['1'].title}
list index (EL) : ${books[param.i].title}
map key (dot) : ${info.title}
map key hyphen : ${info['my-key']}
map key missing : [${info.nothing}]
catalog hyphen : ${catalog['first-edition'].title}
attribute w/ dot : ${sessionScope['user.name']}
method call : ${book.title.toUpperCase()} / ${books.size()} / ${'abc'['toUpperCase']()}
method w/ args : ${book.title.substring(0, 2)}
header hyphen : ${header['accept-language']}
all values : ${headerValues['accept-language'][0]}
curl -H 'Accept-Language: en-GB' "http://localhost:18080/g4elbasics/view/access?i=1"dot property : Dune
bracket property : Dune
dynamic property : Frank Herbert
list index : Dune / Neuromancer
list index (EL) : Neuromancer
map key (dot) : Map title
map key hyphen : hyphenated key
map key missing : []
catalog hyphen : Dune
attribute w/ dot : ada
method call : DUNE / 3 / ABC
method w/ args : Du
header hyphen : en-GB
all values : en-GB
Four details are visible in the output. A string index into a list (books['1']) is converted to the number 1.
${book[prop]} read the property named by a variable. A name with a hyphen or a dot cannot follow a dot: ${header.user-agent}
would be parsed as header.user - agent (see the pitfalls), so these names always use the bracket form, and so does an
attribute whose name contains a dot, such as the session attribute user.name. And EL calls methods: the language
specification describes method invocation with arguments through both operators, and the call on a string literal shows
that the bracket form can name the method dynamically. Calling methods from a view is allowed but not always wise: a
getter-like call on a business object is a read, while a call that changes state belongs in a controller.
The base of a property access is evaluated first, and the rules for a null base or a missing property are the subject
of the next section.
Null Handling and Default Values
EL was designed so that a page keeps rendering when data is missing. The specification says that the language does not
provide warnings, only default values and errors. A default is a type-correct value assigned to a subexpression when
something is wrong (for instance 0 for a null in arithmetic and false for a null in a logical test); an error is an
exception that the container’s error handling takes over. Which one applies depends on what is missing.
unknown name : [${nothing}]
unknown chain : [${nothing.a.b}]
null address city : [${reader.address.city}]
with default : <c:out value="${reader.address.city}" default="N/A"/>
non-null address : ${bean.address.city}
empty : ${empty nothing} / ${empty reader.address} / ${empty books}
null literal : [${null}]
null in arithmetic : ${nothing + 10}
unknown name : []
unknown chain : []
null address city : []
with default : N/A
non-null address : Lisbon
empty : true / true / false
null literal : []
null in arithmetic : 10
The rule in the language specification (section 1.6) is: when the base of a property access is null, the result is
null if this is the last property being resolved by a call to getValue, and an exception otherwise; in a chain, an
intermediate null makes the rest of the chain null rather than a NullPointerException. That is what the
output shows, and it is the reason ${user.address.city} is safe to write when a user may have no address. empty is
true for a null, an empty string, an empty collection, map or array.
Not everything missing is null, however. The cases that raise an error, all observed on Tomcat 11.0.26:
| Expression | Result | Why |
|---|---|---|
|
|
|
|
|
|
|
|
the base is |
|
|
|
|
|
the base exists, so the miss is a mistake, not missing data |
|
|
same |
|
|
a call on an object needs the method to exist |
|
|
the key cannot be coerced to an integer |
|
|
a non-numeric string cannot be coerced to a number |
The table has a working rule: a miss in data (an unknown name, a missing key, a null link in a chain, an index out of
range) is silent, and a miss in the model (a property that no class defines, a method that is not there) is an
error. The second kind is a programming mistake that a template author or a refactoring introduced, and it is
reported with the type and the property in the message. The first kind hides mistakes too, and that is the reason for the
next switch.
Unknown Identifiers and errorOnELNotFound
${owner} for a request attribute that nobody set looks like an empty field. Jakarta Pages 3.1 added an option that makes
the container throw jakarta.el.PropertyNotFoundException instead, from the NotFoundELResolver at the end of the
chain. It is off by default, it is switched on with the error-on-el-not-found element of a jsp-property-group or the
errorOnELNotFound attribute of the page directive, and it is the first thing to turn on while developing. The
Bookshelf turns it on for one directory.
<jsp-config>
<jsp-property-group>
<url-pattern>/WEB-INF/views/el/strict/*</url-pattern>
<error-on-el-not-found>true</error-on-el-not-found>
</jsp-property-group>
</jsp-config>
<%-- strict/missing.jsp --%>
Owner: ${owner}
<%-- strict/chain.jsp --%>
chain: [${reader.address.city}] map: [${info.nothing}] scope: [${requestScope.owner}] param: [${param.q}]
<%-- pageerr.jsp, outside the strict area --%>
<%@ page contentType="text/plain;charset=UTF-8" errorOnELNotFound="true" %>
owner: [${owner}]
strict/missing HTTP 500 jakarta.el.PropertyNotFoundException: Unknown identifier [owner]
strict/chain HTTP 200 chain: [] map: [] scope: [] param: []
pageerr HTTP 500 jakarta.el.PropertyNotFoundException: Unknown identifier [owner]
The setting affects names, not data. chain.jsp ran in the strict area and printed empty values, because reader.address
is a null link (not an unknown name), info.nothing is a missing key (not an unknown name), and requestScope.owner
looks in a named scope instead of searching. ${param.q} is the implicit object, so it resolves even though no parameter
q was sent. The strict mode throws only when NotFoundELResolver is reached. That includes the operand of empty: in a
strict page ${empty owner} also fails with Unknown identifier [owner] (observed), so a test for an optional
attribute must be written ${empty requestScope.owner} there. A page-level errorOnELNotFound="false" switches the
strictness back off for one page, whatever the group says. And a missing property on an existing bean, such as ${book.isbn},
throws PropertyNotFoundException in either mode (the strict area’s badprop view reports the same message as the
lenient one). Tag files have no group default: they return null unless their own tag directive says true.
EL Output Is Not Escaped
The container writes the value of ${...} as it is. The specification mentions the problem in a single sentence in
section 2.2: where escaping is wanted, for example to help prevent cross-site scripting, use the JSTL tag c:out. This is
the most important security fact about EL in a page, and it is easy to see in two contexts: element text and attribute
value.
raw : ${param.q}
c:out : <c:out value="${param.q}"/>
escapeXml: ${fn:escapeXml(param.q)}
curl "http://localhost:18080/g4elbasics/view/xss?q=%3Cb%3Ex%3C/b%3E"raw : <b>x</b>
c:out : <b>x</b>
escapeXml: <b>x</b>
The attribute context is the same bug with a smaller payload: an unescaped quote in a value ends the attribute and starts
a new one. The two inputs below get the same parameter, the text " onfocus="alert(1) (a double quote, a space and an event handler attribute).
<input name="q" value="${param.q}">
<input name="q" value="<c:out value="${param.q}"/>">
curl -G "http://localhost:18080/g4elbasics/view/attr" --data-urlencode 'q=" onfocus="alert(1)'<input name="q" value="" onfocus="alert(1)">
<input name="q" value="" onfocus="alert(1)">
The first line now carries an attacker’s event handler. The second encodes the quote as ". The rule is therefore:
every expression that prints data from a user, a database or a header into HTML goes through c:out, fn:escapeXml or an
encoder from the OWASP Java Encoder library. c:out escapes the five characters <, >, &, ' and " by default
(its escapeXml attribute defaults to true). Both forms protect HTML text and quoted attribute values only. A value
placed in JavaScript, in a URL or in CSS needs its own context-specific encoder; c:url with c:param builds a URL with
encoded parameters, and an expression inside a script element is better replaced by a data attribute that the script
reads. The Security page of this section expands on this, and the OWASP cheat sheet listed in the references is the
standard checklist.
The same warning applies to the inputs of EL. An expression is code: the name of a property, a method call or a
jsp:include target must never be built from request data, and a string that came from a user is never passed to an
ExpressionFactory (see the Expression Language Configuration page of this section for the Java API).
How the Container Finds a Value: the ELResolver Chain
Every lookup in an expression, a name without a base or a property of an object, is answered by an ELResolver. The JSP
container passes one jakarta.el.CompositeELResolver to all expressions in a page or tag file (in Tomcat 11.0.26 it is
org.apache.jasper.el.JasperELResolver, a subclass, as the observation below shows). A composite asks its components
in order. The first one that sets the propertyResolved flag decides the answer, and the rest are never asked. This is
section 2.10 of the Pages specification, and it explains everything above: why an implicit object hides an attribute of
the same name, why an unknown name is null, why a missing bean property is an error, and where a record or a static
field gets its value.
the first one that sets propertyResolved answers"] comp --> r1 subgraph first["Names with no base, and the extension points"] r1["1 ImplicitObjectELResolver
pageContext, param, header, cookie, initParam, the scope maps"] --> r2["2 Resolvers added with JspApplicationContext.addELResolver
in registration order: Faces, or your own"] r2 --> r3["3 Stream resolver
ExpressionFactory.getStreamELResolver"] end subgraph objects["Properties of an object: the base is not null"] r4["4 StaticFieldELResolver
base is an imported class"] --> r5["5 MapELResolver
key lookup"] r5 --> r6["6 ResourceBundleELResolver
message key"] r6 --> r7["7 ListELResolver
index"] r7 --> r8["8 ArrayELResolver
index and length"] r8 --> r9["9 RecordELResolver
component accessor, read only"] r9 --> r10["10 BeanELResolver
JavaBean property or method"] end subgraph names["Names with no base: nothing above knew the name"] r11["11 ScopedAttributeELResolver
PageContext.findAttribute: page, request, session, application"] --> r12["12 ImportELResolver
imported class, static import"] r12 --> r13["13 NotFoundELResolver
always resolves: null, or PropertyNotFoundException
when errorOnELNotFound is on"] end r3 --> r4 r10 --> r11 r13 --> out["The value, or an exception, goes back to the expression"]
| # | Resolver | Answers when | Notes |
|---|---|---|---|
1 |
|
the base is |
first, so |
2 |
anything registered with |
whatever it is written for |
in registration order; Faces registers its resolver here, and so can an application (see the next example) |
3 |
the resolver returned by |
a collection or an array with a stream operation |
the stream operations are on the Lambdas, Collections and Streams page of this section |
4 |
|
the base is an imported class (an |
|
5 |
|
the base is a |
read/write in JSP |
6 |
|
the base is a |
the key is the property |
7 |
|
the base is a |
read/write in JSP |
8 |
|
the base is an array |
read/write; |
9 |
|
the base is a |
always read only; new in EL 6.0, on by default |
10 |
|
any other base that is not |
JavaBean properties and public methods |
11 |
|
the base is |
|
12 |
|
the base is |
default imports since Pages 3.1; see the Static Access, Records and Optional page of this section |
13 |
|
always, for the base |
returns |
Source: Jakarta Expression Language 6.0 — 1.5 Resolution of Model Objects and their Properties or Methods
The chain handles a request for an identifier, and the language adds three things around it (section 1.5.1). The name
of an identifier is first checked as a lambda argument, then as an EL variable (an expression bound to a name by a
VariableMapper), then handed to the resolvers, and only if all of them fail is it looked up as an imported static
field. An identifier therefore hides every identifier that follows it in this list. With the JSP chain the last step is
never reached for a name, because NotFoundELResolver always resolves; imports are handled inside the chain by
ImportELResolver instead.
Watching the Chain Work
A resolver registered with JspApplicationContext.addELResolver sits second in the chain, behind the implicit objects,
so it is asked for everything the implicit objects do not answer. The next resolver exploits that. It logs only for
requests that carry a request attribute named trace, which the example servlet sets for the views whose name starts
with trace, so every other page is unaffected. It resolves nothing,
so every lookup continues down the chain, and it records each question it hears in the container log. Because it reports
the base type and the property, the log lists the lookups in the order the chain receives them.
public class TraceResolver extends ELResolver {
private final ServletContext ctx;
public TraceResolver(ServletContext ctx) { this.ctx = ctx; }
@Override
public Object getValue(ELContext context, Object base, Object property) {
JspContext jc = (JspContext) context.getContext(JspContext.class);
if (jc instanceof PageContext pc && pc.getRequest().getAttribute("trace") != null) {
ctx.log("ELTRACE base=" + (base == null ? "null" : base.getClass().getSimpleName())
+ " property=" + property);
}
return null; // propertyResolved is not set: the next resolver is asked
}
@Override public Class<?> getType(ELContext c, Object base, Object p) { return null; }
@Override public void setValue(ELContext c, Object base, Object p, Object v) { }
@Override public boolean isReadOnly(ELContext c, Object base, Object p) { return true; }
@Override public Class<?> getCommonPropertyType(ELContext c, Object base) { return null; }
}
@WebListener
public class ShelfListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent event) {
JspFactory.getDefaultFactory().getJspApplicationContext(event.getServletContext())
.addELResolver(new TraceResolver(event.getServletContext()));
}
}
c:out, which escapes it, and is resolved in the same way)${book.title}
<c:out value="${param.q}"/>
${pageContext.request.method}
${nothing}
${info['my-key']}
${books[0].year}
${Integer.MAX_VALUE}
curl "http://localhost:18080/g4elbasics/view/trace?q=zz"base=null property=book
base=Book property=title
base= property=q
base=PageContextImpl property=request
base=ApplicationHttpRequest property=method
base=null property=nothing
base=null property=info
base=LinkedHashMap property=my-key
base=null property=books
base=ListN property=0
base=Book property=year
base=null property=Integer
base=ELClass property=MAX_VALUE
Read it against the figure. ${book.title} asked for the name book (no base) and then for title of a Book; the first
passed through resolver 2 on its way to the scope lookup (11), and the second on its way to the RecordELResolver (9), which
answered. ${param.q} never asked for param: the implicit-object resolver (1) answered before the tracer was reached.
The base of q has an empty type name because the param map is an anonymous class. ${pageContext.request.method} also
did not ask for pageContext, and then asked request of the page context and method of the request, which are
answered by the BeanELResolver. ${nothing} was asked and reached NotFoundELResolver. A LinkedHashMap and a list
(ListN) were read through resolvers 5 and 7. And ${Integer.MAX_VALUE} asked first for a name, Integer, that
ImportELResolver turned into an ELClass, and then for the field MAX_VALUE, which StaticFieldELResolver read. The
class of the composite itself can be printed with ${pageContext.ELContext.ELResolver.getClass().getName()}, which
printed org.apache.jasper.el.JasperELResolver (observed), and the order of Tomcat’s constructor matches the
specification: implicit objects, the registered and stream resolvers, static fields, map, resource bundle, list, array,
record, bean, scoped attributes, imports and not-found (checked with javap on jasper.jar).
The same hook is what Jakarta Faces uses, as the specification says, and what any framework that wants to add names to every page can use. Writing a resolver for real, not a tracer, is covered on the Expression Language Configuration page of this section.
Names That Cannot Be Identifiers
The language reserves a set of words, and the EL parser also checks identifiers against the Java identifier rules. Both surprise the author of a page, because they make an expression fail to parse, which in a JSP page is a translation error and not an empty output.
| Words | Role |
|---|---|
|
logical operators in word form |
|
relational operators in word form |
|
arithmetic operators in word form |
|
the |
|
literals |
A Map key or an attribute may legitimately be called and or empty, and it can still be read with the bracket
operator, because a string literal is not an identifier:
<%-- reserved2.jsp --%>
and: [${info['and']}] class key: [${info['class']}] bean class: [${reader['class'].name}] call: [${reader.getClass().getSimpleName()}]
<%-- reserved1.jsp, reserved3.jsp, reserved5.jsp --%>
${requestScope.and}
${book.class}
${info.empty}
reserved2 HTTP 200 and: [reserved key] class key: [map key class] bean class: [org.example.bookshelf.Reader] call: [Reader]
reserved1 HTTP 500 jakarta.el.ELException: Failed to parse the expression [${requestScope.and}]
reserved3 HTTP 500 jakarta.el.ELException: Failed to parse the expression [${book.class}]
reserved5 HTTP 500 jakarta.el.ELException: Failed to parse the expression [${info.empty}]
The dot form needs an identifier, and and and empty are not identifiers. class is not one either, although it is not
a reserved word of the language: Tomcat’s parser rejects any Java keyword after the dot with The identifier [class] is not
a valid Java identifier as required by section 1.19 of the EL specification, and the message adds that the check can be
disabled with the system property org.apache.el.parser.SKIP_IDENTIFIER_CHECK. Do not disable it: write reader['class']
or reader.getClass() instead. (The section number in the message is wrong, since section 1.19 of the 6.0 specification
is about variables, and the rule of the language is the Identifier production in the collected syntax.) On a record,
book['class'] fails with PropertyNotFoundException: Property [class] not found on type [...Book], because the record
resolver exposes components, not getClass; call book.getClass() there. Old pages that used ${bean.class.name} to
print a class name are therefore the commonest source of this error after a container upgrade.
The Bookshelf Worked Example: A Search Page
The Bookshelf gets a search page. It reads the search term, a list of tags, the preferred language and a theme cookie
from the request, reads the shelf name from web.xml, filters a list and builds a link per hit. It uses almost every part
of this page: implicit objects, the dot and the bracket, a composite expression, a conditional, empty, defaults, and
output escaping. It declares session="false" because nothing here needs a session, and it reads a single
index (books[param.first]) to show where user input becomes an error.
<%@ page contentType="text/html;charset=UTF-8" session="false" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<h1>${initParam.shelfName}</h1>
<c:choose>
<c:when test="${empty param.q}"><p>Type a title to search.</p></c:when>
<c:otherwise>
<p>Results for <b><c:out value="${param.q}"/></b> (${fn:length(books)} books on the shelf)</p>
<ul>
<c:forEach var="b" items="${books}">
<c:if test="${fn:containsIgnoreCase(b.title, param.q)}">
<c:url var="link" value="/view/search"><c:param name="q" value="${b.title}"/></c:url>
<li class="${b.year lt 1970 ? 'classic' : 'modern'}">
<a href="${fn:escapeXml(link)}"><c:out value="${b.title}"/></a>
by <c:out value="${b.author}"/> (${b.year})
</li>
</c:if>
</c:forEach>
</ul>
</c:otherwise>
</c:choose>
<p>Tags: <c:forEach var="t" items="${paramValues.tag}" varStatus="s"><c:out value="${t}"/>${s.last ? '' : ', '}</c:forEach>
| Language: <c:out value="${header['accept-language']}" default="none"/>
| Theme: <c:out value="${cookie.theme.value}" default="light"/>
| Page size: <c:out value="${param.size}" default="10"/> | First: <c:out value="${books[param.first].title}"/></p>
$ curl -s -H 'Accept-Language: en-GB' -H 'Cookie: theme=dark' "http://localhost:18080/g4elbasics/view/search?q=du&tag=sf&tag=classic&first=1&size=5"
<h1>Main shelf</h1>
<p>Results for <b>du</b> (3 books on the shelf)</p>
<ul>
<li class="classic">
<a href="/g4elbasics/view/search?q=Dune">Dune</a>
by Frank Herbert (1965)
</li>
</ul>
<p>Tags: sf, classic
| Language: en-GB
| Theme: dark
| Page size: 5 | First: Neuromancer</p>
$ curl -s "http://localhost:18080/g4elbasics/view/search"
<h1>Main shelf</h1>
<p>Type a title to search.</p><p>Tags:
| Language: none
| Theme: light
| Page size: 10 | First: </p>
$ curl -s -G "http://localhost:18080/g4elbasics/view/search" --data-urlencode 'q=<script>x</script>'
<h1>Main shelf</h1>
<p>Results for <b><script>x</script></b> (3 books on the shelf)</p>
<ul>
</ul>
...
$ curl -s -o /dev/null -w '%{http_code}\n' -G "http://localhost:18080/g4elbasics/view/search" --data-urlencode 'q=a' -d first=x
500
What the run shows. Every parameter, header and cookie is read with the implicit objects and has a default where it is
optional: c:out supplies none, light and 10, and empty param.q chooses the branch without a null check. The
class attribute used a conditional on the number year and a literal, so it needed no escaping, while every
param, header, cookie and book field that is text went through c:out or fn:escapeXml; the hostile
search term was printed as <script> and found no book. The link was built by c:url with c:param, so the
title is URL-encoded and the context path is added, and the whole URL is then escaped as an attribute value. ${b.year lt 1970}
and the word form lt in the condition is also the one to use in a JSP document in XML syntax, where < is not allowed in attribute values.
And the last request shows the limit of EL’s leniency: first=x is not a number, so the [] operator cannot coerce it, and
the whole page fails with HTTP 500 and a NumberFormatException. A value like this has to be validated by the servlet
before the page runs, which is why a form handler belongs in a controller (the
Servlet Fundamentals page). The page does not have jsp:useBean or a scriptlet
anywhere, and all the data came from the servlet.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
|
|
escape it as |
|
an expression in a static attribute, |
match the delimiter to the attribute category in the TLD or the |
|
a composite expression with both delimiters |
use one delimiter per attribute |
|
nested |
write the inner part as plain EL: |
|
a tag file with deferred attributes in an implicit tag library whose version defaults to 2.0 |
add |
|
a tag-file attribute with |
|
|
|
|
|
it parses as |
|
|
it looks for an attribute |
|
|
|
|
|
|
|
|
|
|
|
a property the class does not define (also with |
fix the name; the check is not switched by the setting |
|
a call to a method the object does not have |
check the name and the class of the object |
|
arithmetic or an index on a request parameter that is not a number |
validate in the controller before the page runs |
A typo in a name prints nothing (observed) |
|
enable |
|
|
|
An attribute named |
implicit objects are resolved before attributes (see the Implicit Objects page) |
rename the attribute, or read it as |
|
EL output is never escaped |
|
Sensitive data appears in the page source (observed) |
|
use a JSP comment |
A JavaScript template literal shows the server value, or an error (observed) |
|
|
The space between two expressions disappears (observed) |
|
put a visible character between them, or leave the setting off |
|
EL is switched off for the page, or the application has a |
|
|
|
validate the parameter in the controller before the page runs |
What Changed Recently
| Version | Change |
|---|---|
EL 6.1 and Pages 4.1 (in development) |
milestones |
EL 6.0 (Jakarta EE 11, Tomcat 11.0.x) |
Java 17 minimum; |
EL 5.0 (Jakarta EE 10) |
Java 11 minimum; |
EL 4.0 (Jakarta EE 9) |
the package |
Pages 4.0 (Jakarta EE 11) |
Java 17 minimum; the deprecated classes of the |
Pages 3.1 (Jakarta EE 10) |
the option to raise |
Pages 3.0 (Jakarta EE 9) |
|
EL 3.0 (JSR 341) and later |
the language became its own specification, with an API for standalone evaluation; string concatenation |
JSP 2.1 (JSR 245) |
the unified expression language of JSP and Faces: |
JSP 2.0 and JSTL 1.1 |
the EL moved from JSTL attributes into the JSP specification, and became available in template text |
JSTL 1.0 on JSP 1.2 |
the EL existed only in the attributes of the JSTL tags |
Where the Books Are Outdated
Two of the four books in the section bibliography teach the Expression Language: JSTL in Action devotes a chapter
to it, and Web Development with JavaServer Pages (JSP 1.2) was written before it existed in JSP. The concepts they teach
(the dot and bracket operators, the implicit objects, empty, default values, the four scopes) are unchanged; the
platform around them is not.
| The books' era (JSP 1.2, JSTL 1.0) | Today (Pages 4.0, EL 6.0, Tags 3.0) |
|---|---|
No EL at all, data read with scriptlets and |
EL in template text and attribute values; scriptlets are legacy and can be forbidden with |
EL only inside the attributes of JSTL tags, so every value had to go through a tag such as |
true only of JSTL 1.0 on a JSP 1.2 container; since JSP 2.0 |
Two copies of each library, one for EL and one for request-time |
one library per area, |
|
|
Property access on beans, maps, lists and arrays only; no method invocation |
method invocation with arguments, records, |
No |
deferred |
A cross-reference in the book’s appendix that points to chapter 2 for the EL |
the EL chapter is chapter 3 of that book (a book erratum; the official source for the language is the specification) |
Custom tags evaluating EL by hand with |
attributes with |
|
|
|
still not escaped; use |
Source: Manning — JSTL in Action
Inconsistencies Found in the Official Text
Three small errors in the official material are worth knowing when the text and the container disagree.
-
Section 2.3.4 of the Pages 4.0 specification points to "section 1.23, Type Conversion" of the EL 6.0 specification. In EL 6.0 type conversion is section 1.25, and 1.23 is
java.util.Optional. -
Section 2.10 of the Pages specification calls the static-field resolver
jakarta.el.StaticFieldResolver. The class in the 6.0 Javadoc, and in Tomcat, isjakarta.el.StaticFieldELResolver. -
The Expression Language specification, section 1.2.2, shows
#{'#{'}exprB}as a way to write a literal#{. That is a composite expression of the language, but in a JSP page#{'#{'}book.title}in template text fails at translation with#{...} is not allowed in template text(observed); the escape that works is\#{.
References
-
Jakarta Expression Language 6.0 — 1.2.1.1 Eval-expressions as value expressions
-
Jakarta Expression Language 6.0 — 1.2.3 Composite expressions
-
Jakarta Expression Language 6.0 — 1.4 Errors, Warnings and Default Values
-
Jakarta Expression Language 6.0 — 1.5 Resolution of Model Objects and their Properties or Methods
-
Jakarta Expression Language 6.0 — 1.6 Operators (bracket and dot)
-
Jakarta Expression Language 6.0 — A.1 Changes between 6.0 and 5.0
-
Jakarta Expression Language 6.0 — A.2 Changes between 5.0 and 4.0
-
Jakarta Expression Language 6.0 — A.3 Changes between 4.0 and JSR 341
-
Jakarta Expression Language 6.0 Javadoc — CompositeELResolver
-
Jakarta Expression Language 6.0 Javadoc — MethodNotFoundException
-
Jakarta Expression Language 6.0 Javadoc — PropertyNotFoundException
-
Jakarta Pages 4.0 — 2.3.6 Examples of Using Dollar-Brace and Hash-Brace
-
Jakarta Pages 4.0 — 2.10 Resolution of Variables and their Properties
-
Jakarta Pages 4.0 — 3.4 Backwards Compatibility with JSP 2.0
-
Jakarta Standard Tag Library 3.0 — 3 Expression Language Overview
-
Jakarta Standard Tag Library 3.0 — 3.1 Expressions and Attribute Values
-
Jakarta Standard Tag Library 3.0 — 3.2 Accessing Application Data
-
Jakarta Standard Tag Library 3.0 — 3.3 Nested Properties and Accessing Collections
-
Jakarta Standard Tag Library 3.0 — 3.5 Automatic Type Conversion
-
Manning — Web Development with JavaServer Pages, Second Edition