Account type: ${accountType}
Member since: ${memberSince}
Credits remaining: ${credits}
If you have been writing Java for any length of time, you know the pain of constructing multi-line strings. Every JSON payload, every SQL query, every HTML snippet you needed to embed in Java code turned into a war against escape characters, concatenation operators, and broken indentation. A simple 5-line JSON object became a 10-line mess of "{", + "\n", and backslash escapes that nobody wanted to read or maintain.
Consider what it took to embed a simple JSON string before text blocks existed:
// The old way: multi-line strings were painful
String json = "{\n" +
" \"name\": \"John Doe\",\n" +
" \"age\": 30,\n" +
" \"email\": \"john@example.com\",\n" +
" \"address\": {\n" +
" \"street\": \"123 Main St\",\n" +
" \"city\": \"Springfield\"\n" +
" }\n" +
"}";
Count the problems: escaped double quotes everywhere, explicit \n newline characters, concatenation operators on every line, and indentation that has nothing to do with the actual JSON structure. If you needed to change a field, you had to navigate a minefield of escape sequences.
Text blocks solve all of this. They were introduced as a preview feature in Java 13 (JEP 355), refined in Java 14 (JEP 368), and finalized as a permanent feature in Java 15 (JEP 378). In Java 17 — the current long-term support (LTS) release — text blocks are a stable, production-ready tool that every Java developer should know.
A text block is a multi-line string literal that lets you write strings exactly as they appear, without escape characters for newlines or double quotes. The same JSON from above becomes:
// With text blocks: clean, readable, maintainable
String json = """
{
"name": "John Doe",
"age": 30,
"email": "john@example.com",
"address": {
"street": "123 Main St",
"city": "Springfield"
}
}
""";
No escape characters. No concatenation. The string looks exactly like the JSON it represents. When you need to modify it, you edit it like you would edit a regular JSON file. This is what text blocks bring to the table.
Key benefits of text blocks:
java.lang.String objects, fully compatible with all existing String methodsA text block starts with three double-quote characters ("""), followed by optional whitespace and a mandatory line terminator (newline). The content of the text block starts on the next line. The text block ends with another """.
The rules are simple but strict:
""" must be followed by a line terminator — you cannot put content on the same line as the opening delimiter""" can be on its own line or at the end of the last content line""" is the contentpublic class TextBlockBasics {
public static void main(String[] args) {
// Basic text block
String greeting = """
Hello, World!
Welcome to Java Text Blocks.
This is a multi-line string.
""";
System.out.println(greeting);
// Output:
// Hello, World!
// Welcome to Java Text Blocks.
// This is a multi-line string.
// Single line in a text block (legal but unusual)
String single = """
Just one line
""";
System.out.println(single); // "Just one line\n"
// Empty text block
String empty = """
""";
System.out.println(empty.isEmpty()); // true (only contains "")
// Actually empty is just "\n" -- NOT empty!
// Truly empty-ish text block
String minimal = """
\
""";
System.out.println(minimal.isEmpty()); // true (line continuation removes the newline)
}
}
Important: the opening delimiter rule. The following code will NOT compile because content appears on the same line as the opening """:
// COMPILE ERROR: illegal text block open delimiter sequence
String bad = """Hello""";
// COMPILE ERROR: content on same line as opening delimiter
String alsoBad = """Hello
World""";
// CORRECT: content starts on the next line
String good = """
Hello
World""";
Closing delimiter position matters. Where you place the closing """ affects whether the string ends with a newline:
public class ClosingDelimiterDemo {
public static void main(String[] args) {
// Closing """ on its own line: string ENDS with a newline
String withNewline = """
Hello
""";
System.out.println(withNewline.endsWith("\n")); // true
// Value: "Hello\n"
// Closing """ on the last content line: NO trailing newline
String withoutNewline = """
Hello""";
System.out.println(withoutNewline.endsWith("\n")); // false
// Value: "Hello"
// This distinction matters when building output
System.out.println("---");
System.out.print(withNewline); // prints "Hello" then newline
System.out.print(withoutNewline); // prints "Hello" with no newline
System.out.println("---");
}
}
Double quotes inside text blocks. One of the biggest quality-of-life improvements is that double quotes no longer need escaping (in most cases):
public class QuotesInTextBlocks {
public static void main(String[] args) {
// Double quotes work without escaping
String html = """
Hello, "World"!
""";
System.out.println(html);
// You CAN still escape them if you want
String escaped = """
He said, \"Hello!\"
""";
// Three consecutive double quotes must have at least one escaped
String tricky = """
This contains \""" three quotes
""";
// Or break them up:
String alsoWorks = """
This contains ""\" three quotes
""";
}
}
Indentation management is where text blocks truly shine, and where most developers get confused the first time they use them. The Java compiler uses a clever algorithm to separate incidental whitespace (indentation caused by your source code formatting) from essential whitespace (indentation that is actually part of the string content).
How it works: The compiler looks at every line of the text block, including the line containing the closing """, and finds the common leading whitespace — the smallest number of leading spaces shared by all non-blank lines. It then strips that many spaces from the beginning of every line. This process is called re-indentation.
Think of it like this: the compiler finds the leftmost non-space character across all lines and makes that column the “zero position.” Everything to its left is incidental and gets removed. Everything to its right is essential and stays.
public class IndentationDemo {
public static void main(String[] args) {
// All lines have 16 spaces of leading whitespace (incidental)
// The compiler strips all 16, leaving no leading spaces
String noIndent = """
Line one
Line two
Line three
""";
// Result:
// "Line one\nLine two\nLine three\n"
// Some lines have extra indentation beyond the common baseline
String withIndent = """
Parent
Child 1
Child 2
Grandchild
""";
// Common leading whitespace: 16 spaces (from "Parent" and closing """)
// Result:
// "Parent\n Child 1\n Child 2\n Grandchild\n"
System.out.println(withIndent);
// Output:
// Parent
// Child 1
// Child 2
// Grandchild
}
}
Here is the key insight that trips up beginners: the closing """ participates in the common-whitespace calculation. By moving the closing delimiter left or right, you control how much indentation remains in the final string.
public class ClosingDelimiterIndent {
public static void main(String[] args) {
// Closing """ aligned with content: no indentation in result
String noIndent = """
SELECT *
FROM users
WHERE active = true
""";
System.out.println("--- No indent ---");
System.out.println(noIndent);
// SELECT *
// FROM users
// WHERE active = true
// Closing """ moved LEFT of content: content is indented in result
String indented = """
SELECT *
FROM users
WHERE active = true
""";
// The closing """ has 12 spaces, content has 16 spaces
// Common whitespace = 12, so 16-12 = 4 spaces of indent remain
System.out.println("--- Indented ---");
System.out.println(indented);
// SELECT *
// FROM users
// WHERE active = true
// Closing """ at column zero: all source indentation preserved
String fullyIndented = """
SELECT *
FROM users
WHERE active = true
""";
System.out.println("--- Fully indented ---");
System.out.println(fullyIndented);
// SELECT *
// FROM users
// WHERE active = true
}
}
Visualization of incidental vs essential whitespace:
| Scenario | Closing """ Position |
Result |
|---|---|---|
| Aligned with content | Same column as content | No leading whitespace in output |
| Left of content | Less indented than content | Content retains relative indentation |
| Right of content | More indented than content | Same as aligned (closing delimiter does not add spaces) |
| Column zero | No indentation at all | All source indentation preserved |
Blank lines within a text block are preserved. However, trailing whitespace on each line is stripped by default. This is an important distinction — Java actively removes spaces and tabs at the end of each line during compilation. If you need trailing spaces, you must use the \s escape sequence (covered in the next section).
public class BlankLinesDemo {
public static void main(String[] args) {
// Blank lines are preserved
String withBlanks = """
Paragraph one.
Paragraph two.
Paragraph three.
""";
System.out.println(withBlanks);
// Output:
// Paragraph one.
//
// Paragraph two.
//
// Paragraph three.
// Trailing whitespace is stripped!
String trailing = """
Hello
World
""";
// The spaces after "Hello" and "World" are REMOVED
System.out.println(trailing.contains("Hello ")); // false
System.out.println(trailing.contains("Hello")); // true
}
}
Text blocks support all traditional Java escape sequences (\n, \t, \\, \", etc.) plus two new ones that were introduced specifically for text blocks in Java 14:
| Escape | Name | Purpose |
|---|---|---|
\s |
Space escape | Inserts a single space that is not stripped during trailing whitespace removal |
\ (backslash at end of line) |
Line continuation | Suppresses the newline character, joining the next line to the current one |
\s Escape: Preserving Trailing SpacesSince text blocks strip trailing whitespace by default, the \s escape gives you a way to keep spaces when they matter. The \s translates to a single space character (U+0020), and its presence prevents the trailing-whitespace stripping algorithm from removing spaces on that line — because the line now ends with a non-whitespace escape sequence.
public class SpaceEscapeDemo {
public static void main(String[] args) {
// Without \s: trailing spaces are stripped
String stripped = """
Name: John
Age: 30
""";
System.out.println(stripped);
// "Name: John\nAge: 30\n" -- trailing spaces gone
// With \s: trailing spaces are preserved
String preserved = """
Name: John \s
Age: 30 \s
""";
System.out.println(preserved);
// "Name: John \nAge: 30 \n" -- trailing spaces kept!
// Useful for fixed-width formatting
String table = """
ID Name Status\s
1 Alice Active\s
2 Bob Inactive\s
3 Charlie Active\s
""";
System.out.println(table);
}
}
\ Line Continuation EscapeThe backslash at the end of a line suppresses the newline, effectively joining the current line with the next one. This is invaluable when you have a very long line that you want to break in source code for readability, but you need it to be a single line in the actual string.
public class LineContinuationDemo {
public static void main(String[] args) {
// Without line continuation: each source line becomes a separate line
String multiLine = """
This is a very long sentence that \
spans multiple lines in source code \
but produces a single line in the output.""";
System.out.println(multiLine);
// "This is a very long sentence that spans multiple lines in source code but produces a single line in the output."
// Practical use: long SQL query readable in source, single line at runtime
String sql = """
SELECT u.id, u.name, u.email, u.created_at, \
u.updated_at, u.status, u.role \
FROM users u \
WHERE u.status = 'ACTIVE' \
AND u.created_at > '2024-01-01'""";
System.out.println(sql);
// Single line: SELECT u.id, u.name, u.email, ...
// Combining \s and \ for precise control
String message = """
Dear Customer,\s\
your order #12345 has been shipped.\s\
Expected delivery: 3-5 business days.""";
System.out.println(message);
// "Dear Customer, your order #12345 has been shipped. Expected delivery: 3-5 business days."
}
}
Text blocks support all the classic escape sequences alongside the new ones:
public class AllEscapesDemo {
public static void main(String[] args) {
String allEscapes = """
Tab here:\tafter tab
Newline in middle:
first\nsecond
Backslash: \\
Single quote: \'
Double quote: \"
Unicode: \u2603 (snowman)
Trailing space preserved:\s
This line continues \
on the same line.
""";
System.out.println(allEscapes);
// Null character and other rarely used escapes also work
String special = """
Backspace: \b
Form feed: \f
Carriage return: \r
Octal: \101 (letter A)
""";
System.out.println(special);
}
}
Java introduced three new instance methods on String that are designed to work with text blocks, though they can be used with any string. These methods give you programmatic control over the same operations the compiler performs automatically on text blocks.
| Method | Introduced | Purpose |
|---|---|---|
stripIndent() |
Java 12 | Applies the text-block indentation algorithm to any string |
translateEscapes() |
Java 12 | Processes Java escape sequences in a string literal |
formatted(Object... args) |
Java 15 | Instance-method version of String.format() |
This method applies the same re-indentation algorithm that the compiler applies to text blocks. It is useful when you load multi-line text from a file or database and want to normalize the indentation.
public class StripIndentDemo {
public static void main(String[] args) {
// Simulating text loaded from external source with inconsistent indentation
String fromFile = " Line one\n" +
" Line two\n" +
" Line three (indented)\n" +
" Line four\n";
System.out.println("Before stripIndent():");
System.out.println(fromFile);
// Line one
// Line two
// Line three (indented)
// Line four
String stripped = fromFile.stripIndent();
System.out.println("After stripIndent():");
System.out.println(stripped);
// Line one
// Line two
// Line three (indented)
// Line four
// stripIndent() on a text block has no additional effect
// because the compiler already stripped incidental whitespace
String textBlock = """
Hello
World
""";
String doubleStripped = textBlock.stripIndent();
System.out.println(textBlock.equals(doubleStripped)); // true
}
}
This method processes Java escape sequences in a string. It is especially useful when reading strings from configuration files or user input where escape sequences appear as literal characters (e.g., the two characters \ and n) and you want to convert them to actual escape characters (e.g., a newline).
public class TranslateEscapesDemo {
public static void main(String[] args) {
// Simulating a string read from a config file
// The file literally contains: Hello\nWorld (6 characters, not a newline)
String raw = "Hello\\nWorld\\tTab\\\\Backslash";
System.out.println("Raw: " + raw);
// Raw: Hello\nWorld\tTab\\Backslash
String translated = raw.translateEscapes();
System.out.println("Translated:");
System.out.println(translated);
// Hello
// World Tab\Backslash
// Works with all Java escape sequences
String escapes = "\\t\\n\\\\\\'\\\"\\s".translateEscapes();
System.out.println("Escapes: [" + escapes + "]");
// Escapes: [ (tab, newline, backslash, quote, double-quote, space)]
}
}
The formatted() method is an instance-method equivalent of String.format(). Instead of writing String.format(template, args), you can write template.formatted(args). This is particularly clean with text blocks because it lets you chain the format call directly onto the text block.
public class FormattedDemo {
public static void main(String[] args) {
String name = "Alice";
int age = 28;
String city = "Seattle";
// Old way: String.format()
String old = String.format("Name: %s, Age: %d, City: %s", name, age, city);
// New way: formatted() -- reads more naturally
String modern = "Name: %s, Age: %d, City: %s".formatted(name, age, city);
System.out.println(old.equals(modern)); // true
// With text blocks -- this is where formatted() really shines
String profile = """
================================
User Profile
================================
Name: %s
Age: %d
City: %s
Status: %s
================================
""".formatted(name, age, city, "Active");
System.out.println(profile);
// Building an HTML email template
String emailHtml = """
Welcome, %s!
Your account has been created successfully.
Account details:
Get started at our dashboard.
""".formatted(name, name.toLowerCase(), "alice@example.com", "Premium", "https://example.com/dashboard"); System.out.println(emailHtml); } }Text blocks transform how you write embedded content in Java. Here are the real-world scenarios where they make the biggest difference.
Building JSON in Java is one of the most common use cases. With text blocks, the JSON in your code looks exactly like the JSON it produces.
public class JsonTextBlocks {
public static void main(String[] args) {
// Static JSON
String config = """
{
"database": {
"host": "localhost",
"port": 5432,
"name": "myapp_db",
"pool": {
"min": 5,
"max": 20,
"timeout": 30000
}
},
"cache": {
"enabled": true,
"ttl": 3600,
"provider": "redis"
}
}
""";
System.out.println(config);
// Dynamic JSON with formatted()
String userId = "usr_12345";
String role = "admin";
double balance = 1500.75;
String userJson = """
{
"id": "%s",
"role": "%s",
"balance": %.2f,
"permissions": ["read", "write", "delete"],
"metadata": {
"created": "2024-01-15T10:30:00Z",
"lastLogin": "2024-06-20T14:22:00Z"
}
}
""".formatted(userId, role, balance);
System.out.println(userJson);
}
}
Complex SQL queries are dramatically more readable as text blocks. The query structure is visible at a glance.
public class SqlTextBlocks {
public static void main(String[] args) {
// Complex JOIN query
String query = """
SELECT
u.id,
u.username,
u.email,
p.display_name,
COUNT(o.id) AS order_count,
SUM(o.total) AS total_spent
FROM users u
INNER JOIN profiles p ON u.id = p.user_id
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'ACTIVE'
AND u.created_at >= '2024-01-01'
AND u.email NOT LIKE '%@test.com'
GROUP BY u.id, u.username, u.email, p.display_name
HAVING COUNT(o.id) > 0
ORDER BY total_spent DESC
LIMIT 100
""";
System.out.println(query);
// Dynamic query with formatted()
String tableName = "products";
String category = "electronics";
int minPrice = 100;
String dynamicQuery = """
SELECT id, name, price, stock
FROM %s
WHERE category = '%s'
AND price >= %d
AND active = true
ORDER BY price ASC
""".formatted(tableName, category, minPrice);
System.out.println(dynamicQuery);
// CREATE TABLE statement
String ddl = """
CREATE TABLE IF NOT EXISTS employees (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
department VARCHAR(100),
salary DECIMAL(10,2),
hire_date DATE NOT NULL,
status ENUM('ACTIVE', 'INACTIVE', 'TERMINATED') DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_department (department),
INDEX idx_status (status)
);
""";
System.out.println(ddl);
}
}
public class HtmlTextBlocks {
public static void main(String[] args) {
String title = "Product Catalog";
String productName = "Wireless Headphones";
double price = 79.99;
String html = """
%s
%s
%s
$%.2f
Premium wireless headphones with noise cancellation.
""".formatted(title, title, productName, price);
System.out.println(html);
}
}
public class XmlTextBlocks {
public static void main(String[] args) {
String appName = "MyApp";
String version = "2.1.0";
String pom = """
4.0.0
com.example
%s
%s
jar
org.springframework.boot
spring-boot-starter-web
3.2.0
org.postgresql
postgresql
42.7.1
runtime
""".formatted(appName, version);
System.out.println(pom);
}
}
public class ErrorMessageTextBlocks {
public static void main(String[] args) {
String endpoint = "/api/users/123";
int statusCode = 404;
String method = "GET";
String errorMessage = """
╔══════════════════════════════════════════╗
║ API ERROR REPORT ║
╠══════════════════════════════════════════╣
║ Endpoint: %s
║ Method: %s
║ Status: %d
║ Timestamp: 2024-06-20T14:30:00Z
╠══════════════════════════════════════════╣
║ The requested resource was not found. ║
║ Please verify the resource ID and retry. ║
╚══════════════════════════════════════════╝
""".formatted(endpoint, method, statusCode);
System.out.println(errorMessage);
}
}
Regular expressions benefit enormously from text blocks because you avoid double-escaping. In a regular string, matching a literal backslash requires "\\\\". In a text block, it is just \\.
import java.util.regex.Pattern;
import java.util.regex.Matcher;
public class RegexTextBlocks {
public static void main(String[] args) {
// Old way: escape nightmare
String oldPattern = "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}[+-]\\d{2}:\\d{2}";
// Text block way: still need regex escapes, but no double-escaping for Java
// Actually, for single-line regex, regular strings work fine.
// Text blocks shine for COMPLEX, COMMENTED regex using (?x) verbose mode:
String emailRegex = """
(?x) # Enable verbose mode
^ # Start of string
[a-zA-Z0-9._%+-]+ # Local part: alphanumeric and special chars
@ # The @ symbol
[a-zA-Z0-9.-]+ # Domain name
\\. # Literal dot
[a-zA-Z]{2,} # Top-level domain (2+ chars)
$ # End of string
""";
Pattern pattern = Pattern.compile(emailRegex);
String[] testEmails = {
"user@example.com",
"john.doe+work@company.co.uk",
"invalid@",
"@nodomain.com",
"valid123@test.org"
};
for (String email : testEmails) {
boolean matches = pattern.matcher(email).matches();
System.out.printf("%-30s -> %s%n", email, matches ? "VALID" : "INVALID");
}
}
}
Let us put text blocks side by side with the old approach. These before/after comparisons make the readability improvement undeniable.
public class BeforeAfterComparison {
public static void main(String[] args) {
// ===== EXAMPLE 1: JSON =====
// BEFORE: String concatenation
String jsonOld = "{\n" +
" \"user\": {\n" +
" \"name\": \"John\",\n" +
" \"roles\": [\"admin\", \"user\"],\n" +
" \"settings\": {\n" +
" \"theme\": \"dark\",\n" +
" \"notifications\": true\n" +
" }\n" +
" }\n" +
"}";
// AFTER: Text block
String jsonNew = """
{
"user": {
"name": "John",
"roles": ["admin", "user"],
"settings": {
"theme": "dark",
"notifications": true
}
}
}
""";
// ===== EXAMPLE 2: SQL =====
// BEFORE
String sqlOld = "SELECT e.id, e.name, d.department_name, " +
"COUNT(p.id) AS project_count " +
"FROM employees e " +
"JOIN departments d ON e.dept_id = d.id " +
"LEFT JOIN projects p ON e.id = p.lead_id " +
"WHERE e.status = 'ACTIVE' " +
"GROUP BY e.id, e.name, d.department_name " +
"ORDER BY project_count DESC";
// AFTER
String sqlNew = """
SELECT e.id, e.name, d.department_name,
COUNT(p.id) AS project_count
FROM employees e
JOIN departments d ON e.dept_id = d.id
LEFT JOIN projects p ON e.id = p.lead_id
WHERE e.status = 'ACTIVE'
GROUP BY e.id, e.name, d.department_name
ORDER BY project_count DESC
""";
// ===== EXAMPLE 3: Multi-line error message =====
// BEFORE
String errorOld = "Error: Invalid configuration detected.\n" +
" - Property 'database.url' is required but missing.\n" +
" - Property 'server.port' must be between 1024 and 65535.\n" +
" - Property 'cache.ttl' must be a positive integer.\n" +
"\n" +
"Please check your application.properties file and fix the above issues.";
// AFTER
String errorNew = """
Error: Invalid configuration detected.
- Property 'database.url' is required but missing.
- Property 'server.port' must be between 1024 and 65535.
- Property 'cache.ttl' must be a positive integer.
Please check your application.properties file and fix the above issues.""";
// They produce the same strings
System.out.println(jsonOld.equals(jsonNew.stripTrailing()));
System.out.println(errorOld.equals(errorNew));
}
}
Readability comparison summary:
| Aspect | String Concatenation | Text Block |
|---|---|---|
| Escape characters | Required for " and \n |
Not needed for quotes or newlines |
| Newlines | Explicit \n on every line |
Automatic from source line breaks |
| Concatenation operators | + on every line |
None needed |
| Visual match to output | Obscured by syntax noise | Content looks like the output |
| Maintenance effort | High — easy to break escaping | Low — edit like a text file |
| Copy-paste from external tool | Requires reformatting | Paste directly, usually works |
In real applications, strings rarely contain only static content. You need to inject dynamic values — user names, timestamps, computed quantities. Text blocks work seamlessly with both String.format() and the newer formatted() method.
public class DynamicTextBlocks {
public static void main(String[] args) {
// === Method 1: String.format() ===
String template1 = """
Dear %s,
Your order #%d has been confirmed.
Total: $%.2f
Estimated delivery: %s
Thank you for shopping with us!
""";
String email1 = String.format(template1, "Alice", 98765, 249.99, "March 15, 2024");
System.out.println(email1);
// === Method 2: formatted() -- cleaner, more fluent ===
String email2 = """
Dear %s,
Your subscription to the %s plan is now active.
Monthly charge: $%.2f
Next billing date: %s
Manage your subscription at: %s
""".formatted("Bob", "Premium", 29.99, "April 1, 2024", "https://example.com/account");
System.out.println(email2);
// === Method 3: String.replace() for named placeholders ===
String template3 = """
{
"event": "{{EVENT_TYPE}}",
"user": "{{USER_ID}}",
"timestamp": "{{TIMESTAMP}}",
"payload": {
"action": "{{ACTION}}",
"resource": "{{RESOURCE}}"
}
}
""";
String event = template3
.replace("{{EVENT_TYPE}}", "user.action")
.replace("{{USER_ID}}", "usr_abc123")
.replace("{{TIMESTAMP}}", "2024-06-20T10:30:00Z")
.replace("{{ACTION}}", "delete")
.replace("{{RESOURCE}}", "document_456");
System.out.println(event);
// === Method 4: MessageFormat for positional arguments ===
// (useful when you need to reuse the same argument)
// import java.text.MessageFormat;
String template4 = """
Hello {0}! Welcome back, {0}.
You have {1} unread messages.
Last login: {2}
""";
String greeting = java.text.MessageFormat.format(
template4, "Charlie", 5, "yesterday");
System.out.println(greeting);
}
}
Here is a practical example of building a simple template engine using text blocks and replace():
import java.util.Map;
public class SimpleTemplateEngine {
private final String template;
public SimpleTemplateEngine(String template) {
this.template = template;
}
public String render(Map variables) {
String result = template;
for (Map.Entry entry : variables.entrySet()) {
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
}
return result;
}
public static void main(String[] args) {
// Define the template using a text block
SimpleTemplateEngine engine = new SimpleTemplateEngine("""
${title}
Welcome, ${username}!
Account type: ${accountType}
Member since: ${memberSince}
Credits remaining: ${credits}
""");
// Render with variables
String html = engine.render(Map.of(
"title", "My Dashboard",
"username", "Alice",
"accountType", "Premium",
"memberSince", "January 2023",
"credits", "1,250",
"footerText", "Copyright 2024 Example Corp."
));
System.out.println(html);
}
}
Text blocks are intuitive once you understand the rules, but there are several traps that catch developers — especially during the first few weeks of using them.
This is the most common surprise. Spaces and tabs at the end of each line in a text block are silently removed during compilation. If your string depends on trailing spaces (e.g., fixed-width formatting, test assertions), the output will not match your expectations.
public class PitfallTrailingWhitespace {
public static void main(String[] args) {
// You might expect 10-char wide columns, but trailing spaces are stripped
String table = """
Name Age City
Alice 30 Seattle
Bob 25 Portland
""";
// Actual content: "Name Age City\nAlice 30 Seattle\n..."
// The trailing spaces after "City", "Seattle", "Portland" are GONE
// FIX: Use \s at the end of each line
String fixedTable = """
Name Age City \s
Alice 30 Seattle \s
Bob 25 Portland \s
""";
// Now trailing spaces are preserved because \s prevents stripping
}
}
Whether your string ends with a newline depends on where you place the closing """. This matters in tests and when comparing strings.
public class PitfallMissingNewline {
public static void main(String[] args) {
// CAREFUL: These two produce DIFFERENT strings
String withNewline = """
Hello"""; // No trailing newline -- value is "Hello"
String alsoWithNewline = """
Hello
"""; // Has trailing newline -- value is "Hello\n"
System.out.println(withNewline.equals(alsoWithNewline)); // false!
System.out.println(withNewline.length()); // 5
System.out.println(alsoWithNewline.length()); // 6
// This can break assertEquals in tests:
// assertEquals("Hello\n", textBlock); // might fail if closing """ is inline
}
}
If you do not understand the common-whitespace-removal algorithm, you might get more or fewer leading spaces than you expect.
public class PitfallIndentation {
public static void main(String[] args) {
// SURPRISE: This has NO indentation in the output
String noIndent = """
{
"key": "value"
}
""";
// Common whitespace = 16 spaces (all lines have at least 16)
// Result: "{\n \"key\": \"value\"\n}\n"
// The 16 spaces are stripped; the 4 extra spaces on "key" remain
// SURPRISE: This has 4 spaces of indentation on ALL lines
String withIndent = """
{
"key": "value"
}
""";
// Common whitespace = 12 (from closing """)
// All content lines have 16 spaces, so 16-12 = 4 remain
// Result: " {\n \"key\": \"value\"\n }\n"
// TIP: To control indentation precisely, focus on the closing """
System.out.println("--- No indent ---");
System.out.println(noIndent);
System.out.println("--- With indent ---");
System.out.println(withIndent);
}
}
If your content contains three or more consecutive double-quote characters, you must escape at least one of them. Otherwise, the compiler interprets them as the closing delimiter.
public class PitfallConsecutiveQuotes {
public static void main(String[] args) {
// COMPILE ERROR: the compiler sees """ as the closing delimiter
// String bad = """
// She said """Hello""" loudly
// """;
// FIX 1: Escape the first quote of the triple
String fix1 = """
She said \"""Hello\""" loudly
""";
// FIX 2: Escape the last quote before the triple
String fix2 = """
She said ""\"""Hello""\""" loudly
""";
// In practice, three consecutive quotes are rare in JSON/SQL/HTML,
// so this pitfall usually only matters for documentation or test strings.
}
}
The common-whitespace algorithm treats tabs and spaces as different characters. If some lines use tabs for indentation and others use spaces, the common prefix will be shorter than you expect, resulting in extra whitespace in the output. Always use consistent indentation — ideally spaces only — within text blocks.
public class PitfallMixedIndentation {
public static void main(String[] args) {
// If your IDE mixes tabs and spaces, the result may surprise you.
// Stick to one indentation style within text blocks.
// All spaces -- predictable
String clean = """
Line one
Line two
Line three
""";
// Tip: Configure your IDE to insert spaces instead of tabs
// IntelliJ: Settings > Editor > Code Style > Java > Use tab character: OFF
// VS Code: "editor.insertSpaces": true
}
}
After working with text blocks across production codebases, here are the guidelines that lead to clean, maintainable code.
| Use Text Blocks When | Use Regular Strings When |
|---|---|
| String spans 2+ lines | String fits on one line |
| Content has double quotes (JSON, HTML, XML) | No special characters |
| Readability is improved by visual structure | String is short and simple |
| You are embedding another language (SQL, JSON, YAML) | Dynamic string built from variables |
| Test fixtures and expected output | Simple error messages or labels |
public class BestPractices {
// GOOD: Closing """ aligned with content for no extra indentation
private static final String CREATE_USER_SQL = """
INSERT INTO users (name, email, role)
VALUES (?, ?, ?)
""";
// GOOD: Using formatted() for dynamic content
public static String buildGreeting(String name, int unreadCount) {
return """
Hello, %s!
You have %d unread messages.
""".formatted(name, unreadCount);
}
// GOOD: Text block constants as static final fields
private static final String ERROR_TEMPLATE = """
Error in module: %s
Message: %s
Suggestion: %s
""";
// GOOD: Using replace() for named templates
private static final String NOTIFICATION_TEMPLATE = """
Hi ${name},
Your ${itemType} "${itemName}" has been ${action}.
""";
public static String buildNotification(String name, String itemType,
String itemName, String action) {
return NOTIFICATION_TEMPLATE
.replace("${name}", name)
.replace("${itemType}", itemType)
.replace("${itemName}", itemName)
.replace("${action}", action);
}
// AVOID: Don't use text blocks for single-line strings
// Bad:
String overkill = """
Hello, World!
""";
// Good:
String simple = "Hello, World!";
// AVOID: Don't use text blocks when StringBuilder is more appropriate
// If you're building a string in a loop, StringBuilder is still the right tool.
public static void main(String[] args) {
System.out.println(buildGreeting("Alice", 5));
System.out.println(buildNotification(
"Bob", "document", "Q4 Report", "approved"));
// Text blocks are String objects -- all String methods work
String json = """
{
"key": "value"
}
""";
System.out.println("Length: " + json.length());
System.out.println("Contains key: " + json.contains("key"));
System.out.println("Uppercase:\n" + json.toUpperCase());
System.out.println("Lines: " + json.lines().count());
}
}
""" must be followed by a newline — no content on the same line""" position controls indentation — align it with content for zero indentation\s to preserve it\ joins lines — great for long single-line stringsformatted() is your friend — chain it directly onto text blocksstatic final when the text block is a constant templateText blocks are one of those features that, once you start using, you wonder how you ever lived without. They eliminate an entire category of string-formatting bugs and make your code look like it respects the developer who has to read it next. Start using them for any multi-line string in your codebase — your future self will thank you.