JSTL Functions

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 Standard Tag Library (Jakarta Tags) 3.0, chapter 15 "Functions: function tag library", Jakarta Pages 4.0 section 2.11 "Functions" and Jakarta Expression Language 6.0, running on Apache Tomcat 11.0.26 with the jakarta.servlet.jsp.jstl-api 3.0.2 API and the org.glassfish.web:jakarta.servlet.jsp.jstl 3.0.1 implementation on Java 21. Everything was verified on 2026-10-08: the facts against the Tags 3.0 specification, and every example by building it into a Maven WAR, running it on Tomcat 11.0.26 and requesting it with curl; the outputs and error messages shown are the ones that run produced. The function library is the one JSTL library that has no tags: it adds sixteen static methods to the Expression Language, so that a page can measure a collection, trim, cut, search, replace, split, join and escape strings inside ${...} without a scriptlet. It is small, stable since 2003 and easy to misuse in three ways: the null and range rules are not those of java.lang.String, fn:split is not String.split, and fn:escapeXml escapes for HTML text and quoted attributes only. The running scenario is the Bookshelf, a small catalogue of books; the data the functions read comes from the scopes explained on the Implicit Objects & Scopes page and the syntax they plug into is described on the Expression Language Basics page. The sibling JSTL pages (core, iteration, URLs, i18n, formatting, SQL and XML) cover the tag libraries that these functions usually appear inside.

The Bookshelf Used on This Page

All the examples below are views of one WAR, deployed as g5jstlfunctions.war under the context path /g5jstlfunctions. A servlet mapped to /demo/* puts the same small object graph in the request and forwards to /WEB-INF/views/<name>.jsp; every listing below is one of those views (the page and taglib directives are shown once and left out afterwards), and every output is what curl printed.

The data model and the servlet that fills the request (imports omitted)
public record Book(long id, String title, String author, int year) { }

@WebServlet("/demo/*")
public class DemoServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        req.setAttribute("books", List.of(
                new Book(1, "  Dune  ", "Frank Herbert", 1965),
                new Book(2, "Neuromancer", "William Gibson", 1984),
                new Book(3, "The Left Hand of Darkness", "Ursula K. Le Guin", 1969)));
        req.setAttribute("tags", new String[] {"sci-fi", "classic", "award"});
        req.setAttribute("tagList", List.of("sci-fi", "classic"));
        req.setAttribute("review", "Great <b>book</b> & \"classic\" it's");
        req.setAttribute("isbn", "978-0-441-17271-9");
        Map<String, Integer> stock = new LinkedHashMap<>();
        stock.put("Dune", 3);
        stock.put("Neuromancer", 0);
        req.setAttribute("stock", stock);
        req.setAttribute("none", null);
        String page = req.getPathInfo().substring(1);
        req.getRequestDispatcher("/WEB-INF/views/" + page + ".jsp").forward(req, resp);
    }
}

The first title has two leading and two trailing spaces on purpose, and the third author has a middle initial: both are used below to show where the functions help and where they surprise.

What the Function Library Is

An EL function is a public static Java method that a tag library descriptor (TLD) publishes under a name, so that an expression can call it as prefix:name(args). The Pages 4.0 specification (section 2.11) defines the mechanism: the function is qualified by the prefix of the tag library that declares it, the mapping is bound into the expression when the page is parsed, and the method is looked up in the function-class named by the TLD. Jakarta Tags 3.0 uses that mechanism to ship one library of general-purpose string and collection helpers.

Table 1. The function library at a glance
Item Value

URI

jakarta.tags.functions (the JSTL 1.1 to 1.2 URI http://java.sun.com/jsp/jstl/functions is still accepted, see What Changed Recently)

Conventional prefix

fn

TLD in the implementation JAR

META-INF/fn.tld (and the legacy META-INF/fn-1_1.tld)

Implementation class

org.apache.taglibs.standard.functions.Functions: every function is a public static method of this one class

Number of functions

16: length, contains, containsIgnoreCase, startsWith, endsWith, indexOf, substring, substringBefore, substringAfter, replace, split, join, trim, toUpperCase, toLowerCase, escapeXml

Where it needs a JAR

Tomcat does not bundle Jakarta Tags: a WAR on Tomcat adds jakarta.servlet.jsp.jstl-api and the org.glassfish.web:jakarta.servlet.jsp.jstl implementation, as the Getting Started page of this section does

Version added

JSTL 1.1 with JSP 2.0 (2003); the set of 16 has not changed since

The fn: library is the only part of JSTL that works outside a tag attribute: because it is an EL function it can be used anywhere an expression is allowed, in template text, in the attributes of standard and custom actions, in tag files and in the EL of JSP documents. It needs the taglib directive (or xmlns in a JSP document) like every other library, and without it the page fails at translation time.

<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
flowchart LR A["taglib directive:
prefix fn, URI jakarta.tags.functions"] --> B["TLD fn.tld in the JSTL JAR
(name, function-class, function-signature)"] B --> C["Page is translated:
fn:substring(a, b, c) is checked against the TLD"] C --> D["Request time:
Functions.substring(String, int, int) is invoked"] D --> E["Result goes back into the
surrounding EL expression"] C -. "wrong name or arity" .-> F["Translation error:
ELException or JasperException"] D -. "wrong argument type" .-> G["ELException:
Problems calling function"]

The diagram shows why two different kinds of mistakes fail in two different places. A function that does not exist in the TLD, or is called with the wrong number of arguments, is rejected when the page is translated, so the first request to that JSP fails and the error names the function. A call whose arguments cannot be coerced to the declared parameter types fails when the request is processed, with an ELException whose message starts with Problems calling function. The sibling Tag Library Descriptors & EL Functions page shows how the same mechanism is used to publish a function of your own.

The Sixteen Functions

The specification groups the functions by domain: length, case, substring, trimming, replacement, searching, splitting and joining, and XML escaping. The table lists every function with its declared signature (from fn.tld), the null rule of the specification and the verified behaviour. In every function a null String argument is processed as the empty string, so the library never throws a NullPointerException for a missing value, which is the single biggest difference from calling the same method on a String through EL 6.0.

Table 2. The fn: functions (Tags 3.0 sections 15.1 to 15.17)
Function Signature Returns Behaviour

fn:length(input)

Object (a collection, array, map, iterator, enumeration or String; the types c:forEach accepts in items)

int

Number of items, or the number of characters of a String; null gives 0. An object of any other type, such as a record or a bean, throws JspTagException: Don't know how to iterate over supplied "items"

fn:contains(string, substring)

String, String

boolean

Same as fn:indexOf(string, substring) != -1; an empty substring always matches

fn:containsIgnoreCase(string, substring)

String, String

boolean

As fn:contains after converting both arguments to upper case

fn:startsWith(string, prefix)

String, String

boolean

java.lang.String.startsWith; an empty prefix is always true

fn:endsWith(string, suffix)

String, String

boolean

Same for the end of the string; an empty suffix is always true

fn:indexOf(string, substring)

String, String

int

0-based index of the first occurrence, -1 when absent, 0 for an empty substring

fn:substring(string, begin, end)

String, int, int

String

begin inclusive, end exclusive; out-of-range values are corrected instead of failing (see below); -1 as end means "to the end"

fn:substringBefore(string, substring)

String, String

String

Text before the first occurrence; empty when the substring is absent or empty

fn:substringAfter(string, substring)

String, String

String

Text after the first occurrence; empty when the substring is absent, the whole string when the substring is empty

fn:replace(string, before, after)

String, String, String

String

Replaces every occurrence of a literal substring; not a regular expression; replaced text is not reprocessed; an empty before returns the input

fn:split(string, delimiters)

String, String

String[]

Tokenizes with java.util.StringTokenizer: every character of delimiters is a delimiter, empty tokens are dropped

fn:join(array, separator)

String[], String

String

Concatenates the elements with the separator; a null array gives ""

fn:trim(string)

String

String

java.lang.String.trim (removes characters up to U+0020, not every Unicode space)

fn:toUpperCase(string)

String

String

java.lang.String.toUpperCase() with the default locale of the JVM

fn:toLowerCase(string)

String

String

java.lang.String.toLowerCase() with the default locale of the JVM

fn:escapeXml(string)

String

String

The same escaping as c:out with escapeXml="true": &, <, >, ' and " become &amp;, &lt;, &gt;, &#039; and &#034;

All Sixteen in One View

The view below calls every function on the data of the Bookshelf. It uses text/plain so that the output can be read as printed, and trimDirectiveWhitespaces so that the directive lines leave no blank lines.

all.jsp (excerpt, one line of the view per group of functions)
<c:set var="t" value="${books[0].title}"/>
length=${fn:length(t)} trim=[${fn:trim(t)}] length(trim)=${fn:length(fn:trim(t))}
upper=${fn:toUpperCase(fn:trim(t))} lower=${fn:toLowerCase('Dune')}
length(books)=${fn:length(books)} length(tags)=${fn:length(tags)} length(stock)=${fn:length(stock)} length(null)=${fn:length(none)}
contains=${fn:contains('Neuromancer','man')} containsIC=${fn:containsIgnoreCase('Neuromancer','NEURO')}
startsWith=${fn:startsWith(isbn,'978')} endsWith=${fn:endsWith(isbn,'-9')}
indexOf=${fn:indexOf(isbn,'-')} indexOf-missing=${fn:indexOf(isbn,'x')} indexOf-empty=${fn:indexOf(isbn,'')}
before=[${fn:substringBefore(isbn,'-')}] after=[${fn:substringAfter(isbn,'-')}]
replace=[${fn:replace(isbn,'-','')}]
split=${fn:length(fn:split(isbn,'-'))} join=${fn:join(tags,' | ')}
escape=${fn:escapeXml(review)}
length=8 trim=[Dune] length(trim)=4
upper=DUNE lower=dune
length(books)=3 length(tags)=3 length(stock)=2 length(null)=0
contains=true containsIC=true
startsWith=true endsWith=true
indexOf=3 indexOf-missing=-1 indexOf-empty=0
before=[978] after=[0-441-17271-9]
replace=[9780441172719]
split=5 join=sci-fi | classic | award
escape=Great &lt;b&gt;book&lt;/b&gt; &amp; &#034;classic&#034; it&#039;s

Two details of that output matter later. fn:length(stock) counts the entries of a Map, because a map is one of the types c:forEach can iterate; and fn:escapeXml produced &#034; for the double quote and &#039; for the apostrophe, the numeric references that c:out also produces, not the named &quot; and &apos;.

fn:length: Collections, Arrays, Maps and Strings

A feature that JSTL 1.0 lacked and that Tags 3.0 section 15.1.1 explains is the size of a collection: Collection.size() is not a JavaBeans property, so ${books.size} fails, and before EL 3.0 there was no way to call it. fn:length takes anything that c:forEach can loop over and returns its size, and for a String it returns the number of characters (UTF-16 code units, as String.length()). It also accepts an Iterator or an Enumeration, because c:forEach does (the sequence is read to the end).

<h1>Bookshelf (${fn:length(books)} books)</h1>
array=${fn:length(tags)} map=${fn:length(stock)} null=${fn:length(none)} empty=${fn:length('')}

Printed: array=3 map=2 null=0 empty=0 (a String[] of three tags, a Map of two entries, a null and an empty string).

The rule is the one of c:forEach, so a record or a bean is not accepted. The failure shows its origin in the stack trace (Functions.length), not in the page:

${fn:length(books[0])}   books[0] is a Book record
jakarta.el.ELException: Problems calling function [fn:length]
jakarta.servlet.jsp.JspTagException: Don't know how to iterate over supplied "items" in <forEach>
	org.apache.taglibs.standard.functions.Functions.length(Functions.java:218)

Case, Trimming and Searching

fn:trim, fn:toUpperCase and fn:toLowerCase delegate to the String methods of the same name. The Bookshelf’s first title carries two spaces on each side; trimming it before comparing, sorting or building a URL is the most common use of the library, and the specification’s own example trims a value before putting it in c:url. A trimmed value used as the value of a parameter avoids the + signs that the encoder would otherwise produce for the spaces.

<c:set var="slug" value="${fn:toLowerCase(fn:replace(fn:trim(books[0].title), ' ', '-'))}"/>
<c:url var="searchUrl" value="/search">
  <c:param name="q" value="${fn:trim(books[0].title)}"/>
</c:url>
slug=${slug}
url=${searchUrl}

Printed output (the session identifier is added by c:url on the first request of a client that has not sent a cookie yet, a rewriting rule that the JSTL URLs, Imports & Redirects page of this section explains):

slug=dune
url=/g5jstlfunctions/search;jsessionid=25CA3D98D6DD525DA218908ACB244855?q=Dune

The searching functions answer yes or no questions. fn:contains and fn:containsIgnoreCase are the functions behind a simple filter; fn:startsWith and fn:endsWith suit prefixes such as an ISBN group or a file extension, and fn:indexOf is the building block for the cutting functions below. All of them treat an empty second argument as matching at the start (or end), and all of them treat null as the empty string, which gives results that differ from a null-safe String call:

contains=${fn:contains(none,'a')} contains-null-sub=${fn:contains('a',none)}
startsWith=${fn:startsWith(none,'')} indexOf=${fn:indexOf(none,'a')}
upper=[${fn:toUpperCase(none)}] trim=[${fn:trim(none)}] subst=[${fn:substring(none,0,3)}]
undefined=[${fn:toUpperCase(missingVar)}]
contains=false contains-null-sub=true
startsWith=true indexOf=-1
upper=[] trim=[] subst=[]
undefined=[]

fn:containsIgnoreCase upper-cases both arguments with the default locale of the JVM, so on a JVM whose default locale is Turkish or Azerbaijani the letters i and I do not match each other (the same dotted and dotless i rule as String.toUpperCase()); set the default locale of the container explicitly if the pages compare locale-sensitive text.

Cutting Strings: substring, substringBefore and substringAfter

fn:substring(string, begin, end) follows String.substring with one major difference: it never throws. The specification says that a begin below 0 becomes 0, an end below 0 or beyond the length becomes the length, and an end before begin or a begin beyond the last character gives the empty string. The specification suggests -1 as the end for "to the end of the string", and Tomcat behaves exactly so:

substring(2,6)=[${fn:substring('Neuromancer',2,6)}]
substring(3,-1)=[${fn:substring('Neuromancer',3,-1)}]
substring(-5,4)=[${fn:substring('Neuromancer',-5,4)}]
substring(3,100)=[${fn:substring('Neuromancer',3,100)}]
substring(20,25)=[${fn:substring('Neuromancer',20,25)}]
substring(5,2)=[${fn:substring('Neuromancer',5,2)}]
substring(2,6)=[urom]
substring(3,-1)=[romancer]
substring(-5,4)=[Neur]
substring(3,100)=[romancer]
substring(20,25)=[]
substring(5,2)=[]

The function has exactly three parameters. Calling it with two, as the introductory example of chapter 15 does (see Pitfalls and Error Messages), is a translation error, not a shortcut for "to the end".

fn:substringBefore and fn:substringAfter cut around the first occurrence of a delimiter and return the empty string when the delimiter is absent, so they cannot tell "nothing after the delimiter" from "no delimiter". With an empty delimiter substringBefore returns "" and substringAfter returns the whole input, consistently with fn:indexOf returning 0 for an empty substring.

before=[${fn:substringBefore(isbn,'-')}] after=[${fn:substringAfter(isbn,'-')}]
before-missing=[${fn:substringBefore(isbn,'x')}] after-missing=[${fn:substringAfter(isbn,'x')}]
after-empty=[${fn:substringAfter(isbn,'')}] before-empty=[${fn:substringBefore(isbn,'')}]
before=[978] after=[0-441-17271-9]
before-missing=[] after-missing=[]
after-empty=[978-0-441-17271-9] before-empty=[]

The specification’s example of extracting the text between brackets combines fn:substring with fn:indexOf. On the Bookshelf it extracts the year from a display string:

<c:set var="text" value="Neuromancer (1984)"/>
${fn:substring(text, fn:indexOf(text, '(')+1, fn:indexOf(text, ')'))}

Printed: 1984.

Replacing, Splitting and Joining

fn:replace replaces every occurrence of a literal substring. It is not a regular expression, so a dot is a dot; the replaced text is not scanned again, so replace('aaa','aa','a') gives aa, not a; an empty before returns the input unchanged; and a null after removes the matches.

replace=[${fn:replace(isbn,'-','')}]
replace-regexchars=[${fn:replace('a.b.c','.','/')}]
replace-empty-before=[${fn:replace('abc','','X')}]
replace-null-after=[${fn:replace('a-b-c','-',none)}]
replace-noreproc=[${fn:replace('aaa','aa','a')}]
replace=[9780441172719]
replace-regexchars=[a/b/c]
replace-empty-before=[abc]
replace-null-after=[abc]
replace-noreproc=[aa]

fn:split returns a String[] and breaks the string with java.util.StringTokenizer. Two consequences are easy to get wrong. The second argument is a set of delimiter characters, not a delimiter string, so fn:split('a, b;c', ',; ') gives three tokens. And consecutive delimiters do not produce empty tokens, so fn:split('a,,b', ',') has two elements where String.split(",") has three. An empty input or an empty delimiter set returns an array with a single element, the input.

split=${fn:length(fn:split(isbn,'-'))}
split-multi=${fn:length(fn:split('a, b;c',',; '))}
split-adjacent=${fn:length(fn:split('a,,b',','))}
split-empty-str=${fn:length(fn:split('',','))}
split-empty-delim=${fn:length(fn:split('a,b',''))}
split=5
split-multi=3
split-adjacent=2
split-empty-str=1
split-empty-delim=1

The array that fn:split returns is an ordinary value, so it plugs into c:forEach and fn:join. A c:forEach over the words of a title, with the varStatus properties of the iteration page, and a join that re-assembles a split string:

<c:forEach var="w" items="${fn:split(books[2].title,' ')}" varStatus="s">${s.count}:${w}<c:if test="${!s.last}">,</c:if></c:forEach>
join-split=${fn:join(fn:split('x y z',' '),'+')}
years=${fn:join(fn:split('1965,1984,1969', ','), ' / ')}
1:The,2:Left,3:Hand,4:of,5:Darkness
join-split=x+y+z
years=1965 / 1984 / 1969

fn:join is declared for String[], and that is what it needs. A String[] (the tags attribute) works, with a null array giving the empty string and a null separator giving no separator at all (sci-ficlassicaward). A List<String> does not: EL does not coerce a list to an array, so the call fails when the request is processed, in the way described in Pitfalls and Error Messages. Joining a list is one of the cases where an EL 6.0 method call is the better tool (see EL 6.0 Method Calls and the Role of the Functions Today).

fn:escapeXml and Output Escaping

fn:escapeXml(string) returns the string with the five markup characters replaced, with exactly the conversions of c:out when its escapeXml attribute is true. The specification introduces it as the concise alternative to c:out when the escaped value is needed as an expression and not as output, for example as the value of an attribute of a custom action, and it uses it to avoid cross-site scripting when echoing a request parameter. The two forms produce the same text; combining them escapes twice:

escape.jsp (curl 'http://localhost:18080/g5jstlfunctions/demo/escape?q=%3Cscript%3E%22x')
raw:     ${review}
fn:      ${fn:escapeXml(review)}
c:out:   <c:out value="${review}"/>
c:out+fn:<c:out value="${fn:escapeXml(review)}"/>
c:out(noescape)+fn: <c:out value="${fn:escapeXml(review)}" escapeXml="false"/>
attr:    <input name="q" value="${fn:escapeXml(param.q)}">
attr-raw:<input name="q" value="${param.q}">
raw:     Great <b>book</b> & "classic" it's
fn:      Great &lt;b&gt;book&lt;/b&gt; &amp; &#034;classic&#034; it&#039;s
c:out:   Great &lt;b&gt;book&lt;/b&gt; &amp; &#034;classic&#034; it&#039;s
c:out+fn:Great &amp;lt;b&amp;gt;book&amp;lt;/b&amp;gt; &amp;amp; &amp;#034;classic&amp;#034; it&amp;#039;s
c:out(noescape)+fn: Great &lt;b&gt;book&lt;/b&gt; &amp; &#034;classic&#034; it&#039;s
attr:    <input name="q" value="&lt;script&gt;&#034;x">
attr-raw:<input name="q" value="<script>"x">

The last two lines are the security point of the page. Plain EL, ${param.q}, writes the request parameter into the page as it is, and <script>"x in a quoted attribute closes the attribute and opens a tag; fn:escapeXml makes the same value inert. Remember what the function is: an HTML (and XML) text and quoted-attribute encoder. It does not protect a value placed in a <script> block, in an event handler such as onclick, in a style attribute, in a URL, or in an unquoted attribute. In a JavaScript string it leaves the backslash alone (a Windows path C:\temp\new is printed as C:\temp\new, which JavaScript reads with a tab and a newline in it), and the HTML references it produces (&#039;) are not decoded inside a script element. For those contexts use c:url and c:param for URLs, send data for JavaScript as JSON and let the script read it, or use a context-aware encoder library such as the OWASP Java Encoder (org.owasp.encoder:encoder-jakarta-jsp 1.5.0), which provides separate functions for HTML content, attributes, JavaScript, CSS and URLs.

var p = '${fn:escapeXml(path)}';
var q = '${path}';
var p = 'C:\temp\new';
var q = 'C:\temp\new';

Whether to use fn:escapeXml or c:out is mostly a matter of where the result goes. For output, c:out is clearer, has a default attribute and is the form that auditors search for. For an attribute of another action, fn:escapeXml avoids the c:set and jsp:attribute detours that the specification shows. Neither is applied automatically: Jakarta Pages 4.0 has no setting that escapes EL output, so every value that comes from a request or a database has to pass through one of them. The page on syntax, comments and escaping shows the other places where text reaches the page, and the EL configuration page lists the EL settings that do exist.

A Complete Bookshelf View

The view below is a realistic use of the functions: a catalogue that shows the number of books, filters by a trimmed query parameter, builds a CSS-friendly slug and a short excerpt, and lists the tags. It is the only example of the page that uses HTML output, and it escapes every value that comes from data.

list.jsp
<h1>Bookshelf (${fn:length(books)} books)</h1>
<c:set var="q" value="${fn:trim(param.q)}"/>
<ul>
<c:forEach var="b" items="${books}">
  <c:set var="title" value="${fn:trim(b.title)}"/>
  <c:if test="${empty q or fn:containsIgnoreCase(title, q)}">
  <li data-slug="${fn:toLowerCase(fn:replace(title, ' ', '-'))}">${fn:escapeXml(title)}, surname ${fn:substringAfter(b.author, ' ')}, initial ${fn:substring(b.author, 0, 1)}</li>
  </c:if>
</c:forEach>
</ul>
<p>Tags: ${fn:join(tags, ', ')}</p>
<p>Excerpt: ${fn:substring(fn:trim(books[2].title), 0, 12)}${fn:length(books[2].title) > 12 ? '...' : ''}</p>
<h1>Bookshelf (3 books)</h1>
<ul>
<li data-slug="dune">Dune, surname Herbert, initial F</li>
  <li data-slug="neuromancer">Neuromancer, surname Gibson, initial W</li>
  <li data-slug="the-left-hand-of-darkness">The Left Hand of Darkness, surname K. Le Guin, initial U</li>
  </ul>
<p>Tags: sci-fi, classic, award</p>
<p>Excerpt: The Left Han...</p>

With ?q=dune only the first item remains (the filter trims the query and ignores case), and a query that is only markup, such as ?q=%3Cscript%3E, matches nothing and prints nothing because the query is only compared, never output.

The view also shows a limit of the library. The third author is "Ursula K. Le Guin", and fn:substringAfter(author, ' ') cuts at the first space, so the "surname" is K. Le Guin. There is no substringAfterLast, lastIndexOf, startsWith with an offset or regular expression anywhere in the library: such rules belong in Java code (the servlet or a view model) and not in the page.

EL 6.0 Method Calls and the Role of the Functions Today

The functions were designed when EL could not call methods. Since EL 2.2 (Pages 2.2) and in EL 6.0 any public method of an object can be called from an expression, and the Java String methods are available on every string, so many of the functions have a direct equivalent. The equivalents are not identical: a method call on a null value yields an empty result, as ${none.contains('x')} printing nothing, but a null as an argument is passed to the Java method and fails.

Table 3. The fn: functions and their EL 6.0 method-call equivalents (Tomcat 11.0.26)
Function EL method call Difference

fn:length(s)

s.length(), list.size(), array.length

The function is the only one that handles every type with one name, and the only one that is null-safe

fn:toUpperCase(s)

s.toUpperCase()

Same result for a non-null value

fn:contains(s, x)

s.contains(x)

'abc'.contains(none) fails with ELException: java.lang.NullPointerException: Cannot invoke "java.lang.CharSequence.toString()" because "s" is null; the function returns true

fn:split(s, d)

s.split(r)

'a.b.c'.split('\\.') returns 3 elements and takes a regular expression; fn:split('a.b.c', '.') returns 3 elements and takes a set of delimiter characters

fn:join(a, sep)

String.join(sep, list)

String.join(', ', tagList) joins a List<String>, which fn:join cannot; java.lang types such as String are available without an import in EL 6.0

fn:trim(s)

s.strip()

strip removes Unicode white space, trim only the characters up to U+0020

fn:substring(s, b, e)

s.substring(b)

The method throws StringIndexOutOfBoundsException for a bad index; the function corrects it

fn:escapeXml(s)

none

No method-call equivalent; keep using the function or c:out

upper=${'Dune'.toUpperCase()} contains=${'Neuromancer'.contains('man')} length=${'Neuromancer'.length()}
strip=[${'  Dune '.strip()}]
split-regex=${'a.b.c'.split('\\.').length} fnsplit-dot=${fn:length(fn:split('a.b.c','.'))}
size=${books.size()} tags-length=${tags.length} join=${String.join(', ', tagList)}
iter-length=${fn:length(tagList.iterator())}
null-contains=${fn:contains(none,'x')}
upper=DUNE contains=true length=11
strip=[Dune]
split-regex=3 fnsplit-dot=3
size=3 tags-length=3 join=sci-fi, classic
iter-length=2
null-contains=false

The practical rule is therefore: use fn: where the value may be null or of an unknown type (a request parameter, an optional bean property, a collection of unknown kind), for fn:escapeXml, for fn:length and for code that must keep working in a page that is also compiled by a tool that does not support EL method calls. Use method calls where the types are known and a richer operation is needed (String.join, strip, collections, streams, the topic of the EL lambdas, collections and streams page). A page that mixes both styles is common and works; the one inconsistency to keep in mind is the null handling.

Pitfalls and Error Messages

Table 4. What goes wrong with fn: and how it shows
Mistake What happens Fix

Forgetting the taglib directive for fn

Translation fails: JasperException: /WEB-INF/views/noprefix.jsp (line: [2], column: [2]) The attribute prefix [fn] does not correspond to any imported tag library

Add the taglib directive with prefix fn and URI jakarta.tags.functions to the page (each page or fragment translated on its own needs its own)

Calling a function with the wrong number of arguments, such as fn:substring('Neuromancer', 3)

Translation fails: ELException: Function [fn:substring] specifies [3] params, but [2] were declared

Pass all three parameters, -1 for "to the end"

fn:join(list, ',') with a List

Request fails: ELException: Problems calling function [fn:join], caused by ELException: Cannot convert of type [class java.util.ImmutableCollections$List12] to [class [Ljava.lang.String;]

Convert in Java (String[]), use String.join(',', list), or iterate with c:forEach

fn:length(record) or a bean

ELException: Problems calling function [fn:length], caused by JspTagException: Don't know how to iterate over supplied "items"

Count a property (fn:length(book.title)), or put a collection in the scope

Using the colon of the prefix where a dot belongs, fn:escapeXml(param:q) (a typo that appears in the specification’s own example)

Translation fails: ELException: Failed to parse the expression [${fn:escapeXml(param:q)}], caused by ParseException: Encountered ":" at line 1, column 21

param.q or param['q']

Using fn:split as String.split

No error, wrong results: delimiters are a character set and empty tokens are dropped

Use String.split(regex) as an EL method call, or process in Java

Assuming fn:replace takes a regular expression

No error; fn:replace('a.b.c','.','/') gives a/b/c

Do regex work in Java

Double escaping, <c:out value="${fn:escapeXml(x)}"/>

&lt; appears literally as &amp;lt;

Escape once: c:out or fn:escapeXml, not both

Relying on fn:escapeXml inside <script>, an event handler, a URL or an unquoted attribute

Data is not safe and may be wrong (backslashes, line breaks)

Encode for the context: JSON, c:url/c:param, a context-aware encoder

fn:trim on a value with non-breaking spaces

The U+00A0 character stays, because trim only removes characters up to U+0020

s.strip() for Unicode white space

fn:toUpperCase on a JVM with a Turkish default locale

i becomes dotted capital İ

Set -Duser.language explicitly, or normalise in Java with Locale.ROOT

Using the spec’s 2-argument example fn:substring(text, fn:length(text)-10)

The same arity error as above

Write the third argument: -1, or fn:length(text)

What Changed Recently

Table 5. The history of the function library
Version Change

JSTL 1.0 (2002)

No function library, and no EL outside JSTL attributes: string operations and the size of a collection needed scriptlets or custom tags

JSTL 1.1 and JSP 2.0 (2003)

The fn: library with the 16 functions, URI http://java.sun.com/jsp/jstl/functions, made possible by EL functions in JSP 2.0

JSTL 1.2 and EL 2.2 (2009)

No new functions; EL 2.2 adds method calls on objects, which duplicates many of them

Jakarta Tags 2.0 (EE 9, 2020)

The javax.servlet.jsp.jstl package becomes jakarta.servlet.jsp.jstl; no change to the functions

Jakarta Tags 3.0 (EE 10, April 2022)

URIs renamed to jakarta.tags.* (jakarta.tags.functions), Java 11 baseline, JPMS module descriptor, no dependency on XML binding in the API; the functions are unchanged

Jakarta Tags 3.0.2 API and 3.0.1 implementation (current on 2026-10-08)

Maintenance releases; the implementation JAR still carries the old TLDs, so http://java.sun.com/jsp/jstl/functions still resolves (verified on Tomcat 11.0.26: ${fn:toUpperCase('legacy ok')} printed LEGACY OK)

Jakarta Tags 3.1 (under development for Jakarta EE 12)

Milestone 3.1.0-M1 (API artifact 3.1.0-M2 on Maven Central); planned items are java.time support in fmt:parseDate and fmt:formatDate and Java 21; the project lists no removals and no new functions

Moving a page from Java EE to Jakarta EE therefore changes only the taglib URI (and only if the old one is to be left behind): http://java.sun.com/jsp/jstl/functions still works, but it is a taglib identifier only, not a reachable web page, and new code uses jakarta.tags.functions. The javax.servlet.jsp.jstl.* Java API, if used in custom code, has to change to jakarta.servlet.jsp.jstl.*.

Where the Books Are Outdated

  • JSTL in Action (2002) describes JSTL 1.0: there are no fn: functions in it, because they arrived with JSTL 1.1. The techniques it uses for string work (a scriptlet, a custom tag or a c:set with c:out) are replaced by the functions on this page, and by EL method calls on Jakarta EE 11. Its taglib URIs (http://java.sun.com/jstl/core, and the _rt twin libraries) are replaced by jakarta.tags.*.

  • Web Development with JavaServer Pages (2nd edition, 2001) targets JSP 1.2, where EL does not exist outside JSTL attributes and there are no EL functions at all; string handling is done with scriptlets and the java.lang.String methods.

  • Any tutorial that declares the library with http://java.sun.com/jsp/jstl/functions is Java EE era; the current form is jakarta.tags.functions.

  • Tutorials written before EL 2.2 say that the size of a collection is available only through fn:length; on Jakarta EE 11 ${list.size()} also works, although fn:length is still the null-safe form.

Official Documentation Inconsistencies

The specification’s own examples need attention, and the discrepancies were found by running them.

  • The introductory example of chapter 15 shows ${fn:substring(text, fn:length(text)-10)}, with two arguments. The function is declared with three, and Tomcat 11.0.26 rejects the page at translation time with Function [fn:substring] specifies [3] params, but [2] were declared. A working form is ${fn:substring(text, fn:length(text)-10, -1)}.

  • The same chapter shows ${fn:escapeXml(param:userName)} with a colon between param and the name; the EL parser rejects the page (ParseException: Encountered ":"). The working expression is ${fn:escapeXml(param.userName)}.

  • The example ${fn:replace(text, '-', '&#149;')} writes a numeric character reference to U+0095, a C1 control character. Browsers map it to a bullet for historical reasons (the Windows-1252 table), but HTML 5 reports it as a parse error; &#8226; is the reference for a bullet.

  • The text of fn:endsWith says "If substring is null" where the parameter is called suffix: a copy-paste error that does not change the behaviour.