Java string templates were one of the most anticipated language features of the JDK 21 era, and many tutorials still present them as if they were part of the language. They are not. Template expressions such as STR."Hello \{name}" were a preview feature in JDK 21 (JEP 430) and again in JDK 22 (JEP 459). The design was then withdrawn, and JDK 23 shipped without it. No JDK release since has included string templates: neither JDK 26 nor JDK 27, released in September 2026, lists a template or interpolation JEP, and no replacement design has shipped.

That history is still worth understanding. The preview introduced a sound idea, keeping a template's literal text and its embedded values apart until a processor decides how to combine them, and that idea is how you should build SQL, HTML, JSON and shell commands today, with or without language support. This article explains how the preview worked, what went wrong with it, how to migrate code that used it, and how to get the same safety on current JDKs with a small amount of code.

Advertisement

The problem templates set out to solve

Java has always had three ways to put values into text. Concatenation with + is readable for short strings and, since JDK 9, compiles to an efficient invokedynamic call rather than a chain of StringBuilder appends. String.format and the instance method formatted (JDK 15) separate the text from the values but make you match positions by eye. MessageFormat adds localisation and its own quoting rules.

All three share a deeper flaw: the result is a plain String, so the value and the text it sits in become indistinguishable. When that string is a SQL query, an HTML fragment or a shell command, a value containing a quote can change the meaning of the surrounding text. That is the root of injection bugs. The string templates proposal tried to fix readability and safety at once: interpolation as easy as in other languages, with a hook that lets a library see the structure before it is flattened.

How the preview worked

A template expression had three parts: a processor, a dot, and a string literal or text block containing embedded expressions written as \{expression}. The escape \{ was chosen deliberately. Before templates it was an illegal escape sequence in Java, so no existing program could contain it, and the new syntax could not silently change the meaning of old code. A literal with embedded expressions but no processor was a compile error.

// JDK 21 or 22 only, compiled with: javac --release 21 --enable-preview Demo.java
import static java.util.FormatProcessor.FMT;

String name = "Ada";
int items = 3;
double total = 41.5;

String a = STR."Hello \{name}, you have \{items} items";      // STR was auto-imported
String b = FMT."%-6s\{name} owes %8.2f\{total}";              // format specifier precedes \{...}
String c = STR."""
    {
      "user": "\{name}",
      "count": \{items + 1}
    }
    """;                                                       // works in text blocks too
// String d = "Hello \{name}";   // compile error: a template needs a processor
How a JDK 21/22 preview template expression was evaluated: split, evaluate, hand both lists to a processorSQL."... id = \{id}"template expressionfragments()values()Processor<R, E>process(StringTemplate)STR -> Stringplain interpolationFMT -> Stringformat specifierscustom -> PreparedStatementvalidates, parameterisesThe fragments never mix with the values until the processor decides how. That separation was the security story:a SQL processor turns values into bind parameters, so a hostile value cannot change the query's shape.Status: preview in JDK 21 (JEP 430) and JDK 22 (JEP 459); removed in JDK 23; redesign pending, no shipped replacement.
The preview's evaluation model: the literal is split into fragments, the embedded expressions are evaluated into values, and a processor receives both and returns any type.

At run time the compiler split the literal into a list of fragments, the text between embedded expressions, and evaluated each embedded expression into a list of values. Both lists were wrapped in a StringTemplate object and handed to the processor. The standard processors were STR, which interpolates and was automatically imported into every source file; FMT in java.util.FormatProcessor, which reads a java.util.Formatter specifier immediately before each embedded expression; and RAW, which returns the unprocessed StringTemplate so that code can pass it on.

Advertisement

Custom processors and the security argument

The heart of the design was StringTemplate.Processor<R, E extends Throwable>, a functional interface with one method, R process(StringTemplate st) throws E. The return type R did not have to be a String, and the processor could throw a checked exception when validation failed. JEP 459 shows a JSON processor and a query builder along these lines:

// JDK 21/22 preview API (JEP 430 / JEP 459), shown for understanding, not for new code
record QueryBuilder(Connection conn)
        implements StringTemplate.Processor<PreparedStatement, SQLException> {
    @Override
    public PreparedStatement process(StringTemplate st) throws SQLException {
        String sql = String.join("?", st.fragments());     // structure only
        PreparedStatement ps = conn.prepareStatement(sql);
        int i = 1;
        for (Object v : st.values()) {                      // values become bind parameters
            ps.setObject(i++, v);
        }
        return ps;
    }
}

var SQL = new QueryBuilder(conn);
PreparedStatement ps = SQL."SELECT * FROM orders WHERE customer = \{customer} AND status = \{status}";

Look at what the query processor does with the two lists. The fragments become the statement text, joined with ? placeholders. The values never touch the text; they become bind parameters. If customer is the string ' OR '1'='1, it is compared as a literal value and matches nothing. The processor type makes the safe path the short path: writing SQL."..." is no more effort than writing STR."...", but it produces a PreparedStatement, not a string, so the unsafe result cannot be passed to executeQuery(String) by accident.

Other languages have the same mechanism under different names. JavaScript's tagged templates call a function with the array of literal strings and the list of substituted values. Scala's string interpolators, such as s"..." and custom ones defined on StringContext, receive the parts and the arguments separately. Java's version added checked exceptions and an arbitrary result type, which fit the language's existing conventions.

Why the design was withdrawn

During the second preview, Brian Goetz, the Java language architect, wrote to the Amber project's mailing list in spring 2024 explaining that experience with the prototype had exposed problems. The processor-centric design confused users: the STR. prefix that everyone typed for ordinary interpolation looked like ceremony, and the processor syntax made templates hard to compose, for example when building one query from pieces produced by different methods. The discussion that followed was long and did not converge in the weeks before JDK 23's feature freeze, so the feature was removed rather than previewed a third time in its old form.

The direction discussed on the list was to treat a template as an ordinary value of type StringTemplate that APIs accept as an argument, rather than something applied to a processor with special syntax. In that model, a database library would offer a method taking a StringTemplate and decide internally how to bind values, and ordinary interpolation would be a separate, explicit operation. Treat that as a direction, not a specification: no JEP has delivered it in a JDK release at the time of writing, so check the OpenJDK JEP index before planning around it.

Consequences if you used the preview

Preview features are opt-in for a reason. Class files compiled with --enable-preview on JDK 21 are tagged as depending on that release's preview features and will only run on a JDK 21 runtime with preview enabled. Source code using \{ templates does not compile on JDK 23 or later at all. If a team used templates on JDK 21, the code is pinned to that JDK until it is rewritten.

The rewrite is mechanical. STR."Hello \{name}" becomes "Hello " + name. FMT."%8.2f\{total}" becomes "%8.2f".formatted(total). Multi-line STR text blocks become a text block followed by formatted(...), with the text block rules for indentation and trailing spaces unchanged. Custom processors become ordinary methods that take the parts explicitly, which the next section shows. A regex search for \.\s*"[^"]*\\\{ and for StringTemplate finds most occurrences; review every match, because templates inside text blocks span lines.

Worked example: template-style safe SQL on any JDK

You do not need language support to keep fragments and values apart. A small builder with two methods, one for trusted text and one for untrusted values, gives you the security property of the query processor on JDK 8 and later. A third method handles the case placeholders cannot: identifiers such as table or column names, which must come from an allow-list.

import java.sql.*;
import java.util.*;

/** Fragments and values kept apart, like a template processor, on any JDK. */
public final class Sql {
    private final StringBuilder text = new StringBuilder();
    private final List<Object> params = new ArrayList<>();

    public static Sql of(String fragment) { return new Sql().sql(fragment); }

    public Sql sql(String fragment) {            // trusted, constant text only
        text.append(fragment);
        return this;
    }

    public Sql param(Object value) {             // untrusted values always go here
        text.append('?');
        params.add(value);
        return this;
    }

    public Sql identifier(String name, Set<String> allowed) {   // table / column names
        if (!allowed.contains(name)) throw new IllegalArgumentException("not allowed: " + name);
        text.append(name);
        return this;
    }

    public PreparedStatement prepare(Connection conn) throws SQLException {
        PreparedStatement ps = conn.prepareStatement(text.toString());
        for (int i = 0; i < params.size(); i++) ps.setObject(i + 1, params.get(i));
        return ps;
    }

    @Override public String toString() { return text + " " + params; }
}

Using it reads close to a template, and every value goes through param:

Set<String> sortable = Set.of("created_at", "total_cents");

Sql q = Sql.of("SELECT id, total_cents FROM orders WHERE customer_id = ").param(customerId)
           .sql(" AND status = ").param(status)
           .sql(" ORDER BY ").identifier(sortColumn, sortable)
           .sql(" LIMIT ").param(50);

try (PreparedStatement ps = q.prepare(conn); ResultSet rs = ps.executeQuery()) {
    while (rs.next()) { /* ... */ }
}
// q.toString(): SELECT id, total_cents FROM orders WHERE customer_id = ? AND status = ?
//               ORDER BY created_at LIMIT ? [c-981, PAID, 50]

Trace a hostile request. A caller sends status = "PAID' OR '1'='1" and sortColumn = "id; DROP TABLE orders". The status becomes the second bind parameter and is compared literally, so it matches no rows. The sort column fails the allow-list check and throws before any SQL is sent. Neither value can change the statement's shape, which is exactly what the preview's processor guaranteed. The toString output, text with placeholders followed by the parameter list, is also safe to log, unlike an interpolated query that may contain personal data.

For larger codebases, libraries already offer the same separation with more features: JDBC templates in Spring, jOOQ's typed query DSL, and JDBI's named parameters. For HTML, use a templating engine that escapes by default. The principle is the same in every case: constant structure from the developer, values from the outside world, combined only by code that knows the target language's quoting rules.

Choosing a string-building tool today

NeedUseWhy
Short log or message text+ concatenationReadable; compiled to an efficient indy call since JDK 9
Aligned numbers, padding, locale"...".formatted(...) or String.formatFormatter specifiers, same as FMT used
Multi-line literal with a few valuesText block plus formattedKeeps layout visible; see the text block rules
SQLPreparedStatement via a builder, Spring JDBC, jOOQValues as bind parameters; identifiers allow-listed
HTML or XMLAn escaping template engineContext-aware escaping per attribute and element
JSONA JSON library's object modelCorrect quoting and Unicode escaping
Shell or process argumentsProcessBuilder with an argument listNo shell parsing, so no quoting to get wrong

Failure modes

  • Following an outdated tutorial. Code using \{...} fails on JDK 23 and later with syntax errors that do not mention templates. Check the JDK version a guide was written for.
  • Shipping preview code. Builds compiled with --enable-preview are tied to one JDK release; an upgrade fails at class loading with an unsupported class file version error.
  • Treating interpolation as safe. STR in the preview, formatted and + today all produce plain strings. None of them escape anything.
  • Parameterising identifiers. Placeholders bind values, not table or column names; drivers reject or misinterpret them. Allow-list identifiers instead.
  • Logging interpolated queries. They leak personal data into logs. Log the text with placeholders and, if needed, a redacted parameter list.
  • Over-building. A bespoke builder for every target language becomes its own maintenance burden; prefer an established library once you need joins, dialects or nested fragments.

Trade-offs

The preview offered the shortest possible syntax with safety built into the type system, but it was a moving target that tied code to a single JDK. A hand-rolled builder works everywhere and is easy to audit, at the cost of being more verbose than a template. Query DSLs such as jOOQ give compile-time checking of column names and types in exchange for a dependency and a code generation step. Plain formatted is fine for text meant for humans and wrong for text meant for an interpreter.

Many current Java features that pair well with these patterns have shipped and are stable: records for immutable parameter carriers such as the preview's QueryBuilder, and pattern matching for switch for binding values by type, as JEP 459's own example did. For the memory side of heavy string building, G1 string deduplication explains what the collector can and cannot reclaim.

What to do next

  1. Search your repositories and build files for --enable-preview, StringTemplate and \{ inside string literals; list every module pinned to JDK 21 or 22 by templates.
  2. Rewrite STR uses to concatenation or text blocks, and FMT uses to formatted, then remove the preview flag.
  3. Find every place that builds SQL, HTML, JSON or shell commands with + or formatted and move it to bind parameters, an escaping engine or a library object model.
  4. Introduce a small fragments-and-values builder, or adopt Spring JDBC, JDBI or jOOQ, and allow-list every dynamic identifier.
  5. Add a test that feeds ' OR '1'='1 and an identifier containing a semicolon through each query path and asserts no extra rows and a rejected identifier.
  6. Track the OpenJDK JEP index and the Amber mailing list for a redesigned templates proposal, and do not adopt it until it is final.
Key takeaway: String templates were a preview in JDK 21 (JEP 430) and JDK 22 (JEP 459): template expressions such as STR."Hello \{name}" split a literal into fragments and values and passed both to a processor that could return any type, which let a SQL processor turn values into bind parameters. The processor-centric design proved confusing and hard to compose, so it was removed in JDK 23 and no replacement has shipped. Migrate preview code to concatenation, text blocks and formatted, and keep the idea that mattered: build SQL, HTML, JSON and commands from trusted fragments and separately bound values, with allow-listed identifiers.