Skip to content

How to Use Text Blocks in Java 21 for SQL, JSON and Emails

Learn how to use text blocks in Java 15+ on a currency-rates feed: SQL, JSON and email templates, the indentation rules, six compiler errors and 15 checks.

how-to-tutorials core-java modern-java
Gopi Gorantala
Reading Progress

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:

  1. Line endings are normalised to \n. I compiled a source file saved with Windows \r\n endings and printed s.contains("\r"): false, with a length of 4 (x, newline, y, newline). So a Windows checkout doesn't leak \r into your strings.
  2. 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.
  3. 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.
  4. Trailing whitespace is stripped from each line, and only then are escapes (\n, \t, \s, \") interpreted. That order is why \s can 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

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; \s keeps 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\n in the source becomes \n in 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.

References

how-to-tutorialscore-javamodern-java

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments