StringJoiner
StringJoiner is a utility class introduced in Java 8 that builds a sequence of strings separated by a delimiter, with optional prefix and suffix. It solves the classic delimiter problem — joining a list of items with a separator while avoiding a trailing separator on the last element — cleanly and without manual index tracking. StringJoiner is the foundation behind String.join() and the Collectors.joining() stream collector. Understanding StringJoiner means understanding the delimiter problem it solves, how it handles edge cases like empty joiners, how it integrates with streams, and when it is more appropriate than StringBuilder. This entry covers the full API, the empty value feature, merging joiners, stream integration, and practical patterns.
The Delimiter Problem StringJoiner Solves
// ── The problem StringJoiner solves ──────────────────────────────────
// NAIVE approach — conditional delimiter logic pollutes the loop
List<String> names = List.of("Alice", "Bob", "Carol", "Dave");
// Approach 1: check if first
StringBuilder sb = new StringBuilder();
boolean first = true;
for (String name : names) {
if (!first) sb.append(", ");
sb.append(name);
first = false;
}
System.out.println(sb); // Alice, Bob, Carol, Dave
// Approach 2: append then trim trailing separator
StringBuilder sb2 = new StringBuilder();
for (String name : names) {
sb2.append(name).append(", ");
}
if (sb2.length() > 0) sb2.setLength(sb2.length() - 2);
System.out.println(sb2); // Alice, Bob, Carol, Dave
// ── StringJoiner — clean solution ────────────────────────────────────
StringJoiner sj = new StringJoiner(", ");
for (String name : names) {
sj.add(name); // no conditional, no trimming
}
System.out.println(sj); // Alice, Bob, Carol, Dave
// ── With prefix and suffix ────────────────────────────────────────────
StringJoiner sj2 = new StringJoiner(", ", "[", "]");
for (String name : names) {
sj2.add(name);
}
System.out.println(sj2); // [Alice, Bob, Carol, Dave]
// ── SQL IN clause ─────────────────────────────────────────────────────
List<Long> ids = List.of(1L, 2L, 3L, 4L, 5L);
StringJoiner inClause = new StringJoiner(", ", "WHERE id IN (", ")");
for (Long id : ids) {
inClause.add(String.valueOf(id));
}
System.out.println(inClause); // WHERE id IN (1, 2, 3, 4, 5)Construction, API, and the Empty Value
// ── Constructor variants ─────────────────────────────────────────────
StringJoiner simple = new StringJoiner(", "); // no prefix/suffix
StringJoiner bracketed = new StringJoiner(", ", "[", "]"); // with prefix/suffix
StringJoiner sqlSet = new StringJoiner(", ", "(", ")"); // SQL tuple
// ── add() returns this — supports chaining ────────────────────────────
String result = new StringJoiner(", ")
.add("Alice")
.add("Bob")
.add("Carol")
.toString();
System.out.println(result); // Alice, Bob, Carol
// ── Empty joiner behaviour ────────────────────────────────────────────
StringJoiner empty1 = new StringJoiner(", ");
System.out.println(empty1.toString()); // "" (empty, no prefix/suffix)
System.out.println(empty1.length()); // 0
StringJoiner empty2 = new StringJoiner(", ", "[", "]");
System.out.println(empty2.toString()); // "[]" — just prefix + suffix
// ── setEmptyValue() — customise what empty returns ────────────────────
StringJoiner withDefault = new StringJoiner(", ", "[", "]");
withDefault.setEmptyValue("(no items)");
System.out.println(withDefault); // (no items) — not "[]"
withDefault.add("Alice");
System.out.println(withDefault); // [Alice] — empty value ignored
// ── length() — current output length ─────────────────────────────────
StringJoiner sj = new StringJoiner(", ", "[", "]");
System.out.println(sj.length()); // 2 ("[]".length())
sj.add("Alice");
System.out.println(sj.length()); // 7 ("[Alice]".length())
sj.add("Bob");
System.out.println(sj.length()); // 12 ("[Alice, Bob]".length())
// ── toString() called implicitly ──────────────────────────────────────
StringJoiner sj2 = new StringJoiner(", ");
sj2.add("x").add("y").add("z");
System.out.println(sj2); // calls sj2.toString() implicitly
String s = "Items: " + sj2; // toString() called by + operator
System.out.println(s); // Items: x, y, zMerging StringJoiners
// ── merge() — combining two joiners ──────────────────────────────────
StringJoiner main = new StringJoiner(", ", "[", "]");
StringJoiner other = new StringJoiner(", ", "{", "}");
main.add("Alice").add("Bob");
other.add("Charlie").add("Dave");
main.merge(other); // other's content (without {}) added to main
System.out.println(main); // [Alice, Bob, Charlie, Dave]
// main's delimiter ", " and brackets "[", "]" are preserved
// other's "{", "}" and delimiter are ignored in the merge
// ── Merging preserves main's structure ────────────────────────────────
StringJoiner a = new StringJoiner(" | ", "<<", ">>");
StringJoiner b = new StringJoiner(" | ");
a.add("A1").add("A2");
b.add("B1").add("B2").add("B3");
a.merge(b);
System.out.println(a); // <<A1 | A2 | B1 | B2 | B3>>
// ── Merging empty joiner — no effect ─────────────────────────────────
StringJoiner base = new StringJoiner(", ");
StringJoiner empty = new StringJoiner(", ");
empty.setEmptyValue("EMPTY");
base.add("X").add("Y");
base.merge(empty); // empty joiner — no elements added
System.out.println(base); // X, Y — empty merge had no effect
// ── Parallel construction and merge ──────────────────────────────────
StringJoiner first = new StringJoiner(", ");
StringJoiner second = new StringJoiner(", ");
StringJoiner third = new StringJoiner(", ");
// Different parts built independently (e.g., by different code paths)
List.of("a", "b", "c").forEach(first::add);
List.of("d", "e").forEach(second::add);
List.of("f", "g", "h").forEach(third::add);
// Assemble in order
first.merge(second).merge(third);
System.out.println(first); // a, b, c, d, e, f, g, hString.join() and Collectors.joining()
// ── String.join() ────────────────────────────────────────────────────
// Varargs form
String s1 = String.join(", ", "Alice", "Bob", "Carol");
System.out.println(s1); // Alice, Bob, Carol
// Iterable form
List<String> names = List.of("Alice", "Bob", "Carol");
String s2 = String.join(" | ", names);
System.out.println(s2); // Alice | Bob | Carol
// Path construction
String path = String.join("/", "usr", "local", "bin", "java");
System.out.println(path); // usr/local/bin/java
// ── Collectors.joining() — stream integration ─────────────────────────
List<String> words = List.of("hello", "world", "java");
// No separator
String concat = words.stream().collect(Collectors.joining());
System.out.println(concat); // helloworldjava
// Delimiter only
String withSep = words.stream().collect(Collectors.joining(", "));
System.out.println(withSep); // hello, world, java
// Delimiter + prefix + suffix
String full = words.stream()
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(full); // [hello, world, java]
// ── Practical stream joining patterns ─────────────────────────────────
record Person(String name, int age) {}
List<Person> people = List.of(
new Person("Alice", 30),
new Person("Bob", 25),
new Person("Carol", 35)
);
// Join names of people over 28
String result = people.stream()
.filter(p -> p.age() > 28)
.map(Person::name)
.collect(Collectors.joining(", "));
System.out.println(result); // Alice, Carol
// Build SQL IN clause from stream
String inClause = ids.stream()
.map(String::valueOf)
.collect(Collectors.joining(", ", "id IN (", ")"));
System.out.println(inClause); // id IN (1, 2, 3, 4, 5)
// ── Decision guide ────────────────────────────────────────────────────
// String.join() → joining a collection with delimiter, one line
// Collectors.joining() → already in a stream pipeline
// StringJoiner → need setEmptyValue(), merge(), or incremental adds
// StringBuilder → complex conditions, not a simple join