EL Configuration & ELProcessor

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 (API jakarta.el:jakarta.el-api 6.0.1), Jakarta Pages 4.0 (chapters 2 and 3), Jakarta Servlet 6.1 and Apache Tomcat 11.0.26 on Java 21. Everything was verified on 2026-10-08: the facts against the specifications and the Javadoc, and every example by building it into a Maven WAR and running it on Tomcat 11.0.26 with curl, or, for the standalone part, by running it with the Eclipse Expressly 6.0.0 implementation; the outputs and error messages shown are the ones those runs produced. The expression language itself (what ${book.title} means, the operators, lambdas and streams) is the subject of the neighbouring EL pages of this section: Expression Language Basics, EL Operators & Type Coercion, EL Lambdas, Collections & Streams and EL Static Access, Records & Optional. This page is about the environment of EL: the switches a container gives a page author to turn evaluation off or make it stricter, the two syntaxes ${...} and #{...} and where each is legal, the way a web application adds its own resolver to the chain that every expression goes through, and the way a program that is not a JSP page evaluates EL by itself. The running scenario is the Bookshelf, a small catalogue of books.

Where EL Runs

The expression language was designed for the presentation layer, but since EL 3.0 it is a stand-alone specification with an API that does not need a servlet container. There are three situations, and the rest of the page keeps them apart because the defaults differ.

Table 1. The three places an EL expression is evaluated
Environment Who evaluates the expression Configuration

A JSP page or tag file

The JSP container (Jasper in Tomcat) parses ${...} in template text and in attribute values at translation time and evaluates it at request time with the ELResolver chain that Pages 4.0 section 2.10 defines

el-ignored, error-on-el-not-found and deferred-syntax-allowed-as-literal in web.xml; the matching page and tag directive attributes; resolvers added through JspApplicationContext

Application code inside a web application

The code, through the ExpressionFactory that the container supplies (JspApplicationContext.getExpressionFactory(), or ELManager.getExpressionFactory() which finds the container’s implementation)

The ELContext the code builds

A stand-alone program

The code, with ELProcessor (the simple API) or ExpressionFactory plus StandardELContext (the complete one); an EL implementation must be on the class path, for example Expressly

ELManager (resolvers, imports, functions, bean-name resolvers)

Implementations: Tomcat ships its own (org.apache.el.ExpressionFactoryImpl, verified below by asking a servlet inside Tomcat 11.0.26), Eclipse GlassFish 8 uses Expressly (org.glassfish.expressly:expressly 6.0.0 on Maven Central), and a stand-alone program or a unit test adds one dependency: the API alone (jakarta.el-api) cannot create an ExpressionFactory.

Switching EL Off: el-ignored and isELIgnored

The patterns ${expr} and #{expr} were not reserved before JSP 2.0 (and JSP 2.1 for the second), so a page written for JSP 1.2 can contain them as plain text. The specification therefore lets a page, a group of pages or a tag file say that the characters must pass through untouched. The rules are in Pages 4.0 sections 2.5 and 3.3.2, and the configuration page and the directives page show the descriptor and directive forms; this section keeps the parts that depend on EL itself.

Table 2. Is EL evaluated? The rule, for JSP pages and for tag files
Group el-ignored isELIgnored in the page directive Result

unspecified

unspecified

Ignored if the web.xml is Servlet 2.3 or older, evaluated otherwise

false

unspecified

Evaluated

true

unspecified

Ignored

any

false

Evaluated

any

true

Ignored

(no group applies to tag files)

isELIgnored in the tag directive: unspecified or false

Evaluated

(no group applies to tag files)

isELIgnored="true" in the tag directive

Ignored

Two consequences are easy to miss. First, a tag file never inherits el-ignored from the page that uses it: a legacy directory whose group turns EL off still gets EL inside any tag file it calls. Second, the setting also decides whether the quote sequences \$ and \# are recognised: with EL ignored they are not quotes, and the backslash is output as it stands. The Bookshelf shows the first consequence with a group for an old legacy/ directory and a tag file title.tag:

web.xml: EL is ignored for everything under /legacy
<jsp-config>
    <jsp-property-group>
        <url-pattern>/legacy/*</url-pattern>
        <el-ignored>true</el-ignored>
    </jsp-property-group>
</jsp-config>
WEB-INF/tags/title.tag and legacy/page.jsp
<%-- WEB-INF/tags/title.tag --%>
<%@ tag body-content="empty" pageEncoding="UTF-8" %>
<%@ attribute name="text" required="true" %>
title=${text} sum=${1 + 2}

<%-- legacy/page.jsp --%>
<%@ page contentType="text/plain; charset=UTF-8" %>
<%@ taglib prefix="t" tagdir="/WEB-INF/tags" %>
page: sum=${1 + 2}
tag file: <t:title text="Dune"/>

The response of /legacy/page.jsp was HTTP 200 with:

page: sum=${1 + 2}
tag file:
title=Dune sum=3

The page printed its expression verbatim, the tag file evaluated it. A page that needs EL inside the same directory overrides the group with <%@ page isELIgnored="false" %>; /legacy/override.jsp printed page: sum=3 (verified). Without any group, /ignored-default.jsp printed sum=3, because the descriptor is version="6.1".

isELIgnored="true" is a compatibility tool and not a security feature: it does not make a page safe, because a scriptlet or a tag can still print request data, and the directive is itself in the page, so it protects against nothing an attacker controls. Output escaping (c:out, fn:escapeXml) is what protects against cross-site scripting; the guidance on Security collects the rest.

Unknown Identifiers: error-on-el-not-found and errorOnELNotFound

In a JSP page an identifier that no resolver knows is not an error by default. Pages 4.0 section 2.7 explains that the last resolver of the chain, NotFoundELResolver, "always resolves the requested value, returning null"; EL’s lenient semantics then turn the null into an empty string, a zero or false. That keeps a half-filled page presentable and hides typing mistakes. Since Pages 3.1 a page can ask for the strict behaviour: error-on-el-not-found in a group and errorOnELNotFound in the page and tag directives make the same lookup throw jakarta.el.PropertyNotFoundException.

Table 3. Unknown identifier settings (Table JSP.3-4 and JSP.3-5)
Group error-on-el-not-found errorOnELNotFound in the directive An unknown identifier

unspecified or false

unspecified

null is returned by the resolver

true

unspecified

PropertyNotFoundException is thrown

any

false

null is returned

any

true

PropertyNotFoundException is thrown

(tag files have no group)

errorOnELNotFound in the tag directive: unspecified or false

null is returned

(tag files have no group)

errorOnELNotFound="true" in the tag directive

PropertyNotFoundException is thrown

notfound.jsp and notfound-strict.jsp: the same expressions, lenient and strict
<%-- notfound.jsp --%>
<%@ page contentType="text/plain; charset=UTF-8" %>
[${missing}] [${missing.title}] [${empty missing}]

<%-- notfound-strict.jsp --%>
<%@ page contentType="text/plain; charset=UTF-8" errorOnELNotFound="true" %>
[${missing}]

The first page returned HTTP 200 with [] [] [true]: the three lookups of an undefined missing were silently null, empty and true. The second returned HTTP 500 with jakarta.el.PropertyNotFoundException: Unknown identifier [missing] as the innermost cause. A group /strict/* with error-on-el-not-found set to true made /strict/page.jsp fail the same way, and /strict/override.jsp with errorOnELNotFound="false" printed [] (all verified).

The setting concerns the first name of an expression only. A property that does not exist on an object that does exist is a PropertyNotFoundException in any configuration: with book in the request, ${book.nope} returned HTTP 500 with Property [nope] not found on type [org.example.bookshelf.Book] (verified). The practical rule for the Bookshelf is to turn error-on-el-not-found on for the whole application during development and for a regression run, and to guard the optional attributes of a live page with c:if and empty instead of relying on silence.

Invalid Expressions and Default Values

A syntactically invalid expression is a translation error (Pages 4.0 section 2.8), reported when the page is first compiled and not when a user is looking at it. A page with a broken expression:

bad4.jsp: an invalid expression in template text
<%@ page contentType="text/plain" %>
Total: ${1 +}

returned HTTP 500 from Tomcat, and the chain of causes (verified) ended with:

jakarta.el.ELException: Failed to parse the expression [${1 +}]
org.apache.el.parser.ParseException: Encountered " "}" "} "" at line 1, column 6.

Section 2.9 states the philosophy of the whole language: the EL "does not provide warnings, just default values and errors". A default value is a type-correct value assigned to a subexpression with a problem (null becomes 0 in arithmetic, "" as text, false as a condition) and an error is an exception handled by the standard JSP machinery, for example an error page. The Bookshelf errors are therefore always one of three kinds: a translation error (the syntax), a PropertyNotFoundException or MethodNotFoundException (a property or method that is not there), and any exception thrown by a getter, which EL wraps in an ELException.

Immediate and Deferred Syntax

The two delimiters are parsed the same way by EL, and Pages adds rules about where each may be used: ${expr} is evaluated immediately, #{expr} is a deferred expression whose evaluation is left to someone else (a tag handler, a Faces component). Before JSP 2.1 only ${} existed, which is why #{ in an old page is an error (next section). Which delimiter is legal depends on where the expression appears:

  • In template text, only ${}. #{} is a translation error unless the compatibility switch below is on.

  • In an attribute value, it depends on the attribute’s category in the tag library descriptor (TLD) or in the attribute directive of a tag file. This is the only place deferred syntax is meaningful in a JSP page.

Table 4. How an attribute value is interpreted, by TLD category (Pages 4.0 sections 2.3.1 to 2.3.5)
Category in the TLD Accepts What the tag handler receives

Static (rtexprvalue false)

A literal only; no EL at all

The text, converted to the setter’s type

Dynamic (rtexprvalue true)

A literal, a scriptlet expression or ${...}

The value, already evaluated and coerced to the declared type (default java.lang.Object)

Deferred value (deferred-value)

A literal or #{...}

A jakarta.el.ValueExpression, not evaluated; a literal becomes a ValueExpression whose isLiteralText() is true. The setter’s argument type must be ValueExpression

Deferred method (deferred-method)

A literal or #{...}

A jakarta.el.MethodExpression; a literal is allowed only if the method signature’s return type is not void

Dynamic or deferred (both rtexprvalue and a deferred element)

A literal, a scriptlet, ${...} or #{...}

An Object: the evaluated value for ${}, the expression object for #{}; the setter discriminates with instanceof

The specification’s own table of examples (Table JSP.2-1) is the quickest reference: for an attribute declared static, ${x[y]} and #{x[y]} are errors; for a dynamic one, ${x[y]} is fine and #{x[y]} is an error; for a deferred one, #{x[y]} is fine and ${x[y]} is an error; a plain word is fine in all three. The Bookshelf checks it with one deferred and one dynamic attribute. The deferred tag keeps the expression and evaluates it when it chooses; the dynamic one receives the value:

WEB-INF/tlds/bs.tld (the two attribute categories), LazyTag and EagerTag
<tag>
    <name>lazy</name>
    <tag-class>org.example.bookshelf.LazyTag</tag-class>
    <body-content>empty</body-content>
    <attribute>
        <name>value</name>
        <required>true</required>
        <deferred-value>
            <type>java.lang.String</type>
        </deferred-value>
    </attribute>
</tag>
<tag>
    <name>eager</name>
    <tag-class>org.example.bookshelf.EagerTag</tag-class>
    <body-content>empty</body-content>
    <attribute>
        <name>value</name>
        <required>true</required>
        <rtexprvalue>true</rtexprvalue>
        <type>java.lang.String</type>
    </attribute>
</tag>
public class LazyTag extends SimpleTagSupport {
    private ValueExpression value;

    public void setValue(ValueExpression value) {       // deferred: the argument is the expression
        this.value = value;
    }

    @Override
    public void doTag() throws JspException, IOException {
        var out = getJspContext().getOut();
        out.print("literal=" + value.isLiteralText() + " expr=" + value.getExpressionString());
        if (!value.isLiteralText()) {
            Object v = value.getValue(getJspContext().getELContext());   // evaluated now, by the handler
            out.print(" value=" + v);
        }
        out.println();
    }
}
<%-- deferred.jsp --%>
<%@ page contentType="text/plain; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="bs" uri="https://example.org/bookshelf/tags" %>
<c:set var="title" value="Dune" scope="request"/>
<bs:lazy value="#{title}"/>
<bs:lazy value="plain text"/>
<bs:eager value="${title}"/>

The response was HTTP 200 with:

literal=false expr=#{title} value=Dune
literal=true expr=plain text
eager value=Dune

The deferred attribute reached the handler as an expression object (literal=false, the expression string intact), which the handler evaluated with the page’s own ELContext (getJspContext().getELContext()), and the literal became a literal-text ValueExpression. Two of the invalid combinations were run: <bs:eager value="#{title}"/> (dynamic attribute with deferred syntax) and <bs:lazy value="${title}"/> (deferred attribute with immediate syntax) both returned HTTP 500 with According to TLD or attribute directive in tag file, attribute [value] does not accept any expressions (observed; the message says the same for both, and for each the specification says "error"). A tag file declares the same categories with the deferredValue, deferredValueType, deferredMethod and deferredMethodSignature attributes of its attribute directive (see Directives). Deferred expressions are what Jakarta Faces tags and component libraries use; a plain JSP application meets them mainly when it writes its own tags that must read and write a value, because a ValueExpression supports setValue as well as getValue.

The Reserved Sequence in Plain Text

Because #{ has been reserved since JSP 2.1, plain text that contains it is a translation error. Total: #{books} in a page returned HTTP 500 with #{...} is not allowed in template text (observed, line and column included). There are three ways out, in the order of preference: write \#{ for the one occurrence, set deferredSyntaxAllowedAsLiteral="true" in the page directive, or set deferred-syntax-allowed-as-literal to true for a group of legacy pages. The group and directive forms are on Configuration and Property Groups; the escape rules are on the syntax page.

Adding Your Own ELResolver

Every identifier and every . or [] in an expression is resolved by an ELResolver. Pages 4.0 section 2.10 specifies the chain that the container passes to all expressions of a page or tag file, in order, as a jakarta.el.CompositeELResolver: the implicit-object resolver, any resolvers added through JspApplicationContext.addELResolver() (in registration order), the resolver from ExpressionFactory.getStreamELResolver(), then the resolvers for static fields, maps, resource bundles, lists, arrays, records (read-only) and beans, and finally the three JSP resolvers for scoped attributes, imports and "not found". The first resolver that sets setPropertyResolved(true) on the ELContext wins; the later ones are not asked. The Expression Language Basics page draws the whole chain; what matters here is that the application’s own resolvers sit very early in it, before the map, list, array and bean resolvers and before the scoped-attribute resolver that looks up request, session and application attributes.

sequenceDiagram participant C as Container participant L as ServletContextListener participant A as JspApplicationContext participant P as JSP page (first request) C->>L: contextInitialized L->>A: addELResolver(BookshelfSettings) Note over A: resolver is appended after the implicit objects and before the standard resolvers C->>P: first request for any JSP P->>A: first ELContext is created (chain is frozen) L-->>A: addELResolver after that point A-->>L: IllegalStateException

That ordering has a security and a correctness side. Security: a resolver you register is asked first for every expression of every page of the application, so it must answer only for names it owns and must never resolve a name taken from the request. Correctness: a resolver that answers a name hides a request, session or application attribute of the same name, as the example below shows on purpose.

Table 5. The five methods of an ELResolver that every custom resolver must implement (the sixth, getFeatureDescriptors, has a default)
Method Contract

getValue(ELContext, base, property)

Return the value, and call context.setPropertyResolved(true) if (and only if) this resolver handled the pair; base is null for the first name of an expression, and then property is the name as a String

getType(ELContext, base, property)

The type of the property for setValue, also marking the pair resolved; it must return null when the resolver or the property is read-only (the EL 5.0 change log made this explicit)

setValue(ELContext, base, property, value)

Assign, or throw PropertyNotWritableException for a read-only property

isReadOnly(ELContext, base, property)

true when setValue would fail; also marks the pair resolved

getCommonPropertyType(ELContext, base)

The most general type of the properties this resolver handles for that base, or null if it does not resolve for it

invoke and convertToType (optional, with defaults)

Method calls on the base and type conversion; a resolver that does not override them leaves the pair unresolved

The Bookshelf keeps its site-wide settings in an immutable map and exposes them as ${settings.siteName}. The resolver is read-only, answers one name and never looks at the request:

BookshelfSettings: a read-only resolver for the name "settings"
public class BookshelfSettings extends ELResolver {

    private static final String NAME = "settings";
    private final Map<String, String> values;

    public BookshelfSettings(Map<String, String> values) {
        this.values = Map.copyOf(values);
    }

    @Override
    public Object getValue(ELContext context, Object base, Object property) {
        if (base == null && NAME.equals(property)) {
            context.setPropertyResolved(true);
            return values;
        }
        return null;                       // not mine: leave the pair unresolved
    }

    @Override
    public Class<?> getType(ELContext context, Object base, Object property) {
        if (base == null && NAME.equals(property)) {
            context.setPropertyResolved(true);
        }
        return null;                       // read-only: getType must return null
    }

    @Override
    public void setValue(ELContext context, Object base, Object property, Object value) {
        if (base == null && NAME.equals(property)) {
            context.setPropertyResolved(true);
            throw new PropertyNotWritableException("settings is read-only");
        }
    }

    @Override
    public boolean isReadOnly(ELContext context, Object base, Object property) {
        if (base == null && NAME.equals(property)) {
            context.setPropertyResolved(true);
            return true;
        }
        return false;
    }

    @Override
    public Class<?> getCommonPropertyType(ELContext context, Object base) {
        return base == null ? String.class : null;
    }
}

Registration happens once, while the application starts. The ServletContextListener asks the JSP factory for the application’s JspApplicationContext:

SettingsInstaller: registering the resolver at start-up
@WebListener
public class SettingsInstaller implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent event) {
        ServletContext ctx = event.getServletContext();
        JspFactory.getDefaultFactory().getJspApplicationContext(ctx)
                .addELResolver(new BookshelfSettings(Map.of("siteName", "Bookshelf", "pageSize", "20")));
    }
}
settings.jsp: the resolver, the missing key, and a request attribute of the same name
<%@ page contentType="text/plain; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="settings" value="request attribute" scope="request"/>
site=${settings.siteName} pageSize=${settings.pageSize + 1} missing=[${settings.nope}]
other=${requestScope.settings}

The response was HTTP 200 with:

site=Bookshelf pageSize=21 missing=[]
other=request attribute

${settings.siteName} found the resolver’s map even though a request attribute called settings existed, which is the ordering of section 2.10 at work: the request attribute is reachable only by its scope, ${requestScope.settings}. ${settings.pageSize + 1} shows the coercion of the string "20" to a number, and ${settings.nope} is an unknown key of a Map, hence null and an empty string (a Map never throws for a missing key).

Registration is time-limited. The Javadoc says it is illegal to register an ELResolver "after the application has received any request from the client", and that the call throws IllegalStateException once all ServletContextListener`s have run; the restriction lets the container optimise the common case of an unextended chain, and a resolver cannot be removed. On Tomcat 11.0.26 the limit is the first evaluation of a JSP: a servlet that tried to register a second resolver as the very first request of a freshly deployed application succeeded, and the same servlet called after one JSP page had been served returned `IllegalStateException: Cannot call addELResolver after the first request has been made (both observed). Register from the listener and treat anything later as an error. Frameworks use the same hook: Faces registers its resolver this way, and addELContextListener lets a resolver that needs extra context (not only the JspContext) put it into every new ELContext.

EL Outside a Page: ELProcessor, ELManager and StandardELContext

EL 3.0 made the language usable without a JSP page. There are two layers.

  • jakarta.el.ELProcessor is the simple one: a facade that evaluates expressions written without delimiters, assigns values, defines beans, variables and functions. It is documented as "not thread safe": one instance per task, or per session if the user is meant to configure an environment that lasts.

  • jakarta.el.ELManager and jakarta.el.StandardELContext are the environment behind it: they hold the ExpressionFactory, the resolvers, the imports and the function and variable maps. ELProcessor.getELManager() returns the manager, which is where custom resolvers, bean-name resolvers and imports are added. The complete, lower-level API, with ExpressionFactory.createValueExpression and createMethodExpression, is the one JSP and Faces containers use.

Table 6. The ELProcessor API (EL 6.0)
Method What it does

<T> T eval(String expression)

Evaluates the expression and returns the result. The string is bracketed by the processor, so book.title, not ${book.title}; delimiters are not allowed in these methods

<T> T getValue(String expression, Class<T> expectedType)

Evaluates and coerces to the type

void setValue(String expression, Object value)

Assigns through the ELResolver chain; the expression must be an lvalue such as book.title

void setVariable(String variable, String expression)

Binds a name to an expression (stored as a ValueExpression); in the run below limit was bound to 1900 and then used inside a lambda

void defineBean(String name, Object bean)

Adds a bean to the local bean repository; a bean hides another of the same name

void defineFunction(String prefix, String function, String className, String method) or (..., Method method)

Maps a public static method to prefix:function

ELManager getELManager()

The manager for the resolver, import and function configuration

Table 7. The ELManager and StandardELContext operations used for configuration
Operation Purpose

addELResolver(ELResolver)

Adds a custom resolver to the chain of this environment (same idea as JspApplicationContext.addELResolver)

addBeanNameResolver(BeanNameResolver)

Registers a BeanNameResolver (an adapter from a name to a bean) in a BeanNameELResolver; it cannot be removed afterwards

importClass, importPackage, importStatic

Adds to the ImportHandler; imported classes and static members are used without package names in expressions

mapFunction(prefix, function, Method)

Maps a static method to an EL function

setVariable(String, ValueExpression)

Assigns a ValueExpression to a variable

addEvaluationListener(EvaluationListener)

Observes the evaluation of every expression

static ExpressionFactory getExpressionFactory()

Finds the implementation on the class path

getELContext() / setELContext(ELContext)

The StandardELContext used for parsing and evaluation

The next program exercises every row of the first table and the most useful ones of the second. It does not run in a servlet container: it needs jakarta.el-api 6.0.1 and Expressly 6.0.0 on the class path, and the Book record of the Bookshelf.

Standalone.java: a stand-alone evaluator with the Bookshelf data (imports of jakarta.el.* and java.util.* omitted)
public class Standalone {
    public static void main(String[] args) throws Exception {
        ELProcessor elp = new ELProcessor();
        System.out.println("1+2 -> " + elp.eval("1 + 2"));

        elp.defineBean("book", new Book(1, "Dune", "Frank Herbert", 1965));
        System.out.println("book.title -> " + elp.eval("book.title"));
        System.out.println("book.year + 1 -> " + elp.eval("book.year + 1"));
        Integer year = elp.getValue("book.year", Integer.class);
        System.out.println("getValue year -> " + year);

        // assignment goes through the ELResolver; records are read-only
        try {
            elp.setValue("book.title", "Emma");
        } catch (RuntimeException e) {
            System.out.println("setValue on a record -> " + e.getClass().getName() + ": " + e.getMessage());
        }

        elp.defineBean("shelf", new java.util.ArrayList<>(List.of(
                new Book(1, "Dune", "Frank Herbert", 1965),
                new Book(2, "Emma", "Jane Austen", 1815),
                new Book(3, "Ubik", "Philip K. Dick", 1969))));
        System.out.println("titles after 1900 -> "
                + elp.eval("shelf.stream().filter(b -> b.year > 1900).map(b -> b.title).toList()"));

        elp.setVariable("limit", "1900");
        System.out.println("variable limit -> " + elp.eval("shelf.stream().filter(b -> b.year > limit).count()"));

        elp.defineFunction("bs", "isbn", "org.example.bookshelf.Isbn", "format");
        System.out.println("function -> " + elp.eval("bs:isbn('9780441172719')"));

        System.out.println("assign and semicolon -> " + elp.eval("n = 4; n * n"));
        System.out.println("lambda -> " + elp.eval("(x -> x * 2)(21)"));

        // ELManager: the environment behind ELProcessor
        ELManager mgr = elp.getELManager();
        mgr.importClass("java.util.Locale");
        System.out.println("importClass -> " + elp.eval("Locale.ROOT.toString().isEmpty()"));
        mgr.importStatic("java.lang.Math.max");
        System.out.println("importStatic -> " + elp.eval("max(3, 7)"));

        // BeanNameResolver: look beans up by name on demand
        Map<String, Object> repo = Map.of("featured", new Book(9, "Solaris", "Stanislaw Lem", 1961));
        mgr.addBeanNameResolver(new BeanNameResolver() {
            @Override
            public boolean isNameResolved(String beanName) {
                return repo.containsKey(beanName);
            }

            @Override
            public Object getBean(String beanName) {
                return repo.get(beanName);
            }
        });
        System.out.println("bean name resolver -> " + elp.eval("featured.title"));

        // unknown identifier in standalone mode
        try {
            elp.eval("nothing.title");
        } catch (PropertyNotFoundException e) {
            System.out.println("unknown identifier -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }

        // Low level: ExpressionFactory + StandardELContext, with deferred syntax
        ExpressionFactory factory = ELManager.getExpressionFactory();
        StandardELContext ctx = new StandardELContext(factory);
        ctx.getVariableMapper().setVariable("book",
                factory.createValueExpression(new Book(2, "Emma", "Jane Austen", 1815), Book.class));
        ValueExpression ve = factory.createValueExpression(ctx, "#{book.title}", String.class);
        System.out.println("ValueExpression -> " + ve.getValue(ctx) + " (" + ve.getExpressionString() + ")");
        System.out.println("implementation -> " + factory.getClass().getName());
    }
}

Run with java -cp 'target/classes:lib/*' org.example.bookshelf.Standalone on Java 21, it printed:

1+2 -> 3
book.title -> Dune
book.year + 1 -> 1966
getValue year -> 1965
setValue on a record -> jakarta.el.PropertyNotWritableException: The class 'org.example.bookshelf.Book' does not have a writable property 'title'.
titles after 1900 -> [Dune, Ubik]
variable limit -> 2
function -> 978-0441172719
assign and semicolon -> 16
lambda -> 42
importClass -> true
importStatic -> 7
bean name resolver -> Solaris
unknown identifier -> PropertyNotFoundException: ELResolver cannot handle a null base Object with identifier 'nothing'
ValueExpression -> Emma (#{book.title})
implementation -> org.glassfish.expressly.ExpressionFactoryImpl

Inside a container no implementation has to be added: the API is a provided dependency, as in the Bookshelf POM, and the container supplies the factory.

ElProcessorServlet: ELProcessor inside the Bookshelf WAR on Tomcat
@WebServlet("/elprocessor")
public class ElProcessorServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        resp.setContentType("text/plain;charset=UTF-8");
        ELProcessor elp = new ELProcessor();
        elp.defineBean("book", new Book(1, "Dune", "Frank Herbert", 1965));
        resp.getWriter().println(elp.<String>eval("book.title"));
        resp.getWriter().println(jakarta.el.ELManager.getExpressionFactory().getClass().getName());
    }
}

The response was Dune and org.apache.el.ExpressionFactoryImpl.

Four observations carry over to real code:

  • The defaults differ from a page. In a page an unknown identifier is null; in the stand-alone run nothing.title threw PropertyNotFoundException, whereas the resolver chain of a page ends with NotFoundELResolver, which answers null (Pages 4.0 section 2.10). The same expression can therefore behave differently in a unit test with ELProcessor and in the page it was extracted from.

  • Assignment is the resolver’s decision. The EL specification says an assignment "is determined by the ELResolver": a stand-alone environment lets n = 4 create a bean named n, a JSP container may assign to a scoped attribute, and a record refuses (PropertyNotWritableException). The ; operator evaluates and discards the left side, so n = 4; n * n returns 16.

  • No delimiters. eval("${book.title}") is wrong for ELProcessor (the processor adds the brackets); the full ExpressionFactory API needs the delimiters (#{book.title} above) and distinguishes createValueExpression from createMethodExpression.

  • The implementation is chosen by the class path. ELManager.getExpressionFactory() returned org.glassfish.expressly.ExpressionFactoryImpl in the stand-alone run, and org.apache.el.ExpressionFactoryImpl inside Tomcat 11.0.26, where a servlet in the Bookshelf WAR called new ELProcessor() with the API as a provided dependency and returned Dune for book.title (verified). A web application must therefore not bundle its own EL implementation.

Security: Do Not Evaluate Untrusted Text

EL is a programming language: it can call methods, assign, use lambdas and, when classes and static members are imported, reach static fields and methods. ELProcessor.eval(String) compiles and runs whatever it is given, so a string built from a request parameter, a header or a database column the user can edit is code execution, and a page that passes user input to an EL-evaluating tag attribute or to ExpressionFactory.createValueExpression has the same flaw (this is what "EL injection" means). Four rules follow.

  1. Never pass untrusted text as the expression. Pass it as a value: elp.defineBean("query", userText) and an expression such as query.length() keep the data and the code apart.

  2. Keep expression strings in the application: constants, configuration files that only operators can write, resources inside the WAR.

  3. In a custom resolver, answer only the names you own, make it read-only, and do not resolve names taken from the request (an attacker would use it to reach beans or settings you did not intend).

  4. Keep output escaped: a resolved value is still text that goes into HTML, so c:out or fn:escapeXml applies to settings just as to any other value. Do not combine isELIgnored="true" with the idea that it is a defence; it only changes the characters a page treats as EL.

A Look at EL 6.1 (Jakarta EE 12, Under Development)

Jakarta EE 12 is in progress and none of it is released. The Jakarta EE site lists Jakarta Expression Language 6.1 as "under development" with these planned items:

  • Language enhancements requested by users: an Elvis operator and the use of + to merge collections.

  • A performance optimisation for scoped attributes, done together with Jakarta Pages 4.1.

  • Pluggable caching mechanisms.

  • The TCK moves to the Expression Language project. No removals, deprecations or incompatible changes are planned.

The same page gives Java SE 17 as the minimum. Nothing on this page needs 6.1, the 6.1 API has only milestone releases on Maven Central (6.1.0-M2), and an example written with an Elvis operator would not run on Tomcat 11, so no code is shown for it. The advice is to write for 6.0 and to re-read the release page when 6.1 and Pages 4.1 are final.

The Bookshelf Scenario, Put Together

  1. In development the Bookshelf turns error-on-el-not-found on for *.jsp (a group in web.xml), so a mistyped ${bokk.title} is an error on the first request instead of an empty cell.

  2. The old admin pages under /legacy/ keep el-ignored while they are migrated; their tag files still evaluate EL, so those tag files are reviewed first.

  3. A start-up listener registers BookshelfSettings; pages say ${settings.siteName}.

  4. A custom bs:lazy tag takes a #{...} expression and evaluates it when it renders.

  5. The nightly import job evaluates configured price rules with ELProcessor on jakarta.el-api and Expressly, with the catalogue records defined as beans and the rules read from a file the operators control.

Pitfalls and Error Names

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

${...} printed as text on one directory only, though other pages evaluate it (observed)

A group with el-ignored true applies to that directory

Change the group, or set isELIgnored="false" in the page

${...} evaluated inside a tag file although the calling page ignores EL (observed)

Groups do not apply to tag files, whose default is to evaluate

Set isELIgnored="true" in the tag directive of the file

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

#{ in a page’s text, reserved since JSP 2.1

Write \#{, or use deferredSyntaxAllowedAsLiteral

According to TLD or attribute directive in tag file, attribute [value] does not accept any expressions (observed)

The wrong delimiter for the attribute category: #{} for a dynamic attribute, ${} for a deferred one

Match the delimiter to the TLD category (Table JSP.2-1) or declare the attribute as dynamic or deferred

jakarta.el.PropertyNotFoundException: Unknown identifier [missing] (observed)

errorOnELNotFound is on and the first name is not in any scope

Fix the name or the scope; guard optional data with c:if or empty

jakarta.el.PropertyNotFoundException: Property [nope] not found on type [...] (observed)

A property that the bean does not have; this is an error whatever the configuration says

Fix the property name or add the getter

jakarta.el.ELException: Failed to parse the expression (observed)

Invalid syntax: a translation error, found on first compilation

Fix the expression; precompile the JSPs (see the Jasper page of this section) so such errors appear at build time

PropertyNotWritableException on setValue or on an assignment (observed)

The target is a record, a read-only resolver or an immutable map

Assign to a mutable bean or define the variable with setVariable

IllegalStateException: Cannot call addELResolver after the first request has been made (observed)

Registration after the first evaluated JSP

Register in a ServletContextListener

${settings.x} shows a request attribute instead of the resolver’s value, or the other way round

The resolver order: custom resolvers before the scoped-attribute resolver

Choose names that do not collide, and use requestScope / sessionScope for attributes

eval("${book.title}") fails or returns the wrong value

ELProcessor brackets its expression strings itself

Pass book.title

A unit test with ELProcessor passes and the page prints an empty string (or the reverse)

Stand-alone context throws on unknown identifiers; a page returns null

Test with the same switches (errorOnELNotFound) the page uses, or assert on defined beans only

ELProcessor shared between threads gives wrong results

It keeps mutable state and is not thread safe

One instance per task or session; configure then copy the immutable data

ExceptionInInitializerError caused by jakarta.el.ELException: Provider com.sun.el.ExpressionFactoryImpl not found when new ELProcessor() runs (observed)

Only jakarta.el-api is on the class path, there is no ExpressionFactory implementation

Add Expressly (or another implementation)

Application bundles an EL implementation and the container’s version conflicts

The implementation JAR is inside WEB-INF/lib

Use the API as provided and let the container supply the implementation

What Changed Recently

Table 9. EL and its use from JSP, from EL 2.1 to the 6.1 plan (the specification’s own change log and overview)
Version Change

EL 2.1 and JSP 2.1

Unified EL: #{...} deferred syntax, a pluggable ELResolver API, deferred evaluation of value and method expressions and lvalue expressions, aligned between JSP 2.1 and Faces 1.2

EL 3.0

Stand-alone use (ELProcessor, ELManager, StandardELContext), direct evaluation APIs, collection operations, lambdas

EL 4.0 (Jakarta EE 9)

The transition from the javax.el to the jakarta.el namespace

EL 5.0 (Jakarta EE 10)

Java 11 minimum; BeanELResolver considers default methods; coercion of a lambda to a functional interface; MethodReference; generics in the API; ELResolver.getFeatureDescriptors() deprecated; arrays can be coerced; a special case for the length property of arrays (change #175 in the 5.0 log; the 6.0 log lists the length property again)

EL 6.0 (Jakarta EE 11)

Java 17 minimum; deprecated code removed; the java.desktop dependency (java.beans) is optional; length property for arrays; OptionalELResolver (not enabled by default); RecordELResolver (enabled by default); module visibility for class methods; all SecurityManager references removed

Pages 3.1 (Jakarta EE 10)

errorOnELNotFound and error-on-el-not-found

Pages 4.0 (Jakarta EE 11)

The deprecated classes of jakarta.servlet.jsp.el removed (Jakarta Pages 4.0 change log); the section 2.10 chain of resolvers is the one used on this page

EL 6.1 and Pages 4.1 (Jakarta EE 12, planned)

Elvis operator, + merging collections, pluggable caches, faster scoped attributes; milestones only

Where the Books Are Outdated

The JSP and JSTL books consulted for this section (listed in the section bibliography) predate the unified expression language, so what they teach about EL is either absent or a different mechanism.

Table 10. The books' era and the current mechanism
The books' era Today (Pages 4.0, EL 6.0)

No expression language in JSP pages (JSP 1.2); every dynamic value is a scriptlet or an <%= %> expression

EL in template text and attribute values of every page, evaluated by the container; scripting can be disabled with scripting-invalid

EL only inside the attributes of JSTL 1.0 tags, evaluated by the tag library itself (the c library with EL, the c_rt twin for Java expressions)

A single language evaluated by the container for every tag; the twin libraries are gone

Custom tags that evaluate EL themselves, with the Jakarta Taglibs ExpressionEvaluatorManager helper

A rtexprvalue attribute (the container evaluates ${...} before the handler sees it) or a deferred-value attribute (the handler receives a ValueExpression); tag files do the same with the attribute directive

<%@ page isELIgnored="true" %> as the way to cope with old text, with a Servlet 2.3 descriptor ignoring EL by default

Servlet 2.4+ descriptors evaluate by default; el-ignored per group, isELIgnored per page or tag file; reserved #{ and deferred-syntax-allowed-as-literal

Missing values silently print nothing, and nothing in the book says it can be changed

errorOnELNotFound and error-on-el-not-found since Pages 3.1

Extending variable resolution with a custom VariableResolver or PropertyResolver (JSP 2.0 API)

ELResolver since JSP 2.1; the old javax.servlet.jsp.el.* classes are removed from jakarta.servlet.jsp.el in Pages 4.0

EL cannot be used outside a page; testing a rule means a request to a container

ELProcessor and ELManager since EL 3.0, with an implementation such as Expressly on the class path

javax.el / javax.servlet.jsp.el packages

jakarta.el / jakarta.servlet.jsp since EL 4.0 and Jakarta EE 9; Tomcat 10 and later need recompiled applications or the Tomcat migration tool

References