Character Encoding

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 (chapter 4 "Internationalization Issues" and Appendix B "Page Encoding Detection"), Jakarta Expression Language 6.0, Jakarta Standard Tag Library 3.0 and Jakarta Servlet 6.1 (Jakarta EE 11), as implemented by Apache Tomcat 11.0.26 on Java 21. Everything was verified on 2026-10-08: the rules against the specifications and the Tomcat 11 documentation, and every example by building it into a Maven WAR and running it on Tomcat 11.0.26 with curl; the outputs shown are the outputs of those runs. Encoding is the layer under every other JSP topic: a page that works on a developer machine and prints ñ in production nearly always has one of three settings wrong, and the three are different settings. The page explains each of them, the order in which the container consults them, the two XML configuration styles that set them for a whole application, and the pitfalls that remain. The running scenario is the Bookshelf, whose titles now include Cien años de soledad and a Japanese novel, so that the Latin-1 range and the full Unicode range are both exercised. The Servlet side of the story (the ServletRequest and ServletResponse methods) is summarised in the character encoding part of the Servlet Fundamentals page; this page covers what JSP adds.

Three Encodings, Not One

Pages 4.0 chapter 4 separates four encodings that beginners treat as one. Three of them matter every day, and each is set by a different mechanism and read at a different moment.

Table 1. The encodings Jakarta Pages distinguishes (Pages 4.0, chapter 4)
Encoding What it is Set by Read when

Page character encoding

The encoding of the JSP source file (or tag file) itself, as bytes on disk

A BOM, the page-encoding property, the pageEncoding attribute, the charset of contentType, else ISO-8859-1

At translation time, once per file

Response character encoding

The encoding of the text sent to the browser, the charset of the Content-Type header

The contentType attribute, default-content-type, response.setCharacterEncoding, setLocale, the response-character-encoding element

At request time, until the response is committed

Request character encoding

The encoding used to turn the bytes of form parameters into strings

The client’s Content-Type, request-character-encoding, fmt:requestEncoding, a filter, request.setCharacterEncoding

At request time, before the first parameter is read

XML view character encoding

The encoding of the XML view of a page, used by validators

Fixed: always UTF-8

When a tag library validator runs

The diagram follows one request through the three. The source file is decoded once, when the container translates it; the response is encoded on the way out; the request is decoded on the way in. A mismatch between any two of them produces text that looks plausible but is wrong.

flowchart LR src["JSP source file (bytes on disk)"] -->|"page encoding: decode at translation time"| gen["Generated servlet (Java strings)"] req["Browser request (form parameters)"] -->|"request encoding: decode parameters"| gen gen -->|"response encoding: encode the output"| resp["Response bytes with a charset in Content-Type"] resp --> browser["Browser decodes with the declared charset"] browser --> req

The Java platform itself represents every character as Unicode; the specification states that any Java runtime must support UTF-8, UTF-16BE, UTF-16LE, ISO-8859-1 and US-ASCII, and that encodings are named with the names of the IANA charset registry. Java SE 21 implements Unicode 15.0 (Chapter 4 of the specification still says "As of Java 8, the Unicode 6.2 character set" because it was written for Java 8). The practical consequence is that a character that does not fit the chosen response encoding is replaced by ? when it is written, which is visible in several examples below.

The Bookshelf Used on This Page

All the examples are files of one Maven WAR, g3encoding.war, deployed under the context path /g3encoding on Tomcat 11.0.26 (plus four small variants that differ only in web.xml, named where they appear). The WAR has the same dependencies as Getting Started (provided Servlet, Pages and EL APIs, plus the Jakarta Tags API and its implementation). One servlet, DataServlet, mapped to /data/*, puts a list of Book records in the request and forwards to /e/<name>.jsp, so /data/pageenc runs /e/pageenc.jsp. The data is Unicode strings made from escapes in Java source, so it is always correct inside the JVM; any damage seen in the output is therefore caused by an encoding setting and never by the data.

The servlet that feeds every page, with escapes so that the source file’s own encoding cannot matter
@WebServlet("/data/*")
public class DataServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        List<Book> books = List.of(
                new Book(1, "Cien años de soledad", "Gabriel García Márquez", 1967),
                new Book(2, "吾輩は猫である", "夏目漱石", 1905));
        req.setAttribute("books", books);
        req.getRequestDispatcher("/e" + req.getPathInfo() + ".jsp").forward(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        doGet(req, resp);
    }
}

The first title is Cien años de soledad (the ñ is U+00F1, one byte in ISO-8859-1 and two bytes, C3 B1, in UTF-8). The second is Wagahai wa neko de aru by Natsume Sōseki, whose characters exist only in Unicode and in Japanese encodings. A character like ñ shows whether the page agrees with Latin-1 or UTF-8 and a Japanese title shows whether anything but Unicode would have been enough.

A second fact about the test setup matters for the output shown below. The conf/web.xml that ships in the Tomcat 11 distribution (and in the 10.1.x branch; the 9.0.x branch does not have it) already contains

<request-character-encoding>UTF-8</request-character-encoding>
<response-character-encoding>UTF-8</response-character-encoding>

for every web application, so on a stock Tomcat 11 a page without any encoding setting is answered as UTF-8 and form posts without a charset are read as UTF-8. The specification’s own defaults (ISO-8859-1 for both) are what a container without those two lines, or one that does not set them, still applies. To show both behaviours the examples were run on two Tomcat 11.0.26 instances that share the same binaries: the stock one, and a second instance whose conf/web.xml has those two lines removed. The commands use $B for the context URL of the instance a result came from, and each result is labelled "specification default" or "stock Tomcat 11". The specification default is the safe thing to design for, because the application then behaves the same on every container and in every future upgrade.

Page Character Encoding: Reading the Source File

A JSP is a text file, and the container must know how to turn its bytes into characters before it can find a single directive. The page character encoding is determined for each file separately, even when one file includes another with the include directive (see Directives). For a JSP in standard syntax the container consults, in this order of precedence:

Table 2. Sources of the page character encoding for a JSP page in standard syntax (Pages 4.0, 4.1.1)
Order Source Notes

1

A byte order mark (BOM) at the start of the file

UTF-8 (EF BB BF), UTF-16 and UTF-32 marks are recognised; the BOM never appears in the output

2

The page-encoding element of a jsp-property-group whose url-pattern matches

Must agree with the BOM, if there is one

3

The pageEncoding attribute of the page directive

Translation error if it differs from the property group or the BOM; applies only to the file that contains it

4

The charset of the contentType attribute of the page directive

Used for the source only when neither of the two above exists

5

ISO-8859-1

The default, whatever the container’s response default is

Rows 3 and 4 have a position rule: the container needs to read the directive before it knows the encoding, so the specification asks parsers to look for pageEncoding and contentType only if the directive appears at the beginning of the file, and only if the encoding is an ASCII extension, that is, byte values 0 to 127 mean what they mean in ASCII until the attribute is found. UTF-8 and ISO-8859-1 satisfy that, UTF-16 and EBCDIC encodings do not; for those use a BOM or the page-encoding property. For tag files in standard syntax the sources are a BOM, the pageEncoding attribute of the tag directive and ISO-8859-1. For documents in XML syntax the rules of the XML specification apply (section 4.3.3 and Appendix F.1) and the details are on JSP Documents (XML Syntax); an unsupported encoding is a translation error in both syntaxes.

The first example is a Bookshelf page saved as UTF-8 with no declaration of any kind. The container applies the last row of the table and reads the file as ISO-8859-1; every byte of a file is a valid ISO-8859-1 character, so nothing fails.

/e/none.jsp: UTF-8 source with no encoding declaration (the "before")
<html><body><h1>Cien años de soledad</h1>
<p>${books[0].title} / ${books[1].title}</p></body></html>
The "before" on the specification default: the response claims ISO-8859-1, the Japanese title cannot be written
curl -si $B/data/none | grep -v '^Date\|^Keep\|^Transfer\|^Connection\|Set-Cookie\|^$'
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 95
<html><body><h1>Cien años de soledad</h1>
<p>Cien a�os de soledad / ???????</p></body></html>

Two things are wrong at once. The static text still looks right in a terminal, because its two UTF-8 bytes were read as two Latin-1 characters and written back as the same two bytes, but a browser told charset=ISO-8859-1 will show ñ. The data from the servlet, correct Unicode, was written with a Latin-1 writer: the ñ became the single byte F1 (invalid UTF-8, shown as �) and the seven Japanese characters became ?. On stock Tomcat 11 the response header says UTF-8 instead, and the damage moves into the static text:

The same page on stock Tomcat 11: the data is right, the source text is now the broken part
Content-Type: text/html;charset=UTF-8
<html><body><h1>Cien años de soledad</h1>
<p>Cien años de soledad / 吾輩は猫である</p></body></html>

The fix is the same on both: tell the container how the file is encoded. The pageEncoding attribute does it, and because Pages 4.0 section 4.2 also uses the page encoding as the initial response encoding for a standard-syntax page, the response becomes UTF-8 as well.

/e/pageenc.jsp: the file is UTF-8 and says so
<%@ page pageEncoding="UTF-8" %>
<html><body><h1>Cien años de soledad</h1>
<p>${books[0].title} / ${books[1].title}</p></body></html>
Content-Type: text/html;charset=UTF-8
<html><body><h1>Cien años de soledad</h1>
<p>Cien años de soledad / 吾輩は猫である</p></body></html>

The contentType attribute works as well for the source (row 4 of the table) when it carries a charset, which is why the most common JSP header in the wild, contentType="text/html;charset=UTF-8", "just works" for files saved as UTF-8:

/e/ctype.jsp: charset in contentType also decodes the source (same output as above)
<%@ page contentType="text/html;charset=UTF-8" %>
<html><body><h1>Cien años de soledad</h1>
<p>${books[0].title} / ${books[1].title}</p></body></html>

When both attributes are present pageEncoding wins for reading the file, and contentType supplies the response. That makes a mismatch possible, and it is the mismatch that produces the classic double-encoded text, años:

/e/both-mismatch.jsp: the file is UTF-8 but declared ISO-8859-1 for the source
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="ISO-8859-1" %>
<html><body><h1>Cien años de soledad</h1>
<p>${books[0].title} / ${books[1].title}</p></body></html>
Content-Type: text/html;charset=UTF-8
<html><body><h1>Cien años de soledad</h1>
<p>Cien años de soledad / 吾輩は猫である</p></body></html>

The source was decoded as Latin-1 (two characters, Ã and ±) and written as UTF-8 (four bytes). The data from the servlet is intact, which is the tell-tale sign that the problem is in the source file’s declaration and not in the request or the database. The reverse mismatch (pageEncoding="UTF-8" with contentType="text/html;charset=ISO-8859-1") reads the source correctly but writes ? for the Japanese title and a single F1 byte for ñ, which a browser then interprets according to the header; it is legal and rarely what anyone wants.

Rules and errors for the page directive attributes

Several rules are enforced when the page is translated, so the failure is an immediate JasperException and never a subtly wrong page. All the messages below were observed on Tomcat 11.0.26 as the Message line of the HTTP 500 report.

Table 3. Translation-time errors caused by encoding declarations (observed on Tomcat 11.0.26)
Page or configuration Rule Observed message

<%@ page pageEncoding="NOT-A-CHARSET" %>

An unsupported page encoding is a translation error

org.apache.jasper.JasperException: Unsupported encoding: [NOT-A-CHARSET]

Two page directives, each with pageEncoding

pageEncoding can occur at most once per file

/e/twice.jsp (line: [2], column: [1]) Page directive must not have multiple occurrences of pageencoding

pageEncoding="ISO-8859-1" in a page that a property group declares as UTF-8

Different encodings in the attribute and in a matching page-encoding are an error; naming the same encoding twice is legal

/pgconflict/page.jsp (line: [1], column: [2]) Page-encoding specified in jsp-property-group [UTF-8] is different from that specified in page directive [ISO-8859-1]

A UTF-8 BOM plus pageEncoding="ISO-8859-1"

A BOM and a declaration must name the same encoding

/bom/bom-conflict.jsp (line: [1], column: [2]) Page-encoding specified in XML prolog [UTF-8] is different from that specified in page directive [ISO-8859-1]

Two details of that table are worth remembering. The specification’s text about duplicates is contradictory: the general sentence says import and pageEncoding "are exempt" from the one-occurrence rule, and the attribute table says pageEncoding "can occur at most once per file"; Tomcat enforces the second. And Tomcat words the BOM conflict as an "XML prolog" conflict, because it reuses one internal check for both a BOM and an XML declaration; the cause is the BOM.

Response Character Encoding and contentType

The response character encoding is the charset the browser is told to decode the page with, and it is primarily the characterEncoding property of the ServletResponse. Before it runs a page the container calls ServletResponse.setContentType() with an initial type and encoding, derived as follows.

Table 4. How the initial response content type and encoding are derived (Pages 4.0, 4.2)
Item Rule

Initial content type

The type of the contentType attribute; if absent, the default-content-type of a matching property group; if absent, text/html for a JSP page in standard syntax and text/xml for a document in XML syntax

Initial encoding when contentType has a charset

That charset

Initial encoding without a charset, standard syntax

The encoding named by the BOM, by pageEncoding or by a matching page-encoding; only the requested page counts, not files it includes with the include directive

Initial encoding without a charset, XML syntax

UTF-8

Nothing specified anywhere

No encoding is passed to setContentType, so the ServletResponse default applies: ISO-8859-1 (or whatever response-character-encoding set)

That rule explains an observation worth a second look: a page that says only contentType="text/plain" still answers in UTF-8 when its file is declared UTF-8 by a property group, because the page encoding is the fallback for the response.

After the initial value the page can change the encoding with response.setCharacterEncoding and response.setLocale, directly or through JSTL actions, but only until the response is committed or the writer is obtained. For a buffered page (the default, 8 KB at least) "committed" means the buffer was flushed; for an unbuffered page (buffer="none") it means the first byte of content, including a newline after a directive. The three pages below make the timing visible by switching to ISO-8859-1 after writing ñ:

Three pages that call setCharacterEncoding("ISO-8859-1") at different moments
<%-- /e/change-buffered.jsp: before any output, on a buffered page --%>
<%@ page pageEncoding="UTF-8" %><% response.setCharacterEncoding("ISO-8859-1"); %>
<html><body><p>Cien años de soledad</p></body></html>

<%-- /e/change-unbuffered.jsp: after the first output, no buffer --%>
<%@ page pageEncoding="UTF-8" buffer="none" %>
<html><body><p>Cien años de soledad</p>
<% response.setCharacterEncoding("ISO-8859-1"); %>
</body></html>

<%-- /e/change-flushed.jsp: after out.flush() on a buffered page --%>
<%@ page pageEncoding="UTF-8" %>
<html><body><p>Cien años de soledad</p>
<% out.flush(); response.setCharacterEncoding("ISO-8859-1"); %>
</body></html>
Header and the bytes written for ñ (specification default)
for n in change-buffered change-unbuffered change-flushed; do
  curl -si $B/data/$n | grep Content-Type; curl -s $B/data/$n | xxd | grep -i 'c3\|f1'
done
Content-Type: text/html;charset=ISO-8859-1
00000010: 3c70 3e43 6965 6e20 61f1 6f73 2064 6520  <p>Cien a.os de
Content-Type: text/html;charset=UTF-8
00000010: 3c70 3e43 6965 6e20 61c3 b16f 7320 6465  <p>Cien a..os de
Content-Type: text/html;charset=UTF-8
00000010: 3c70 3e43 6965 6e20 61c3 b16f 7320 6465  <p>Cien a..os de

Only the first page changed anything: the single byte F1 is Latin-1 ñ. In the other two the call was silently ignored, because the writer already existed or the response was committed, and the header stayed UTF-8. No exception is raised, so the page looks fine on the developer machine and breaks when output size or buffering changes. The Servlet specification states the rule: calls after getWriter or after commit have no effect, setContentType changes the encoding only when the string has a charset, and setLocale changes it only when neither setCharacterEncoding nor setContentType did before. The specification of Pages adds a warning that applies to every example here: "beware of whitespace". A blank line after a directive on an unbuffered page is content, and sends the response. The trimDirectiveWhitespaces attribute (and the trim-directive-whitespaces property) removes template text that consists only of whitespace.

Locale and the response encoding

The JSTL internationalization actions (JSTL i18n & Messages) set the response locale (fmt:setLocale, fmt:setBundle, fmt:bundle, and fmt:message when it establishes a localization context), and ServletResponse.setLocale may also set the encoding through a locale to encoding mapping. The JSTL specification says it plainly: unless an encoding was explicitly defined by other JSP elements or by direct Servlet calls, calling setLocale also sets the response encoding, and it assumes a buffer big enough that setLocale runs before getWriter. The mapping comes from locale-encoding-mapping-list in web.xml, otherwise it is container-dependent. Observed on the specification-default instance:

/e/locale.jsp and /e/locale-fixed.jsp: a Japanese locale does not choose UTF-8 by itself
<%-- locale.jsp: no encoding declared --%>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<fmt:setLocale value="ja_JP"/>
<html><body><p>locale=${pageContext.response.locale} enc=${pageContext.response.characterEncoding}</p></body></html>

<%-- locale-fixed.jsp: the same page with contentType --%>
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<fmt:setLocale value="ja_JP"/>
<html><body><p>locale=${pageContext.response.locale} enc=${pageContext.response.characterEncoding}</p></body></html>
Content-Type: text/html;charset=ISO-8859-1
Content-Language: ja-JP
<html><body><p>locale=ja_JP enc=ISO-8859-1</p></body></html>
Content-Type: text/html;charset=UTF-8
Content-Language: ja-JP
<html><body><p>locale=ja_JP enc=UTF-8</p></body></html>

Tomcat 11.0.26 has no built-in mapping for ja (the response stays ISO-8859-1), so a localized page that does not declare its encoding sends Content-Language: ja-JP together with a Latin-1 charset. Declaring UTF-8 once (or one of the web.xml settings below) is the answer; adding a locale mapping to web.xml works too and was verified on a variant:

web.xml of the variant /g3encoding3: a locale mapping and the Tomcat encoding filter
<locale-encoding-mapping-list>
    <locale-encoding-mapping><locale>ja</locale><encoding>UTF-8</encoding></locale-encoding-mapping>
</locale-encoding-mapping-list>

With that element /g3encoding3/data/locale answered enc=UTF-8. The details of localization (resource bundles, number and date formatting, fmt:message) are on JSTL i18n & Messages; the point here is only that those actions touch the response encoding as a side effect.

BOM Detection and the Detection Algorithm

A byte order mark is the Unicode character U+FEFF placed at the start of a file. The specification reserves the byte sequences of table JSP.4-1 to identify the encoding of a JSP page in standard syntax, and they never appear in the output.

Table 5. Byte order marks recognised by Jakarta Pages (Pages 4.0, table JSP.4-1)
Bytes Encoding form

FE FF

UTF-16, big-endian

FF FE

UTF-16, little-endian

00 00 FE FF

UTF-32, big-endian

FF FE 00 00

UTF-32, little-endian

EF BB BF

UTF-8

A BOM is the most reliable declaration there is, because it needs no ASCII-compatible prefix and cannot be forgotten by a page author. The cost is that a UTF-8 BOM is not recommended by the Unicode Consortium, is invisible in most editors, and breaks tools that expect #! or <?xml at byte zero; many teams therefore prefer pageEncoding or a property group over a BOM. With a BOM the declaration is optional, and legal as long as it agrees:

Four files that differ only in the BOM and the declaration (specification default)
printf '\xEF\xBB\xBF' > bom.jsp; cat >> bom.jsp <<'EOF'
<html><body><h1>Cien años de soledad</h1></body></html>
EOF
curl -si $B/bom/bom.jsp | grep Content-Type; curl -s $B/bom/bom.jsp | xxd | head -1
curl -si $B/bom/nobom.jsp | grep Content-Type
curl -si $B/bom/bom-same.jsp | grep Content-Type
curl -so /dev/null -w '%{http_code}\n' $B/bom/bom-conflict.jsp
Content-Type: text/html;charset=UTF-8
00000000: 3c68 746d 6c3e 3c62 6f64 793e 3c68 313e  <html><body><h1>
Content-Type: text/html;charset=ISO-8859-1
Content-Type: text/html;charset=UTF-8
500

The file with the BOM and no declaration is read and answered as UTF-8, the first output byte is 3C (<) so the BOM is not sent to the browser, an identical file without the BOM falls back to ISO-8859-1, a BOM plus pageEncoding="UTF-8" agrees, and a BOM plus pageEncoding="ISO-8859-1" is the 500 error shown in the table above.

Appendix B turns all of the previous rules into one algorithm that containers must reproduce (an optimised version is allowed if it detects the same encoding in every case). In outline, for a file in standard syntax:

flowchart TD start["Source file"] --> xml{"JSP document? is-xml, .jspx or a root element jsp:root"} xml -->|"yes"| xmlrule["XML rules: prolog or initial bytes, check page-encoding and pageEncoding for conflicts"] xml -->|"no"| bom{"BOM at the start?"} bom -->|"yes"| bomrule["Use the BOM encoding; error if page-encoding or pageEncoding disagrees"] bom -->|"no"| pe{"page-encoding property matches the file?"} pe -->|"yes"| pecheck["Use it; error if pageEncoding names a different one"] pe -->|"no"| attr{"pageEncoding attribute?"} attr -->|"yes"| useattr["Use pageEncoding"] attr -->|"no"| ct{"charset in the contentType attribute?"} ct -->|"yes"| usect["Use that charset"] ct -->|"no"| latin["ISO-8859-1"]

Three consequences of the algorithm are not obvious. First, the property group wins over the attribute only in the sense that a disagreement is an error, so there is no "override": the two must match. Second, the container looks for the attributes with a provisional encoding (ASCII-compatible, ISO-8859-1 style) and then re-reads the whole file with the final one, which is why a Shift_JIS or UTF-8 page can say pageEncoding in ASCII letters and still use non-ASCII characters afterwards. Third, tag files have their own, shorter algorithm (BOM, pageEncoding of the tag directive, ISO-8859-1) that has no property group, because jsp-property-group applies to JSP pages; tag files are covered on Tag Files & Layouts.

Declaring Encodings for Many Pages: page-encoding and default-content-type

Writing pageEncoding in every page is exactly the kind of repetition that gets forgotten. The jsp-config section of web.xml can set the page encoding, and the default content type, for every page matching a URL pattern. Both elements are sub-elements of jsp-property-group; the full list of its sub-elements and the precedence rules between several matching groups are on Configuration and Property Groups.

Table 6. Elements of jsp-property-group that deal with encoding (Pages 4.0, 3.3.5 and 3.3.10)
Element Meaning Valid values

page-encoding

Sets the page character encoding (the pageEncoding property) for the matching pages and included segments; needed only for standard syntax

Those of the pageEncoding attribute: IANA charset names

default-content-type

Sets the contentType default for the matching pages; used when the page has no contentType attribute

Those of the contentType attribute, with or without charset

trim-directive-whitespaces

Removes template text that is only whitespace, so a leading newline after a directive does not commit the response

true or false

The first example is the one the specification itself shows, assigning Shift_JIS to a directory of pages. The Bookshelf’s Japanese edition lives in /ja and its files are saved in Shift_JIS, which a modern editor can do and a modern team usually should not; it is the correct demonstration that the declaration decides how the bytes are read, and not the other way round.

web.xml: three groups of the example application
<jsp-config>
    <jsp-property-group>
        <url-pattern>/ja/*</url-pattern>
        <page-encoding>Shift_JIS</page-encoding>
    </jsp-property-group>
    <jsp-property-group>
        <url-pattern>/pg/*</url-pattern>
        <page-encoding>UTF-8</page-encoding>
        <default-content-type>text/html;charset=UTF-8</default-content-type>
    </jsp-property-group>
    <jsp-property-group>
        <url-pattern>/pgconflict/*</url-pattern>
        <page-encoding>UTF-8</page-encoding>
    </jsp-property-group>
</jsp-config>
/ja/title.jsp (a file saved as Shift_JIS, with no declaration in the page) and /pg/plain.jsp
<html><body><h1>吾輩は猫である</h1><p>夏目漱石</p></body></html>

<%-- /pg/plain.jsp, a UTF-8 file: only the MIME type is declared --%>
<%@ page contentType="text/plain" %>
Cien años de soledad
curl -si $B/ja/title.jsp | grep Content-Type
curl -s $B/ja/title.jsp | iconv -f SHIFT_JIS -t UTF-8
curl -si $B/pg/plain.jsp | grep Content-Type
Content-Type: text/html;charset=Shift_JIS
<html><body><h1>吾輩は猫である</h1><p>夏目漱石</p></body></html>
Content-Type: text/plain;charset=UTF-8

The response of /ja/title.jsp is Shift_JIS as well (the property group is the page encoding, and the page encoding is the initial response encoding), which a browser decodes correctly; piped through iconv the bytes turn back into the Japanese text. The page /pg/plain.jsp declares only text/plain and still answers charset=UTF-8, which is the "page encoding is the response fallback" rule at work. A page that disagrees with its group, such as /pgconflict/page.jsp in the table of errors above, fails to translate.

A group can use any URL pattern the Servlet mapping rules allow: an exact path, a prefix pattern (/ja/*), an extension pattern (*.jsp) and several url-pattern elements in one group. A pattern such as /WEB-INF/views/*.jsp that mixes a prefix and an extension is rejected at deployment ("Invalid <url-pattern>"). The most useful group in a modern application is also the shortest: one that names every file the container treats as a JSP, including the fragments, because fragments are the files most often left without a declaration.

web.xml of the variant /g3encoding5: UTF-8 for every page and every fragment
<jsp-config>
    <jsp-property-group>
        <url-pattern>*.jsp</url-pattern>
        <url-pattern>*.jspf</url-pattern>
        <page-encoding>UTF-8</page-encoding>
    </jsp-property-group>
</jsp-config>

On that variant the undeclared /e/none.jsp of the first example answers Content-Type: text/html;charset=UTF-8 and its static text and data are both right, and the fragment problem of the next section disappears as well.

Included Files Have Their Own Encoding

The page character encoding is determined for each file, even when the include directive merges the file into the including page at translation time. The pageEncoding of the including page does not flow into the fragment, and the fragment’s own default is ISO-8859-1. A footer fragment saved as UTF-8 and included from a UTF-8 page is therefore decoded as Latin-1 unless something tells the container otherwise:

A UTF-8 fragment included from a UTF-8 page, with and without a declaration in the fragment
<%-- /e/footer-nodecl.jspf (UTF-8 bytes, no declaration) --%>
<p>Año de publicación</p>

<%-- /e/footer-decl.jspf (UTF-8 bytes, declares its encoding) --%>
<%@ page pageEncoding="UTF-8" %>
<p>Año de publicación</p>

<%-- /e/inc-nodecl.jsp and /e/inc-decl.jsp: the same including page, pointing to one fragment each --%>
<%@ page pageEncoding="UTF-8" %>
<html><body><h1>Cien años de soledad</h1>
<%@ include file="footer-nodecl.jspf" %>
</body></html>
curl -s $B/data/inc-nodecl
curl -s $B/data/inc-decl
<html><body><h1>Cien años de soledad</h1>
<p>Año de publicación</p>

</body></html>
<html><body><h1>Cien años de soledad</h1>

<p>Año de publicación</p>

</body></html>

The first page prints the fragment as Año de publicación although the page around it is correct. The two remedies are a page directive with pageEncoding at the top of the fragment (which is legal in a fragment and applies to that file only) or the *.jspf pattern of the property group above; the second needs no per-file discipline. The reverse rule also holds: the response encoding of the including page is used, and encodings of included files are not consulted for it. A dynamic include (jsp:include, covered on Includes, Forwards & Page Composition) is a separate request dispatch of a complete resource, which is translated with its own page encoding and written through the same response; it cannot change the response encoding once the including page has started writing.

Request Character Encoding: Form Parameters

The request character encoding is the encoding in which parameters of an incoming request are interpreted, managed as the characterEncoding property of the ServletRequest. The JSP specification does not offer its own mechanism ("The JSP specification doesn’t provide functionality to handle the request character encoding directly"); a page without embedded Java uses the JSTL action fmt:requestEncoding, and an application uses the web.xml element or a filter.

A browser that submits a form with method="post" sends the body in the encoding of the page that contained the form, and almost never includes charset in its Content-Type, which is why the container needs a default: the Servlet specification says application/x-www-form-urlencoded without a charset is parsed as US-ASCII with %nn values read as ISO-8859-1, and any other body without a charset as ISO-8859-1. The Bookshelf’s search form posts Cien años de soledad to a page that prints ${param.title} (through c:out, which escapes the markup characters but leaves the decoded text as it is):

/e/form.jsp and /e/search.jsp (the "before": nothing configures the request encoding)
<%-- form.jsp --%>
<%@ page contentType="text/html;charset=UTF-8" %>
<html><body>
<form method="post" action="${pageContext.request.contextPath}/data/search">
  <input name="title" value="Cien años de soledad">
  <button>Search</button>
</form>
</body></html>

<%-- search.jsp --%>
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<html><body><p>title=<c:out value="${param.title}"/></p></body></html>
A POST as a browser sends it (no charset), on the specification default and on stock Tomcat 11
curl -s --data-urlencode 'title=Cien años de soledad' $B/data/search | grep title
<html><body><p>title=Cien años de soledad</p></body></html>      (specification default)
<html><body><p>title=Cien años de soledad</p></body></html>      (stock Tomcat 11)

The same bytes are decoded as Latin-1 on the specification default, giving años, and as UTF-8 on stock Tomcat 11, whose conf/web.xml sets the request encoding. The next three sections are the three ways to make the application independent of that default, in the order recommended.

request-character-encoding and response-character-encoding in web.xml

Two elements of web.xml (present in the web-app 4.0 schema and in every later one) set the default encodings for the whole web application, and Servlet 6.1 documents them in sections 3.13 and 5.6. They apply to servlets, filters and JSP pages alike, are read before any user code runs, and are the lowest-effort fix:

web.xml of the variant /g3encoding2
<request-character-encoding>UTF-8</request-character-encoding>
<response-character-encoding>UTF-8</response-character-encoding>

On that variant the same POST answers title=Cien años de soledad, and the undeclared /e/none.jsp answers Content-Type: text/html;charset=UTF-8. Note what the elements do not do: they do not change how the JSP source is read. /e/none.jsp is still decoded as ISO-8859-1 (its static ñ is still wrong in the browser, as in the stock Tomcat 11 output above), so the page encoding still has to be declared by a BOM, pageEncoding or a property group. The default request encoding is also overridden by an explicit charset sent by the client: a client that declares ISO-8859-1 is believed.

A client that declares the wrong charset wins, on the variant that sets UTF-8 by default
curl -s --data-binary 'title=Cien%20a%C3%B1os' \
  -H 'Content-Type: application/x-www-form-urlencoded; charset=ISO-8859-1' http://localhost:18381/g3encoding2/data/search | grep title
<html><body><p>title=Cien años</p></body></html>

fmt:requestEncoding

The JSTL action sets the request encoding from inside a page, for the case where the encoding is a property of the page (or even of the user’s locale) and not of the application. Its single attribute value takes a charset name. When the encoding of the form is decided dynamically (for example from the user’s locale), the attribute must be omitted, and the action then looks for a charset in the request Content-Type, then for the session-scoped variable jakarta.servlet.jsp.jstl.fmt.request.charset, and finally falls back to ISO-8859-1. The specification says why the action exists: "most browsers do not follow the HTTP specification and fail to include a Content-Type header in their requests", and what it does: it calls setCharacterEncoding() on the request and "must be used before any parameters are retrieved, either explicitly or through the use of an Expression Language expression". The second pair of examples shows the rule:

/e/search-fmt.jsp and /e/search-late.jsp
<%-- search-fmt.jsp: the action comes before any parameter --%>
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<fmt:requestEncoding value="UTF-8"/>
<html><body><p>title=<c:out value="${param.title}"/></p></body></html>

<%-- search-late.jsp: a parameter is read first --%>
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<html><body><p>before=<c:out value="${param.title}"/></p>
<fmt:requestEncoding value="UTF-8"/>
<p>after=<c:out value="${param.title}"/></p></body></html>
<html><body><p>title=Cien años de soledad</p></body></html>
<html><body><p>before=Cien años de soledad</p>
<p>after=Cien años de soledad</p></body></html>

On the specification default the first page decodes correctly. In the second, the first read of ${param.title} parses all the parameters using the default encoding, and the later fmt:requestEncoding changes nothing: even the second read, after the action, shows the same damaged value, because the parameters are parsed once. The call has to be the first thing in the page and in the request, which in practice means it cannot work if a filter, a listener, a tag in an earlier include or an EL expression in the including page touched the parameters first. The action solves a per-page problem; the web.xml element solves the application-wide one. Because the action calls setCharacterEncoding, an explicit value overrides the application default for that request, provided no parameter was read before it.

An encoding filter

Before request-character-encoding existed, every application installed a filter that called request.setCharacterEncoding("UTF-8"), and many still do. Tomcat ships one, org.apache.catalina.filters.SetCharacterEncodingFilter, whose encoding parameter names the charset and whose ignore parameter decides whether the charset sent by the client is ignored (true: always forced) or respected (false, the default: set only when the client sent none). Its documentation points out the one thing that makes filters fragile: the value is applied when parameters are parsed later than the filter, "thus the order of filter mappings is important". The Bookshelf’s variant /g3encoding3 uses it:

web.xml of the variant /g3encoding3 (Tomcat-specific; the filter class is in the container, not in the WAR)
<filter>
    <filter-name>setEncoding</filter-name>
    <filter-class>org.apache.catalina.filters.SetCharacterEncodingFilter</filter-class>
    <init-param><param-name>encoding</param-name><param-value>UTF-8</param-value></init-param>
    <init-param><param-name>ignore</param-name><param-value>false</param-value></init-param>
</filter>
<filter-mapping><filter-name>setEncoding</filter-name><url-pattern>/*</url-pattern></filter-mapping>

The variant /g3encoding4 declares a small filter ahead of it that reads a parameter, as an audit or logging filter often does:

PeekFilter, declared before the encoding filter in web.xml (the pitfall)
public class PeekFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
            throws IOException, ServletException {
        req.getParameter("title");   // first parameter access fixes the decoding
        chain.doFilter(req, resp);
    }
}
The same POST against the three variants (specification default instance)
for c in g3encoding2 g3encoding3 g3encoding4; do
  curl -s --data-urlencode 'title=Cien años de soledad' http://localhost:18381/$c/data/search | grep title
done
<html><body><p>title=Cien años de soledad</p></body></html>      (/g3encoding2: web.xml element)
<html><body><p>title=Cien años de soledad</p></body></html>      (/g3encoding3: Tomcat filter first)
<html><body><p>title=Cien años de soledad</p></body></html>      (/g3encoding4: PeekFilter ran first)

A portable filter is a few lines (Servlet Fundamentals shows one). The web.xml element is simpler and has no ordering problem; use a filter only when the encoding has to be chosen per request, for example from a path or a header.

Query strings are not decoded by the request encoding

Parameters in the URL, as in /data/search?title=Cien%20a%C3%B1os%20de%20soledad, are decoded by the connector with its URIEncoding attribute, UTF-8 by default in Tomcat 11, and not with the request encoding: the Tomcat documentation says useBodyEncodingForURI (default false) exists "for compatibility with Tomcat 4.1.x" and applies to the query string only, not to the path. The same request answered title=Cien años de soledad on the specification default instance even though the body encoding there is ISO-8859-1, which is the right design: URLs are UTF-8 on the modern web, and JSP code should not try to re-decode them with new String(s.getBytes("ISO-8859-1"), "UTF-8"), a workaround that appears in older tutorials and corrupts correctly decoded values.

UTF-8 Everywhere: a Checklist

One encoding for every layer is the only configuration that does not produce a combination to debug. UTF-8 is the encoding of the web (the HTML Standard requires the encoding of a document to be UTF-8), of JSON (RFC 8259), of the default charset since JDK 18 (JEP 400), and of the URI decoding of Tomcat’s connector. The settings that make a Bookshelf application UTF-8 end to end, with the layer each one covers:

Table 7. UTF-8 end to end: where each layer is configured
Layer Setting Covers

Java source and resources (Maven)

<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

javac reading .java files and resource filtering

JSP source files

A BOM, pageEncoding="UTF-8", or <page-encoding>UTF-8</page-encoding> on *.jsp and *.jspf; save the files as UTF-8 in the editor

The page character encoding

Generated Java for JSP (Jasper)

javaEncoding init-parameter of the JSP servlet, default UTF8

The generated .java file only; does not affect the page, response or request

Response

contentType="text/html;charset=UTF-8", default-content-type, or <response-character-encoding>UTF-8</response-character-encoding>

The Content-Type header and the writer

Request body

<request-character-encoding>UTF-8</request-character-encoding>

Form parameters

URL path and query string

Connector URIEncoding (UTF-8 by default in Tomcat 11)

Path and query parameters

HTML

<meta charset="utf-8"> in the first 1024 bytes of head, matching the header; accept-charset on forms only when it must differ

Offline copies of the page and defence against a missing header

Non-JSP resources

The fileEncoding init-parameter of Tomcat’s default servlet for static text files, and the charset of JSON and CSV responses

Everything that is not a JSP

Database and message brokers

The connection and column encodings of the data layer (see the data pages of this site)

Persistence; outside this page

A <meta charset> element is a second declaration, not a replacement. Browsers choose the encoding by the HTML encoding sniffing algorithm: a BOM first, then a user override, then the charset of the HTTP Content-Type, and only then a meta element, so a meta that contradicts the header is ignored, and a header that is missing makes the meta the deciding vote. That is why the response header is the declaration that matters, and why the mismatch between a UTF-8 header and an ISO-8859-1 meta copied from an old template is harmless but confusing.

Non-HTML Content: MIME Types, charset and Whitespace

contentType sets the MIME type of the response as well as the charset, and a JSP is a good generator for any text format: CSV exports, CSS and JavaScript with server-side values, JSON, XML, plain text. The attribute is static, so every request to the page has the same type. For a type that depends on the request, call response.setContentType before any output, or (better) select the format in a controller servlet and forward to one JSP per format. What changes with a non-HTML type is that nothing is forgiving any more: a stray blank line, a missing charset or an HTML escape in the wrong place makes a parser fail.

Table 8. MIME types the Bookshelf serves from JSP, and how they are produced
Format contentType Notes

Spreadsheet export

text/csv;charset=UTF-8

RFC 4180 describes text/csv; add Content-Disposition: attachment to download; spreadsheets often need a BOM to treat the file as UTF-8, an application decision

Server-generated CSS

text/css;charset=UTF-8

Prefer static CSS; a JSP is justified for theme colours; escape values that come from users

JSON

application/json

RFC 8259 says JSON is UTF-8 and defines no charset parameter; Tomcat appends ;charset= anyway (see the example)

XML

text/xml;charset=UTF-8 or application/xml

<?xml must be the first bytes of the response

Plain text

text/plain;charset=UTF-8

A debugging view, a robots file, a log extract

Code generation

text/javascript;charset=UTF-8

Same escaping concerns as CSS; never emit user data into a script without escaping for JavaScript

The CSV export and the CSS file are the two simplest examples. Both use trimDirectiveWhitespaces="true" so that the blank lines left by the directives do not become the first line of the file.

/e/books-csv.jsp and /e/style-css.jsp
<%-- books-csv.jsp --%>
<%@ page contentType="text/csv;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<% response.setHeader("Content-Disposition", "attachment; filename=\"books.csv\""); %>
id,title,author
<c:forEach var="b" items="${books}">${b.id},"${b.title}","${b.author}"
</c:forEach>

<%-- style-css.jsp --%>
<%@ page contentType="text/css;charset=UTF-8" trimDirectiveWhitespaces="true" %>
<% String accent = "#2a5d9f"; %>
h1::before { content: "\00a7 "; color: <%= accent %>; }
curl -si $B/data/books-csv | grep -v '^Date\|^Keep\|^Transfer\|^Connection\|Set-Cookie\|^$'
curl -si $B/data/style-css | grep -v '^Date\|^Keep\|^Transfer\|^Connection\|Set-Cookie\|^$'
HTTP/1.1 200
Content-Disposition: attachment; filename="books.csv"
Content-Type: text/csv;charset=UTF-8
Content-Length: 111
id,title,author
1,"Cien años de soledad","Gabriel García Márquez"
2,"吾輩は猫である","夏目漱石"
HTTP/1.1 200
Content-Type: text/css;charset=UTF-8
Content-Length: 51
h1::before { content: "\00a7 "; color: #2a5d9f; }

The scriptlets are the labelled legacy part of those two examples; Scripting Elements (Legacy) shows how to replace them with EL and tag files. Where the format is structured, the parser is the judge. The XML page below fails in the "before" form, and xmllint reports it:

/e/books-xml-bad.jsp versus /e/books-xml.jsp: the same page with and without trimDirectiveWhitespaces
<%@ page contentType="text/xml;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<?xml version="1.0" encoding="UTF-8"?>
<books><c:forEach var="b" items="${books}"><book id="${b.id}"><c:out value="${b.title}"/></book></c:forEach></books>
curl -s $B/data/books-xml-bad | xmllint --noout -
curl -s $B/data/books-xml     | xmllint --noout -  && echo well-formed
-:3: parser error : XML declaration allowed only at the start of the document
<?xml version="1.0" encoding="UTF-8"?>
     ^
well-formed

The "bad" page differs from the good one only by the missing trimDirectiveWhitespaces="true": the newlines after the two directives were emitted before <?xml. The title is written with c:out, which escapes the five XML special characters, so a title such as Dombey & Son keeps the document well-formed; a plain ${b.title} would emit a bare & and break it (the numeric id needs no escaping). A JSP document in XML syntax avoids the problem by construction, and that is one of the arguments for it on JSP Documents (XML Syntax).

The JSON page shows two other points, the charset Tomcat adds to a type that does not need one and the way a character outside Latin-1 is lost when the page does not declare its encoding:

/e/json.jsp, a JSON response whose page is not declared as UTF-8 (specification default)
<%@ page contentType="application/json" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
[<c:forEach var="b" items="${books}" varStatus="s">{"id":${b.id},"title":"${b.title}"}${s.last ? '' : ','}</c:forEach>]
Content-Type: application/json;charset=ISO-8859-1
[{"id":1,"title":"Cien a�os de soledad"},{"id":2,"title":"???????"}]

The Japanese title became seven question marks, because the writer was Latin-1: data was lost, not mis-displayed, and no later setting can restore it. The JSON is also invalid UTF-8 for the first title. Adding charset=UTF-8 to contentType, or the response encoding default of web.xml, fixes it. The page also writes the title without any escaping, so it is only safe for trusted values: a title containing a double quote or a backslash breaks the JSON, and a value from a user could inject members into it. c:out and fn:escapeXml do not help, because they escape for XML (a quote becomes &#034;), not for JSON. A real application builds JSON with a JSON library (Jakarta JSON Binding, Jackson) in a servlet and not by concatenation in a JSP; the example is about encoding, not about JSON.

Client detection and content negotiation

Older JSP books detect the client by reading the User-Agent header and sending different markup or types to different browsers. That practice is obsolete: user agents lie, and HTTP has a proper mechanism, content negotiation with the Accept header (RFC 9110 section 12), plus Vary: Accept so caches keep the variants apart. A JSP can implement it with EL method calls on the response, which must run before any output: one sets Vary, and one per branch sets the type. The JSON branch has the same escaping caveat as the JSON page above.

/e/negotiate.jsp: the same title as JSON or as plain text depending on Accept
<%@ page pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
${pageContext.response.setHeader('Vary', 'Accept')}
<c:choose>
  <c:when test="${header.accept.contains('application/json')}">${pageContext.response.setContentType('application/json;charset=UTF-8')}[{"title":"${books[0].title}"}]</c:when>
  <c:otherwise>${pageContext.response.setContentType('text/plain;charset=UTF-8')}${books[0].title}</c:otherwise>
</c:choose>
curl -si -H 'Accept: application/json' $B/data/negotiate | grep -v '^Date\|^Keep\|^Transfer\|^Connection\|Set-Cookie'
curl -si -H 'Accept: */*' $B/data/negotiate | grep -v '^Date\|^Keep\|^Transfer\|^Connection\|Set-Cookie'
Vary: Accept
Content-Type: application/json;charset=UTF-8
[{"title":"Cien años de soledad"}]
Vary: Accept
Content-Type: text/plain;charset=UTF-8
Cien años de soledad

In a real application the negotiation belongs in the controller (Servlet Fundamentals does it with a servlet, which also adds Vary: Accept); the example shows that the type and the encoding can be chosen per request from a JSP as long as nothing was written yet.

Security: Declare the Charset

A response without a charset is a security problem as well as a correctness one. Tomcat’s documentation of AddDefaultCharsetFilter explains it: browsers may attempt to auto-detect the character set of a text response that does not declare one, and "this may be exploited by an attacker to perform an XSS attack". The filter sets an explicit charset when the servlet did not. The better defence is to send charset=UTF-8 on every text response, which both response-character-encoding and contentType="…;charset=UTF-8" do. Encoding is also the foundation of output escaping: c:out and fn:escapeXml turn markup characters into entities, and the escape is only effective if the browser decodes the page with the encoding the server believes it sent. Security covers escaping contexts (HTML body, attribute, JavaScript, URL, CSS) and the OWASP Java Encoder.

Pitfalls and Error Names

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

años in the page’s static text but correct data from the servlet (observed)

The file is UTF-8 but was read as ISO-8859-1: no pageEncoding, no BOM, no property group, no charset

Declare the page encoding once, preferably <page-encoding> for *.jsp and *.jspf

Cien a?os or a single F1 byte, Japanese text as ???? (observed)

The response is ISO-8859-1, so characters outside Latin-1 become ? at write time (data loss)

charset=UTF-8 in contentType, default-content-type, or <response-character-encoding>

años in form values but static text fine (observed)

The request had no charset and was read as ISO-8859-1

<request-character-encoding>UTF-8</request-character-encoding>; fmt:requestEncoding before any parameter; a filter

fmt:requestEncoding or a filter has no effect (observed)

A parameter was read first by an earlier filter, tag, include or EL expression

Move the call or filter first, or use the web.xml element

JasperException: ... Page-encoding specified in jsp-property-group [UTF-8] is different from that specified in page directive [ISO-8859-1] (observed)

The attribute and a matching page-encoding name different encodings

Make them equal or remove one

JasperException: ... Page-encoding specified in XML prolog [UTF-8] is different from that specified in page directive for a file with a BOM (observed)

The BOM and pageEncoding disagree (Tomcat words it as "XML prolog")

Make the declaration agree with the BOM, or drop one of them

JasperException: Unsupported encoding: [X] (observed)

A charset name the JVM does not know

Use the IANA name, for example UTF-8

Page directive must not have multiple occurrences of pageencoding (observed)

Two page directives with pageEncoding in one file

One directive per file

setCharacterEncoding or setContentType in a scriptlet changes nothing (observed)

Writer already obtained or response committed; buffer="none" and a blank line after a directive commit early

Put it first; trimDirectiveWhitespaces="true"; prefer contentType

The fragment’s text is Año although its page is fine (observed)

An included file has its own page encoding, defaulting to ISO-8859-1

pageEncoding in the fragment, or a *.jspf property group

XML declaration allowed only at the start of the document (observed)

Whitespace emitted before <?xml

trimDirectiveWhitespaces="true", or a JSP document

Content-Language: ja-JP with a Latin-1 charset (observed)

fmt:setLocale without a locale mapping or an encoding setting

Declare UTF-8; add locale-encoding-mapping-list if needed

JSON or CSV downloads show garbage in a spreadsheet

No charset, or a spreadsheet that ignores the header

charset=UTF-8, and for spreadsheets consider a BOM written by the application

A query-string value is wrong after "fixing" it with new String(..., "UTF-8")

The connector already decoded it as UTF-8 (URIEncoding)

Remove the workaround; set URIEncoding only if the URL encoding really is different

ISO-8859-1 in the output of a page that "should" be UTF-8 after changing container or Tomcat line

The conf/web.xml of the new container does not set response-character-encoding (the 9.0.x branch does not)

Declare the encodings in the application, not in the container

The same bytes look right in curl and wrong in a browser (observed)

Static text read as Latin-1 and written back unchanged; the header says ISO-8859-1

Test with a character outside ASCII, read the Content-Type, and look at the bytes with xxd

What Changed Recently

The encoding rules of Jakarta Pages are stable. The change logs of Pages 3.0, 3.1 and 4.0 (appendix C) list no change to chapter 4 or to Appendix B, so the rules above are the ones of the 2.x era with the package names changed. What did change is around them:

Table 10. Changes that affect encoding in the JSP world
Version Change Effect for JSP authors

Servlet 4.0

<request-character-encoding> and <response-character-encoding> are in the web-app 4.0 schema

The application-wide default of the two encodings, without a filter

Servlet 6.1 (Jakarta EE 11)

setCharacterEncoding(Charset) overloads on ServletRequest and ServletResponse; the web.xml elements and ServletContext setters are documented in sections 3.13 and 5.6

Type-safe calls in servlets and filters; JSP code keeps using the string forms or the declarative elements

Pages 4.0 (Jakarta EE 11)

Java 17 minimum; removed isThreadSafe, jsp:plugin and deprecated APIs; no change in encoding chapters

Nothing to migrate for encoding

Tomcat 10.1 and 11

conf/web.xml ships with both character encoding elements set to UTF-8 (the 9.0.x branch does not)

A page without an encoding declaration is answered as UTF-8 on these lines and as ISO-8859-1 on 9.0.x; applications should declare their own

Java 18 (JEP 400)

UTF-8 is the default charset of the platform

file.encoding no longer depends on the operating system; code that uses the default charset is deterministic

Where the Books Are Outdated

Web Development with JavaServer Pages (Manning, 2001, listed in the section bibliography) and the other Manning books of the section were written for JSP 1.2 and Servlet 2.3. The concepts they cover on this page (MIME types, contentType against response.setContentType, client detection, plain text, XML, dynamic CSS and JavaScript, spreadsheets, code generation, fmt:requestEncoding) survived; the platform around them did not.

Table 11. JSP 1.2-era encoding material and its modern equivalent
The book’s era Today (Pages 4.0, Servlet 6.1)

javax.servlet and javax.servlet.jsp names in examples

jakarta.servlet, jakarta.servlet.jsp and jakarta.tags.* URIs; Tomcat’s migration tool converts a javax WAR

<%@ page contentType="text/html; charset=ISO-8859-1" %> copied into every page as boilerplate

UTF-8, declared once: page-encoding and default-content-type in a property group, or response-character-encoding

request.setCharacterEncoding("UTF-8") in a scriptlet at the top of each page

<request-character-encoding>, a filter, or fmt:requestEncoding; a scriptlet is too late as soon as any earlier code read a parameter

Repairing values with new String(param.getBytes("ISO-8859-1"), "UTF-8")

Fix the cause (the request encoding); the workaround corrupts values that were already decoded correctly

Client detection by reading the User-Agent header to choose markup or content type

Content negotiation with Accept and Vary: Accept, or responsive HTML; user agents are not a reliable key

Spreadsheets, XML and code generated by JSP with scriptlets and PrintWriter

A servlet or library for CSV, JSON and XML; JSP only when the output is mostly template text, with trimDirectiveWhitespaces and an explicit charset

JSP source files left in the platform’s default encoding

The default charset is UTF-8 since Java 18 (JEP 400), but the JSP engine reads files as ISO-8859-1 unless the page encoding is declared

A container default (usually ISO-8859-1) assumed to apply everywhere

The specifications still say ISO-8859-1, while Tomcat 10.1 and 11 ship UTF-8 through conf/web.xml; declare the encodings in the application so the result does not depend on the container

References