EL Operators & Type Coercion

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 EE 11), Jakarta Pages 4.0, Jakarta Standard Tag Library 3.0 and Jakarta Servlet 6.1, running on Apache Tomcat 11.0.26 and Java 21 (EL 6.0 needs Java 17 or later). Everything was verified on 2026-10-08: the rules against sections 1.7 to 1.17 and section 1.25 of the Expression Language 6.0 specification and against section 2.8 and 2.9 of the Pages 4.0 specification, and every example by building it into a Maven WAR (context path /g4elops) and running it on Tomcat 11.0.26 with curl; the outputs and error messages shown are the ones that run produced. An Expression Language expression is small, but it is not simple: ${a + b} is numeric addition and never string concatenation, ${a == b} calls equals after a round of type conversions, a comparison with a missing value is quietly false, and a page can fail with an HTTP 500 because a request parameter was not a number. All of that is decided by two sets of rules in the EL specification: one per operator (sections 1.7 to 1.16) and one for converting a value to the type an operator or an attribute expects (section 1.25). This page lists both, operator by operator, with the Bookshelf (a small catalogue of books) as the running scenario, and ends with the pitfalls, what changed from JSP 2.0 to EL 6.0 and where the books in the section bibliography are out of date. The syntax of ${...} and #{...}, the implicit objects and the property access operators . and [] are on the Expression Language Basics page of this section; the lambda operator ->, collection literals and streams, static access, records and Optional, and ELProcessor have a page of their own each (Lambdas, Collections and Streams, Static Access, Records and Optional and Configuration and ELProcessor).

The Bookshelf Used on This Page

The examples need data in scope and nothing else, so a ServletContextListener puts a handful of values into application scope when the web application starts. The Book record is the one used in the rest of the section. Records are read as beans by the EL since version 6.0, so ${dune.title} works without getters.

The Bookshelf values used by the examples (shortened; the full listener also defines a few more constants of the same kind)
public record Book(long id, String title, String author, int year) { }

public enum Status { AVAILABLE, LOANED }

public record Loan(Book book, Status status, LocalDate due) { }

@WebListener
public class ShelfListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent event) {
        var ctx = event.getServletContext();
        var dune = new Book(1, "Dune", "Frank Herbert", 1965);
        var neuro = new Book(2, "Neuromancer", "William Gibson", 1984);
        var dispossessed = new Book(3, "The Dispossessed", "Ursula K. Le Guin", 1974);
        ctx.setAttribute("books", List.of(dune, neuro, dispossessed));
        ctx.setAttribute("noBooks", List.of());
        ctx.setAttribute("catalog", Map.of("dune", dune));
        ctx.setAttribute("dune", dune);
        ctx.setAttribute("price", new BigDecimal("12.50"));      // BigDecimal
        ctx.setAttribute("copies", Integer.valueOf(3));          // Integer
        ctx.setAttribute("count", Long.valueOf(7));              // Long
        ctx.setAttribute("rating", Double.valueOf(4.5));         // Double
        ctx.setAttribute("ratio", Float.valueOf(0.1f));          // Float
        ctx.setAttribute("big", new BigInteger("9223372036854775807"));
        ctx.setAttribute("status", Status.AVAILABLE);            // enum
        ctx.setAttribute("loan", new Loan(dune, Status.LOANED, LocalDate.of(2026, 11, 1)));
        ctx.setAttribute("today", LocalDate.of(2026, 10, 8));
        ctx.setAttribute("letter", Character.valueOf('a'));
        ctx.setAttribute("blank", "");
        ctx.setAttribute("spaces", "   ");
        ctx.setAttribute("trueText", "true");
        ctx.setAttribute("yes", Boolean.TRUE);
        ctx.setAttribute("no", Boolean.FALSE);
        ctx.setAttribute("noTags", new String[0]);
        ctx.setAttribute("tags", new String[] {"sf", "classic"});
    }
}

Every page of the examples starts with the same directive, which is left out of the listings: <%@ page contentType="text/plain;charset=UTF-8" trimDirectiveWhitespaces="true" %>. It makes the response plain text so that the output below is exactly what curl printed, and it removes the blank lines left by directives. The name nothing that appears in many examples is deliberately not defined anywhere: an unknown identifier evaluates to null (the errorOnELNotFound switch that makes it an error is described on the configuration page). A value is called null in the rest of the page whenever it is null, whether through an unknown name, a missing map key or a property that returns null.

The Operators at a Glance

EL has five groups of operators and two structural ones. The word forms exist for two reasons: they avoid the entity references that <, > and & need in XML syntax (see Operators in XML Syntax), and they are the spelling XPath and ECMAScript users know. A symbol and its word form behave identically.

Table 1. Every operator of Expression Language 6.0, highest precedence first (operators on one row have equal precedence)
Level Operators (word form in parentheses) Meaning Specification section

1

[] .

Property, element, key or method access (basics page)

1.6

2

()

Grouping, and function or lambda call

1.15, 1.18, 1.20

3

- (unary), ! (not), empty

Numeric negation, logical negation, null-or-empty test

1.7.4, 1.10.2, 1.11

4

*, / (div), % (mod)

Multiplication, division, remainder

1.7.1 to 1.7.3

5

+, - (binary)

Addition and subtraction

1.7.1

6

+=

String concatenation

1.8

7

< (lt), > (gt), <= (le), >= (ge)

Ordering comparisons

1.9.1

8

== (eq), != (ne)

Equality

1.9.2

9

&& (and)

Logical and, short-circuit

1.10.1

10

|| (or)

Logical or, short-circuit

1.10.1

11

? :

Conditional, right associative

1.12

12

->

Lambda expression, right associative (lambda page)

1.20

13

=

Assignment, right associative

1.13

14

;

Sequence: evaluate the left side, return the right

1.14

Three words from this table, empty, div and mod, cannot be used as identifiers; the full list is in Reserved Words and Identifiers. The operators +=, =, ; and -> were added in EL 3.0 (Java EE 7, 2013). A page written for JSP 2.0 to 2.2 knows only the arithmetic, relational, logical, empty and conditional operators, which is all that the books in the bibliography describe.

Arithmetic Operators

The operators are +, -, *, / (or div), % (or mod) and the unary minus. They work on integers (BigInteger and Long) and on floating-point numbers (BigDecimal and Double); every other numeric type, and a String, is converted first. The specification picks the working type for +, - and * from the operands:

flowchart TD start["A and B evaluated"] --> nn{"Both null?"} nn -- yes --> zero["Result is the Long 0"] nn -- no --> bd{"Either operand a BigDecimal?"} bd -- yes --> rbd["Both become BigDecimal"] bd -- no --> fl{"Either a Float, a Double, or a String containing . e or E?"} fl -- yes --> bi1{"Either a BigInteger?"} bi1 -- yes --> rbd bi1 -- no --> rdb["Both become Double"] fl -- no --> bi2{"Either a BigInteger?"} bi2 -- yes --> rbi["Both become BigInteger"] bi2 -- no --> rlg["Both become Long"]

The other three operators have their own rules.

Table 2. The working type chosen by each arithmetic operator
Operator Rule Typical result

+, -, *

The diagram above: BigDecimal, then Double, then BigInteger, then Long

3 + 4 is the Long 7

/, div

BigDecimal division (rounding half up, keeping the scale of the left operand) when either side is BigDecimal or BigInteger, otherwise Double

7 / 2 is 3.5, 8 div 2 is 4.0

%, mod

Double if either side is BigDecimal, Float, Double or a String with ., e or E; BigInteger remainder if either is BigInteger; otherwise Long

7 mod 3 is 1, -7 mod 3 is -1

- (unary)

BigDecimal and BigInteger are negated; a String becomes a Double if it contains ., e or E and a Long otherwise; Byte, Short, Integer, Long, Float and Double keep their type; anything else, such as a Boolean, is an error

-copies is the Integer -3

When both operands are null the result is the Long 0, and a single null operand counts as 0 (the coercion of null to a number is 0, see Type Coercion). An operation that throws, such as a Long remainder by zero, becomes an error.

ops/arith.jsp: arithmetic on the Bookshelf values
7 + 3 = ${7 + 3}
7 / 2 = ${7 / 2}
8 div 2 = ${8 div 2}
7 % 3 = ${7 % 3}
-7 mod 3 = ${-7 mod 3}
-copies = ${-copies}
price * copies = ${price * copies}
price + 1 = ${price + 1}
price / 3 = ${price / 3}
big + 1 = ${big + 1}
rating * 2 = ${rating * 2}
count * 1.0 = ${count * 1.0}
ratio * 3 = ${ratio * 3}
'2.5' + 1 = ${'2.5' + 1}
'12' + 1 = ${'12' + 1}
'' + 1 = ${'' + 1}
1 / 0 = ${1 / 0}
-1 / 0 = ${-1 / 0}
0 / 0 = ${0 / 0}
9223372036854775807 + 1 = ${9223372036854775807 + 1}
nothing + 1 = ${nothing + 1}
nothing + nothing = ${nothing + nothing}
-nothing = ${-nothing}
2 + 3 * 4 = ${2 + 3 * 4}
(2 + 3) * 4 = ${(2 + 3) * 4}

The response (verified on Tomcat 11.0.26):

7 + 3 = 10
7 / 2 = 3.5
8 div 2 = 4.0
7 % 3 = 1
-7 mod 3 = -1
-copies = -3
price * copies = 37.50
price + 1 = 13.50
price / 3 = 4.17
big + 1 = 9223372036854775808
rating * 2 = 9.0
count * 1.0 = 7.0
ratio * 3 = 0.30000000447034836
'2.5' + 1 = 3.5
'12' + 1 = 13
'' + 1 = 1
1 / 0 = Infinity
-1 / 0 = -Infinity
0 / 0 = NaN
9223372036854775807 + 1 = -9223372036854775808
nothing + 1 = 1
nothing + nothing = 0
-nothing = 0
2 + 3 * 4 = 14
(2 + 3) * 4 = 20

Reading the output against the rules:

  • Integer division does not exist. / is Double division unless a BigDecimal or BigInteger is involved, so ${7 / 2} is 3.5 and even ${8 div 2} prints 4.0. There is no operator for the whole-number quotient; round the result with fmt:formatNumber (maximum fraction digits 0, which rounds half to even) or move the arithmetic into the controller.

  • The money rule. price is a BigDecimal with two decimals, so every operation stays exact and keeps scale 2 for +, - and * (37.50, 13.50); the division is rounded half up to the scale of the left operand (4.17). A Double never enters a BigDecimal calculation unless it is a literal such as 0.1: see the pitfalls.

  • A Float is widened to a Double, which exposes the binary representation: 0.1f * 3 prints 0.30000000447034836. Prefer Double or BigDecimal in model classes that a page multiplies.

  • Long arithmetic wraps silently. 9223372036854775807 + 1 is -9223372036854775808. Use a BigInteger (the big value) when overflow is possible; ${big + 1} is exact.

  • Division by zero is not an error for Double: 1 / 0 is Infinity and 0 / 0 is NaN. A Long or BigInteger remainder by zero is an error (see the next example).

  • Null counts as zero, and a String is parsed ('12' + 1 is 13, '2.5' + 1 is 3.5), including the empty String, which is 0 (${'' + 1} is 1).

ops/err-mod.jsp, err-plus.jsp, err-plus2.jsp and err-boolnum.jsp: arithmetic that fails
${5 mod 0}
${'abc' + 1}
${'Dune' + ' (1965)'}
${true + 1}

The four pages each end with HTTP 500. The root causes of the exception report (Tomcat prints the page line and the cause of the failure) are:

java.lang.ArithmeticException: / by zero
java.lang.NumberFormatException: For input string: "abc"
java.lang.NumberFormatException: For input string: "Dune"
jakarta.el.ELException: Cannot convert [true] of type [class java.lang.Boolean] to [Number]

Two lessons are in this output. First, the third line is the commonest mistake of everybody who comes from Java or JavaScript: + never concatenates. The operands of 'Dune' + ' (1965)' are parsed as numbers, and the parse fails. The concatenation operator is += (next section). Second, a number-conversion failure surfaces as a raw NumberFormatException from Long.valueOf and not as an ELException, while a Boolean operand is rejected with an ELException; handlers that map exceptions to pages must be ready for both. Whatever the type, the page never renders partially in this case: the container answers 500 (or the configured error page, see the error handling page).

String Concatenation: +=

${A += B} converts both operands to String and returns the concatenation. It exists because + is arithmetic only. A null operand becomes the empty String and an enum becomes its name(), so there is no NullPointerException and no "null" text, as there would be in Java.

ops/concat.jsp: building a label
'Bookshelf: ' += copies = ${'Bookshelf: ' += copies}
dune.title += ' (' += dune.year += ')' = ${dune.title += ' (' += dune.year += ')'}
nothing += 'x' = [${nothing += 'x'}]
nothing += nothing = [${nothing += nothing}]
status += '!' = ${status += '!'}
1 + 2 += 3 + 4 = ${1 + 2 += 3 + 4}
'Bookshelf: ' += copies = Bookshelf: 3
dune.title += ' (' += dune.year += ')' = Dune (1965)
nothing += 'x' = [x]
nothing += nothing = []
status += '!' = AVAILABLE!
1 + 2 += 3 + 4 = 37

The last line shows the precedence: += binds more loosely than binary +, so both additions run first (3 and 7) and the two numbers are then joined as "37". For readable pages keep the operands short and prefer c:out and fmt tags for anything that is displayed; += is best for building an attribute value such as a CSS class or a URL fragment.

Relational and Equality Operators

The ordering operators <, >, <=, >= (lt, gt, le, ge) and the equality operators ==, != (eq, ne) return a Boolean. Their rules are tried top to bottom, and the first that matches decides the type of the comparison. The rule list is the key to the surprises, so it is reproduced as a table.

Table 3. How an ordering comparison A op B is evaluated, in order
Step Rule

1

If A and B are the same object, <=, le, >= and ge return true

2

If either is null, the result is false

3

If either is a BigDecimal, both become BigDecimal and compareTo decides

4

If either is a Float or Double, both become Double

5

If either is a BigInteger, both become BigInteger and compareTo decides

6

If either is a Byte, Short, Character, Integer or Long, both become Long

7

If either is a String, both become String and are compared lexically

8

If A is Comparable, A.compareTo(B) decides; else if B is Comparable, B.compareTo(A) decides (the sign is reversed)

9

Otherwise it is an error

Table 4. How an equality comparison A == B (and A != B) is evaluated, in order
Step Rule

1

If A and B are the same object, the operator is applied to that fact (== true, != false)

2

If either is null, == is false and != is true

3

If either is a BigDecimal, both become BigDecimal and A.equals(B) decides, so the scale matters

4

If either is a Float or Double, both become Double

5

If either is a BigInteger, both become BigInteger and equals decides

6

If either is a Byte, Short, Character, Integer or Long, both become Long

7

If either is a Boolean, both become Boolean

8

If either is an enum, both become that enum type (a String is looked up with Enum.valueOf)

9

If either is a String, both become String and are compared as text

10

Otherwise A.equals(B) decides; an exception in equals is an error

ops/rel.jsp: comparisons that behave as the rules say (selection)
3 lt 5 = ${3 lt 5}
5 >= 6 = ${5 >= 6}
'abc' lt 'abd' = ${'abc' lt 'abd'}
'Zebra' lt 'apple' = ${'Zebra' lt 'apple'}
'10' lt '9' = ${'10' lt '9'}
10 lt '9' = ${10 lt '9'}
'10' lt 9 = ${'10' lt 9}
nothing lt 1 = ${nothing lt 1}
nothing ge 1 = ${nothing ge 1}
nothing le nothing = ${nothing le nothing}
nothing lt nothing = ${nothing lt nothing}
1 eq 1.0 = ${1 eq 1.0}
'1' eq 1 = ${'1' eq 1}
copies == 3.0 = ${copies == 3.0}
count == 7 = ${count == 7}
dune.title == 'Dune' = ${dune.title == 'Dune'}
dune.title ne 'dune' = ${dune.title ne 'dune'}
nothing eq null = ${nothing eq null}
nothing == 0 = ${nothing == 0}
nothing == '' = ${nothing == ''}
nothing != 1 = ${nothing != 1}
blank == null = ${blank == null}
books[0] == dune = ${books[0] == dune}
yes == 'true' = ${yes == 'true'}
status == 'AVAILABLE' = ${status == 'AVAILABLE'}
status ne 'LOANED' = ${status ne 'LOANED'}
loan.status == 'LOANED' = ${loan.status == 'LOANED'}
loan.due lt today = ${loan.due lt today}
today lt loan.due = ${today lt loan.due}
today == loan.due = ${today == loan.due}
1 < 2 == true = ${1 < 2 == true}
3 lt 5 = true
5 >= 6 = false
'abc' lt 'abd' = true
'Zebra' lt 'apple' = true
'10' lt '9' = true
10 lt '9' = false
'10' lt 9 = false
nothing lt 1 = false
nothing ge 1 = false
nothing le nothing = true
nothing lt nothing = false
1 eq 1.0 = true
'1' eq 1 = true
copies == 3.0 = true
count == 7 = true
dune.title == 'Dune' = true
dune.title ne 'dune' = true
nothing eq null = true
nothing == 0 = false
nothing == '' = false
nothing != 1 = true
blank == null = false
books[0] == dune = true
yes == 'true' = true
status == 'AVAILABLE' = true
status ne 'LOANED' = true
loan.status == 'LOANED' = true
loan.due lt today = false
today lt loan.due = true
today == loan.due = false
1 < 2 == true = true

Points the output makes concrete:

  • Numbers compare as numbers whatever their type: 1 eq 1.0, copies == 3.0 (an Integer against a Double) and count == 7 (a Long against an Integer literal) are true. A String that is compared with a number is converted to a number ('1' eq 1, ${'10' lt 9} is false because 10 is not less than 9).

  • Two Strings compare lexically, not numerically: '10' lt '9' is true. The comparison is also case sensitive and based on String.compareTo, so 'Zebra' lt 'apple' is true (upper case sorts first). To sort or filter book titles in a locale-aware way, do it in Java.

  • Null makes every ordering comparison false, with one exception: nothing le nothing is true because the two operands are the same (null) and the operator allows equality (step 1 comes before the null test). nothing == 0 and nothing == '' are false (null is never equal to a non-null value), and blank == null is false: the empty String is not null.

  • A String is compared with an enum by constant name (status == 'AVAILABLE'), a Boolean with the text 'true' or 'false', and any Comparable, such as LocalDate, with another of its kind (loan.due lt today).

  • Equality of unrelated objects falls back to equals: books[0] == dune is true because the list holds the same record. A record has value-based equals, so two Book records with the same components are equal as well.

The cases where the rules produce an error rather than a false are worth seeing once.

ops/err-eq.jsp, err-cmp.jsp, err-letter.jsp, err-enum.jsp and cmp2.jsp: comparisons that fail
${'1.0' eq 1}
${books[0] lt books[1]}
${letter == 'a'}
${status == 'MISSING'}
${1 lt 2 lt 3}
jakarta.el.ELException: Cannot convert [1.0] of type [class java.lang.String] to [class java.lang.Long]
jakarta.el.ELException: Cannot compare [Book[id=1, title=Dune, author=Frank Herbert, year=1965]] to [Book[id=2, title=Neuromancer, author=William Gibson, year=1984]]
jakarta.el.ELException: Cannot convert [a] of type [class java.lang.String] to [class java.lang.Long]
jakarta.el.ELException: Cannot convert [MISSING] of type [class java.lang.String] to [class org.example.bookshelf.Status]
jakarta.el.ELException: Cannot convert [true] of type [class java.lang.Boolean] to [class java.lang.Long]

Each failure follows from the rules. '1.0' eq 1 meets an integer (step 6), so both become Long, and "1.0" is not a Long. A Book record is not Comparable (step 8 finds neither side comparable), so lt between two books is an error; compare books[0].year lt books[1].year instead. A Character is an integer for step 6, which turns the String 'a' into a Long and fails, even though comparing a Character with a one-letter text is what the author meant; compare letter eq 97, or convert the text in Java. A String that does not name an enum constant fails (an empty String does not: ${status == ''} and ${status == nothing} print false and false). The last line shows that chained comparisons do not exist: 1 lt 2 lt 3 is (1 lt 2) lt 3, which compares a Boolean with 3. Write 1 lt 2 and 2 lt 3.

The BigDecimal rule deserves its own example because money values are the commonest BigDecimal in a page.

ops/res2.jsp: BigDecimal equality compares the scale
price eq '12.50' = ${price eq '12.50'}
price eq '12.5' = ${price eq '12.5'}
price == 12.50 = ${price == 12.50}
12.50 == 12.5 = ${12.50 == 12.5}
price lt 12.5 = ${price lt 12.5}
price le 12.5 = ${price le 12.5}
price ge 12.5 = ${price ge 12.5}
price ne 12.5 = ${price ne 12.5}
price + '0.1' = ${price + '0.1'}
price + 0.1 = ${price + 0.1}
price eq '12.50' = true
price eq '12.5' = false
price == 12.50 = false
12.50 == 12.5 = true
price lt 12.5 = false
price le 12.5 = true
price ge 12.5 = true
price ne 12.5 = true
price + '0.1' = 12.60
price + 0.1 = 12.6000000000000000055511151231257827021181583404541015625

price is 12.50 (scale 2). == calls BigDecimal.equals, which is false for 12.50 and 12.5, so ${price == 12.50} is false too: the literal 12.50 is a Double that is converted to BigDecimal as 12.5. The ordering operators use compareTo, so le and ge both say "equal". To test a BigDecimal for equality in a page, use price le x and price ge x, or a Double-free string (price eq '12.50' has the same scale), or do the comparison in Java. The last two lines show the same trap in arithmetic: a numeric literal such as 0.1 is a Double, and converting it to a BigDecimal exposes the binary expansion of 0.1; the quoted '0.1' is converted to a BigDecimal from its text and stays exact.

Logical Operators

&& (and), || (or) and ! (not) coerce their operands to Boolean. The binary operators stop as soon as the result is known: in A and B and C, if B is false then C is never evaluated. The unary ! applies to the operand that follows.

ops/logic.jsp: logical operators and what counts as true
true && false = ${true && false}
true and true = ${true and true}
false or false = ${false or false}
not false = ${not false}
not not yes = ${not not yes}
'true' and 'TRUE' = ${'true' and 'TRUE'}
'false' or 'false' = ${'false' or 'false'}
'yes' and true = ${'yes' and true}
'' or true = ${'' or true}
nothing and true = ${nothing and true}
nothing or true = ${nothing or true}
not nothing = ${not nothing}
trueText and yes = ${trueText and yes}
not falseText = ${not falseText}
copies gt 0 and not empty books = ${copies gt 0 and not empty books}
false and (1/0 lt 1) = ${false and (1/0 lt 1)}
true or nonsense.x.y = ${true or nonsense.x.y}
true && false = false
true and true = true
false or false = false
not false = true
not not yes = true
'true' and 'TRUE' = true
'false' or 'false' = false
'yes' and true = false
'' or true = true
nothing and true = false
nothing or true = true
not nothing = true
trueText and yes = true
not falseText = true
copies gt 0 and not empty books = true
false and (1/0 lt 1) = false
true or nonsense.x.y = true

The coercion to Boolean (section 1.25.5) is lenient for Strings and strict for numbers. A null or empty String is false; the text "true", in any letter case, is true; any other text is false and not an error ('yes' and true is false), because Boolean.valueOf never throws. A number is an error, as the following two pages show.

ops/err-and.jsp and err-not.jsp: a number is not a Boolean
${1 and true}
${not copies}
jakarta.el.ELException: Cannot convert [1] of type [class java.lang.Long] to [class java.lang.Boolean]
jakarta.el.ELException: Cannot convert [3] of type [class java.lang.Integer] to [class java.lang.Boolean]

Short-circuit evaluation is a safety feature as much as an optimisation. ${not empty param.q and fn:length(param.q) gt 3} never evaluates the call when the parameter is absent, and ${false and (1/0 lt 1)} never reaches the division. The guard has to be on the left of the operator.

The empty Operator

${empty A} is true when A is null, an empty String, an empty array, an empty Map or an empty Collection; otherwise it is false. It is a prefix operator, so it can be combined with not or ! to ask the opposite question.

ops/empty.jsp: what is empty
empty nothing = ${empty nothing}
empty blank = ${empty blank}
empty spaces = ${empty spaces}
empty 'x' = ${empty 'x'}
empty 0 = ${empty 0}
empty false = ${empty false}
empty books = ${empty books}
empty noBooks = ${empty noBooks}
empty catalog = ${empty catalog}
empty tags = ${empty tags}
empty noTags = ${empty noTags}
empty dune = ${empty dune}
empty dune.title = ${empty dune.title}
empty param.q = ${empty param.q}
not empty param.q = ${not empty param.q}
!empty param.q = ${!empty param.q}
empty books[5] = ${empty books[5]}
empty catalog.missing = ${empty catalog.missing}
empty null = ${empty null}
empty empty nothing = ${empty empty nothing}
empty nothing = true
empty blank = true
empty spaces = false
empty 'x' = false
empty 0 = false
empty false = false
empty books = false
empty noBooks = true
empty catalog = false
empty tags = false
empty noTags = true
empty dune = false
empty dune.title = false
empty param.q = true
not empty param.q = false
!empty param.q = false
empty books[5] = true
empty catalog.missing = true
empty null = true
empty empty nothing = false

The details that matter in practice: a string of spaces is not empty, 0 and false are not empty, and empty books[5] is true because a list index outside the list is null in EL (not an error). The operator returns true for a request parameter that is absent and also for one that is present but blank in the query string (?q=), so a search page cannot tell "no filter" from "empty filter" with it. empty gives the standard guard for a missing value: ${empty param.q ? 'all books' : param.q}; remember to wrap anything printed from a parameter in c:out (the next sections and the pitfalls explain why). Its precedence is the same as unary minus and not, so ${not empty param.q and copies gt 0} means (not (empty param.q)) and (copies gt 0).

The Conditional Operator

${A ? B : C} coerces A to Boolean and evaluates and returns B when it is true and C when it is false; the branch that is not selected is never evaluated. It is right associative, so a chain of tests needs no parentheses.

ops/cond.jsp: the conditional operator
copies gt 0 ? 'in stock' : 'sold out' = ${copies gt 0 ? 'in stock' : 'sold out'}
empty noBooks ? 'none' : books[0].title = ${empty noBooks ? 'none' : books[0].title}
dune.year lt 1970 ? 'classic' : dune.year lt 2000 ? 'modern' : 'recent' = ${dune.year lt 1970 ? 'classic' : dune.year lt 2000 ? 'modern' : 'recent'}
neuro: ${books[1].year lt 1970 ? 'classic' : books[1].year lt 2000 ? 'modern' : 'recent'}
trueText ? 1 : 2 = ${trueText ? 1 : 2}
blank ? 1 : 2 = ${blank ? 1 : 2}
nothing ? 1 : 2 = ${nothing ? 1 : 2}
copies gt 5 ? 'many' : null = [${copies gt 5 ? 'many' : null}]
true ? 1 : 2.5 = ${true ? 1 : 2.5}
true ? 'a' : 1/0 = ${true ? 'a' : 1/0}
param.q != null ? param.q : 'Dune' = ${param.q != null ? param.q : 'Dune'}
not empty param.q ? param.q : 'all' = ${not empty param.q ? param.q : 'all'}
copies gt 0 ? 'in stock' : 'sold out' = in stock
empty noBooks ? 'none' : books[0].title = none
dune.year lt 1970 ? 'classic' : dune.year lt 2000 ? 'modern' : 'recent' = classic
neuro: modern
trueText ? 1 : 2 = 1
blank ? 1 : 2 = 2
nothing ? 1 : 2 = 2
copies gt 5 ? 'many' : null = []
true ? 1 : 2.5 = 1
true ? 'a' : 1/0 = a
param.q != null ? param.q : 'Dune' = Dune
not empty param.q ? param.q : 'all' = all

Two details. The result type is the type of the branch that ran: ${true ? 1 : 2.5} is the integer 1, not 1.0 as in Java, because EL does no numeric promotion between the branches. And the test is a Boolean coercion, so the String "true" is true and the empty String and null are false. The Elvis operator ?: that many languages have (a default for a null or empty value) does not exist in EL 6.0; the specification project lists it for EL 6.1 (see What Changed Recently). Until then use ${empty x ? 'default' : x}.

The conditional has one parsing trap, which the specification describes in section 1.16: a namespace prefix makes a colon part of a qualified function name, and qualified functions bind tighter than any operator.

ops/err-cond3.jsp: an identifier glued to a colon before a call
${yes ? copies:count()}

The page is not translated; the first request fails with:

org.apache.jasper.JasperException: /ops/err-cond3.jsp (line: [3], column: [2]) The attribute prefix [copies] does not correspond to any imported tag library

copies:count() is read as the function count in the namespace copies. Put spaces around the colon, or parentheses around the call (${c ? b : (f())}). Without a call following the colon there is no problem, and the page ops/cond2.jsp printed 3 for ${yes ? copies:count} and for ${yes ? copies : count}.

Assignment and the Semicolon

EL 3.0 added two operators that change state, both designed for the stand-alone ELProcessor and for lambdas, and both available in a page.

  • ${A = B} evaluates B and writes it to the writable reference A, and the whole expression returns the value written. It is right associative: ${a = b = 5} is a = (b = 5). A that cannot be written throws PropertyNotWritableException.

  • ${A ; B} evaluates A, discards the value, then evaluates and returns B. It is the C comma operator, so it chains assignments: ${x = 5; y = x * 2; x + y}.

What a bare name means to = is decided by the resolvers. In a page, the Pages ScopedAttributeELResolver sets the attribute in whichever scope already holds that name, searching page, request, session and application (the scopes page has the details), and creates a page-scope attribute only if no scope has the name.

ops/assign.jsp: assignment and sequence in a page
before = [${bestSeller}]
bestSeller = 'Dune' => ${bestSeller = 'Dune'}
after = [${bestSeller}]
pageScope.bestSeller = [${pageScope.bestSeller}]
requestScope.bestSeller = [${requestScope.bestSeller}]
a = b = 5 => ${a = b = 5}
a, b = ${a}, ${b}
shelfSize = books.size() => ${shelfSize = books.size()}
shelfSize = ${shelfSize}
semicolon: ${x = 5; y = x * 2; x + y}
discard: [${z = 1; ''}] z=${z}
before = []
bestSeller = 'Dune' => Dune
after = [Dune]
pageScope.bestSeller = [Dune]
requestScope.bestSeller = []
a = b = 5 => 5
a, b = 5, 5
shelfSize = books.size() => 3
shelfSize = 3
semicolon: 15
discard: [] z=1

An assignment prints its value, which is why ${x = 5} shows up in the page; ${z = 1; ''} is the idiom for "assign and print nothing". The nastiest consequence is the existing-name rule, which is not what a reader of the specification text alone would expect:

ops/typo.jsp: a mistyped == overwrites application data (run twice)
copies == 3 => ${copies == 3}
copies = 99 => ${copies = 99}
copies now = ${copies}
applicationScope.copies = ${applicationScope.copies}
pageScope.copies = ${pageScope.copies}
first request:
copies == 3 => true
copies = 99 => 99
copies now = 99
applicationScope.copies = 99
pageScope.copies =

second request:
copies == 3 => false
copies = 99 => 99
copies now = 99
applicationScope.copies = 99
pageScope.copies =

A single = typed where == was meant overwrites the shared application attribute for every user until the web application restarts, and the second request proves it: the "stock" value is now 99 for everyone. pageScope.copies stays empty because the write went to the scope that held the name. Treat a page that contains a bare = as code that changes the model, review it as such, and never build an expression with = from request data (see Security Notes). Targets that are not writable fail instead:

ops/err-assign.jsp and err-assign2.jsp: assigning to a read-only target
${param.q = 'x'}
${pageScope = 1}
jakarta.servlet.ServletException: jakarta.el.PropertyNotWritableException: java.lang.UnsupportedOperationException
jakarta.el.PropertyNotWritableException

Precedence, Associativity and Parentheses

The precedence table in The Operators at a Glance is the specification’s, highest first; operators on one row have equal precedence. All binary operators are left associative except ? :, = and ->, which are right associative. When the order is not obvious, add parentheses: (...) is free and never changes the type of the result.

ops/prec.jsp: precedence and associativity
1 + 2 * 3 = ${1 + 2 * 3}
-2 * 3 = ${-2 * 3}
10 - 2 - 3 = ${10 - 2 - 3}
100 / 10 / 5 = ${100 / 10 / 5}
2 + 3 += 'x' = ${2 + 3 += 'x'}
'x' += 2 + 3 = ${'x' += 2 + 3}
1 < 2 == true = ${1 < 2 == true}
1 + 1 == 2 = ${1 + 1 == 2}
true || false && false = ${true || false && false}
(true || false) && false = ${(true || false) && false}
not true || true = ${not true || true}
not (true || true) = ${not (true || true)}
empty noBooks == true = ${empty noBooks == true}
empty noBooks and copies gt 0 = ${empty noBooks and copies gt 0}
not empty noBooks and copies gt 0 = ${not empty noBooks and copies gt 0}
true ? 1 : false ? 2 : 3 = ${true ? 1 : false ? 2 : 3}
false ? 1 : false ? 2 : 3 = ${false ? 1 : false ? 2 : 3}
v = a = 3 => ${v = a = 3}
1 + 2 * 3 = 7
-2 * 3 = -6
10 - 2 - 3 = 5
100 / 10 / 5 = 2.0
2 + 3 += 'x' = 5x
'x' += 2 + 3 = x5
1 < 2 == true = true
1 + 1 == 2 = true
true || false && false = true
(true || false) && false = false
not true || true = true
not (true || true) = false
empty noBooks == true = true
empty noBooks and copies gt 0 = true
not empty noBooks and copies gt 0 = false
true ? 1 : false ? 2 : 3 = 1
false ? 1 : false ? 2 : 3 = 3
v = a = 3 => 3

Reading the table against the output: && binds tighter than || (true || false && false is true || (false && false)), which is why a third operand needs parentheses to be read correctly; not binds tighter than ||, so not true || true is (not true) || true; relational operators bind tighter than equality (1 < 2 == true is (1 < 2) == true); and empty is a prefix operator with the same rank as not, so empty noBooks == true tests the emptiness and then compares the answer. Subtraction and division associate left (10 - 2 - 3 is 5, 100 / 10 / 5 is 2.0). The two += lines show that concatenation comes after addition.

Reserved Words and Identifiers

Sixteen words are reserved by section 1.17 and cannot be used as identifiers: and, or, not, eq, ne, lt, gt, le, ge, div, mod, empty, true, false, null and instanceof. Some of them are operators today; others, instanceof, are held back for future use and are already off limits. A name that collides cannot be read with the dot syntax, but the bracket syntax with a String does work:

ops/res3.jsp, res4.jsp, res8.jsp and err-reserved2.jsp: a reserved word as a name (the c taglib directive is left out)
<c:set var="div" value="ten"/><c:set var="and" value="both"/>
pageScope['div'] = ${pageScope['div']}
pageScope['and'] = ${pageScope['and']}
${pageScope.div}
${catalog.and}
${instanceof}

The first two lines print ten and both. Each of the last three fails when the page is translated, before any output is produced, with HTTP 500 and:

org.apache.jasper.JasperException: jakarta.el.ELException: Failed to parse the expression [${pageScope.div}]
org.apache.jasper.JasperException: jakarta.el.ELException: Failed to parse the expression [${catalog.and}]
org.apache.jasper.JasperException: jakarta.el.ELException: Failed to parse the expression [${instanceof}]

The same failure happens for any syntactically invalid expression: section 2.8 of the Pages specification requires a translation error for an invalid EL expression in an attribute value or in template text, and ${1 + } fails with the same Failed to parse the expression message. The practical rules are to avoid the sixteen words as names of attributes, map keys and bean properties (a property called empty or div is reachable only through the bracket form), and never to name a scoped variable empty, not or and.

Operators in XML Syntax

JSP documents (.jspx, covered on the JSP documents page) are XML, so < and & in an expression must be written as < and &; in an attribute value a bare < is not allowed at all. The word forms exist to avoid that.

ops/jx.jspx: the same test in the four spellings
<?xml version="1.0" encoding="UTF-8"?>
<jsp:root xmlns:jsp="http://java.sun.com/JSP/Page" version="4.0">
  <jsp:directive.page contentType="text/plain;charset=UTF-8"/>
  <jsp:text>copies lt 5 = ${copies lt 5}
copies &lt; 5 = ${copies &lt; 5}
copies &gt;= 3 and not empty books = ${copies &gt;= 3 and not empty books}
copies &gt;= 3 &amp;&amp; not empty books = ${copies &gt;= 3 &amp;&amp; not empty books}
</jsp:text>
</jsp:root>
copies lt 5 = true
copies < 5 = true
copies >= 3 and not empty books = true
copies >= 3 && not empty books = true

The XML parser decodes the entities before EL sees the text, so the expression is evaluated as copies < 5. Writing lt, gt, le, ge, and and or in JSP documents, tag files in XML syntax and Facelets-style XML pages avoids the escaping altogether, and is a good habit even in plain .jsp pages when the expression sits in an attribute.

Type Coercion

Every expression is evaluated in the context of an expected type: an operator expects numbers, a Boolean or a String; a tag attribute expects the type in its TLD; a method call expects the parameter type. The result of the sub-expression rarely matches, so section 1.25 defines how a value X becomes a type Y. The first step is to find a custom converter: an ELResolver can implement convertToType, and the resolvers in the chain are asked in order. Only when no resolver converts does the default conversion run.

Table 5. The default coercion rules of section 1.25 (null handling first; "error" means an ELException or the exception of the conversion)
Target type Rule

Any non-primitive, non-String target

A null value stays null

String

null becomes ""; a String stays; an enum becomes name(); otherwise toString() (an exception is an error)

Number type N (Byte …​ BigDecimal)

null or "" becomes 0; a Character becomes its numeric value; a Boolean is an error; a Number is converted quietly to N (a BigDecimal to BigInteger with toBigInteger(), a Double to BigDecimal with new BigDecimal(double)); a String is parsed with N.valueOf (new BigDecimal(text) and new BigInteger(text) for those two); anything else is an error

Character or char

null or "" becomes (char) 0; a Character stays; a Boolean is an error; a Number is converted to a Short and then to the character with that value; a String gives its first character; anything else is an error

Boolean or boolean

null or "" becomes false; a Boolean stays; a String goes through Boolean.valueOf (so only "true", in any case, is true); anything else is an error

Enum type T

null stays null; an assignable value stays; "" becomes null; a String goes through Enum.valueOf; anything else is an error

Array of T

null stays null; an assignable array stays; any other array is copied with each element coerced to T; anything else is an error (one failing element fails the array)

Functional interface

A lambda is wrapped so that calling the interface method invokes it (lambda page)

Any other type T

null stays null; an assignable value stays; a String is converted through T’s PropertyEditor; an empty String becomes null when there is no editor or the editor fails; anything else is an error

A primitive target is coerced as its box type and then unboxed; if the box is null, the conversion is an error. In practice null becomes 0 and "" becomes 0 or false for primitive targets, which section 1.25.1 spells out.

Coercion in Operators

All operators above call these rules, so ${'12' + 1} is a String-to-Long coercion and ${'abc' + 1} is the failing one. The most useful single rule is the null one: null is not special in EL arithmetic or boolean tests, it is 0, false or the empty String, whichever the operator needs, which is why a missing value rarely breaks a page and a malformed value does. The page ops/coerce.jsp (excerpt) shows the rules for numbers and text:

ops/coerce.jsp: coercion to a String and to a number
nothing: [${nothing}]
null literal: [${null}]
status: ${status}
price: ${price}
1E3: ${1E3}
dune: ${dune}
noBooks: ${noBooks}
'1e2' + 0 = ${'1e2' + 0}
'.5' + 0 = ${'.5' + 0}
letter + 1 = ${letter + 1}
nothing + '' = ${nothing + ''}
0.1 + 0.2 = ${0.1 + 0.2}
0.1 + 0.2 == 0.3 = ${0.1 + 0.2 == 0.3}
10 / 4 = ${10 / 4}
10 / 5 = ${10 / 5}
price div 4 = ${price div 4}
big * 2 = ${big * 2}
big / 2 = ${big / 2}
big mod 10 = ${big mod 10}
nothing: []
null literal: []
status: AVAILABLE
price: 12.50
1E3: 1000.0
dune: Book[id=1, title=Dune, author=Frank Herbert, year=1965]
noBooks: []
'1e2' + 0 = 100.0
'.5' + 0 = 0.5
letter + 1 = 98
nothing + '' = 0
0.1 + 0.2 = 0.30000000000000004
0.1 + 0.2 == 0.3 = false
10 / 4 = 2.5
10 / 5 = 2.0
price div 4 = 3.13
big * 2 = 18446744073709551614
big / 2 = 4611686018427387904
big mod 10 = 7

The text conversions are what a page prints: a null prints nothing, an enum prints its constant name, and any other object prints toString(), which for a record is the readable Book[id=1, ...] form shown. Only a String is run through a parser: '1e2' is the Double 100.0 because it contains e, and ${letter + 1} is 98 because the Character 'a' is the number 97. 0.1 + 0.2 is the Double result of floating-point arithmetic, so equality tests on computed Double values are as unreliable in a page as in Java.

Coercion in Method Calls and Tag Attributes

EL 2.2 added method invocation (${shelf.total(counts)}), and the arguments are coerced to the declared parameter types by the same rules. The Bookshelf’s Shelf bean is a plain Java class with a few methods that take an int[], an enum, a primitive int, an Integer, a boolean, a char and a LocalDate.

ops/coerce.jsp (second part): coercion of method arguments and of tag attributes
shelf.total(counts) = ${shelf.total(counts)}
shelf.describe('LOANED') = ${shelf.describe('LOANED')}
shelf.describe('') = ${shelf.describe('')}
shelf.twice('21') = ${shelf.twice('21')}
shelf.twice('') = ${shelf.twice('')}
shelf.twice(nothing) = ${shelf.twice(nothing)}
shelf.maybeTwice('') = ${shelf.maybeTwice('')}
shelf.maybeTwice(nothing) = ${shelf.maybeTwice(nothing)}
shelf.flag('true') = ${shelf.flag('true')}
shelf.flag('yes') = ${shelf.flag('yes')}
shelf.letter('abc') = ${shelf.letter('abc')}
shelf.letter('') = ${shelf.letter('')}
shelf.when('') = ${shelf.when('')}
forEach: <c:forEach var="i" begin="${'1'}" end="${'3'}">[${i}]</c:forEach>
if 'true': <c:if test="${'true'}">T</c:if>
if 'yes': <c:if test="${'yes'}">T</c:if>[nothing printed: false]
if '1': <c:if test="${'1'}">T</c:if>[nothing printed: false]
if blank: <c:if test="${blank}">T</c:if>[nothing printed: false]
shelf.total(counts) = 6
shelf.describe('LOANED') = loaned
shelf.describe('') = no status
shelf.twice('21') = 42
shelf.twice('') = 0
shelf.twice(nothing) = 0
shelf.maybeTwice('') = 0
shelf.maybeTwice(nothing) = null
shelf.flag('true') = on
shelf.flag('yes') = off
shelf.letter('abc') = char 97
shelf.letter('') = char 0
shelf.when('') = no date
forEach: [1][2][3]
if 'true': T
if 'yes': [nothing printed: false]
if '1': [nothing printed: false]
if blank: [nothing printed: false]

The rows line up with the table. The String[] {"1","2","3"} becomes an int[] element by element and sums to 6. The String 'LOANED' becomes the enum constant and the empty String becomes null, so describe('') reaches the method with a null. The primitive int parameter turns both '' and null into 0, while the boxed Integer parameter turns '' into 0 and keeps null. 'yes' is false for a boolean and for the test attribute of c:if, and a one-character '1' is false as well, so <c:if test="${param.admin}"> is false for ?admin=1: the attribute expects "true" or "false" and converts everything else to false without complaint. A String to char gives the first character ('abc' is 97) and '' is the character 0.

A value that cannot be converted fails, but not always with the exception you expect: method resolution happens first, and when no overload accepts the argument the error is a MethodNotFoundException, not a number or conversion error.

ops/err-co1.jsp to err-co5.jsp: arguments that cannot be coerced
${shelf.when('2026-10-08')}
${shelf.describe('MISSING')}
${shelf.twice('abc')}
${shelf.flag(copies)}
${shelf.total(1)}
jakarta.el.MethodNotFoundException: Method not found: class org.example.bookshelf.Shelf.when(java.lang.String)
jakarta.el.MethodNotFoundException: Method not found: class org.example.bookshelf.Shelf.describe(java.lang.String)
jakarta.el.MethodNotFoundException: Method not found: class org.example.bookshelf.Shelf.twice(java.lang.String)
jakarta.el.MethodNotFoundException: Method not found: class org.example.bookshelf.Shelf.flag(java.lang.Integer)
jakarta.el.MethodNotFoundException: Method not found: class org.example.bookshelf.Shelf.total(java.lang.Long)

LocalDate has no PropertyEditor, so '2026-10-08' cannot be coerced to it (only the empty String, which becomes null, can). A date has to be parsed in Java, or the page passes a Date-typed value from the model; a custom ELResolver.convertToType (see the configuration page of this section) is the extension point when many pages need the conversion.

Worked Example: a Paginated Shelf

The following page uses most operators for something a catalogue page does: it reads a page number, computes the slice of books to show, labels each row and offers previous and next links. It uses empty and the conditional operator for the default, arithmetic and lt for the slice, mod for the row style, a nested conditional for the label, and +/- in the link targets.

shelf.jsp: pagination with operators only
<%@ page contentType="text/html;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="perPage" value="2"/>
<c:set var="page" value="${empty param.page ? 1 : param.page + 0}"/>
<c:set var="first" value="${(page - 1) * perPage}"/>
<c:set var="shown" value="${books.size() - first lt perPage ? books.size() - first : perPage}"/>
<p>Showing ${first + 1} to ${first + shown} of ${books.size()}</p>
<ul>
<c:forEach var="book" items="${books}" begin="${first}" end="${first + perPage - 1}" varStatus="row">
  <c:set var="age" value="${today.year - book.year}"/>
  <li class="${row.index mod 2 eq 0 ? 'even' : 'odd'}"><c:out value="${book.title}"/> (${book.year}): ${age gt 50 ? 'classic' : age gt 30 ? 'established' : 'recent'}</li>
</c:forEach>
</ul>
<c:if test="${page gt 1}"><a href="?page=${page - 1}">previous</a></c:if>
<c:if test="${first + perPage lt books.size()}"><a href="?page=${page + 1}">next</a></c:if>

curl for /g4elops/shelf.jsp, /g4elops/shelf.jsp?page=2 and /g4elops/shelf.jsp?page=3:

<p>Showing 1 to 2 of 3</p>
<ul>
<li class="even">Dune (1965): classic</li>
<li class="odd">Neuromancer (1984): established</li>
</ul>
<a href="?page=2">next</a>
<p>Showing 3 to 3 of 3</p>
<ul>
<li class="even">The Dispossessed (1974): classic</li>
</ul>
<a href="?page=1">previous</a>
<p>Showing 5 to 3 of 3</p>
<ul>
</ul>
<a href="?page=2">previous</a>

Notes on the page. param.page + 0 is the idiom for "this parameter, as a number": it turns the text "2" into the Long 2 so that page - 1 and page + 1 do arithmetic and the links print the numbers 1 and 3. An absent or empty parameter is covered by empty param.page, but a parameter that is not a number (?page=abc) still ends in NumberFormatException: For input string: "abc" and an HTTP 500: validate numeric input in a servlet or filter, or in a tag that catches the error (c:catch), before the page reads it. The third response is the other trap: ?page=3 is a valid number past the end of the data, and the page announces "Showing 5 to 3 of 3" with an empty list. The operators cannot clamp the value without a Math.min, so compute the page range in the controller and hand the view the numbers. The titles are printed through c:out; the ${book.year} in text is a number and needs no escaping, which is not true of ${param.q} or of any text typed by a user.

Security Notes

  • EL does not escape. The result of an expression in template text is written as is, so ${param.q} is an XSS vector. Use c:out (which escapes <, >, &, ' and " by default) or fn:escapeXml; operators do not change this, and the conditional ${empty param.q ? 'all' : param.q} is as unsafe as the parameter it returns.

  • A request value is an operand, never code. ${param.page + 1} cannot make the container run the user’s text as an expression. EL injection needs the text to be evaluated, for example by building an expression string from input and passing it to ExpressionFactory.createValueExpression or ELProcessor.eval; never do that. The syntax and escaping page of this section covers the template-text case, and the Security page covers the rest.

  • = and ; are statements. Pages that contain them modify scoped attributes (see the typo example). Keep them out of templates, and prefer c:set for the assignments that do belong in a page.

  • Numeric input fails with 500. An unvalidated param.n + 1 is a denial-of-service nuisance and fills the error log; map NumberFormatException and ELException to an error page.

Pitfalls and Error Names

Table 6. Operator and coercion pitfalls (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

java.lang.NumberFormatException: For input string: "Dune" from ${'Dune' + ' (1965)'} (observed)

+ is arithmetic; both Strings are parsed as numbers

${'Dune' += ' (1965)'}

NumberFormatException and HTTP 500 for ?page=abc with ${param.page + 1} (observed)

a String operand is parsed with Long.valueOf; there is no fallback

validate in a servlet or filter; c:catch; an error page for NumberFormatException

${8 div 2} prints 4.0 and ${7 / 2} prints 3.5 (observed)

/ is Double division unless a BigDecimal or BigInteger is involved

no integer division in EL: fmt:formatNumber with zero fraction digits, or compute in Java

${9223372036854775807 + 1} is negative (observed)

Long arithmetic wraps without error

use a BigInteger or BigDecimal operand

java.lang.ArithmeticException: / by zero for ${5 mod 0}, but Infinity and NaN for Double division (observed)

integer remainder throws, floating-point does not

test the divisor with ${d ne 0} before dividing

${price == 12.50} is false for a price of 12.50 (observed)

BigDecimal.equals compares scale and the literal 12.50 is the Double 12.5

price le x and price ge x, or compare in Java

${price + 0.1} prints 12.6000000000000000055511151231257827021181583404541015625 (observed)

a Double literal is converted to BigDecimal from its binary value

quote the literal: ${price + '0.1'}

${'10' lt '9'} is true (observed)

two Strings compare lexically

convert one operand to a number: ${'10' + 0 lt '9' + 0} or compare in Java

ELException: Cannot convert [1.0] of type [class java.lang.String] to [class java.lang.Long] for ${'1.0' eq 1} (observed)

an integer operand makes the String a Long, and "1.0" is not one

${'1.0' + 0 eq 1} or use a Double operand

ELException: Cannot convert [a] ... to [class java.lang.Long] for ${letter == 'a'} (observed)

a Character takes the integer path of the equality rules

${letter eq 97}, or compare Strings

ELException: Cannot compare [Book[...]] to [Book[...]] (observed)

neither value is Comparable

compare a property: ${a.year lt b.year}

ELException: Cannot convert [true] ... to [class java.lang.Long] for ${1 lt 2 lt 3} (observed)

comparisons are not chained

${1 lt 2 and 2 lt 3}

ELException: Cannot convert [1] ... to [class java.lang.Boolean] for ${1 and true} (observed)

a number is not a Boolean

compare explicitly: ${n ne 0}

${'yes' and true} is false, silently (observed)

Boolean.valueOf is false for any text but "true"

expose real Booleans from the model

<c:if test="${param.admin}"> is false for ?admin=1 (observed)

the test attribute is coerced to boolean; only "true" is true

compare explicitly, or check the value in the controller

${nothing ge 1} and ${nothing lt 1} are both false (observed)

any ordering comparison with null is false, except identical operands with le and ge

test ${empty x} first

${nothing == 0} is false while ${nothing + 0 == 0} is true (observed)

null is not equal to a non-null value; in arithmetic it is 0

add 0 or test empty

${empty spaces} is false for " " (observed)

only the empty String is empty

trim in the controller

${yes ? copies:count()}: "The attribute prefix [copies] does not correspond to any imported tag library" (observed)

copies:count() parses as a qualified function

spaces around the colon, or parentheses

Failed to parse the expression [${catalog.and}] (observed)

a reserved word used as a name after a dot

${catalog['and']}

${copies = 99} changes the application-wide value (observed)

= assigns to the scope that already holds the name

type ==; keep assignments out of templates

PropertyNotWritableException for ${param.q = 'x'} or ${pageScope = 1} (observed)

assignment to a read-only map or implicit object

assign to a scoped attribute name

MethodNotFoundException: Method not found: ... twice(java.lang.String) for ${shelf.twice('abc')} (observed)

no overload accepts the coerced argument, so the failed coercion shows as a missing method

validate or convert first

&& or a bare < in a .jspx file is an XML error

XML needs && and <

use and, or, lt, gt, le, ge

${false ? ... : ...} evaluates the wrong branch because of a missing parenthesis

?: binds tighter than = and ; but looser than every other operator

put the whole test in parentheses

What Changed Recently

Table 7. Changes that touch operators and coercion
Change Detail

EL 6.1 (Jakarta EE 12, under development)

the release page of the project lists the Elvis operator and the use of + to merge collections as planned language enhancements, together with pluggable caching and a performance optimisation for scoped attributes (done with Pages 4.1); no removals are planned and Java 17 stays the minimum. Not yet usable on Tomcat 11

EL 6.0 (Jakarta EE 11)

Java 17 minimum; the deprecated code is removed; RecordELResolver is on by default (a record is a bean); OptionalELResolver is available but off by default; arrays have the length property; module visibility is honoured; java.beans is optional. The appendix lists no change to the operators or to the coercion rules

EL 5.0 (Jakarta EE 10)

Java 11 minimum; a LambdaExpression can be coerced to a functional-interface method invocation (section 1.25.8); arrays can be coerced (section 1.25.7); java.lang.* stays the only default import and other specifications may add more

EL 4.0 (Jakarta EE 9)

the package changed from javax.el to jakarta.el; code that wraps the EL API needs recompiling

EL 3.0 (Java EE 7, 2013)

added the string concatenation +=, assignment =, semicolon ;, lambda ->, static field and method references, and collection construction and streams; before it, EL had only the operators of section 1.7 and 1.9 to 1.12

EL 2.2 (JSP 2.2, 2009)

method invocation, ${obj.method(args)}, which brought argument coercion

JSP 2.0 (2003)

EL becomes part of the container and may appear in template text and every attribute (before that only JSTL 1.0 evaluated it in its own tag attributes); defines empty, div, mod and the word forms

The specification text still carries small leftovers of older Java versions: section 1.7.2 refers to the constant BigDecimal.ROUND_HALF_UP (deprecated since Java 9; the 3.13 seen above is the half-up result), and section 1.25.3 describes conversions with the deprecated new Short(...) style constructors. The behaviour is what matters and it is as described.

Where the Books Are Outdated

The section bibliography lists JSTL in Action (Manning, 2002), which teaches the expression language of JSTL 1.0 on JSP 1.2, and Web Development with JavaServer Pages (Manning, 2001), which predates the EL. The operator concepts the first one teaches (the arithmetic, comparison and logical operators with their word forms, empty and null) are still exactly right; what has moved is where the EL may be used, the number of operators, and the platform.

Table 8. The books' material and its current equivalent
The books' era Today (EL 6.0, Pages 4.0)

EL is available only inside the attributes of JSTL 1.0 tags (and not in template text); a template-text ${...} is printed literally on JSP 1.2

EL in template text and in every attribute of every page since JSP 2.0, unless isELIgnored (see the directives page) or a property group (see the configuration page) switches it off; a JSP 1.2 page needs the old rt libraries to compute with Java expressions

A fixed set of operators: arithmetic, empty, comparison, logic, conditional

the same operators and the same word forms, plus +=, =, ;, ->, method calls, static references, collection literals and streams (EL 3.0, EL 2.2)

${a + b} as the only way to combine values, with + sometimes assumed to join text

+ is arithmetic only; join with += or c:out/fmt tags

EL types limited to Long, Double, String and Boolean as the textbooks describe them

BigDecimal and BigInteger arithmetic, Character, enum, array and PropertyEditor coercion, and custom conversion through ELResolver.convertToType

Comparison of a bean with == assumed to be identity

an object comparison calls equals after the numeric, Boolean, enum and String rules; a record has value equality

java.sun.com/jstl/* URIs and the javax.servlet.jsp packages in the examples

jakarta.tags.core and jakarta.servlet.jsp; the old javax.el is jakarta.el since EL 4.0

Appendix A.1 of JSTL in Action refers to chapter 2 for the expression language, and the text states that EL does not work in template text

the EL chapter is chapter 3 of the book, and the claim is true only for JSTL 1.0 on JSP 1.2

Scriptlet logic (<% if (x > 5) { %>) next to the EL, for anything the EL cannot say

the conditional, the logical operators and c:if / c:choose cover it; scripting is switched off with scripting-invalid

References