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.

The data model: a record, two beans and the servlet that fills the request (imports omitted)
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.

Table 1. The pieces of the Expression Language and who owns them
Piece What it is Where to read about it

The language

Expressions, literals, operators, the . and [] operators, functions, lambdas, collection operations

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

${} in the attributes of c:if, c:forEach, c:out and the others; coercion of the result to the attribute type

Jakarta Tags 3.0, chapter 3 (an overview, not normative) and the tag documentation; this page

The Java API

ELResolver, ELContext, ExpressionFactory, ELProcessor, ELManager

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.ValueExpression or jakarta.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.

Table 2. Where each delimiter is allowed (Jakarta Pages 4.0, sections 2.2 and 2.3)
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 String

translation error

#{...} is not allowed in template text (observed), unless the page says deferredSyntaxAllowedAsLiteral or the configuration allows it

Attribute that accepts a run-time expression (TLD rtexprvalue true): a dynamic attribute

evaluated now, value coerced to the attribute type

translation error

a deferred expression in a dynamic attribute is rejected at translation

Attribute declared as deferred-value or deferred-method

translation error

parsed and passed to the tag as a ValueExpression or MethodExpression

an immediate expression in a deferred attribute is rejected at translation

Attribute declared both rtexprvalue true and deferred (section 2.3.5)

dynamic: evaluated now

deferred: passed unevaluated

the setter must accept java.lang.Object and tell the two apart with instanceof

Static attribute (rtexprvalue false)

translation error

translation error

the value must be a literal String

Body of a tag whose body-content is tagdependent

not evaluated, passed as written

not evaluated

the tag receives the text unchanged

A JSP comment (<%-- --%>)

ignored

ignored

nothing is translated

An HTML comment (<!-- -->), an HTML script or style block

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 ExpressionFactory API from Java

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.

WEB-INF/tags/probe.tag, one attribute of each category
<%@ 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}]
WEB-INF/tags/implicit.tld, which raises the JSP version of the implicit tag library
<?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.

Table 3. Table JSP.2-1 as run on Tomcat 11.0.26
Use Result Observed

<x:probe stat="xyz"/>

OK

static=[xyz] dynamic=[] deferred=[]

<x:probe stat="${book.title}"/>

ERROR

HTTP 500, translation error According to TLD or attribute directive in tag file, attribute [stat] does not accept any expressions

<x:probe stat="#{book.title}"/>

ERROR

the same message for stat

<x:probe dyn="xyz"/>

OK

static=[] dynamic=[xyz] deferred=[]

<x:probe dyn="${book.title}"/>

OK

static=[] dynamic=[Dune] deferred=[]

<x:probe dyn="#{book.title}"/>

ERROR

the same message for dyn

<x:probe def="xyz"/>

OK

static=[] dynamic=[] deferred=[xyz]

<x:probe def="${book.title}"/>

ERROR

the same message for def

<x:probe def="#{book.title}"/>

OK

static=[] dynamic=[] deferred=[Dune]

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.

WEB-INF/tags/lazy.tag, changes the attribute and then prints both attributes
<%@ 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}]
lazy.jsp, taglib directives left out
<c:set var="when" value="before" scope="request"/>
<x:lazy value="#{when}" now="${when}"/>
Output
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 value for def="${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 reports Failed 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 default rtexprvalue (true) is handled by Tomcat 11.0.26 as a deferred attribute only: #{...} works, while ${...} and a plain literal fail at run time with ClassCastException: 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 type Object, so write rtexprvalue="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.

hashesc.jsp, escaping the two delimiters
escaped: \#{book.title} and \${book.title}
dollar-quoted: ${'${'}book.title}
Output
escaped: #{book.title} and ${book.title}
dollar-quoted: ${book.title}
hashlit.jsp, a page that allows #{ as plain text (the page directive is shown)
<%@ page contentType="text/plain;charset=UTF-8" deferredSyntaxAllowedAsLiteral="true" %>
literal hash: #{not evaluated} and ${book.title}
Output
literal hash: #{not evaluated} and Dune
ignored.jsp, a page that switches EL off altogether
<%@ page contentType="text/plain;charset=UTF-8" isELIgnored="true" %>
ignored: ${book.title} #{book.title}
Output
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.jsp, the same data in template text, in a composite attribute and escaped
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"'}
Output
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.

nonstr.jsp, expressions that are not text
object (toString) : ${book}
number            : ${book.year + 1}
missing parameter : ${param.start + 10}
boolean           : ${book.year gt 1900 and not empty book.title}
Output
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.

comments.jsp, three kinds of comment or script around EL
<%-- 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>
Output
<!-- 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.

WEB-INF/tags/raw.tag and tagdep.jsp, a tagdependent body is not evaluated
<%-- 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>
Output
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.

jstl.jsp, EL in the attributes of three JSTL tags (the taglib directive is left out)
<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"/>
Output of 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.

Table 4. The EL implicit objects at a glance (Jakarta Pages 4.0, 2.4)
Name What it gives you Bookshelf use

pageContext

the PageContext, and through it request, response, session, servletContext, out

${pageContext.request.contextPath} to build a link

pageScope, requestScope, sessionScope, applicationScope

one scope each, as a Map, without the search

${sessionScope['user.name']}

param, paramValues

request parameters: the first value, all values

${param.q}, ${paramValues.tag}

header, headerValues

request headers: the first value, all values

${header['accept-language']}

cookie

cookies by name, each a jakarta.servlet.http.Cookie

${cookie.theme.value}

initParam

context initialization parameters of web.xml

${initParam.shelfName}

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.

scopes.jsp, one name in two scopes, removed from one of them
<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']}
Output
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.

Table 5. How the same operator reads each kind of base
Base Expression What the operator does

JavaBean

${bean.address.city}

calls getAddress() and then getCity(); isX() for a boolean

Record (EL 6.0)

${book.title}

calls the component accessor title(), read only

Map

${info.title}, ${info['my-key']}

map.get("title"), map.get("my-key"); a key that is missing gives null

List

${books[0]}, ${books[param.i]}

list.get(index); the index is coerced to an integer

Array

${names[1]}, ${names.length}

element access; length since EL 6.0

A name chosen at run time

${book[prop]}

the expression in the brackets is evaluated and used as the property name

A method

${book.title.toUpperCase()}, ${'abc'['toUpperCase']()}

calls the method; the arguments are expressions

access.jsp, each case of the table run on the Bookshelf (the directives are left out)
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]}
Output of 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.

nulls.jsp, what a missing value does (the directives are left out)
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}
Output
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:

Table 6. What a missing value does, by kind of miss
Expression Result Why

${nothing} (a name in no scope)

null

NotFoundELResolver always resolves, with null, at the end of the chain

${info.nothing} (a missing map key)

null

MapELResolver returns null for a missing key

${reader.address.city} (an intermediate null)

null

the base is null, so the rest of the chain is null

${books[5]}, ${books[-1]} (an index out of range)

null

ListELResolver returns null instead of an IndexOutOfBoundsException

${book.isbn} (no such property on an existing bean or record)

PropertyNotFoundException: Property [isbn] not found on type [org.example.bookshelf.Book]

the base exists, so the miss is a mistake, not missing data

${reader.nickname}

PropertyNotFoundException: Property [nickname] not found on type [org.example.bookshelf.Reader]

same

${book.nothing()}

MethodNotFoundException: Method not found: class org.example.bookshelf.Book.nothing()

a call on an object needs the method to exist

${books['x']} (a list index that is not a number)

NumberFormatException: For input string: "x"

the key cannot be coerced to an integer

${param.n + 1} with n=abc

NumberFormatException: For input string: "abc"

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.

web.xml, the strict area
<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, strict/chain.jsp and a page that opts in with the directive
<%-- 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}]
Observed
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.

xss.jsp, three ways to print a request parameter
raw      : ${param.q}
c:out    : <c:out value="${param.q}"/>
escapeXml: ${fn:escapeXml(param.q)}
Output of curl "http://localhost:18080/g4elbasics/view/xss?q=%3Cb%3Ex%3C/b%3E"
raw      : <b>x</b>
c:out    : &lt;b&gt;x&lt;/b&gt;
escapeXml: &lt;b&gt;x&lt;/b&gt;

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).

attr.jsp, the same parameter in an attribute value
<input name="q" value="${param.q}">
<input name="q" value="<c:out value="${param.q}"/>">
Output of 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="&#034; onfocus=&#034;alert(1)">

The first line now carries an attacker’s event handler. The second encodes the quote as &#034;. 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.

flowchart TD expr["EL expression: each name and each property step is a lookup"] --> comp["CompositeELResolver asks its components in order
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"]
Table 7. The JSP resolver chain, in the order of the specification and of Tomcat 11.0.26
# Resolver Answers when Notes

1

jakarta.servlet.jsp.el.ImplicitObjectELResolver

the base is null and the name is an implicit object

first, so ${param} is never an attribute named param

2

anything registered with JspApplicationContext.addELResolver

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 ExpressionFactory.getStreamELResolver()

a collection or an array with a stream operation

the stream operations are on the Lambdas, Collections and Streams page of this section

4

jakarta.el.StaticFieldELResolver

the base is an imported class (an ELClass)

${Integer.MAX_VALUE}; the specification text calls it StaticFieldResolver, the class is StaticFieldELResolver

5

jakarta.el.MapELResolver

the base is a Map

read/write in JSP

6

jakarta.el.ResourceBundleELResolver

the base is a ResourceBundle

the key is the property

7

jakarta.el.ListELResolver

the base is a List

read/write in JSP

8

jakarta.el.ArrayELResolver

the base is an array

read/write; length since EL 6.0

9

jakarta.el.RecordELResolver

the base is a java.lang.Record

always read only; new in EL 6.0, on by default

10

jakarta.el.BeanELResolver

any other base that is not null

JavaBean properties and public methods

11

jakarta.servlet.jsp.el.ScopedAttributeELResolver

the base is null

PageContext.findAttribute(name): page, request, session, application

12

jakarta.servlet.jsp.el.ImportELResolver

the base is null and the name is an imported class

default imports since Pages 3.1; see the Static Access, Records and Optional page of this section

13

jakarta.servlet.jsp.el.NotFoundELResolver

always, for the base null

returns null; with errorOnELNotFound throws PropertyNotFoundException

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.

TraceResolver and the listener that registers it (imports omitted)
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()));
    }
}
trace.jsp, seven expressions that exercise different resolvers (the directives are left out; the request parameter is written through 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}
The container log after 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.

Table 8. The reserved words (Expression Language 6.0, section 1.17)
Words Role

and, or, not

logical operators in word form

eq, ne, lt, gt, le, ge

relational operators in word form

div, mod

arithmetic operators in word form

empty, instanceof

the empty operator; instanceof is reserved so that it can be added later

true, false, null

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 the three views that fail to parse
<%-- 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}
Observed
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.

search.jsp, the whole page
<%@ 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>
Observed: a search with two tags, a language, a cookie and a page size
$ 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>
Observed: no search term, then a hostile search term, then a bad index
$ 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>&lt;script&gt;x&lt;/script&gt;</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

Table 9. EL pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

#{...} is not allowed in template text (observed)

#{ in template text is reserved since JSP 2.1

escape it as \#{, set deferredSyntaxAllowedAsLiteral, or remove it

attribute [x] does not accept any expressions (observed)

an expression in a static attribute, #{} in a dynamic attribute (this includes every JSTL attribute), or ${} in a deferred attribute

match the delimiter to the attribute category in the TLD or the attribute directive

Cannot use both ${} and #{} EL expressions in the same attribute value (observed)

a composite expression with both delimiters

use one delimiter per attribute

Failed to parse the expression [${books[${param.i}].title}] (observed)

nested ${}

write the inner part as plain EL: books[param.i]

Invalid JSP version defined for tag file (observed)

a tag file with deferred attributes in an implicit tag library whose version defaults to 2.0

add implicit.tld with a version of 2.1 or higher, or declare the tag in a TLD

ClassCastException: String cannot be cast to ValueExpression (observed)

a tag-file attribute with deferredValue="true" and the default rtexprvalue receives ${} or a literal

rtexprvalue="false", and pass #{}

${book.class} or ${requestScope.and} fails to parse (observed)

class is a Java keyword and and is reserved; the dot form needs an identifier

book['class'], book.getClass(), requestScope['and']

${header.user-agent} prints 0 (observed)

it parses as header.user - agent, a subtraction of two nulls

${header['user-agent']}

${sessionScope.user.name} prints nothing (observed)

it looks for an attribute user and then its name; the attribute is called user.name

${sessionScope['user.name']}

${request.method} prints nothing (observed)

request is not an EL object, so it resolves to an unknown name

${pageContext.request.method}

PropertyNotFoundException: Property [length] not found on type [java.lang.String] (observed)

${book.title.length}: a String has a method length() and no property

${book.title.length()} or ${fn:length(book.title)}

NumberFormatException: For input string: "size" (observed)

${books.size} on a List: a list reads a property as an index

${books.size()} or ${fn:length(books)}

PropertyNotFoundException: Property [isbn] not found on type [...Book] (observed)

a property the class does not define (also with errorOnELNotFound off)

fix the name; the check is not switched by the setting

MethodNotFoundException: Method not found (observed)

a call to a method the object does not have

check the name and the class of the object

NumberFormatException: For input string: "abc" (observed)

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)

NotFoundELResolver returns null for an unknown name

enable errorOnELNotFound in development; it is on for the strict area of the example

Unknown identifier [x] on ${empty x} in a strict page (observed)

empty evaluates its operand, and an unknown name throws in strict mode

${empty requestScope.x}

An attribute named param, header, cookie, pageContext or a scope name is ignored

implicit objects are resolved before attributes (see the Implicit Objects page)

rename the attribute, or read it as ${requestScope.param}

${param.q} prints raw HTML (observed)

EL output is never escaped

c:out, fn:escapeXml or an encoder

Sensitive data appears in the page source (observed)

${} inside an HTML comment is evaluated

use a JSP comment <%-- --%>

A JavaScript template literal shows the server value, or an error (observed)

${...} in a script block is EL

\${, isELIgnored, or move the script to a static file

The space between two expressions disappears (observed)

trimDirectiveWhitespaces removes template text that is only whitespace, so ${a} ${b} prints ab

put a visible character between them, or leave the setting off

${book.title} appears literally in the output (observed with isELIgnored="true")

EL is switched off for the page, or the application has a web.xml of version 2.3 or earlier, whose default is to ignore EL

isELIgnored="false", el-ignored false, and a web-app version of 2.4 or later

ELException: Cannot convert [abc] of type [class java.lang.String] to [int] (observed)

begin="${param.start}" on c:forEach with a value that is not a number

validate the parameter in the controller before the page runs

What Changed Recently

Table 10. The Expression Language and its JSP integration across versions
Version Change

EL 6.1 and Pages 4.1 (in development)

milestones 6.1.0-M2 and 4.1.0-M2 are on Maven Central; they are not final releases, so do not depend on them; the Expression Language configuration page of this section tells what is announced

EL 6.0 (Jakarta EE 11, Tomcat 11.0.x)

Java 17 minimum; RecordELResolver added and on by default; OptionalELResolver added but off by default; a length property for arrays; java.beans made optional (java.desktop is no longer required); module visibility respected when calling methods; all references to the Java SecurityManager removed; deprecated code removed

EL 5.0 (Jakarta EE 10)

Java 11 minimum; BeanELResolver also considers default methods; a lambda can be coerced to a functional interface; MethodReference added; generics in the API; ELResolver.getFeatureDescriptors deprecated

EL 4.0 (Jakarta EE 9)

the package javax.el becomes jakarta.el

Pages 4.0 (Jakarta EE 11)

Java 17 minimum; the deprecated classes of the jakarta.servlet.jsp.el package (ExpressionEvaluator, VariableResolver and their relatives) are removed; the ELResolver.getFeatureDescriptors overrides are removed

Pages 3.1 (Jakarta EE 10)

the option to raise PropertyNotFoundException for an unknown identifier (error-on-el-not-found, errorOnELNotFound); the default imports of the EL environment aligned with the scripting environment; the special handling of imports and unresolved names removed from ScopedAttributeELResolver, into ImportELResolver and NotFoundELResolver

Pages 3.0 (Jakarta EE 9)

javax.servlet.jsp becomes jakarta.servlet.jsp

EL 3.0 (JSR 341) and later

the language became its own specification, with an API for standalone evaluation; string concatenation +=, assignment =, the semicolon operator, lambdas, collection construction and stream operations, and static references

JSP 2.1 (JSR 245)

the unified expression language of JSP and Faces: #{} and deferred evaluation, ELResolver

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.

Table 11. Material from the books and its current equivalent
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 <%= %> (Web Development with JavaServer Pages)

EL in template text and attribute values; scriptlets are legacy and can be forbidden with scripting-invalid

EL only inside the attributes of JSTL tags, so every value had to go through a tag such as c:out; the claim that EL does not work in template text

true only of JSTL 1.0 on a JSP 1.2 container; since JSP 2.0 ${} works in template text, but still does not escape

Two copies of each library, one for EL and one for request-time <%= %> values (the _rt libraries)

one library per area, jakarta.tags.core and the others; request-time expressions in attributes are discouraged and can be disabled

http://java.sun.com/jstl/core and the other 1.0 URIs

jakarta.tags.core; the old URIs still resolve on Tomcat 11.0.26 only because the JSTL jar ships legacy TLDs

Property access on beans, maps, lists and arrays only; no method invocation

method invocation with arguments, records, Optional, array length, lambdas, streams, static members and imports

No #{}; everything evaluated immediately

deferred #{} for tags and for Faces, with the rules of the table above

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 ExpressionEvaluatorManager (Jakarta Taglibs) or, later, jakarta.servlet.jsp.el.ExpressionEvaluator

attributes with rtexprvalue or deferred-value; the old ExpressionEvaluator classes are removed in Pages 4.0; a tag handler that evaluates by hand uses ExpressionFactory and the ELContext of the PageContext

javax.servlet.jsp.el.* and javax.el.*

jakarta.servlet.jsp.el.* and jakarta.el.* since Jakarta EE 9 (EL 4.0, Pages 3.0)

${param.name} and friends printed directly

still not escaped; use c:out, fn:escapeXml or an encoder. The XSS awareness of the 2000s was lower than today’s, and no EL version has changed this

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, is jakarta.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