skip to content

How does Collectors.joining work, including its delimiter, prefix, and suffix overloads, and when is it preferable to manual string concatenation in a stream?

level: juniorimportance: should knowfreq 58%

answer

  1. joining() / joining(delim) / joining(delim, prefix, suffix)
  2. delimiter goes BETWEEN only
  3. elements must be CharSequence -> map to String first
  4. backed by StringJoiner/StringBuilder, O(n)
  5. empty + prefix/suffix -> just prefix+suffix

basics

~20 s

Collectors.joining() concatenates the stream's CharSequence elements into one String. Overloads let you add a separator between elements, plus an optional prefix and suffix, e.g. joining(", ", "[", "]") turns names into "[a, b, c]". It only works on CharSequence elements, so map to String first.

solid answer

~40 s

joining is a collector for String elements (more precisely CharSequence). joining() just concatenates; joining(delimiter) inserts the delimiter between elements only — not at the ends; joining(delimiter, prefix, suffix) wraps the whole result. Internally it uses a StringBuilder via a StringJoiner, so it's a single efficient pass rather than repeated string '+' concatenation, which would create many intermediate strings. The elements must be CharSequence, so for non-string elements you map(...) to String first, e.g. items.stream().map(Item::name).collect(joining(", ", "[", "]")). It correctly handles the empty stream: joining() returns "", and the prefix/suffix overload returns just prefix+suffix. Prefer it over reduce("", String::concat) or manual loops because it's clearer, avoids the leading/trailing delimiter bug, and is O(n) on a StringBuilder.

go deeper

for a junior

Knows joining concatenates strings and that overloads add a delimiter plus optional prefix/suffix; can map objects to strings first.

for a middle

Knows the delimiter is between-only, the empty-stream behavior, and that it is StringBuilder-backed and O(n); uses it as a groupingBy downstream.

for a senior

Explains why joining beats reduce/manual concat (complexity + correctness), and combines mapping+joining as a downstream cleanly.

for a principal

Advises on string-building performance broadly, recognizes StringJoiner under the hood, and sets idioms to prevent O(n^2) concat anti-patterns in code review.

## What it solves You often have a stream of strings and want a single combined string: a comma-separated list, a bracketed array-like rendering, a sentence. `Collectors.joining` is the built-in collector for exactly that. ## Requirement: CharSequence elements `joining` only accepts elements that are `CharSequence` — the interface `String`, `StringBuilder`, etc. implement. If your stream holds other objects, you must first `map` them to strings: `people.stream().map(Person::getName)`. ## The three overloads 1. **`joining()`** — concatenates everything with nothing between. `Stream.of("a","b","c")` → `"abc"`. 2. **`joining(delimiter)`** — inserts the delimiter *between* adjacent elements only, never before the first or after the last. → `"a, b, c"` with `", "`. 3. **`joining(delimiter, prefix, suffix)`** — adds the prefix once at the very start and the suffix once at the very end, with the delimiter between elements. With `(", ", "[", "]")` → `"[a, b, c]"`. ## How it works internally It is backed by a **`StringJoiner`**, which wraps a `StringBuilder`. Each element is appended; the delimiter logic is handled so there's no trailing separator. This is a single linear pass that grows one buffer. Compare with the naive alternatives: - `reduce("", (a,b) -> a + b)` creates a brand-new `String` at every step → O(n²) characters copied. - A manual loop with `result += x` has the same quadratic problem, and hand-managing "don't add a delimiter before the first element" is a classic off-by-one bug. `joining` avoids both: it is O(n) and delimiter-correct. ## Empty-stream behavior - `joining()` on an empty stream → `""`. - `joining(", ")` on empty → `""`. - `joining(", ", "[", "]")` on empty → `"[]"` (prefix + suffix only, no delimiter). This is exactly what `StringJoiner` guarantees (its `setEmptyValue` is not used here, so prefix+suffix is the empty representation). ## As a downstream Because it's a collector, `joining` is frequently used as a **downstream** of `groupingBy`, e.g. group people by city and join their names: `groupingBy(Person::city, mapping(Person::name, joining(", ")))` → `Map<City, String>`. ## Term glossary - **CharSequence**: a read-only sequence of characters; the common supertype of `String` and `StringBuilder`. - **StringJoiner**: a small utility that builds a delimited string with optional prefix/suffix. - **Downstream collector**: a collector used to aggregate each group of a `groupingBy`/`partitioningBy`.

  • Why is joining preferable to string '+' inside a reduce?
    joining uses one StringBuilder (O(n)); reduce with '+' creates a new String each step (O(n^2) character copies) and risks delimiter off-by-one errors.
  • How do you join names of objects rather than strings?
    Map first: stream.map(Obj::getName).collect(Collectors.joining(", ")). joining requires CharSequence elements.

saying these in an interview costs you the question

  • Thinking the delimiter is added at the ends too
  • Using joining on non-String streams without mapping first
  • Reaching for reduce("", String::concat) which is O(n^2)
  • Assuming an empty stream with prefix/suffix returns empty string

context