☕ Java

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

Joining strings with a separator is one of the most common string operations in programming — building CSV rows, constructing SQL IN clauses, formatting lists for display. The naive approach with StringBuilder requires special-casing the last element to avoid a trailing delimiter, or trimming the trailing separator after the loop. Both approaches are error-prone and verbose. The classic StringBuilder delimiter pattern requires a conditional on every iteration: if not the first element, append the delimiter first, then append the element. This works but clutters the loop body with housekeeping logic rather than the actual content being built. An alternative — append element then delimiter, then trim the last delimiter at the end — requires knowing the delimiter length and is fragile if the delimiter contains special characters. StringJoiner eliminates this problem entirely. It internally tracks whether any element has been added yet, and prepends the delimiter automatically before every element after the first. The calling code adds elements unconditionally; the class handles the separator logic. The result is that the loop body contains only the business logic (what to add) without any delimiter housekeeping. This separation of concerns is the essence of what StringJoiner provides.
Java
// ── 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

StringJoiner is constructed with a required delimiter and optional prefix and suffix. The two-argument constructor new StringJoiner(delimiter) creates a joiner with no prefix or suffix. The three-argument constructor new StringJoiner(delimiter, prefix, suffix) creates a joiner whose output is surrounded by the prefix and suffix. The most important edge case is the empty joiner — a StringJoiner with no elements added. By default, an empty StringJoiner with prefix and suffix returns just the prefix and suffix concatenated: new StringJoiner(", ", "[", "]").toString() returns "[]". This may not always be the desired output. The setEmptyValue() method customises what an empty joiner returns. This is valuable for user-facing output where "no items" should show a meaningful message rather than empty brackets, or for programmatic use where an empty joiner should return null or empty string rather than "[]". The toString() method produces the final string. StringJoiner also implements toString() automatically, so it can be used directly in string concatenation and println calls. The length() method returns the length of the current string representation including prefix, suffix, and delimiter, which is useful for sizing decisions.
Java
// ── 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, z

Merging StringJoiners

The merge() method combines two StringJoiners. The other joiner's content — without its prefix, suffix, or empty value — is appended to the current joiner as if each element of the other joiner had been individually added. This enables parallel construction of sub-joiners that are later combined, a pattern useful in parallel stream processing or in code that builds different sections of a string independently and assembles them at the end. The merge behaviour preserves the current joiner's delimiter, prefix, and suffix. The other joiner contributes only its accumulated content. If the other joiner is empty (no elements added), merging it has no effect — the current joiner's state is unchanged and the empty value of the other joiner is not used. This is the mechanism that Collectors.joining() uses internally when parallel streams split work across multiple threads: each thread accumulates into its own StringJoiner, and the partial results are merged at the end. Understanding merge() explains how joining works correctly in parallel even though each thread works independently.
Java
// ── 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, h

String.join() and Collectors.joining()

String.join() is a static convenience method backed by StringJoiner. It takes a delimiter and either a varargs of CharSequences or an Iterable<? extends CharSequence>. It is the simplest way to join a known list of strings — no StringJoiner creation, no loop, one line. String.join() does not support prefix/suffix, but for delimiter-only joining it is the most concise option. Collectors.joining() is the stream counterpart — it collects a Stream<String> into a single joined string. It comes in three variants: no arguments (concatenation with no separator), one argument (delimiter only), and three arguments (delimiter, prefix, suffix). It is implemented with StringJoiner internally and handles parallel stream merging correctly through the joiner's merge() method. The choice between these mechanisms follows a natural hierarchy: use String.join() for a pre-existing collection with a simple delimiter, use Collectors.joining() when already working with a stream, use StringJoiner directly when needing the empty value customisation, merge(), or incremental building logic that does not fit neatly into a stream pipeline.
Java
// ── 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

Related Topics in Strings

String Class
String is one of the most fundamental classes in Java — used in virtually every program, yet deeply misunderstood by many developers. A String represents an immutable sequence of Unicode characters. It is not a primitive type but a full class in java.lang, automatically imported into every Java file. Understanding String means understanding how it is stored in memory, why it is immutable, how the string pool works, what the difference between == and equals() means for strings, and how to use the class efficiently. This entry covers String's nature as a class, its internal representation, the critical distinction between reference equality and value equality, String's place in the type hierarchy, and the design decisions that make String behave the way it does.
String Pool
The string pool (also called the string intern pool or string constant pool) is a special memory region maintained by the JVM that stores a single copy of each unique string value. When two string literals have the same content, they refer to the same object in the pool rather than two separate objects. The pool is a flyweight pattern applied at the language level — it dramatically reduces memory consumption in applications that use many repeated string values, which is nearly every application. This entry covers how the pool works, where it lives in JVM memory, how to interact with it programmatically, the intern() method, performance implications, and when to use or avoid pool entries.
Immutable String
String immutability is the most important design decision in Java's String class. Once a String object is created, its character sequence can never change. No method on String modifies the string; every method that appears to modify returns a new String object containing the result. This design decision drives thread safety, enables the string pool, makes strings safe hash map keys, and simplifies reasoning about string values. Understanding why String is immutable, how immutability is enforced, and what the consequences of immutability are clarifies the behaviour of virtually every piece of Java code that handles strings.
Mutable String
Java provides two mutable string classes for scenarios where String's immutability would be inefficient: StringBuilder and StringBuffer. Both maintain an internal character buffer that can be modified in place — characters can be appended, inserted, deleted, and replaced without creating new objects. StringBuilder is the modern choice for single-threaded use; StringBuffer is the legacy thread-safe version with synchronised methods. This entry covers the internal buffer mechanics, the full API of both classes, performance characteristics, when to use each, thread safety implications, and the complete patterns for efficient string construction.