On This Page
Start from the query you'd rather read
Here's a SQL query as it sits in the finished project, and what the program prints for it:
public static final String LATEST_RATES = """
SELECT base, quote, rate, quoted_at
FROM fx_rates
WHERE quoted_at >= ?
ORDER BY quoted_at DESC
""";
SELECT base, quote, rate, quoted_at$
FROM fx_rates$
WHERE quoted_at >= ?$
ORDER BY quoted_at DESC$
(I piped the output through cat -A so the $ marks each line end. No stray spaces, no \n, no +.)
That's what text blocks are for. If you're looking up how to use text blocks in Java, the short answer is: open with """ and a line break, write the text as it should look, close with """. The rest of this article is the part the short answer leaves out, which is how the compiler decides what's indentation and what's content, what happens to trailing spaces, and why formatted() blows up on a harmless %.
You'll build a small currency-rates feed helper: a SQL query, a JSON message, and an alert email template, plus 15 runnable checks. It's for you if you know Java 8 strings and want to stop writing "...\n" + in a row. Nothing here needs a library.
What you need to compile this
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| javac | 21.0.12.1 | javac -version |
| Build tool | none, plain javac |
Text blocks became a standard feature in Java 15 (JEP 378). Anything from 15 up compiles this code. String.formatted(), stripIndent() and translateEscapes() arrived in the same release. On Java 14 the compiler tells you so; I forced that error below.
I built and ran everything from the command line. The IntelliJ IDEA and Eclipse notes in the next section come from the vendors' documentation, not from me clicking through them. Total build time was about 35 minutes, and most of it went on getting each trap to print something clear instead of just failing.
Lay out the rates-feed project
rates/
└── src/main/java/com/example/rates/
├── Queries.java
├── RateJson.java
├── AlertEmail.java
├── Traps.java
├── Main.java
└── Checks.java
From the command line. mkdir -p rates/src/main/java/com/example/rates && cd rates. Compile with javac -Xlint:all --release 21 -d out $(find src -name '*.java'). On Windows PowerShell, find isn't there; list the six files explicitly.
In IntelliJ IDEA. Create a plain Java project, set the project SDK to a JDK 21 and the language level to 21 (File → Project Structure → Project). Language level 15 or higher is what turns text blocks on. IntelliJ can also convert a concatenated string: place the caret on the string and press Alt+Enter, and look for the "Replace with text block" intention. I haven't checked that the label is identical in your version, so look at the Alt+Enter menu rather than trusting me.
In Eclipse. Create a Java project and set the compiler compliance to 21 (Project → Properties → Java Compiler). Any release that supports Java 21 compliance handles text blocks. Not run by me.
With Maven or Gradle. Set maven.compiler.release to 21 in the pom.xml, or java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } in build.gradle.kts. I didn't run either for this article.
How to use text blocks in Java, one rule at a time
Every step below has a checkpoint. Compile once at the end of Step 4 and you'll have a runnable program.
Step 1: Write a SQL query as a text block
Goal: replace a concatenated query with a literal that looks like SQL.
src/main/java/com/example/rates/Queries.java
package com.example.rates;
public final class Queries {
private Queries() {
}
/** Latest rate per currency pair, newest first. Plain SQL, readable as SQL. */
public static final String LATEST_RATES = """
SELECT base, quote, rate, quoted_at
FROM fx_rates
WHERE quoted_at >= ?
ORDER BY quoted_at DESC
""";
/** Same query with the trailing newline removed by hand-placing the closing delimiter. */
public static final String LATEST_RATES_NO_NEWLINE = """
SELECT base, quote, rate, quoted_at
FROM fx_rates
WHERE quoted_at >= ?
ORDER BY quoted_at DESC""";
/** Old way, for comparison. */
public static final String LATEST_RATES_CONCAT =
"SELECT base, quote, rate, quoted_at\n" +
" FROM fx_rates\n" +
" WHERE quoted_at >= ?\n" +
" ORDER BY quoted_at DESC\n";
/** A line continuation: the backslash-newline joins two source lines into one string line. */
public static final String LONG_FILTER = """
SELECT base, quote, rate FROM fx_rates \
WHERE base = ? AND quote = ? \
AND quoted_at >= ?""";
}
Three things to notice. The opening """ has to be the last thing on its line. The text starts on the next line. And the closing """ sits on its own line, which means the string ends with a newline. LATEST_RATES_NO_NEWLINE shows the other choice: put the delimiter right after DESC and there's no trailing \n. For SQL it rarely matters, but for a file you write or a message body it often does, so decide on purpose.
The backslash at the end of a line in LONG_FILTER is a line continuation. The line break is removed from the string, so a long statement can wrap in your source and stay one line in the value. That escape is new with text blocks. In a normal "..." string it's a compile error.
Step 2: Build JSON without escaping a single quote
Goal: a JSON message where every " is just a ".
src/main/java/com/example/rates/RateJson.java
package com.example.rates;
import java.math.BigDecimal;
public final class RateJson {
private RateJson() {
}
/** Double quotes need no escaping inside a text block. */
public static String rateMessage(String base, String quote, BigDecimal rate) {
return """
{
"base": "%s",
"quote": "%s",
"rate": %s
}
""".formatted(base, quote, rate.toPlainString());
}
/** The braces of JSON are fine with formatted(); only % needs care. */
public static String percentExample(int pct) {
return """
{"change": "%d%%"}""".formatted(pct);
}
}
formatted() is String.format as an instance method, which reads better at the end of a block. JSON's braces are harmless to it; only % is special. Look at percentExample: %d%% renders 12%. That %% matters. I'll show what a bare % does in the troubleshooting section.
I'd reach for Jackson to build real JSON. A text block is right for fixtures, test input and small constant messages, where the point is to see the exact text.
Step 3: Write an email template with the escapes that matter
Goal: use \<newline>, \s and \t, and see what survives.
src/main/java/com/example/rates/AlertEmail.java
package com.example.rates;
import java.math.BigDecimal;
public final class AlertEmail {
private AlertEmail() {
}
public static String render(String name, String pair, BigDecimal rate, BigDecimal threshold) {
return """
Hi %s,
The %s rate crossed your alert level.
now: %s
threshold: %s
Quote: "%s" is up \
from your last alert.
Trailing space kept here:\s\s
Tab escape:\tdone
""".formatted(name, pair, rate, threshold, pair);
}
}
Two escapes need explaining. \s is a single space that the compiler won't strip, which is how you keep trailing whitespace. Without it, trailing spaces on a source line are removed. And the \ at the end of "is up " joins that line to the next one.
Step 4: Collect the indentation traps in one class
Goal: see the closing delimiter decide your indentation, on purpose.
src/main/java/com/example/rates/Traps.java
package com.example.rates;
public final class Traps {
private Traps() {
}
/** Closing delimiter further LEFT of the text: the gap becomes leading spaces (here four). */
public static String closingLeft() {
return """
four spaces in from the margin
second line
""";
}
/** Closing delimiter on the same line as the last text: no trailing newline. */
public static String closingSameLine() {
return """
one
two""";
}
/** Trailing spaces on a source line are stripped; \s keeps them. */
public static String trailingSpaces() {
return "a \n".stripTrailing() + "|"
+ """
b
c\s\s
""".replace("\n", "$");
}
/** Three quotes in a row must be escaped; two are fine. */
public static String quotes() {
return """
She said ""hi"" and then \"""bye\""".""";
}
/** stripIndent and translateEscapes on a regular string. */
public static String stripIndentDemo() {
String raw = " a\n b\n c";
return raw.stripIndent();
}
public static String translateDemo() {
return "line1\\nline2\\tx".translateEscapes();
}
}
Now compile. My first compile printed a warning I hadn't asked for:
src/main/java/com/example/rates/Traps.java:26: warning: [text-blocks] trailing white space will be removed
+ """
^
1 warning
That warning only shows up with -Xlint:all, and it's useful: I'd left trailing spaces on the b line of trailingSpaces() to demonstrate that they vanish, and javac noticed. If you ever see it in real code, it means someone's editor added spaces that the compiler is about to throw away. (Several formatters strip trailing spaces on save, so a text block that relies on them is fragile. Use \s.)
Step 5: Add Main and run it
src/main/java/com/example/rates/Main.java
package com.example.rates;
import java.math.BigDecimal;
public class Main {
public static void main(String[] args) {
System.out.print(Queries.LATEST_RATES);
System.out.println("--");
System.out.print(RateJson.rateMessage("EUR", "USD", new BigDecimal("1.0841")));
System.out.println("--");
System.out.print(AlertEmail.render("Asha", "EUR/USD", new BigDecimal("1.0841"), new BigDecimal("1.0800")));
System.out.println("--");
System.out.println(RateJson.percentExample(12));
}
}
javac -Xlint:all --release 21 -d out $(find src -name '*.java')
java -cp out com.example.rates.Main | cat -A
SELECT base, quote, rate, quoted_at$
FROM fx_rates$
WHERE quoted_at >= ?$
ORDER BY quoted_at DESC$
--$
{$
"base": "EUR",$
"quote": "USD",$
"rate": 1.0841$
}$
--$
Hi Asha,$
$
The EUR/USD rate crossed your alert level.$
$
now: 1.0841$
threshold: 1.0800$
$
Quote: "EUR/USD" is up from your last alert.$
Trailing space kept here: $
Tab escape:^Idone$
--$
{"change": "12%"}$
If you see ^I before done and $ at every line end with no ^M, the hard part's done.
Run the feed and read the bytes
The output above is the proof for three claims. The SQL has no trailing spaces. The JSON is indented relative to its own margin, not to the Java source (the { is at column 0 even though the code that makes it sits 16 columns in). And the email's "is up" and "from your last alert" are on one line, because of the backslash.
Run it again with java -cp out com.example.rates.Main | od -c | head if you want to look at actual bytes. I used cat -A because it's quicker to read.
Fifteen checks that pin the rules down
No JUnit here. I couldn't reach Maven Central from my build environment, so the checks are a 40-line main with a check(name, expected, actual) helper. If you'd rather use JUnit 5, each check call becomes one assertEquals.
src/main/java/com/example/rates/Checks.java
package com.example.rates;
import java.math.BigDecimal;
public class Checks {
static int passed = 0;
static int failed = 0;
static void check(String name, Object expected, Object actual) {
if (expected.equals(actual)) {
passed++;
System.out.println("PASS " + name);
} else {
failed++;
System.out.println("FAIL " + name + "\n expected: " + show(expected) + "\n actual: " + show(actual));
}
}
static String show(Object o) {
return o.toString().replace("\n", "\\n").replace("\t", "\\t");
}
public static void main(String[] args) {
check("text block equals concatenation", Queries.LATEST_RATES_CONCAT, Queries.LATEST_RATES);
check("closing delimiter on own line keeps trailing newline", true, Queries.LATEST_RATES.endsWith("DESC\n"));
check("closing delimiter on last line drops newline", Queries.LATEST_RATES.stripTrailing(), Queries.LATEST_RATES_NO_NEWLINE);
check("backslash-newline joins lines",
"SELECT base, quote, rate FROM fx_rates WHERE base = ? AND quote = ? AND quoted_at >= ?",
Queries.LONG_FILTER);
check("json message", "{\n \"base\": \"EUR\",\n \"quote\": \"USD\",\n \"rate\": 1.0841\n}\n",
RateJson.rateMessage("EUR", "USD", new BigDecimal("1.0841")));
check("percent escaped", "{\"change\": \"12%\"}", RateJson.percentExample(12));
check("closing delimiter left of text adds four spaces",
" four spaces in from the margin\n second line\n", Traps.closingLeft());
check("closing on same line has no trailing newline", "one\ntwo", Traps.closingSameLine());
check("trailing spaces stripped, \\s keeps them", "a|b$c $", Traps.trailingSpaces());
check("triple quote escape", "She said \"\"hi\"\" and then \"\"\"bye\"\"\".", Traps.quotes());
check("stripIndent", "a\n b\nc", Traps.stripIndentDemo());
check("translateEscapes", "line1\nline2\tx", Traps.translateDemo());
String mail = AlertEmail.render("Asha", "EUR/USD", new BigDecimal("1.0841"), new BigDecimal("1.0800"));
check("email has no carriage returns", false, mail.contains("\r"));
check("email keeps two trailing spaces via \\s", true, mail.contains("kept here: \n"));
check("email line continuation", true, mail.contains("is up from your last alert."));
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) System.exit(1);
}
}
javac -Xlint:all --release 21 -d out $(find src -name '*.java')
java -cp out com.example.rates.Checks
PASS text block equals concatenation
PASS closing delimiter on own line keeps trailing newline
PASS closing delimiter on last line drops newline
PASS backslash-newline joins lines
PASS json message
PASS percent escaped
PASS closing delimiter left of text adds four spaces
PASS closing on same line has no trailing newline
PASS trailing spaces stripped, \s keeps them
PASS triple quote escape
PASS stripIndent
PASS translateEscapes
PASS email has no carriage returns
PASS email keeps two trailing spaces via \s
PASS email line continuation
15 passed, 0 failed
Two of my own expectations were wrong the first time, and the check output told me so:
FAIL closing delimiter left of text adds spaces
expected: indented two more than the margin\n second line\n
actual: indented two more than the margin\n second line\n
FAIL same-line text keeps relative indent
expected: one\n two
actual: one\ntwo
I'd put the closing delimiter four columns left of the text and written "two" in my head. And in closingSameLine I'd forgotten the delimiter doesn't count as a line, so the two text lines are both at the margin and nothing's left over. Which leads to the next section, because that's the rule.
What the compiler does to your indentation
Four steps, in this order, from the language specification (JLS 3.10.6) and the String methods that mirror them:
- Line endings are normalised to
\n. I compiled a source file saved with Windows\r\nendings and printeds.contains("\r"):false, with a length of 4 (x, newline,y, newline). So a Windows checkout doesn't leak\rinto your strings. - The compiler finds the minimum indentation across every non-blank line, and the closing delimiter line if it's on its own line. That's the margin.
- That many leading whitespace characters come off every line. Whatever's left is real indentation. This is why moving the closing
"""left by four columns gave every line four spaces. - Trailing whitespace is stripped from each line, and only then are escapes (
\n,\t,\s,\") interpreted. That order is why\scan protect a trailing space: the backslash-s is still just two characters when the stripping happens.
You can run the same steps on any ordinary string with stripIndent() and translateEscapes(), which is how Traps.stripIndentDemo() and translateDemo() work. They're handy for text you read from a file.
Mixing tabs and spaces in the same block is a trap I didn't force, because the compiler counts each whitespace character as one, so a tab-indented line and a space-indented line look aligned in your editor but are not. Pick one, and let your formatter enforce it.
Compiler errors and surprises I forced
Each entry is exactly what javac 21.0.12.1 printed.
Text after the opening quotes.
E.java:2: error: illegal text block open delimiter sequence, missing line terminator
String a = """hello""";
A text block can't be a one-liner. The opening """ must be followed by a line break. Use a normal string for one line.
An escape that doesn't exist.
E.java:5: error: illegal escape character
\q
^
Backslashes are escapes inside text blocks, exactly as in other strings. A Windows path or a regex needs \\. Same fix as always.
A missing closing delimiter.
E.java:7: error: unclosed text block
String c = """
^
The error points to the opening line, so look up, not down.
Three quotes inside the text.
F.java:2: error: ';' expected
has "triple""" inside
Two quotes in a row are fine. Three end the block. Escape one of them (\"""), as Traps.quotes() does.
Compiling for Java 14.
Queries.java:9: error: text blocks are not supported in -source 14
(use -source 15 or higher to enable text blocks)
That is --release 14. Check your build tool's release setting before you suspect the code.
A bare percent sign in formatted().
Exception in thread "main" java.util.UnknownFormatConversionException: Conversion = '"'
at java.base/java.util.Formatter.parse(Formatter.java:2852)
This is a runtime failure, not a compile error, and the compiler can't see it. {"change": "12%"} followed by .formatted() makes the formatter read %" as a conversion. Write %%. I hit this by pasting a real JSON sample, which is the usual way people hit it.
Before this reaches production code
- Don't build SQL from user input with
formatted(). A text block is only a nicer way to write the constant. Use?placeholders and aPreparedStatementfor values, asLATEST_RATESdoes. - Decide on a trailing newline per constant. I use the "closing delimiter on its own line" form for SQL and logs, and the same-line form for anything compared with
equals. - Keep text blocks short. I'd move anything over 30 lines or so into a resource file. A text block that long is documentation that happens to compile.
- Test fixtures are the sweet spot. JSON or XML pasted into a test reads exactly like the expected value.
- Related: how to use switch expressions and pattern matching, how to use var in Java, how to use Java records, and how to use BigDecimal for financial calculations, which covers the
ratevalue used here. The site's overview of string methods covers the rest ofString. Forward link: how to use the new String methods in Java.
The rules in one list
- Opening
""", then a line break. Nothing else on that line. - The closing delimiter's column is part of the indentation calculation.
- Closing
"""on its own line adds a trailing\n; on the last text line it doesn't. - Trailing spaces are stripped;
\skeeps one. \at the end of a line joins it to the next.- Two quotes are fine; escape the third.
formatted()needs%%for a literal percent.\r\nin the source becomes\nin the string.
Questions about Java text blocks
Which Java version has text blocks? Java 15 made them a standard feature (JEP 378). They were previews in 13 and 14, so a project on 14 needs preview flags. Anything on 17 or 21 has them without any flag.
How do I keep the trailing newline off a text block? Put the closing """ directly after the last character of text, as LATEST_RATES_NO_NEWLINE does. Or call stripTrailing() on the result.
Can I use variables inside a text block? Not as interpolation. Use formatted() with %s placeholders, as RateJson does, or String.replace for named markers.
Do text blocks work with String.format? Yes. A text block is an ordinary String, so String.format(block, args) and block.formatted(args) behave the same. Watch the % rule above.