Scripting Elements (Legacy)

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 Pages 4.0 (Jakarta EE 11), Jakarta Expression Language 6.0, Jakarta Standard Tag Library 3.0 and Jakarta Servlet 6.1 on Apache Tomcat 11.0.26 and Java 21 (the specifications need Java 17 or later). Everything was verified on 2026-10-08: the facts against the Pages 4.0 specification (sections 1.12 and 3.3.3 and chapter 9), the Jakarta Tags 3.0 specification and the Tomcat 11 documentation, and every example by building it into a Maven WAR (context path /g3scripting) and running it on Tomcat 11.0.26 with curl; the outputs shown are the outputs that run produced. Scripting elements are the oldest way to put logic into a JSP: Java code written between <% and %>. They still work in every container and the specification still requires them, but since JSP 2.0 (2003) the Expression Language and the tag libraries do the same job without Java code in the page, and this page therefore treats the three elements as legacy. It explains how they work, because a large amount of existing code uses them and because the translation model explains most of the surprising behaviour of JSP, and then shows how to switch them off and how to replace them. The running scenario is the Bookshelf, a small catalogue of books: the same list page is written first with scripting elements and then with EL, JSTL and a tag file.

The Three Scripting Elements

A JSP page contains template text (sent to the browser unchanged), directives, actions and, optionally, three kinds of scripting element. The scripting language of a page is given by the language attribute of the page directive, and in Pages 4.0 the only value the specification defines is java. All containers must support Java scripting, and the semantics are defined precisely by chapter 9 of the specification in terms of the generated page implementation class.

Table 1. The scripting elements of a JSP page
Element Standard syntax XML syntax What the container does with it Output

Declaration

<%! ... %>

<jsp:declaration>

Copied into the class body, before _jspService: fields, methods, nested types, jspInit, jspDestroy

None

Scriptlet

<% ... %>

<jsp:scriptlet>

Copied unchanged into the body of _jspService, between the statements that write the template text

Only what the code itself writes to out

Expression

<%= ... %>

<jsp:expression>

Wrapped as out.print( expression ) inside _jspService

The value converted to a String

Four rules from the specification matter everywhere else on this page:

  • White space after <%!, <% and <%= and before %> is optional.

  • A declaration must be a complete declarative statement, or a sequence of them. A scriptlet need not be a complete statement, because several scriptlets together must form valid statements: that is what makes <% for (...) { %>, template text and <% } %> legal.

  • An expression must be a complete expression, with no trailing semicolon, and its value is coerced to a String. A value that cannot be coerced is a translation error when detected at translation time, otherwise a ClassCastException at request time.

  • To use the characters %> inside a scriptlet without ending it, write %\>.

How the Container Translates Them

Pages are translated, not interpreted. In the translation phase the container turns the page into the source of a Java class (the JSP page implementation class), compiles it and loads it; in the execution phase it calls that class like any servlet. The spec describes the class as three sections: the declarations section, the implicit objects section and the main section, with the main section containing the template data, scriptlets and expressions in the order in which they appear in the page. The container need not generate this class literally, but it must behave as if it did, and Tomcat’s Jasper does generate it.

flowchart LR src["Page source"] --> tr["Translation, once per change"] tr --> d["Declarations"] tr --> t["Template text"] tr --> s["Scriptlets"] tr --> e["Expressions"] d --> cls["Members of the page class: fields and methods"] t --> w["out.write(...) calls in _jspService"] s --> c["Code copied into _jspService"] e --> p["out.print(expression) in _jspService"] cls --> inst["One instance, shared by all threads"] w --> loc["Local variables, one set per request"] c --> loc p --> loc

Consequences that follow from the diagram, each of which is demonstrated below:

  • Declared fields belong to the single page instance, so they are shared by every request and every session, and they must be thread-safe.

  • Variables declared in a scriptlet are local variables of _jspService: they exist for one request, are invisible to EL, to other pages (except through a static include) and to declared methods, and cannot be declared twice in one page.

  • The code of all scriptlets is one method body, so a block opened in one scriptlet can be closed in a later one with template text in between.

  • A syntax error in scriptlet code is found by the Java compiler, which reports it against the generated class; Jasper maps the error back to the JSP line.

  • Names of the form _jsp* and _jspx* (in any combination of case) are reserved for the container.

The Bookshelf Used on This Page

All the examples are in one WAR, g3scripting.war, deployed at /g3scripting on Tomcat 11.0.26. It has the Maven dependencies of the Getting Started page of this section (the Servlet, Pages and EL APIs with scope provided, the Jakarta Tags API and the GlassFish JSTL implementation), the record Book(long id, String title, String author, int year) and a servlet that puts the list in request scope and forwards to a view under WEB-INF/views.

The Bookshelf controller shared by the examples (imports and package line omitted)
@WebServlet("/books/*")
public class BooksServlet extends HttpServlet {

    private static final List<Book> BOOKS = List.of(
            new Book(1, "Dune", "Frank Herbert", 1965),
            new Book(2, "Neuromancer", "William Gibson", 1984),
            new Book(3, "Solaris", "Stanislaw Lem", 1961));

    private final AtomicInteger hits = new AtomicInteger();

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws ServletException, IOException {
        String view = req.getPathInfo() == null ? "/after" : req.getPathInfo();
        req.setAttribute("books", BOOKS);
        req.setAttribute("hits", hits.incrementAndGet());
        req.getRequestDispatcher("/WEB-INF/views" + view + ".jsp").forward(req, resp);
    }
}

The path after /books selects the view, so /g3scripting/books/before renders WEB-INF/views/before.jsp and /g3scripting/books/modern renders WEB-INF/views/modern.jsp. The servlet and the dispatch are explained in Servlet Fundamentals, section RequestDispatcher.

Declarations

A declaration adds members to the generated class. It can declare instance fields, static fields, methods and nested types, and it can override the page lifecycle methods. A declaration produces no output.

WEB-INF/views/before.jsp: the Bookshelf page in the scripting style (this is a "before" snippet, not a recommendation)
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" import="java.util.List,org.example.bookshelf.Book" %>
<%! private int hits = 0;

    private String shout(String s) {
        return s.toUpperCase();
    }
%>
<!DOCTYPE html>
<html>
<body>
<h1>Bookshelf (scriptlet version)</h1>
<%
    @SuppressWarnings("unchecked")
    List<Book> books = (List<Book>) request.getAttribute("books");
    hits++;
%>
<p>Page served <%= hits %> time(s); <%= books.size() %> books.</p>
<ul>
<% for (Book b : books) { %>
  <li><%= shout(b.title()) %> by <%= b.author() %> (<%= b.year() %>)</li>
<% } %>
</ul>
</body>
</html>

The page uses all three elements: a declaration for a field and a helper method, a scriptlet that reads the model and increments the field, and expressions inside a loop built from two scriptlets. Request curl http://localhost:18080/g3scripting/books/before and the response is:

<!DOCTYPE html>
<html>
<body>
<h1>Bookshelf (scriptlet version)</h1>

<p>Page served 1 time(s); 3 books.</p>
<ul>

  <li>DUNE by Frank Herbert (1965)</li>

  <li>NEUROMANCER by William Gibson (1984)</li>

  <li>SOLARIS by Stanislaw Lem (1961)</li>

</ul>
</body>
</html>

The blank lines are the line breaks that follow each scriptlet; they are template text. Tomcat can remove them, which is a setting of the Directives and Configuration and Property Groups pages of this section (trimDirectiveWhitespaces and the trimSpaces option of the Jasper servlet, in the Tomcat Jasper How-To), not of scripting.

What Jasper Generates

Tomcat keeps the generated source (the option keepgenerated defaults to true) in the work directory of the application, here work/Catalina/localhost/g3scripting/org/apache/jsp/WEB_002dINF/views/before_jsp.java. An excerpt, reduced to the lines that matter:

The generated class for before.jsp (excerpt; blank lines removed)
package org.apache.jsp.WEB_002dINF.views;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import jakarta.servlet.jsp.*;
import java.util.List;
import org.example.bookshelf.Book;
public final class before_jsp extends org.apache.jasper.runtime.HttpJspBase
    implements org.apache.jasper.runtime.JspSourceDependent, ... {
 private int hits = 0;
    private String shout(String s) {
        return s.toUpperCase();
    }
  ...
  public void _jspInit() { }
  public void _jspDestroy() { }
  public void _jspService(final jakarta.servlet.http.HttpServletRequest request,
                          final jakarta.servlet.http.HttpServletResponse response)
      throws java.io.IOException, jakarta.servlet.ServletException {
    ...
    final jakarta.servlet.jsp.PageContext pageContext;
    jakarta.servlet.http.HttpSession session = null;
    final jakarta.servlet.ServletContext application;
    final jakarta.servlet.ServletConfig config;
    jakarta.servlet.jsp.JspWriter out = null;
    final java.lang.Object page = this;
    try {
      response.setContentType("text/html; charset=UTF-8");
      pageContext = _jspxFactory.getPageContext(this, request, response, null, true, 8192, true);
      ...
      out.write("<h1>Bookshelf (scriptlet version)</h1>\n");
    @SuppressWarnings("unchecked")
    List<Book> books = (List<Book>) request.getAttribute("books");
    hits++;
      out.write("\n");
      out.write("<p>Page served ");
      out.print( hits );
      ...
 for (Book b : books) {
      out.write("\n");
      out.write("  <li>");
      out.print( shout(b.title()) );
      ...
 }
      out.write("\n");
      out.write("</ul>\n");
    } catch (java.lang.Throwable t) {
      ...
        if (_jspx_page_context != null) _jspx_page_context.handlePageException(t);
      ...
    } finally {
      _jspxFactory.releasePageContext(_jspx_page_context);
    }
  }
}

Four observations follow from the listing. The page directive attribute import became ordinary import statements, and the page class name, package and superclass are implementation details of Jasper (the specification says that the name is implementation dependent and that minimal assumptions should be made about the package). The declaration text sits in the class body, ahead of the framework members. The scriptlet and the expressions are in _jspService, with the out.write calls for template text around them, so the order of output equals the order in the page. And the catch block calls handlePageException, which is the entry point for the error-page mechanism covered in the Error Handling page of this section. Debugging a scripting error normally starts here: when the Java compiler rejects the page, the Jasper message names the JSP line, not the line of the generated class.

jspInit and jspDestroy

A declaration is the only place where a page can run code when it is initialised and when it is destroyed. The page implementation class implements jakarta.servlet.jsp.JspPage, whose jspInit() and jspDestroy() "can be defined by a JSP author", while _jspService() is generated and cannot. Note the names: Jasper itself generates empty _jspInit() and _jspDestroy() methods (visible above) and calls them from its own init and destroy; the ones a page author overrides have no leading underscore.

pit/lifecycle.jsp: lifecycle callbacks in a declaration
<%@ page contentType="text/plain; charset=UTF-8" %>
<%!
    public void jspInit() {
        System.out.println("g3scripting: jspInit of " + getServletConfig().getServletName());
    }

    public void jspDestroy() {
        System.out.println("g3scripting: jspDestroy");
    }
%>
ok

On the first request to /g3scripting/pit/lifecycle.jsp Tomcat’s catalina.out showed g3scripting: jspInit of jsp (the servlet name is that of the container’s JSP servlet) and, when the application was redeployed, g3scripting: jspDestroy. As with a servlet, the page is initialised once and destroyed once; this is the legitimate use of a declaration. A modern application does the same work in a ServletContextListener or a managed bean, which is easier to test.

Declared Fields Are Shared by All Threads

The container normally creates one instance of the page class and runs it on many request threads at once, as with any servlet. A field in a declaration is therefore an instance field of that one object. The specification warns that initialising such a variable "should be done with care in the presence of multiple requests on the page", and the isThreadSafe page directive attribute, which used to ask the container to serialise requests, is gone (see What Changed Recently), so there is no container-provided way to serialise access.

pit/counter.jsp: an unsynchronised counter
<%@ page contentType="text/plain; charset=UTF-8" %>
<%! private int hits = 0; %>
<% hits++; %>
hits=<%= hits %>
pit/counter-atomic.jsp: the same counter with a thread-safe type
<%@ page contentType="text/plain; charset=UTF-8" import="java.util.concurrent.atomic.AtomicInteger" %>
<%! private final AtomicInteger hits = new AtomicInteger(); %>
hits=<%= hits.incrementAndGet() %>

The test sent one warm-up request, then 2000 requests with 32 parallel curl processes, then one more request, to each page (seq 1 2000 | xargs -P 32 -I{} curl -s -o /dev/null ...):

counter: hits=2000
counter-atomic: hits=2002

2002 requests were made. The plain int lost two increments, because hits++ is a read, an add and a write, and two threads interleaved. The number varies between runs and can be zero on a lightly loaded machine, which is what makes the defect dangerous: it passes every manual test. The cure is the one for any shared state: do not keep per-request or per-user data in a field; use a local variable, a request or session attribute, or a thread-safe object, as the Servlet Fundamentals page of this section explains. A scriptlet variable is not affected, because it is local to _jspService. The hits counter of the finished application lives in the controller (an AtomicInteger), not in a page.

Scriptlets

A scriptlet is a fragment of Java that runs on every request. Fragments are concatenated into a single method body, so each fragment is not independently valid: only their combination must be. This is how loops and conditionals are written around template text:

A conditional around template text (from the specification, restated for the Bookshelf)
<% if (books.isEmpty()) { %>
  <p>The shelf is empty.</p>
<% } else { %>
  <p><%= books.size() %> books.</p>
<% } %>

Scriptlets have access to the implicit objects described below, to the page’s imports and to every declared member. The rules that bite are these.

Local Variables, Scope and Closers

pit/scope.jsp: a scriptlet variable is not an EL variable
<%@ page contentType="text/plain; charset=UTF-8" %>
<% String title = "Dune"; %>
scriptlet: <%= title %>
EL (local variable): [${title}]
<% pageContext.setAttribute("title", title); %>
EL (page scope): [${title}]
scriptlet: Dune
EL (local variable): []

EL (page scope): [Dune]

EL resolves names against the scoped attributes (page, request, session, application), never against the local variables of _jspService. The bridge between the two worlds is pageContext.setAttribute, request.setAttribute and their getAttribute counterparts, and this is the single biggest source of confusion when scripting and EL are mixed.

pit/dup.jsp: two scriptlets declaring the same local variable
<%@ page contentType="text/plain; charset=UTF-8" %>
<% int n = 1; %>
<% int n = 2; %>
<%= n %>

The response is HTTP 500 with a JasperException whose message points at the JSP, not at the generated class:

An error occurred at line: [3] in the jsp file: [/pit/dup.jsp]
Duplicate local variable n
1: <%@ page contentType="text/plain; charset=UTF-8" %>
2: <% int n = 1; %>
3: <% int n = 2; %>
4: <%= n %>

The two scriptlets are one method body, so the second declaration is a duplicate; by the same logic a variable declared inside the { of one scriptlet’s loop is not visible after the matching }. The message above also shows the source fragment that Tomcat includes in the error (the displaySourceFragment option, default true); that fragment, and a stack trace, are a disclosure problem in production, which the Error Handling page of this section treats.

pit/closer.jsp: the characters that close a scriptlet are also found inside a string
<%@ page contentType="text/plain; charset=UTF-8" %>
<% out.println("100%>"); %>

The first %> ends the scriptlet, however the Java string looks, and the compiler then sees an unterminated literal: String literal is not properly closed by a double-quote at line 2. With the escape from the specification (pit/closer-ok.jsp) the page prints the expected text:

<% out.println("100%\>"); %>
100%>

The Java Language Level of Scriptlets

Scriptlet code is compiled by the container’s compiler, which is the Eclipse compiler (JDT) bundled with Tomcat, and the language level is a setting of the JSP servlet, not the level of the application’s own classes. The Jasper How-To lists compilerSourceVM and compilerTargetVM with the default 17 for Tomcat 11. Observed: a switch with a type pattern in a scriptlet failed on a default Tomcat 11.0.26 although the application is built for Java 21:

pit/switch21.jsp: a Java 21 construct in a scriptlet
<%@ page contentType="text/plain; charset=UTF-8" %>
<%
    Object o = request.getParameter("q");
    String kind = switch (o) {
        case String s -> "string " + s;
        default -> "other";
    };
%>
<%= kind %>
An error occurred at line: [4] in the jsp file: [/pit/switch21.jsp]
Cannot switch on a value of type Object. Only convertible int values, strings or enum variables are permitted

Declaring the container’s JSP servlet in the application’s web.xml with the two options, and redeploying, made the same page answer string x for ?q=x:

WEB-INF/web.xml: raising the scriptlet language level (excerpt)
<servlet>
    <servlet-name>jsp</servlet-name>
    <servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
    <init-param>
        <param-name>compilerSourceVM</param-name>
        <param-value>21</param-value>
    </init-param>
    <init-param>
        <param-name>compilerTargetVM</param-name>
        <param-value>21</param-value>
    </init-param>
    <load-on-startup>3</load-on-startup>
</servlet>

This is a Tomcat setting, not a Jakarta Pages feature, and the error text (a message about switch on Object) does not mention the language level, so it is easy to misdiagnose. It is one more reason to keep logic in compiled classes, which use the project’s own compiler settings.

Leaving the Page with return

A scriptlet is inside _jspService, so return ends the page. The specification does not define this, and the example is a Tomcat observation.

pit/ret.jsp: return inside a scriptlet
<%@ page contentType="text/plain; charset=UTF-8" %>
first
<% if (request.getParameter("stop") != null) { return; } %>
second

curl .../pit/ret.jsp printed first and second; with ?stop=1 it printed only first and finished normally. It is legal on Tomcat, it skips the rest of the output, and it is the kind of control flow that a designer maintaining the page cannot see. A jsp:forward or a controller does the job explicitly.

Expressions

An expression is wrapped by the container as out.print(...), with the value converted to a String. The converted value is written without escaping, and null becomes the text null:

pit/nulls.jsp: scripting expression versus EL for a missing parameter
<%@ page contentType="text/plain; charset=UTF-8" %>
scripting: [<%= request.getParameter("missing") %>]
EL:        [${param.missing}]
scripting: [null]
EL:        []

The two rows of that output are why page authors prefer EL: a missing value yields an empty string rather than the word null. The expression must be complete and must not end with a semicolon:

pit/semi.jsp: a semicolon inside an expression
<%@ page contentType="text/plain; charset=UTF-8" %>
<%= "Dune"; %>
An error occurred at line: [2] in the jsp file: [/pit/semi.jsp]
Syntax error on token ";", delete this token

Neither Scripting Nor EL Escapes Output

An expression writes what it is given. So does an EL expression written in template text, and cross-site scripting is the result when the value comes from the request:

pit/xss.jsp: three ways to print a request parameter
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<p>Unsafe: <%= request.getParameter("q") %></p>
<p>Safe:   <c:out value="${param.q}"/></p>

With ?q=<script>alert(1)</script> (URL-encoded) the output was:

<p>Unsafe: <script>alert(1)</script></p>
<p>Safe:   &lt;script&gt;alert(1)&lt;/script&gt;</p>

pit/elraw.jsp, which prints ${param.q} directly in template text, reproduced the problem without any scripting (<p>EL in template text: <b>x</b></p> for ?q=<b>x</b>). The fix is c:out (which escapes <, >, &, ' and " by default), fn:escapeXml, or an encoder library such as the OWASP Java Encoder tag library, and the habit of never printing request data any other way. The Security page of this section collects the rules.

Implicit Objects Available to Scripting

The initialisation section of the generated class declares a set of local variables that scripting elements can use without declaring them (the generated class above shows them). The scoping and the EL counterparts are the subject of the Implicit Objects & Scopes page of this section; this table is what a scriptlet sees.

Table 2. Implicit objects of a JSP page (Table JSP.1-6)
Name Type Meaning and scope

request

HttpServletRequest (protocol-dependent subtype of ServletRequest)

The request being served; request scope

response

HttpServletResponse

The response; page scope

pageContext

jakarta.servlet.jsp.PageContext

The page context, gateway to all scopes; page scope

session

HttpSession

The session, only valid for HTTP and only when the page takes part in a session; session scope

application

ServletContext

The application; application scope

out

jakarta.servlet.jsp.JspWriter

The buffered writer for the page’s output; page scope

config

ServletConfig

The servlet configuration of the page; page scope

page

Object (this)

The page instance; page scope

exception

Throwable

Only in an error page (isErrorPage="true"); page scope

The names are variables, not attributes: ${request} is not the request variable, and the EL implicit objects have different names (requestScope, param, pageContext and so on). out is the buffered stream that Jasper’s out.write calls use, so a scriptlet that calls out.println is interleaved correctly with the template text around it.

Scripting Variables Defined by Actions

An action can introduce a variable that later scriptlets can use. In the standard actions the one that does so is jsp:useBean: "the name of the variable introduced is the name of the id attribute and its type is the type of the class attribute". Custom tags can declare variables in their TLD or through a TagExtraInfo, with the scopes AT_BEGIN, NESTED and AT_END (the classic and SimpleTag custom tag pages of this section). The scope attribute of the action does not change the visibility of the Java variable: it determines where, and so for how long, additional references to the object are kept.

pit/usebean.jsp: a bean visible to scripting and to EL
<%@ page contentType="text/plain; charset=UTF-8" %>
<jsp:useBean id="shelf" class="java.util.ArrayList" scope="page"/>
<% shelf.add("Dune"); shelf.add("Solaris"); %>
size from scripting: <%= shelf.size() %>
size from EL: ${shelf.size()}
size from scripting: 2
size from EL: 2

The bean is both a scripting variable and a page-scoped attribute, which is why the EL side finds it. The Standard Actions & JavaBeans page of this section covers jsp:useBean, jsp:setProperty and jsp:getProperty in full.

Scriptlet Variables and the Include Mechanisms

The include directive merges the included file into the page before translation, so all scriptlets, local variables and declarations are in the same class. jsp:include runs a different page at request time, in a different class.

pit/inc-vars.jsp: a static include sees the including page’s scriptlet variable
<%@ page contentType="text/plain; charset=UTF-8" %>
<% String shared = "from the parent"; %>
<%@ include file="/pit/inc-part.jspf" %>

The included pit/inc-part.jspf contains the single line static include sees: <%= shared %>, and the response is static include sees: from the parent. The dynamic version, pit/inc-dyn.jsp, is the same page with <jsp:include page="/pit/inc-dyn-part.jsp"/> in place of the directive and <%= shared %> in the included page; it failed with HTTP 500 and the compiler message shared cannot be resolved to a variable for /pit/inc-dyn-part.jsp. Variables must be passed to a dynamically included page as request attributes or jsp:param parameters. The Includes, Forwards & Page Composition page of this section compares the two mechanisms.

The XML Syntax

In a JSP document (an XML file) the only valid forms of the three elements are <jsp:declaration>, <jsp:scriptlet> and <jsp:expression>. The semantics are those of the standard syntax. The rest of the XML view, and the well-formedness rules (a < in code must be escaped or placed in CDATA), are the subject of the JSP Documents page of this section.

pit/doc.jspx: the three elements in XML syntax
<?xml version="1.0" encoding="UTF-8"?>
<jsp:root xmlns:jsp="http://java.sun.com/JSP/Page" version="2.1">
  <jsp:directive.page contentType="text/plain; charset=UTF-8"/>
  <jsp:declaration>private String shout(String s) { return s.toUpperCase(); }</jsp:declaration>
  <jsp:scriptlet>String t = "dune";</jsp:scriptlet>
  <jsp:text>Title: </jsp:text><jsp:expression>shout(t)</jsp:expression>
</jsp:root>

curl .../pit/doc.jspx returned Title: DUNE. The xmlns:jsp namespace http://java.sun.com/JSP/Page is the identifier that Pages 4.0 still uses, although it names a company that no longer owns the technology; it is an identifier, not an address. A standard-syntax page is converted to the same XML view for tag library validators, with <% ... %> becoming <jsp:scriptlet>.

Request-Time Attribute Values

A scripting expression can also appear as the whole value of an attribute of an action, written "<%= expr %>" or '<%= expr %>': a request-time attribute value. Rules from the specification: the expression must appear by itself, not mixed with text; it works only in attributes of actions that accept request-time values (a custom tag attribute whose TLD says rtexprvalue true, or a standard action attribute that the specification lists), and an attribute with page translation-time semantics rejects it; and in a directive it is a translation error. In a JSP page the EL form "${expr}" is the replacement and it can be mixed with text.

pit/attr.jsp: a scripting expression in a tag attribute
<%@ page contentType="text/plain; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:out value="<%= request.getParameter(\"q\") %>" default="(none)"/>

With ?q=a%26b the page printed a&b, because c:out escapes. Note the quoting: the inner double quotes must be escaped as \" (the specification’s quoting conventions), or the attribute must be written with single quotes; this is one of the reasons the form is rarely written correctly the first time. The same value in the EL form is <c:out value="${param.q}" default="(none)"/>.

Why Scripting Elements Are Discouraged

The specification itself calls EL "an alternative to scripting elements" and describes EL pages as scriptless pages that "can’t use Java scriptlets, Java expressions, or Java declaration elements". The Jakarta Tags specification states the motivation: page authors "often see scripting languages as complex and not very well adapted to their needs". The practical reasons, each shown by an example above or in the tables below, are:

Table 3. Why page code is better than scripting
Problem What happens Alternative

Business logic in the view

Database access, decisions and formatting in the page cannot be unit-tested without a container, and cannot be reused by a REST endpoint or a batch job

Controller and service classes; the page only renders a model

Thread safety

Declared fields are shared by all threads (see Declared Fields Are Shared by All Threads)

No state in the page; request or session attributes

Output encoding

<%= %> and EL in template text print raw text, which makes XSS the default (see Neither Scripting Nor EL Escapes Output)

c:out, fn:escapeXml, an encoder tag library

Two variable worlds

Scriptlet locals are invisible to EL and to tags (see Local Variables, Scope and Closers)

Scoped attributes only

Language level and tooling

The scriptlet language level is a container setting (see The Java Language Level of Scriptlets); IDEs and refactoring tools understand Java files far better than Java inside a template

Compiled classes with the project’s own compiler options

Errors found late

A page is compiled on its first request, so a typo is a runtime HTTP 500

Precompile with Jasper’s JspC at build time, and keep pages free of code

Mixed responsibilities

A designer cannot edit a page full of braces without breaking it

Tags and EL, which are the page author’s vocabulary

Hidden control flow

return, break and an exception inside a scriptlet change the output in ways a reader of the template cannot see

Explicit tags and controllers

The first of those rows is also the architectural argument of the Model 2 approach covered in the Introduction & History page of this section: the view should not decide anything. Nothing here makes scripting elements incorrect, and a small internal tool that never leaves a single developer’s hands can use them; the point is that the cost grows with the number of people who touch the page and the number of years it lives.

Disabling Scripting Elements

A project that has decided to be scriptless can make the decision enforceable. There are two mechanisms: a switch in web.xml and a tag library validator, and each is described below together with what was observed on Tomcat 11.0.26.

scripting-invalid

The element scripting-invalid is a child of jsp-property-group (the Configuration and Property Groups page of this section covers the rest of that element) with the values true and false. Scripting is enabled by default, and when the value is true scripting elements found in a matching page are a translation error. The table gives the effect (Table JSP.3-3):

Table 4. Scripting settings (Table JSP.3-3)
scripting-invalid Scripting elements encountered

unspecified

Valid

false

Valid

true

Translation error

The Java EE 5 tutorial (the archived javax era text, chapter 9, "Disabling Scripting") describes the same element and says that with scripting invalid "scriptlets, scripting expressions, and declarations will produce a translation error". That sentence is the behaviour that Tomcat implements, as the observations below show.

WEB-INF/web.xml: a scriptless folder (excerpt)
<jsp-config>
    <jsp-property-group>
        <url-pattern>/WEB-INF/views/strict/*</url-pattern>
        <scripting-invalid>true</scripting-invalid>
    </jsp-property-group>
</jsp-config>

The specification’s own example uses the extension pattern *.jsp for the whole application. A folder pattern gives a migration path: new and converted pages go under the folder, old pages stay where they are, and the folder grows. The property group applies to a page reached through a forward as well as one reached directly (the pattern is compared with the path of the JSP file).

WEB-INF/views/strict/before.jsp: a scripting expression in a scriptless folder
<%@ page contentType="text/html; charset=UTF-8" %>
<p><%= request.getAttribute("books") %></p>

curl .../books/strict/before answered HTTP 500 and the page text

JasperException: /WEB-INF/views/strict/before.jsp (line: [2], column: [6]) Scripting elements ( <%!, <jsp:declaration, <%=, <jsp:expression, <%, <jsp:scriptlet ) are disallowed here.

The error appears when the page is translated, that is at the first request, unless the build precompiles the pages. The EL page in the same folder, strict/after.jsp, is fine:

WEB-INF/views/strict/after.jsp
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<p>${books.size()} books, first: <c:out value="${books[0].title}"/></p>

curl .../books/strict/after returned <p>3 books, first: Dune</p>.

The specification’s grammar also says that when scripting is disabled, a request-time attribute value is a translation error (the validity constraint RTAttributeScriptingEnabled). Tomcat 11.0.26 did not enforce that part:

WEB-INF/views/strict/attr2.jsp: a request-time attribute value in a scriptless folder
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<c:set var="v" value="<%= 1 + 1 %>"/>[${v}]

The page was translated and answered [2] with HTTP 200; strict/attr.jsp (<c:out value="<%= \"x\" %>"/>) printed x. So scripting-invalid on Tomcat blocks declarations, scriptlets and expressions in template text, but not scripting expressions in action attributes. A project that wants to ban those as well needs the tag library validator described next, or a source scan (see Finding What Is Left).

ScriptFreeTLV

Before scripting-invalid existed, the way to forbid scripting was a tag library validator (TLV), and the Jakarta Tags API still ships jakarta.servlet.jsp.jstl.tlv.ScriptFreeTLV (in the Jakarta Tags API JAR, version 3.0.2 here). It takes four initialisation parameters, allowDeclarations, allowScriptlets, allowExpressions and allowRTExpressions. A validator is applied to every page that imports the TLD that names it, so it is opt-in per page, but it distinguishes expressions and request-time attribute values from scriptlets and declarations, which scripting-invalid does not. The GlassFish implementation JAR 3.0.1 contains a ready-made TLD with all four parameters false, META-INF/scriptfree.tld, whose URI is jakarta.tags.scriptfree; a page opts in with one directive:

<%@ taglib prefix="sf" uri="jakarta.tags.scriptfree" %>

On Tomcat 11.0.26 pit2/sf-ok.jsp (EL only) printed 2, and the other two pages failed with HTTP 500 and these validator messages:

pit2/sf-expr.jsp  (<%= 1 + 1 %> in the page)
  Validation error messages from TagLibraryValidator for [scriptfree] in [/pit2/sf-expr.jsp]
  null: JSP page contains 1 expression.

pit2/sf-rt.jsp    (<c:out value="<%= 1 + 1 %>"/>)
  Validation error messages from TagLibraryValidator for [scriptfree] in [/pit2/sf-rt.jsp]
  null: JSP page contains 1 request-time attribute value.

So the validator closes the gap left by scripting-invalid on Tomcat (see above): it rejects the request-time attribute value. A project that wants a looser rule writes its own TLD with other parameter values, for example to forbid scriptlets and declarations but allow expressions:

WEB-INF/tlds/scriptfree.tld: a selective validator
<?xml version="1.0" encoding="UTF-8" ?>
<taglib xmlns="https://jakarta.ee/xml/ns/jakartaee"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-jsptaglibrary_3_0.xsd"
        version="3.0">
    <description>Rejects scriptlets and declarations, still allows expressions.</description>
    <tlib-version>1.0</tlib-version>
    <short-name>scriptfree</short-name>
    <uri>https://example.org/bookshelf/scriptfree</uri>

    <validator>
        <validator-class>jakarta.servlet.jsp.jstl.tlv.ScriptFreeTLV</validator-class>
        <init-param>
            <param-name>allowDeclarations</param-name>
            <param-value>false</param-value>
        </init-param>
        <init-param>
            <param-name>allowScriptlets</param-name>
            <param-value>false</param-value>
        </init-param>
        <init-param>
            <param-name>allowExpressions</param-name>
            <param-value>true</param-value>
        </init-param>
        <init-param>
            <param-name>allowRTExpressions</param-name>
            <param-value>true</param-value>
        </init-param>
    </validator>
</taglib>

With a page that imports https://example.org/bookshelf/scriptfree, pit2/free-ok.jsp (an expression, <%= 1 + 1 %>) printed expression allowed: 2, while a scriptlet (pit2/free-bad.jsp) and a declaration (pit2/free-bad2.jsp) failed with HTTP 500 and the messages null: JSP page contains 1 scriptlet. and null: JSP page contains 1 declaration.

The example in the Jakarta Tags specification (chapter 14, Tag Library Validators) is the model for this TLD, but it cannot be copied as printed: its <jsp-version>2.0</jsp.version> line has mismatched end tags and is not well-formed XML, so the TLD above leaves that element out.

Choosing a Level of Strictness

Table 5. Ways to limit scripting in a project
Mechanism Blocks Does not block

Nothing (the default)

Nothing

Anything

scripting-invalid on a folder or extension

Declarations, scriptlets and expressions in the page body (observed)

Request-time attribute values on Tomcat 11.0.26 (observed)

ScriptFreeTLV with all four flags false (jakarta.tags.scriptfree, observed)

Declarations, scriptlets, expressions and request-time attribute values in the pages that import the TLD

Pages that do not import the TLD

A build check (grep, or a unit test over the view folder)

Whatever the pattern matches, in every page

Nothing, but only as reliable as the pattern

Migrating to EL, JSTL and Tag Files

The replacement is not one-to-one, because the aim is to move logic out of the page. The table maps each scripting idiom to its modern form; the following sections show the Bookshelf page converted.

Table 6. Scripting idioms and their replacements
Scripting Replacement Where it lives

<%= x.getTitle() %>

${x.title} (a record accessor works too: ${b.title} on Book)

Page, EL

<%= request.getParameter("q") %>

${param.q}, escaped with c:out

Page, EL

request.getAttribute("books") and a cast

${books}; the model is already a scoped attribute

Page, EL

<% for (Book b : books) { %> ... <% } %>

c:forEach with var, items and varStatus

JSTL core

<% if (...) { %> ... <% } else { %> ... <% } %>

c:if, or c:choose / c:when / c:otherwise

JSTL core

<% out.println(escapeHtml(s)); %>

<c:out value="${s}"/>

JSTL core

<%= s.toUpperCase() %> and similar helper calls

Functions in the fn library (fn:toUpperCase, fn:length, fn:escapeXml), or an EL function declared in a TLD

JSTL functions, TLD

A helper declared with <%! ... %>

A tag file for markup, an EL function for a pure computation, a service class for logic

WEB-INF/tags, TLD, Java

<%! int hits %> and hits++

A counter in the controller or a service, put in a scope as an attribute

Java

<jsp:include> and <%@ include %> for repeated markup

A tag file

WEB-INF/tags

Database or business calls in the page

A controller and a service, with a model attribute

Java

jspInit and jspDestroy

A ServletContextListener

Java

The EL and JSTL Version

WEB-INF/views/after.jsp: the same page with EL and JSTL
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<!DOCTYPE html>
<html>
<body>
<h1>Bookshelf (EL and JSTL version)</h1>
<p>${fn:length(books)} books.</p>
<ul>
<c:forEach var="b" items="${books}">
  <li>${fn:toUpperCase(b.title)} by <c:out value="${b.author}"/> (${b.year})</li>
</c:forEach>
</ul>
</body>
</html>

curl .../books/after (the default view) returned the same list as the scripting version:

<!DOCTYPE html>
<html>
<body>
<h1>Bookshelf (EL and JSTL version)</h1>
<p>3 books.</p>
<ul>

  <li>DUNE by Frank Herbert (1965)</li>

  <li>NEUROMANCER by William Gibson (1984)</li>

  <li>SOLARIS by Stanislaw Lem (1961)</li>

</ul>
</body>
</html>

There is no declaration, no scriptlet and no cast. ${b.title} works on a Java record on Tomcat 11.0.26, because EL 6.0 resolves record components; on GlassFish 8.0.5 the same expression failed with PropertyNotFoundException for the record (finding recorded on the Getting Started page of this section), so a portable project adds JavaBean-style getters. The <li> line uses fn:toUpperCase where the old page called a declared method.

A Tag File for the Repeated Markup

A tag file is a JSP fragment saved as WEB-INF/tags/<name>.tag and used as a custom tag. It keeps the markup that the declared shout method and the loop body used to share in one reusable place, with typed attributes.

WEB-INF/tags/book-item.tag
<%@ tag body-content="empty" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<%@ attribute name="book" required="true" type="org.example.bookshelf.Book" %>
<%@ attribute name="highlight" required="false" type="java.lang.Boolean" %>
<li><c:if test="${highlight}">* </c:if>${fn:toUpperCase(book.title)} by <c:out value="${book.author}"/> (${book.year})</li>
WEB-INF/views/modern.jsp: EL, JSTL, a tag file and a controller-provided counter
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="b" tagdir="/WEB-INF/tags" %>
<!DOCTYPE html>
<html>
<body>
<h1>Bookshelf (tag file version)</h1>
<p>Page served ${hits} time(s); ${books.size()} books.</p>
<ul>
<c:forEach var="book" items="${books}" varStatus="s">
  <b:book-item book="${book}" highlight="${s.first}"/>
</c:forEach>
</ul>
<c:choose>
  <c:when test="${empty books}"><p>The shelf is empty.</p></c:when>
  <c:otherwise><p>Last on the shelf: ${books[books.size() - 1].title}</p></c:otherwise>
</c:choose>
</body>
</html>

curl .../books/modern produced (blank lines removed):

<h1>Bookshelf (tag file version)</h1>
<p>Page served 1 time(s); 3 books.</p>
<ul>
<li>* DUNE by Frank Herbert (1965)</li>
<li>NEUROMANCER by William Gibson (1984)</li>
<li>SOLARIS by Stanislaw Lem (1961)</li>
</ul>
  <p>Last on the shelf: Solaris</p>

The counter that was a declared field is now hits in the controller, set as a request attribute, and thread-safe because it is an AtomicInteger. A first attempt at the "last" line with a stream lambda and a method reference (books.stream().map(x -> x.year()).min(Integer::compare)) failed at translation with ELException: Failed to parse the expression, because the Tomcat 11.0.26 EL parser rejects the :: method-reference syntax: EL 6.0 has lambdas and the stream API in EL, but not Java method references, which is one more place where "it is Java" misleads. Put such calculations in the controller. The tag file page and the custom tag pages of this section go into attributes, jsp:doBody and fragments.

Finding What Is Left

A conversion needs a to-do list, and a build needs a guard. Because <%@ (directives) and <%-- (comments) are not scripting, a pattern that excludes them lists all scripting elements and the XML forms:

grep -rnE '<%([^@-]|-[^-]|$)|<jsp:(declaration|scriptlet|expression)' src/main/webapp \
  --include='*.jsp' --include='*.jspf' --include='*.jspx' --include='*.tag'

On before.jsp, after.jsp and modern.jsp of this application the command printed exactly the six scripting lines of before.jsp (lines 2, 12, 17, 19, 20 and 21) and nothing for the other two. The pattern also catches request-time attribute values, since they contain <%=. Run in a test or a CI step (! grep ..., or a JUnit test that walks the view folder) it keeps a migrated folder clean. Precompiling the application with Jasper’s JspC (the Tomcat How-To section "Web Application Compilation") finds translation and compile errors in every page at build time instead of at the first request.

A Migration Order That Keeps the Application Running

  1. Put the data the page needs in request attributes in a controller (a servlet, or a Struts or Spring MVC action), so that the page reads the model and never fetches it.

  2. Replace output scriptlets and expressions with EL and c:out, page by page, escaping anything that came from a user.

  3. Replace loops and conditionals with c:forEach, c:if and c:choose.

  4. Replace declared helper methods with tag files, EL functions or service methods.

  5. Move the converted pages into a folder that has scripting-invalid set to true, so that nobody adds code again.

  6. Add the grep check to the build for the folders that are not yet protected.

  7. When the legacy folder is empty, set scripting-invalid on *.jsp.

Pitfalls and Error Names

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

Counter or cached value is wrong under load (observed: 2000 instead of 2002)

A field declared with <%! %> is shared by all threads

No state in declarations; thread-safe types or attributes

JasperException: Unable to compile class for JSP with Syntax error on token ";", delete this token (observed)

A semicolon at the end of an expression

Remove it; an expression is a value, not a statement

Duplicate local variable n (observed)

Two scriptlets of one page declare the same local name

Rename, or scope the variable with braces inside one scriptlet

String literal is not properly closed by a double-quote around %> (observed)

A %> inside a Java string ended the scriptlet

Write %\>, or build the string differently

EL prints nothing for a variable that the scriptlet "has" (observed)

A scriptlet local is not a scoped attribute

pageContext.setAttribute or request.setAttribute

shared cannot be resolved to a variable in an included page (observed)

A jsp:include target is a separate class; only a static include shares locals

Pass the value as an attribute or a jsp:param

Output text is null (observed)

A scripting expression of a null value

Use EL, which prints an empty string, or c:out with default

Script injection in the page (observed)

<%= request.getParameter(...) %> or ${param.x} in template text, unescaped

c:out, fn:escapeXml or an encoder library

Switch pattern, record pattern or other recent syntax fails to compile in a page (observed)

compilerSourceVM defaults to 17 on Tomcat 11

Raise the options on the JSP servlet, or move the code to a class

JasperException: ... Scripting elements ( <%!, ... ) are disallowed here. (observed)

scripting-invalid is true for the page

Convert to EL/JSTL, or use a different folder; do not switch the check off

Validation messages such as JSP page contains 1 scriptlet. (observed)

ScriptFreeTLV, imported by the page through a taglib directive, rejected it

Convert the page; the message does not name the line

[Page directive] has invalid attribute: [isThreadSafe] (observed)

The attribute was removed in Pages 4.0

Delete the attribute; make the state thread-safe

jspInit is never called, or _jspInit is a compile error

The reserved _jsp* names, or the wrong method name

Override jspInit() and jspDestroy() without the underscore

ELException: Failed to parse the expression for Integer::compare (observed)

EL has lambdas but not method references

Use a lambda, or compute in Java

Scripting expression accepted in an attribute although scripting-invalid is true (observed)

Tomcat 11.0.26 does not enforce RTAttributeScriptingEnabled

Import jakarta.tags.scriptfree, or add the grep check

HTTP 500 shows source lines of the page

Jasper includes a source fragment in the error text

An <error-page> and displaySourceFragment off in production

What Changed Recently

Table 8. Changes relevant to scripting elements, from the specification change logs
Version Change

Pages 4.0 (Jakarta EE 11)

Removes the deprecated isThreadSafe page directive attribute (a page that still uses it is rejected on Tomcat 11.0.26, observed); removes the jsp:plugin action and related actions; the minimum Java version is 17; the deprecated classes of jakarta.servlet.jsp.el and JspException.getRootCause are removed. The specification text of 1.12 is unchanged: the three elements still work

Pages 3.1 (Jakarta EE 10)

Deprecates isThreadSafe, because SingleThreadModel was removed in Servlet 6.0; clarifies that the EL environment of a page has the same default imports as the scripting environment; adds the errorOnELNotFound option, which raises PropertyNotFoundException for an unknown EL identifier instead of printing nothing

Pages 3.0 (Jakarta EE 9)

Package move from javax.servlet.jsp to jakarta.servlet.jsp: every scriptlet or page directive import that names a javax.servlet type must change

Pages 2.0

Adds EL and the scripting-invalid configuration element, as the Pages specification text of 1.12 and 3.3.3 states

The Pages 2.0 row is the historical background given by the specification text itself ("JSP 2.0 added EL expressions as an alternative to scripting elements" and "JSP 2.0 made this slightly easier through a JSP configuration element"). The Pages 3.0 row is a consequence of the namespace change, which the What’s Changed & Migration page of this section covers.

Where the Books Are Outdated

The section bibliography lists two books that teach scripting: Web Development with JavaServer Pages (Manning, 2001) is written for JSP 1.2, before EL and JSTL existed; JSTL in Action (Manning, 2002) teaches JSTL 1.0 on JSP 1.2. The concepts, such as the translation model, the scope rules and the thread-safety warning, remain true. The practices do not.

Table 9. Scripting-era material and its modern equivalent
The book’s era Today (Pages 4.0)

Scriptlets, declarations and expressions as the normal way to write a page, with beans and conditionals in Java

Scriptless pages: EL, JSTL and tag files, with scripting-invalid to enforce it

The javax.servlet.jsp package in imports and in scriptlets (javax.servlet.http.* in page import)

jakarta.servlet.jsp and jakarta.servlet.*; Tomcat 10 and later do not run the old names

<%@ page isThreadSafe="false" %> as the cure for shared-field bugs

Removed in Pages 4.0 and rejected by Tomcat 11; no replacement, because SingleThreadModel is gone from Servlet 6.0

JSTL 1.0 with two sets of libraries: EL libraries (c) and request-time libraries (c_rt) whose attributes take <%= %>

JSTL attributes accept EL, and the library URIs are jakarta.tags.core, jakarta.tags.functions and so on. The GlassFish implementation JAR 3.0.1 still contains the old descriptors (META-INF/c-1_0-rt.tld, URI http://java.sun.com/jstl/core_rt), so legacy pages may still load, but new pages should not use them

<%@ taglib uri="http://java.sun.com/jstl/core" prefix="c" %>

<%@ taglib uri="jakarta.tags.core" prefix="c" %>

EL available only inside JSTL tag attributes (JSP 1.2)

EL in template text and in action attributes of every page, unless isELIgnored or the property group turns it off

jsp:plugin for applets, HttpUtils and similar helpers called from scriptlets

Removed (Pages 4.0, Servlet 6.0)

session and out used freely in scripts of every page

Same names, but the default of session="true" makes every JSP create a session (observed: the first response set JSESSIONID); set session="false" on pages that do not need it

The archived Java EE 5 tutorial has the same limits: its chapter 9, "Scripting in JSP Pages", is written for JSP 2.1 and the javax packages, so it remains useful for the concepts and not for imports, namespaces or versions.

References