JAVA / STRINGS AND TEXT HANDLING
Replacing text and joining pieces back together
Pick between literal replace and regex replaceAll, keep $ and \ from breaking your replacement, and rebuild text with String.join or StringJoiner.
What you will learn
- Use replace for fixed text, replaceAll only when you truly need a regex
- Reorder captured groups with $1/$2 back-references in a single replaceAll
- Escape $ and \ in a replaceAll replacement with Matcher.quoteReplacement
- Join pieces with String.join or StringJoiner instead of trimming a trailing comma
Understanding Replacing text and joining pieces back together
Java ships two replacement engines behind method names that look almost identical. replace(CharSequence, CharSequence) and replace(char, char) hunt for exactly the characters you passed, so a dot means a dot; replaceAll and replaceFirst compile their first argument as a regular expression, so a dot means any character and "usr.local.bin".replaceAll(".", "/") comes back as thirteen slashes. Both families return a new String and leave the receiver alone, so the result only exists if you keep it.
The second argument is interpreted too, and that catches people out more often than the pattern does. Inside replaceAll and replaceFirst the replacement is a small template: $1 pastes back whatever capture group 1 matched and a backslash escapes the next character, so a price like $9.99 is read as "group 9, then .99" and fails because there is no group 9. When the replacement is data rather than a template, wrap it in Matcher.quoteReplacement, or use plain replace, which has no template language at all; Pattern.quote does the mirror job for the search side.
Joining is the operation that puts a separator between pieces and nowhere else, which is why String.join exists instead of a loop that appends piece + ", " and then chops off the tail. It takes varargs or any Iterable of CharSequence, so a List<String> goes straight in, while a List<Integer> will not compile because join never calls toString on your behalf. When the result has to be wrapped in brackets, a SELECT clause, or a JSON array, reach for StringJoiner: it owns the prefix, the suffix, and an optional stand-in for the empty case, so the surrounding text is decided once rather than at every append.
import java.util.List;
public class ReplaceAndJoin {
public static void main(String[] args) {
String path = "usr.local.bin";
System.out.println(path.replace(".", "/")); // literal dot
System.out.println(path.replaceAll(".", "/")); // any character
System.out.println(path.replaceAll("\\.", "/")); // escaped dot
List<String> crumbs = List.of("Home", "Docs", "Java");
System.out.println(String.join(" > ", crumbs));
String original = "a-b-c";
System.out.println(original + " " + original.replace('-', '+'));
}
}replace treats both of its arguments as plain text while replaceAll parses the pattern and expands the replacement as regex syntax, and join exists so a delimiter lands only between pieces.
Worked examples
The replacement string is a template
Shows $1-style back-references and what happens when a dollar sign is meant literally.
import java.util.regex.Matcher;
public class ReplacementTemplates {
public static void main(String[] args) {
String date = "2026-09-03";
System.out.println(date.replaceAll("(\\d{4})-(\\d{2})-(\\d{2})", "$3/$2/$1"));
String label = "cost";
System.out.println(label.replace("cost", "$9.99"));
try {
System.out.println(label.replaceAll("cost", "$9.99"));
} catch (RuntimeException e) {
System.out.println("replaceAll rejected the replacement text");
}
System.out.println(label.replaceAll("cost", Matcher.quoteReplacement("$9.99")));
}
}Example explained
Line 1"$3/$2/$1" reorders the three captured groups, so one call parses the date and reassembles it day-first.
Line 2replace("cost", "$9.99") is safe because neither argument is parsed; the dollar sign is just a character.
Line 3The same replacement through replaceAll throws (IndexOutOfBoundsException: No group 9) because $9 asks for capture group 9 and the pattern has no groups.
Line 4Matcher.quoteReplacement escapes the $ so identical text survives the regex path.
StringJoiner for wrapped output
Builds a clause with a prefix and suffix and shows the empty-value stand-in.
import java.util.StringJoiner;
public class JoinPieces {
public static void main(String[] args) {
StringJoiner sql = new StringJoiner(", ", "SELECT ", " FROM users");
sql.add("id").add("email").add("created_at");
System.out.println(sql);
StringJoiner tags = new StringJoiner(", ", "[", "]");
tags.setEmptyValue("(none)");
System.out.println(tags);
tags.add("first");
System.out.println(tags);
System.out.println(String.join("/", "2026", "09", "03"));
}
}Example explained
Line 1The three-argument constructor order is delimiter, prefix, suffix; prefix and suffix appear once, not per element.
Line 2add returns the joiner itself, which is why the three column names chain in one statement.
Line 3setEmptyValue is used only while nothing has been added, so the brackets disappear in that case and come back after the first add.
Line 4String.join is the varargs short form for when there is no prefix or suffix to manage.
replaceFirst, replaceAll, and literal replace
Compares one-match rewriting, every-match rewriting, and a literal search that finds nothing.
public class FirstVersusAll {
public static void main(String[] args) {
String log = "ERROR: disk full; ERROR: retry failed";
System.out.println(log.replaceFirst("ERROR", "WARN"));
System.out.println(log.replaceAll("ERROR", "WARN"));
System.out.println(log.replaceAll("ERROR|WARN", "note"));
System.out.println(log.replace("ERROR|WARN", "note"));
}
}Example explained
Line 1replaceFirst stops after the leftmost match, leaving the second ERROR alone.
Line 2replaceAll keeps scanning from the end of each match, so both occurrences change in one pass.
Line 3In ERROR|WARN the bar is regex alternation, which is why replaceAll matches either spelling.
Line 4Handed to replace, the same argument is searched for as the literal ten-character text ERROR|WARN, which never occurs, so the original string is returned unchanged.
Important notes
String.join renders a null element as the four characters null rather than skipping it; only a null delimiter or null array throws NullPointerException.
replaceAll compiles a fresh Pattern on every call, so inside a loop build Pattern.compile(regex) once and call matcher(text).replaceAll(replacement).
Common mistakes
Reaching for replaceAll out of habit: "1.2.3".replaceAll(".", "-") matches every character rather than the dots and returns "-----".
Writing s.replace("a", "b"); as a bare statement and expecting s to change; the new string is discarded and the bug is silent because nothing throws.
Feeding user input or a Windows path into the replacement argument of replaceAll: a $ is read as a group reference and throws at runtime, and a trailing backslash throws as well.
Try it yourself
Change, predict, then run
Turn List.of("api", "v2", "users") into /api/v2/users using a StringJoiner whose delimiter and prefix are both "/", then use a single replaceAll with back-references to rewrite "2026-09-03" as "03.09.2026".
Open the Java workspaceCheck your understanding
A template string contains the placeholder PRICE and you need to substitute the exact text $5 (net). Which call does that without throwing?
- s.replace("PRICE", "$5 (net)")
- s.replaceAll("PRICE", "$5 (net)")
- s.replaceAll(Pattern.quote("PRICE"), "$5 (net)")
- s.replaceFirst("PRICE", "$5 (net)")
Show answer
replace parses neither argument, so $5 (net) is inserted character for character. The Pattern.quote version is tempting but it only protects the search side; the replacement is still expanded by the matcher, so $5 is read as capture group 5 and the call throws, exactly like the plain replaceAll and replaceFirst versions.