Back to Blog
Java

Java Collectors Joining: Concatenating Stream Elements

java collectors joining: Learn how Collectors.joining() concatenates stream elements into a single String, covering all three overloads, null handling, performance beh...

Java Stream APICollectorsString ConcatenationJava 8
Editorial illustration showing multiple data blocks merging into a single string line, representing Java's Collectors.joining() stream collector

Collectors.joining() is the standard java collectors joining method for concatenating stream elements into a single String. It is a static factory method in java.util.stream.Collectors that returns a Collector which concatenates the string representations of stream elements without writing manual loop logic.

What Collectors.joining() Does

The method has three overloads:

Collectors.joining() Collectors.joining(CharSequence delimiter) Collectors.joining(CharSequence delimiter, CharSequence prefix, CharSequence suffix)

The no-argument version concatenates elements with no separator. The single-argument version inserts a delimiter between consecutive elements. The three-argument version adds a prefix before the first element and a suffix after the last element. All three return a Collector that operates on a stream of CharSequence elements.

Basic Usage with a Delimiter

The most common use case is producing a comma-separated list from a collection or stream:

List<String> names = List.of("Ada", "Grace", "Alan"); String result = names.stream() .collect(Collectors.joining(", ")); // result: "Ada, Grace, Alan"

The delimiter is inserted only between elements, not before the first or after the last. This behavior distinguishes joining() from manual string building, where you would have to track whether you are at the first element to avoid a trailing delimiter.

Adding a Prefix and Suffix

The three-argument overload is useful when the output needs surrounding brackets or other wrappers:

List<Integer> numbers = List.of(1, 2, 3); String bracketed = numbers.stream() .map(String::valueOf) .collect(Collectors.joining(", ", "[", "]")); // result: "[1, 2, 3]"

The prefix and suffix are added unconditionally, even when the stream is empty. An empty stream with the three-argument overload produces just the concatenation of prefix and suffix:

List<String> empty = List.of(); String result = empty.stream() .collect(Collectors.joining(", ", "{", "}")); // result: "{}"

This is useful for generating JSON-like structures or SQL IN clauses where the wrapper must always be present.

How Null Elements Behave

Collectors.joining() internally uses StringBuilder.append(). When a stream element is null, the append call writes the literal text "null" rather than throwing an exception. This is a frequent source of subtle bugs because the output contains "null" where you might expect an error or an empty string.

List<String> values = Arrays.asList("a", null, "c"); String result = values.stream() .collect(Collectors.joining(", ")); // result: "a, null, c"

If you need to skip null elements or replace them with a placeholder, filter or map before collecting:

String result = values.stream() .filter(Objects::nonNull) .collect(Collectors.joining(", ")); // result: "a, c"

Performance Behavior

The collector is designed for efficiency. It accumulates elements into a StringBuilder internally, which avoids repeated string allocation that would occur with naive + concatenation inside a loop. For large streams, this matters because StringBuilder grows its internal buffer with amortized O(n) cost over the total characters appended.

There is one caveat: the collector does not know the total number of characters in advance. If you are joining a very large collection and know the approximate total length, you can pre-size a StringBuilder manually and use a loop instead. For the common case of a few hundred or thousand elements, joining() is the right default because it keeps the code declarative and the performance is comparable to a hand-written loop.

When to Choose Alternatives

String.join() is a simpler alternative when you already have a List<String> or an Iterable of strings and do not need the stream pipeline:

String result = String.join(", ", names);

This is more direct than stream().collect(Collectors.joining(", ")) when no filtering, mapping, or other stream operations are needed. However, String.join() only accepts CharSequence elements, so you cannot use it directly on a stream of integers without mapping to strings first.

Collectors.joining() is the better choice when you are already inside a stream pipeline and need to filter, map, or transform elements before concatenation. For example:

String activeUserNames = users.stream() .filter(User::isActive) .map(User::getName) .collect(Collectors.joining(", "));

Common Mistakes and Edge Cases

One common mistake is applying joining() to a stream of non-string objects without calling map. The collector requires CharSequence elements, so a stream of Integer values will not compile:

// This does not compile: List<Integer> numbers = List.of(1, 2, 3); String result = numbers.stream().collect(Collectors.joining(", "));

You must map to String first:

String result = numbers.stream() .map(String::valueOf) .collect(Collectors.joining(", "));

Another edge case is the empty stream with the two-argument overload, which produces an empty string. This is usually the desired behavior, but if you need a fallback value, use orElse on the result or check isEmpty() before joining.

When the Collector Is Not a Good Fit

Collectors.joining() is not suitable when you need to build a string incrementally across multiple method calls or when the concatenation logic is spread across different parts of the codebase. In those cases, a StringBuilder passed as a parameter or held as a field is more appropriate because it preserves the accumulated state.

Similarly, if the joining logic requires conditional separators based on element properties, a manual loop with a StringBuilder gives you more control. The collector's delimiter is fixed for the entire operation.

The collector is also not designed for very large outputs where you need to stream results to a file or network sink. It materializes the entire string in memory. For output that exceeds available heap, you would need a different approach such as writing each element directly to a Writer.

java collectors joining: Practical Usage and Code Examples | RYUSLOG DEV