Building a comma-separated string is one of those jobs that looks trivial and produces a trailing
comma every time. StringJoiner is the small class Java 8 added to stop that happening,
and two shorter forms cover most of what you would use it for.
The problem
Everyone writes this once, and everyone gets the same bug:
class Demo {
void run() {
List<String> names = List.of("Ana", "Bo", "Cy");
StringBuilder sb = new StringBuilder();
for (String name : names) {
sb.append(name).append(", ");
}
System.out.println(sb); // "Ana, Bo, Cy, " <- trailing comma
// The usual fix, and it throws on an empty list
String fixed = sb.substring(0, sb.length() - 2);
System.out.println(fixed); // "Ana, Bo, Cy"
}
}
The trailing separator, the off-by-two on the trim, and the empty-input crash are three separate bugs in five lines. All three go away if the separator logic is not yours to write.
StringJoiner
class Demo {
void run() {
StringJoiner joiner = new StringJoiner(", ");
joiner.add("Ana");
joiner.add("Bo");
joiner.add("Cy");
System.out.println(joiner); // Ana, Bo, Cy — no trailing comma
// Delimiter, prefix, suffix
StringJoiner bracketed = new StringJoiner(", ", "[", "]");
bracketed.add("Ana").add("Bo");
System.out.println(bracketed); // [Ana, Bo]
}
}
The separator goes between elements, so a single element produces no separator at all and
the empty case is handled rather than crashing. add returns the joiner, so calls
chain.
The empty case
This is the detail that makes it worth using over a hand-rolled loop. An empty joiner produces its prefix and suffix, and you can override that:
class Demo {
void run() {
StringJoiner empty = new StringJoiner(", ", "[", "]");
System.out.println(empty); // []
StringJoiner custom = new StringJoiner(", ", "[", "]");
custom.setEmptyValue("nothing here");
System.out.println(custom); // nothing here
custom.add("Ana");
System.out.println(custom); // [Ana] — empty value no longer applies
System.out.println(custom.length()); // 5
}
}
setEmptyValue only applies while nothing has been added. The moment there is one
element, the prefix and suffix take over again.
Merging joiners
class Demo {
void run() {
StringJoiner first = new StringJoiner(", ", "[", "]");
first.add("Ana").add("Bo");
StringJoiner second = new StringJoiner(", ", "[", "]");
second.add("Cy");
first.merge(second);
System.out.println(first); // [Ana, Bo, Cy]
}
}
merge takes the contents of the other joiner, not its prefix and suffix. That
is deliberate and slightly surprising the first time — merging [Cy] in gives you
Cy, not [Cy]. It is also the mechanism that lets a parallel stream combine
partial results, which is how Collectors.joining works underneath.
The two shorter forms
In real code you will rarely construct a StringJoiner directly. Two wrappers cover
almost every case.
String.join — when you already have the pieces:
class Demo {
void run() {
System.out.println(String.join(", ", "Ana", "Bo")); // Ana, Bo
System.out.println(String.join(", ", List.of("Ana", "Bo"))); // Ana, Bo
System.out.println(String.join("/", "usr", "local", "bin")); // usr/local/bin
}
}
Collectors.joining — when the pieces come out of a stream, which is
where you need to transform them first:
record Person(String name, int age) { }
class Demo {
void run() {
List<Person> people = List.of(new Person("Ana", 30), new Person("Bo", 25));
System.out.println(people.stream()
.map(Person::name)
.collect(Collectors.joining(", ", "[", "]"))); // [Ana, Bo]
// Transforming on the way through is the reason to prefer this over String.join
System.out.println(people.stream()
.filter(p -> p.age() >= 30)
.map(p -> p.name().toUpperCase())
.collect(Collectors.joining(" and "))); // ANA
}
}
StringJoiner versus StringBuilder
They look similar and solve different problems, which is worth being clear about because reaching for the wrong one is how the trailing-comma bug gets written in the first place.
StringBuilder is a general mutable buffer. You control every character that goes into
it, including separators, and it can build anything — a sentence, an HTML fragment, a log line.
StringJoiner is a special case of that: a list of items with something between them. It
gives up generality and in exchange it handles the separator, the empty case and the prefix/suffix
for you.
The performance difference is not a reason to choose either. StringJoiner is backed
by a StringBuilder, so it costs one extra object and no measurable time. Choose on what
you are building, not on speed.
One practical note: StringJoiner is not thread-safe, and neither is
StringBuilder. Neither is intended to be shared across threads — build a string on one
thread, or let Collectors.joining handle the combining, which it does correctly even on
a parallel stream because of the merge mechanism above.
Which to use
| Use | When |
|---|---|
String.join | you have a collection or varargs of strings already |
Collectors.joining | you are in a stream, or need to map before joining |
StringJoiner | you are adding elements conditionally across several statements |
StringBuilder | you are building a string that is not a delimited list |
That third row is the case where the class itself still earns its place — when elements are added from different branches and there is no collection to stream:
class Query {
String build(String name, Integer minAge, boolean activeOnly) {
StringJoiner where = new StringJoiner(" AND ", "WHERE ", "");
where.setEmptyValue(""); // no conditions -> no WHERE clause
if (name != null) where.add("name = ?");
if (minAge != null) where.add("age >= ?");
if (activeOnly) where.add("active = true");
return "SELECT * FROM customers " + where;
}
}
That is the shape worth remembering: conditional pieces, a separator between whatever survives, and a sensible result when nothing does.
Next
Array Parallel Sort is next — one method that uses every core you have, and the conditions under which it is actually faster.