Text blocks arrived as a preview in JDK 13, were previewed again in JDK 14 and became a permanent feature in JDK 15 through JEP 378. They let you write a multi-line string the way it will look, without \n at the end of every line, without escaping every double quote, and without a chain of + operators. The syntax takes a minute to learn; the algorithm javac applies to the content explains every surprise: an extra trailing newline, vanished trailing spaces, indentation that appears after a reformat.
This article explains text blocks from the compiler's point of view: the three processing steps, how the closing delimiter controls indentation, the new escapes and runtime methods, and then practice: SQL, test fixtures, real bugs, and when a text block is the wrong tool.
The problem text blocks solve
Before JDK 15, embedding a SQL query, a JSON document or an HTML fragment in Java meant translating it into a different language: one string literal per line, an explicit \n in each, every embedded quote escaped, and concatenation operators holding it together. Forgetting one newline silently ran two lines together. The two forms below produce exactly the same String:
// Before text blocks: escapes, quotes and concatenation
String query = "SELECT o.id, o.total\n" +
"FROM orders o\n" +
"WHERE o.status = \"OPEN\"\n" +
" AND o.created_at > ?\n";
// With a text block: the same String, character for character
String query2 = """
SELECT o.id, o.total
FROM orders o
WHERE o.status = "OPEN"
AND o.created_at > ?
""";
assert query.equals(query2);The text block version is the query as a database engineer would write it. The important point is that it is not a different kind of value. A text block is a string literal whose content is computed at compile time; at runtime it is an ordinary java.lang.String and nothing in the class file records that it was written as a text block.
Syntax: the two delimiters
A text block opens with three double quotes, """, followed by optional spaces and then a line terminator. Content starts on the next line; nothing may follow the opening delimiter on the same line, which is why a one-line text block does not compile. The block ends at the first unescaped """. Where you put that closing delimiter matters in two ways: whether the string ends with a newline, and how much indentation is removed.
String withNewline = """
hello
"""; // "hello\n" closing delimiter on its own line
String noNewline = """
hello"""; // "hello" closing delimiter ends the last content line
// String bad = """hello""";
// compile error: the opening delimiter must be followed by a line terminatorIf the closing delimiter sits on its own line, the last content line keeps its line terminator, so the string ends with \n. If the closing delimiter follows the last content character, there is no trailing newline. Choose deliberately: a unit-test expectation must match the output exactly.
The compile-time algorithm, step by step
JEP 378 defines three steps, applied in a fixed order to the characters between the delimiters. The order is the key to understanding the feature.
- Line terminators are normalised. Every CR and CRLF in the content becomes LF. The string is the same whether the source file was saved on Windows or Linux, which keeps builds reproducible across platforms.
- Incidental whitespace is removed. Indentation that exists only because the text block sits inside a method is stripped, and trailing whitespace is removed from every line. The details follow below.
- Escape sequences are interpreted.
\n,\t,\", octal escapes and the two text-block escapes are translated last.
Step 2 works on a list of lines. Some of them are determining lines: every line that contains non-whitespace characters, plus the line holding the closing delimiter if that delimiter is on its own line. Blank lines in the middle of the content do not count. For each determining line javac counts the leading whitespace characters, and the minimum of those counts is the amount of incidental whitespace. That many characters are removed from the start of every non-blank line. Blank lines become empty. Finally all trailing whitespace is removed from every line.
The position of the opening """ has no effect on this calculation. The position of the closing """, when it is on its own line, does: it is a determining line, so moving it left of the content increases the indentation kept in the string. Moving it further right than the content changes nothing, because the minimum is still set by the content lines.
Worked example: predicting indentation exactly
Here is a method that returns an HTML fragment. Count the columns, because that is what the compiler does:
class Report {
String html() {
return """
<ul>
<li>alpha</li>
</ul>
""";
}
}
// content lines start at columns 16, 18, 16; the closing line has 12 spaces
// min = 12, so the result is:
// " <ul>\n <li>alpha</li>\n </ul>\n"The determining lines are <ul> with 16 leading spaces, <li> with 18, </ul> with 16, and the closing delimiter's line with 12. The minimum is 12, so 12 characters are removed from each content line. Every line of the result keeps four spaces of indentation, and <li> keeps six. The string ends with a newline because the closing delimiter is on its own line.
Move the closing delimiter to column 16, aligned with <ul>, and the minimum becomes 16: the result starts with <ul> at column zero. Put it at the end of </ul> and it is no longer a separate line, so the minimum is still 16 and the string has no trailing newline. So to keep indentation, for a fragment spliced into larger YAML or HTML, put the closing delimiter left of the content by exactly that amount.
Escapes, including the two that are new
Text blocks are not raw strings. Every escape sequence that works in an ordinary literal works in a text block, and a backslash still has to be doubled when you want a literal backslash. JEP 378 added two escapes that only make sense with multi-line content:
\at the end of a line (a backslash followed by the line terminator) suppresses that newline, so you can wrap a long line in source while producing a single line of output.\sis a single space. Because escapes are interpreted after trailing whitespace is stripped, a\sat the end of a line survives, and so do any ordinary spaces before it, since they are no longer trailing.
String oneLine = """
The quick brown fox \
jumps over the lazy dog.
""";
// "The quick brown fox jumps over the lazy dog.\n"
String padded = """
red \s
green\s
""";
// "red \ngreen \n" (\s also stops trailing-space stripping before it)
String quotes = """
She typed \""" and kept going.
""";
// "She typed \"\"\" and kept going.\n"
String regex = """
\\d{4}-\\d{2}-\\d{2}
""".strip();
// "\d{4}-\d{2}-\d{2}" text blocks are NOT raw strings: backslashes still escapeTwo lines in that example deserve a closer look. A double quote inside a text block needs no escape; only a run of three does, because three quotes would end the block. Escaping the first quote of the three is enough. The regular expression shows the most common misunderstanding: people expect \d to pass through unchanged, as it would in a raw string, but \d is not a valid escape and does not compile. A text block removes the need to escape quotes and newlines, not backslashes.
The runtime methods added alongside
JEP 378 also added three instance methods to String. stripIndent() applies the incidental-whitespace step to any string at runtime, useful for text loaded from a file or produced by a code generator. translateEscapes() applies the escape step, turning the two characters backslash and t into a tab. formatted(Object...) is String.format with the receiver as the format string, which reads naturally after a text block.
// formatted() is String.format with the receiver as the format string
String json = """
{
"id": %d,
"name": "%s"
}
""".formatted(42, "Ada");
// A text block is a string literal, so it is a compile-time constant and interned
String a = """
x
""";
boolean same = (a == "x\n"); // true
// Runtime versions of steps 2 and 3, for strings built at runtime
String raw = " line one\n line two";
String stripped = raw.stripIndent(); // "line one\n line two"
String tab = "col1\\tcol2".translateEscapes(); // "col1<TAB>col2"stripIndent() follows the same rule as the closing delimiter: the last line counts toward the minimum even when it is blank. A string ending in \n has an empty last line with zero leading whitespace, so nothing is stripped. That is why the example above has no trailing newline. Because a text block is a string literal, it is a constant expression and is interned like any other literal, which is why the identity comparison holds. Do not rely on == in real code.
There is no interpolation. String templates (JEPs 430 and 459) were previewed and then withdrawn, and JDK 23 does not include them, so formatted(), String.format, MessageFormat or a template engine remain the ways to substitute values.
Where text blocks pay off
SQL. A query kept as a static final text block can be copied into a database console and back without editing. Combine it with bound parameters so the text block holds structure and the driver holds values:
private static final String FIND_OPEN_ORDERS = """
SELECT o.id, o.total, o.created_at
FROM orders o
WHERE o.customer_id = ?
AND o.status = 'OPEN'
ORDER BY o.created_at DESC
""";
List<Order> openOrders(Connection conn, long customerId) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(FIND_OPEN_ORDERS)) {
ps.setLong(1, customerId); // bound parameter, never formatted()
return mapOrders(ps.executeQuery());
}
}Test fixtures. Expected JSON, XML or CSV in a test is the most common use. Two things decide whether the assertion passes: the trailing newline and the line separator. Close the block on the last content line when the serialiser does not emit a final newline, and convert line endings explicitly when the output uses CRLF, because a text block always contains LF regardless of the platform:
@Test
void rendersCsvWithWindowsLineEndings() {
String expected = """
id,total
42,19.99
""".replace("\n", "\r\n"); // text blocks always contain LF
assertEquals(expected, CsvWriter.render(orders));
}
Failure modes and how to spot them
- Trailing spaces disappear. Markdown hard line breaks (two trailing spaces), fixed-width records and test expectations with padded columns all lose their trailing spaces silently. Use
\sat the end of the line, or build the padding withString.format. - Tabs and spaces mixed. The algorithm counts whitespace characters; it does not expand tabs to columns. A line indented with one tab has a count of one, and a line indented with eight spaces has a count of eight, so mixed indentation strips the wrong amount. Configure the editor and formatter to use one kind in Java sources.
- A reformat changes the string. An IDE or formatter that moves the closing delimiter changes the content. Treat a moved delimiter in a code review as a behaviour change, and keep a test on any text block whose exact whitespace matters.
- Percent signs with formatted(). A literal
%in the content, such as100%in a report template, is a format specifier toformatted()and throws at runtime. Write%%. - Injection.
formatted()is string concatenation. Building SQL, shell commands or HTML with it and user input creates injection bugs exactly as+would. Use bound parameters, argument arrays and escaping libraries.
Trade-offs: text block, resource file or template engine
| Option | Best for | Costs |
|---|---|---|
| Text block | Short, fixed text owned by the code: queries, fixtures, small templates | Recompile to change; whitespace rules to learn; no interpolation |
| Resource file | Long documents, text edited by non-developers, content shared across languages | I/O and encoding handling; a second place to look; missing-file errors at runtime |
| Template engine | Text with loops, conditionals and escaping, such as HTML and email | A dependency, its own syntax, and template errors that surface at runtime |
Rule of thumb: conditional logic means a template engine; long or non-developer-edited text means a resource file; otherwise use a text block.
What to do next
- Confirm your build targets JDK 15 or later, then search for strings built with
+ "\n" +chains and convert the longest ones first. - For each converted string, decide whether it should end with a newline and place the closing delimiter accordingly.
- Add or keep a unit test for every text block whose exact whitespace matters, such as wire formats and expected test output.
- Replace trailing spaces that must survive with
\s, and use backslash-newline to wrap long single-line strings. - Audit every
formatted()call for user input; move SQL to bound parameters and escape HTML with a library. - Escape literal percent signs as
%%in any text block passed toformatted(). - Configure the formatter to use spaces only in Java sources so incidental-whitespace stripping is predictable.
- Read the related pages: Project Amber for how text blocks fit the wider language work, records and pattern matching for the features they pair with, and string deduplication for how the JVM handles duplicate strings created at runtime.