Log Entry
Apex Multiline Strings and Interpolation in Summer '26
This is one of those Apex changes that looks like a small syntax improvement and then immediately makes half your codebase feel older.
Summer '26 adds multiline Apex strings and a better interpolation method with String.template().
Good.
Apex has needed this for a long time.
Anyone who has built JSON payloads, email bodies, test fixtures, error messages, or generated text in Apex knows the old shape:
String body = 'Hello ' + contact.FirstName + ',\n\n' +
'Your case ' + caseNumber + ' has been updated.\n' +
'Status: ' + status + '\n\n' +
'Thanks,\n' +
'Support';
That works.
It also reads like a punishment.
Multiline strings make the intended output visible in the code. String.template() makes interpolation use named values instead of positional {0}, {1}, {2} slots.
That is a very welcome developer-quality improvement.
But there is a sharp edge: readable string building is still string building.
It does not make unsafe dynamic SOQL safe.
Quick snapshot
- What: Apex can declare strings that span multiple lines without repeated concatenation.
- Interpolation:
String.template()interpolates values into regular and multiline strings. - Why it is useful: large text blocks become readable and maintainable.
- Great fits: JSON payloads, email bodies, test data, error messages, generated text.
- Main warning:
String.template()is not a sanitizer. - Dynamic SOQL rule: use bind variables for values, and whitelist identifiers when you cannot bind them.
My short version:
Use multiline strings to make Apex readable. Do not use them to hide injection bugs beautifully.
Example 1: JSON payloads
The old way usually looked like this.
String payload = '{' +
'"accountId":"' + accountId + '",' +
'"accountName":"' + accountName + '",' +
'"priority":"' + priority + '"' +
'}';
That is hard to scan and easy to break.
Missing commas, quotes, and braces become code-review archaeology.
With multiline strings, the shape of the payload is obvious.
String payload = '''
{
"accountId": ${accountIdJson},
"accountName": ${accountNameJson},
"priority": ${priorityJson}
}
'''.template(new Map<String, Object>{
'accountIdJson' => JSON.serialize(accountId),
'accountNameJson' => JSON.serialize(accountName),
'priorityJson' => JSON.serialize(priority)
});
The important detail is that the placeholders are not wrapped in quotes.
The values are already JSON-serialised before interpolation.
That means a customer name like this:
ACME "North"
does not break the JSON payload.
It becomes a valid JSON string value.
For fully dynamic payloads, I would still prefer JSON.serialize() on a Map, class, or DTO.
String payload = JSON.serialize(new Map<String, Object>{
'accountId' => accountId,
'accountName' => accountName,
'priority' => priority
});
Use multiline strings when the surrounding text matters.
Use structured serializers when the structure is the point.
Example 2: email bodies
Plain-text emails are a perfect fit.
Before:
String body = 'Hi ' + firstName + ',\n\n' +
'Your case ' + caseNumber + ' has been updated.\n\n' +
'New status: ' + status + '\n' +
'Next step: ' + nextStep + '\n\n' +
'Thanks,\n' +
'Support';
After:
String body = '''
Hi ${firstName},
Your case ${caseNumber} has been updated.
New status: ${status}
Next step: ${nextStep}
Thanks,
Support
'''.template(new Map<String, Object>{
'firstName' => firstName,
'caseNumber' => caseNumber,
'status' => status,
'nextStep' => nextStep
});
That is not just prettier.
It is easier to review because the source resembles the message the customer receives.
For HTML emails, keep the same caution as JSON: interpolation does not magically escape values for the target format. Escape user-controlled values for HTML, or use an email-template system where that context is already handled.
Example 3: test data and assertions
This will be very nice in tests.
Before:
String expected = 'Account sync failed\n' +
'Account: Edge Communications\n' +
'Reason: Missing external id\n';
After:
String expected = '''
Account sync failed
Account: Edge Communications
Reason: Missing external id
''';
Assert.areEqual(expected, actualMessage);
That is the sort of small improvement that adds up.
Tests often need exact text:
- request bodies
- response fixtures
- generated summaries
- validation messages
- error payloads
- CSV-ish exports
Multiline strings make those fixtures easier to read and less likely to be damaged by concatenation edits.
The one thing to watch is whitespace.
If the string spans multiple lines, the line breaks and indentation are part of what you are testing. That can be good, but be intentional. Do not hide a brittle whitespace assertion inside a huge text block unless the whitespace really matters.
Example 4: error messages
Operational error messages are another good fit.
String message = '''
Failed to publish billing event.
Invoice: ${invoiceNumber}
Account: ${accountName}
Status code: ${statusCode}
Response body:
${responseBody}
'''.template(new Map<String, Object>{
'invoiceNumber' => invoiceNumber,
'accountName' => accountName,
'statusCode' => response.getStatusCode(),
'responseBody' => response.getBody()
});
That is much easier to read than a pile of + '\n' +.
It also nudges teams towards better diagnostic messages.
Instead of:
throw new BillingSyncException('Billing sync failed');
you can include the bits the next developer actually needs.
Do be careful about secrets and personal data. A readable string is still a log line, exception message, or email body. Do not interpolate access tokens, session IDs, full payloads with sensitive data, or anything your logging policy says to keep out.
Example 5: generated text
Generated text is where multiline strings feel natural.
String summary = '''
Customer summary
Account: ${accountName}
Open cases: ${openCaseCount}
Renewal date: ${renewalDate}
Recommended next action:
${nextAction}
'''.template(new Map<String, Object>{
'accountName' => account.Name,
'openCaseCount' => openCaseCount,
'renewalDate' => renewalDate,
'nextAction' => nextAction
});
This could be used for:
- support summaries
- internal notes
- generated task descriptions
- integration diagnostics
- prompt-like text sent to an internal service
Again, the point is that the code now looks like the output.
That is a big readability win.
The dynamic SOQL footgun
Here is the version I do not want to see.
String query = '''
SELECT Id, Name, Industry
FROM Account
WHERE Name LIKE '%${searchTerm}%'
ORDER BY Name
LIMIT 50
'''.template(new Map<String, Object>{
'searchTerm' => searchTerm
});
List<Account> accounts = Database.query(query);
That is readable.
It is also a trap.
If searchTerm comes from a user, URL parameter, Flow input, integration payload, or anything else outside your control, templating it directly into SOQL is the same old injection risk with nicer formatting.
The safer version keeps the query readable but uses a bind variable for the value.
String searchPattern = '%' + searchTerm + '%';
String query = '''
SELECT Id, Name, Industry
FROM Account
WHERE Name LIKE :searchPattern
WITH USER_MODE
ORDER BY Name
LIMIT 50
''';
List<Account> accounts = Database.query(query);
That is the right mental model.
Use multiline strings for the static query shape.
Use bind variables for values.
When you cannot bind it, whitelist it
Bind variables work for values.
They do not work for every part of a query. You cannot bind a field name or sort direction as if it were a value.
That means this is also dangerous:
String query = '''
SELECT Id, Name, Industry
FROM Account
ORDER BY ${sortField} ${sortDirection}
LIMIT 50
'''.template(new Map<String, Object>{
'sortField' => sortField,
'sortDirection' => sortDirection
});
The fix is not "escape harder".
The fix is to allow only known-good identifiers.
private static final Map<String, String> SORT_FIELDS = new Map<String, String>{
'name' => 'Name',
'industry' => 'Industry',
'created' => 'CreatedDate'
};
private static final Set<String> SORT_DIRECTIONS = new Set<String>{ 'ASC', 'DESC' };
public class UnsupportedSortException extends Exception {}
public static List<Account> search(String searchTerm, String sortKey, String requestedDirection) {
String sortField = SORT_FIELDS.get(sortKey);
String sortDirection = requestedDirection == null
? 'ASC'
: requestedDirection.toUpperCase();
if (sortField == null) {
throw new UnsupportedSortException('Unsupported sort field: ' + sortKey);
}
if (!SORT_DIRECTIONS.contains(sortDirection)) {
throw new UnsupportedSortException('Unsupported sort direction: ' + requestedDirection);
}
String searchPattern = '%' + searchTerm + '%';
String query = '''
SELECT Id, Name, Industry
FROM Account
WHERE Name LIKE :searchPattern
WITH USER_MODE
ORDER BY ${sortField} ${sortDirection}
LIMIT 50
'''.template(new Map<String, Object>{
'sortField' => sortField,
'sortDirection' => sortDirection
});
return Database.query(query);
}
Now interpolation is only being used for values chosen by your code.
The user can ask for "name", but your code decides that means Name.
That is a very different security posture.
String.template() vs String.format()
The old String.format() shape is fine for small messages, but the indexes age badly.
String message = String.format(
'User {0} updated {1} on {2}',
new List<String>{
userName,
recordName,
formattedDate
}
);
That gets awkward when the template grows.
Named values are easier to maintain.
String message = '''
User ${userName} updated ${recordName}.
Timestamp: ${timestamp}
Source: ${source}
'''.template(new Map<String, Object>{
'userName' => userName,
'recordName' => recordName,
'timestamp' => formattedDate,
'source' => source
});
The win is not just fewer characters.
The win is that the placeholder explains itself at the point where it appears.
Where I would use this immediately
I would reach for multiline strings in:
- HTTP callout payloads
- GraphQL payloads
- test fixtures
- readable expected strings in assertions
- operational error messages
- generated plain-text emails
- debug-only diagnostic blocks
- static SOQL shape with bind variables
I would be more cautious with:
- JSON values that are not serialised
- HTML values that are not escaped
- dynamic SOQL values
- dynamic field names
- dynamic object names
- strings that include secrets or personal data
- huge templates that should really live in metadata or an email template
This is a readability feature.
It is not a licence to build every structured document by hand.
Practical rule
When using String.template(), ask what language you are generating.
That one question prevents most of the trouble:
Am I inserting text, or am I inserting syntax?
If you are inserting syntax, slow down.
Final take
Multiline strings and String.template() are an excellent Apex quality-of-life improvement.
They make code easier to read, easier to review, and less miserable to maintain.
The best uses are boring in the right way: JSON envelopes, email bodies, test fixtures, error messages, and generated text that should look in source code like it looks at runtime.
Just do not let the nicer syntax make unsafe dynamic SOQL look acceptable.
Readable insecure code is still insecure code.