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.
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.
| 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 |
|
Numeric negation, logical negation, null-or-empty test |
1.7.4, 1.10.2, 1.11 |
4 |
|
Multiplication, division, remainder |
1.7.1 to 1.7.3 |
5 |
|
Addition and subtraction |
1.7.1 |
6 |
|
String concatenation |
1.8 |
7 |
|
Ordering comparisons |
1.9.1 |
8 |
|
Equality |
1.9.2 |
9 |
|
Logical and, short-circuit |
1.10.1 |
10 |
|
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:
The other three operators have their own rules.
| Operator | Rule | Typical result |
|---|---|---|
|
The diagram above: |
|
|
|
|
|
|
|
|
|
|
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.
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.
/isDoubledivision unless aBigDecimalorBigIntegeris involved, so${7 / 2}is 3.5 and even${8 div 2}prints4.0. There is no operator for the whole-number quotient; round the result withfmt:formatNumber(maximum fraction digits 0, which rounds half to even) or move the arithmetic into the controller. -
The money rule.
priceis aBigDecimalwith 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). ADoublenever enters aBigDecimalcalculation unless it is a literal such as0.1: see the pitfalls. -
A
Floatis widened to aDouble, which exposes the binary representation:0.1f * 3prints0.30000000447034836. PreferDoubleorBigDecimalin model classes that a page multiplies. -
Longarithmetic wraps silently.9223372036854775807 + 1is-9223372036854775808. Use aBigInteger(thebigvalue) when overflow is possible;${big + 1}is exact. -
Division by zero is not an error for
Double:1 / 0isInfinityand0 / 0isNaN. ALongorBigIntegerremainder by zero is an error (see the next example). -
Null counts as zero, and a
Stringis parsed ('12' + 1is 13,'2.5' + 1is 3.5), including the empty String, which is 0 (${'' + 1}is 1).
${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.
'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.
| Step | Rule |
|---|---|
1 |
If A and B are the same object, |
2 |
If either is null, the result is false |
3 |
If either is a |
4 |
If either is a |
5 |
If either is a |
6 |
If either is a |
7 |
If either is a |
8 |
If A is |
9 |
Otherwise it is an error |
| Step | Rule |
|---|---|
1 |
If A and B are the same object, the operator is applied to that fact ( |
2 |
If either is null, |
3 |
If either is a |
4 |
If either is a |
5 |
If either is a |
6 |
If either is a |
7 |
If either is a |
8 |
If either is an enum, both become that enum type (a String is looked up with |
9 |
If either is a |
10 |
Otherwise |
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(anIntegeragainst aDouble) andcount == 7(aLongagainst anIntegerliteral) are true. AStringthat 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 onString.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 nothingis true because the two operands are the same (null) and the operator allows equality (step 1 comes before the null test).nothing == 0andnothing == ''are false (null is never equal to a non-null value), andblank == nullis false: the empty String is not null. -
A
Stringis compared with an enum by constant name (status == 'AVAILABLE'), aBooleanwith the text'true'or'false', and anyComparable, such asLocalDate, with another of its kind (loan.due lt today). -
Equality of unrelated objects falls back to
equals:books[0] == duneis true because the list holds the same record. A record has value-basedequals, so twoBookrecords with the same components are equal as well.
The cases where the rules produce an error rather than a false are worth seeing once.
${'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.
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.
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.
${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.
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.
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.
${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}isa = (b = 5). A that cannot be written throwsPropertyNotWritableException. -
${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.
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:
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:
${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.
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:
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.
<?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 < 5 = ${copies < 5}
copies >= 3 and not empty books = ${copies >= 3 and not empty books}
copies >= 3 && not empty books = ${copies >= 3 && 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.
| Target type | Rule |
|---|---|
Any non-primitive, non-String target |
A null value stays null |
|
null becomes |
Number type N ( |
null or |
|
null or |
|
null or |
Enum type T |
null stays null; an assignable value stays; |
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 |
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:
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
Source: Jakarta Expression Language 6.0 — 1.25.2 Coerce A to String and 1.25.3 Coerce A to Number type N
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.
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]
Source: Jakarta Expression Language 6.0 — 1.25.1 To Coerce a Value X to Type Y and Jakarta Tags 3.0 — c:if
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.
${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.
<%@ 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>
Source: Jakarta Tags 3.0 — c:forEach and c:set
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. Usec:out(which escapes<,>,&,'and"by default) orfn: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 toExpressionFactory.createValueExpressionorELProcessor.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 preferc:setfor the assignments that do belong in a page. -
Numeric input fails with 500. An unvalidated
param.n + 1is a denial-of-service nuisance and fills the error log; mapNumberFormatExceptionandELExceptionto an error page.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
|
|
|
|
a String operand is parsed with |
validate in a servlet or filter; |
|
|
no integer division in EL: |
|
|
use a |
|
integer remainder throws, floating-point does not |
test the divisor with |
|
|
|
|
a |
quote the literal: |
|
two Strings compare lexically |
convert one operand to a number: |
|
an integer operand makes the String a |
|
|
a |
|
|
neither value is |
compare a property: |
|
comparisons are not chained |
|
|
a number is not a Boolean |
compare explicitly: |
|
|
expose real Booleans from the model |
|
the |
compare explicitly, or check the value in the controller |
|
any ordering comparison with null is false, except identical operands with |
test |
|
null is not equal to a non-null value; in arithmetic it is 0 |
add |
|
only the empty String is empty |
trim in the controller |
|
|
spaces around the colon, or parentheses |
|
a reserved word used as a name after a dot |
|
|
|
type |
|
assignment to a read-only map or implicit object |
assign to a scoped attribute name |
|
no overload accepts the coerced argument, so the failed coercion shows as a missing method |
validate or convert first |
|
XML needs |
use |
|
|
put the whole test in parentheses |
What Changed Recently
| Change | Detail |
|---|---|
EL 6.1 (Jakarta EE 12, under development) |
the release page of the project lists the Elvis operator and the use of |
EL 6.0 (Jakarta EE 11) |
Java 17 minimum; the deprecated code is removed; |
EL 5.0 (Jakarta EE 10) |
Java 11 minimum; a |
EL 4.0 (Jakarta EE 9) |
the package changed from |
EL 3.0 (Java EE 7, 2013) |
added the string concatenation |
EL 2.2 (JSP 2.2, 2009) |
method invocation, |
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 |
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.
| 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 |
EL in template text and in every attribute of every page since JSP 2.0, unless |
A fixed set of operators: arithmetic, |
the same operators and the same word forms, plus |
|
|
EL types limited to |
|
Comparison of a bean with |
an object comparison calls |
|
|
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 ( |
the conditional, the logical operators and |
Source: Manning — JSTL in Action
References
-
Jakarta Expression Language 6.0 — 1.7.3 Binary operator for remainder
-
Jakarta Expression Language 6.0 — 1.8 String Concatenation Operator
-
Jakarta Expression Language 6.0 — 1.25.1 To Coerce a Value X to Type Y
-
Jakarta Expression Language 6.0 — 1.25.3 Coerce A to Number type N
-
Jakarta Expression Language 6.0 — 1.25.5 Coerce A to Boolean or boolean
-
Jakarta Expression Language 6.0 — 1.25.9 Coerce A to Any Other Type T
-
Jakarta Expression Language 6.0 — A.1 Changes between 6.0 and 5.0
-
Jakarta Expression Language 6.0 — A.2 Changes between 5.0 and 4.0
-
Jakarta Expression Language 6.0 — A.3 Changes between 4.0 and JSR 341
-
Manning — Web Development with JavaServer Pages, Second Edition