StringJoiner, String.join() and Collectors.joining()

StringJoiner assembles a sequence of strings with a delimiter, an optional prefix, and an optional suffix, giving you [a, b, c] or comma-separated values without manual StringBuilder bookkeeping. String.join() is the one-line shorthand for the common case. Collectors.joining() brings the same capability to streams. This article covers all three with practical examples and guidance on when to choose each.

The Problem StringJoiner Solves

Joining a list of values with a separator is one of the most common string-building tasks in Java, generating a CSV row, building a SQL IN clause, formatting a list for a log message. Before Java 8, the only way to do it without a library was a StringBuilder loop with manual delimiter management:

Java
// Pre-Java-8 pattern — error-prone trailing-delimiter problem
List<String> names = List.of("Alice", "Bob", "Carol");
StringBuilder sb = new StringBuilder();
for (String name : names) {
    sb.append(name).append(", ");  // adds trailing comma after last element!
}
// Must strip the trailing ", " — easy to forget or get wrong
String result = sb.substring(0, sb.length() - 2);
System.out.println(result); // Alice, Bob, Carol

The pattern has two failure modes: forgetting to trim the trailing delimiter, and mishandling the edge case where the list is empty. StringJoiner, introduced in Java 8, eliminates both. It knows not to add the delimiter before the first element or after the last, and it handles the empty case with a configurable empty value.

Basic StringJoiner Usage

StringJoiner lives in java.util and has two constructors. The single-argument constructor takes only a delimiter. The three-argument constructor adds a prefix and suffix: useful for producing JSON arrays, SQL IN clauses, or bracketed lists. The add() method appends one element, and toString() produces the final string. If no elements have been added, toString() returns the empty value (which defaults to an empty string, but can be overridden with setEmptyValue()).

Java
import java.util.StringJoiner;

// Simple delimiter
StringJoiner sj = new StringJoiner(", ");
sj.add("Alice");
sj.add("Bob");
sj.add("Carol");
System.out.println(sj); // Alice, Bob, Carol

// With prefix and suffix
StringJoiner bracketed = new StringJoiner(", ", "[", "]");
bracketed.add("one");
bracketed.add("two");
bracketed.add("three");
System.out.println(bracketed); // [one, two, three]

// SQL IN clause
StringJoiner inClause = new StringJoiner(", ", "IN (", ")");
inClause.add("'NY'");
inClause.add("'LA'");
inClause.add("'CH'");
System.out.println(inClause); // IN ('NY', 'LA', 'CH')

// Empty value — returned when no elements are added
StringJoiner empty = new StringJoiner(", ", "[", "]");
empty.setEmptyValue("(none)");
System.out.println(empty); // (none)
empty.add("x");
System.out.println(empty); // [x]  — prefix/suffix appear once an element is added

// Merging two joiners
StringJoiner first = new StringJoiner(", ");
first.add("a").add("b");
StringJoiner second = new StringJoiner(", ");
second.add("c").add("d");
first.merge(second);
System.out.println(first); // a, b, c, d

Note: merge(other) appends the content of other using this joiner's delimiter, ignoring the other joiner's delimiter, prefix, and suffix.

String.join(): The One-Line Shorthand

String.join(), also added in Java 8, is a static convenience method backed by StringJoiner internally. It covers the most common case, joining a fixed set of elements or a collection with a delimiter, in a single expression, with no need to instantiate a StringJoiner or call toString(). It has two overloads: one that accepts varargs, and one that accepts any Iterable.

Java
// Varargs overload
String csv = String.join(", ", "Alice", "Bob", "Carol");
System.out.println(csv); // Alice, Bob, Carol

// Iterable overload — works with List, Set, any Collection
List<String> cities = List.of("New York", "London", "Tokyo");
String joined = String.join(" | ", cities);
System.out.println(joined); // New York | London | Tokyo

// Joining an array
String[] langs = {"Java", "Kotlin", "Scala"};
System.out.println(String.join(", ", langs)); // Java, Kotlin, Scala

// Splitting and rejoining (e.g., normalising separators)
String path = "/usr/local/bin/java";
String[] parts = path.split("/");
String rejoin = String.join("\\", parts); // Windows-style path
String.join() does not support prefix or suffix. If you need [a, b, c] rather than a, b, c, use StringJoiner directly or post-process with "[" + String.join(", ", list) + "]".

Collectors.joining(): Stream-Based Joining

Collectors.joining() is the stream terminal operation equivalent. It collects a Stream<String> into a single joined string, with the same three overloads as StringJoiner: no arguments (concatenate without separator), delimiter only, or delimiter plus prefix and suffix. Because it is a collector, it fits naturally at the end of a stream pipeline, after filter(), map(), or sorted().

Java
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.List;

// 1. No delimiter — simple concatenation
String concat = Stream.of("a", "b", "c").collect(Collectors.joining());
System.out.println(concat); // abc

// 2. Delimiter only
String csv = Stream.of("Alice", "Bob", "Carol")
                   .collect(Collectors.joining(", "));
System.out.println(csv); // Alice, Bob, Carol

// 3. Delimiter + prefix + suffix
String json = Stream.of("\"Alice\"", "\"Bob\"", "\"Carol\"")
                    .collect(Collectors.joining(", ", "[", "]"));
System.out.println(json); // ["Alice", "Bob", "Carol"]

// 4. Map fields before joining — common real-world pattern
record Person(String name, int age) {}
List<Person> people = List.of(
    new Person("Alice", 30),
    new Person("Bob", 25),
    new Person("Carol", 35)
);
String names = people.stream()
    .filter(p -> p.age() >= 30)
    .map(Person::name)
    .sorted()
    .collect(Collectors.joining(", "));
System.out.println(names); // Alice, Carol

// 5. Joining after a transformation
String upperCsv = List.of("java", "kotlin", "scala")
    .stream()
    .map(String::toUpperCase)
    .collect(Collectors.joining(", "));
System.out.println(upperCsv); // JAVA, KOTLIN, SCALA

Choosing Between StringJoiner, String.join(), and Collectors.joining()

All three are backed by the same mechanism and have equivalent performance. The choice is about code clarity and fit with the surrounding context.

Use String.join() when you already have a list or array and just want a one-liner with a simple delimiter. It reads cleanly and requires no imports beyond java.lang.

Use Collectors.joining() when you are already working in a stream pipeline: it fits naturally as the terminal operation and avoids the need to collect to a list first.

Use StringJoiner directly when you need to build a string conditionally (adding elements only when a condition is met), when you need to merge multiple joiners, or when you want to set a custom empty value. It is also the right choice inside utility methods that receive elements one at a time rather than as a collection.

ScenarioBest choice
Join a List or array, delimiter onlyString.join()
Join inside a stream pipelineCollectors.joining()
Need prefix/suffix (e.g., [a, b])StringJoiner or Collectors.joining(d, p, s)
Add elements conditionally one by oneStringJoiner
Custom empty valueStringJoiner.setEmptyValue()
Merge two joinersStringJoiner.merge()