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.
| 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 |
At translation time, once per file |
Response character encoding |
The encoding of the text sent to the browser, the |
The |
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 |
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.
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.
Source: IANA — Character Sets registry
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.
@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:
| Order | Source | Notes |
|---|---|---|
1 |
A byte order mark (BOM) at the start of the file |
UTF-8 ( |
2 |
The |
Must agree with the BOM, if there is one |
3 |
The |
Translation error if it differs from the property group or the BOM; applies only to the file that contains it |
4 |
The |
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>
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:
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.
| Page or configuration | Rule | Observed message |
|---|---|---|
|
An unsupported page encoding is a translation error |
|
Two |
|
|
|
Different encodings in the attribute and in a matching |
|
A UTF-8 BOM plus |
A BOM and a declaration must name the same encoding |
|
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.
| Item | Rule |
|---|---|
Initial content type |
The type of the |
Initial encoding when |
That |
Initial encoding without a |
The encoding named by the BOM, by |
Initial encoding without a |
UTF-8 |
Nothing specified anywhere |
No encoding is passed to |
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 ñ:
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>
ñ (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.
| Bytes | Encoding form |
|---|---|
|
UTF-16, big-endian |
|
UTF-16, little-endian |
|
UTF-32, big-endian |
|
UTF-32, little-endian |
|
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:
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:
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.
| Element | Meaning | Valid values |
|---|---|---|
|
Sets the page character encoding (the |
Those of the |
|
Sets the |
Those of the |
|
Removes template text that is only whitespace, so a leading newline after a directive does not commit the response |
|
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:
<%-- /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>
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.
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);
}
}
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:
| Layer | Setting | Covers |
|---|---|---|
Java source and resources (Maven) |
|
|
JSP source files |
A BOM, |
The page character encoding |
Generated Java for JSP (Jasper) |
|
The generated |
Response |
|
The |
Request body |
|
Form parameters |
URL path and query string |
Connector |
Path and query parameters |
HTML |
|
Offline copies of the page and defence against a missing header |
Non-JSP resources |
The |
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.
Source: JEP 400 — UTF-8 by Default
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.
| Format | contentType |
Notes |
|---|---|---|
Spreadsheet export |
|
RFC 4180 describes |
Server-generated CSS |
|
Prefer static CSS; a JSP is justified for theme colours; escape values that come from users |
JSON |
|
RFC 8259 says JSON is UTF-8 and defines no charset parameter; Tomcat appends |
XML |
|
|
Plain text |
|
A debugging view, a robots file, a log extract |
Code generation |
|
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
"), 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
| Symptom or error | Cause | Fix |
|---|---|---|
|
The file is UTF-8 but was read as ISO-8859-1: no |
Declare the page encoding once, preferably |
|
The response is ISO-8859-1, so characters outside Latin-1 become |
|
|
The request had no charset and was read as ISO-8859-1 |
|
|
A parameter was read first by an earlier filter, tag, include or EL expression |
Move the call or filter first, or use the |
|
The attribute and a matching |
Make them equal or remove one |
|
The BOM and |
Make the declaration agree with the BOM, or drop one of them |
|
A charset name the JVM does not know |
Use the IANA name, for example |
|
Two |
One directive per file |
|
Writer already obtained or response committed; |
Put it first; |
The fragment’s text is |
An included file has its own page encoding, defaulting to ISO-8859-1 |
|
|
Whitespace emitted before |
|
|
|
Declare UTF-8; add |
JSON or CSV downloads show garbage in a spreadsheet |
No charset, or a spreadsheet that ignores the header |
|
A query-string value is wrong after "fixing" it with |
The connector already decoded it as UTF-8 ( |
Remove the workaround; set |
|
The |
Declare the encodings in the application, not in the container |
The same bytes look right in |
Static text read as Latin-1 and written back unchanged; the header says ISO-8859-1 |
Test with a character outside ASCII, read the |
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:
| Version | Change | Effect for JSP authors |
|---|---|---|
Servlet 4.0 |
|
The application-wide default of the two encodings, without a filter |
Servlet 6.1 (Jakarta EE 11) |
|
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 |
Nothing to migrate for encoding |
Tomcat 10.1 and 11 |
|
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 |
|
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.
| The book’s era | Today (Pages 4.0, Servlet 6.1) |
|---|---|
|
|
|
UTF-8, declared once: |
|
|
Repairing values with |
Fix the cause (the request encoding); the workaround corrupts values that were already decoded correctly |
Client detection by reading the |
Content negotiation with |
Spreadsheets, XML and code generated by JSP with scriptlets and |
A servlet or library for CSV, JSON and XML; JSP only when the output is mostly template text, with |
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 |
References
-
Jakarta Servlet 6.1 — Programmatically Configuring Character Encoding
-
Apache Tomcat 11.0.x — conf/web.xml (default request and response character encodings)
-
Apache Maven — Reproducible builds and project.build.sourceEncoding
-
HTML Standard — Specifying the document’s character encoding
-
Jakarta Pages 4.0 — 3.3.9 Removing Whitespaces from Template Text
-
RFC 8259 — The JSON Data Interchange Format, 8.1 Character Encoding
-
W3C — Extensible Markup Language (XML) 1.0, 2.8 Prolog and Document Type Declaration
-
Manning — Web Development with JavaServer Pages, Second Edition