Back to Collectors
Q8Question 8 of 21

Collectors.joining(delimiter, prefix, suffix): concatenate stream to String

Key Concepts
  • Collectors.joining() has three overloads: no-arg concatenates directly, one-arg adds a delimiter, three-arg adds delimiter with prefix and suffix
  • The three-argument form joining(delimiter, prefix, suffix) handles all edge cases: empty streams, single elements, and delimiter placement
  • Internally uses StringJoiner which uses StringBuilder, so performance is identical to manual concatenation
  • Handles empty streams gracefully: joining(", ", "[", "]") on an empty stream produces "[]"
  • Preferred over manual StringBuilder because it eliminates off-by-one delimiter bugs in one line
  • Works as a downstream collector inside groupingBy for per-group string concatenation
  • Stream elements must be CharSequence (usually String); use map(Object::toString) to convert

Definition

Collectors.joining() concatenates stream elements into a single String with an optional delimiter, prefix, and suffix, handling all edge cases automatically.
  • joining() – concatenates all elements with no separator: "RELIANCETCSHDFC".
  • joining(delimiter) – inserts the delimiter between elements but not before the first or after the last: "RELIANCE, TCS, HDFC".
  • joining(delimiter, prefix, suffix) – wraps the result: "[RELIANCE, TCS, HDFC]".
  • Edge cases – empty stream returns "" (no-arg) or prefix+suffix (three-arg: "[]"). Single element gets no delimiter.
  • Internals – uses StringJoiner (backed by StringBuilder), so performance matches manual concatenation.

You have 5 stock symbols and need to print them as a CSV line for an export file. The manual approach requires creating a StringBuilder, looping through symbols, adding a comma between each (but not before the first or after the last), and wrapping in brackets. Collectors.joining(", ", "[", "]") does all of this in one line with no off-by-one errors.

Understanding the Concepts

What is Collectors.joining?

Collectors.joining() is a terminal collector that concatenates all stream elements (which must be CharSequence, typically String) into a single String.

What are the three overloads?

JAVA
package com.bank.service;

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

public class ReportFormatter {

    public void demonstrate() {
        // Overload 1: no separator
        String concat = Stream.of("SAV2001", "CUR3001", "FD4001")
            .collect(Collectors.joining());
        // "SAV2001CUR3001FD4001"

        // Overload 2: delimiter only
        String csv = Stream.of("SAV2001", "CUR3001", "FD4001")
            .collect(Collectors.joining(", "));
        // "SAV2001, CUR3001, FD4001"

        // Overload 3: delimiter + prefix + suffix
        String wrapped = Stream.of("SAV2001", "CUR3001", "FD4001")
            .collect(Collectors.joining(", ", "[", "]"));
        // "[SAV2001, CUR3001, FD4001]"
    }
}

How does it handle edge cases?

JAVA
// Empty stream: no-arg returns empty string
String empty = Stream.<String>empty().collect(Collectors.joining());
// ""

// Empty stream: three-arg returns prefix + suffix
String emptyWrapped = Stream.<String>empty()
    .collect(Collectors.joining(", ", "[", "]"));
// "[]"

// Single element: no delimiter added
String single = Stream.of("SAV2001")
    .collect(Collectors.joining(", ", "[", "]"));
// "[SAV2001]"

Why is joining preferred over manual StringBuilder?

JAVA
// WRONG: manual approach with off-by-one risk
java.util.List<String> accounts = java.util.List.of("SAV2001", "CUR3001", "FD4001");
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < accounts.size(); i++) {
    if (i > 0) sb.append(", ");  // off-by-one logic
    sb.append(accounts.get(i));
}
sb.append("]");
// 5 lines, index tracking, must handle empty list separately

// CORRECT: one-liner with joining
String result = accounts.stream()
    .collect(Collectors.joining(", ", "[", "]"));
// Works for: empty → "[]", one → "[SAV2001]", many → "[SAV2001, CUR3001, FD4001]"

How do you use joining as a downstream collector?

JAVA
import java.util.Map;
import java.util.stream.Collectors;

// Group trade IDs by side and join them
Map<String, String> tradeIdsBySide = trades.stream()
    .collect(Collectors.groupingBy(Trade::getSide,
        Collectors.mapping(Trade::getTradeId,
            Collectors.joining(", ", "(", ")"))));
// {BUY: "(TRD-001, TRD-002)", SELL: "(TRD-003, TRD-005)"}

How does joining work with non-String elements?

JAVA
import java.math.BigDecimal;

// Stream of BigDecimal: must convert to String first
String amounts = trades.stream()
    .map(Trade::getAmount)
    .map(BigDecimal::toPlainString)  // convert to String
    .collect(Collectors.joining(" + "));
// "245050 + 176000 + 330150"

How does joining handle parallel streams?

For parallel streams, joining's combiner merges partial StringJoiner results correctly. Manual StringBuilder in a parallel stream would need explicit synchronization.

What are common mistakes?

JAVA
// WRONG: joining on non-CharSequence stream
// trades.stream().collect(Collectors.joining(", "));
// COMPILE ERROR: Trade is not CharSequence

// CORRECT: map to String first
trades.stream()
    .map(Trade::getTradeId)
    .collect(Collectors.joining(", "));

What should a senior engineer know?

  • joining() is equivalent to String.join(delimiter, collection) for simple cases, but joining works in stream pipelines and supports prefix/suffix.
  • Internally, joining uses StringJoiner which uses StringBuilder, so performance is identical.
  • For building JSON-like arrays, joining(", ", "[", "]") is cleaner than manual formatting.

What are common interview follow-up questions?

  • What is the difference between String.join and Collectors.joining? (String.join is a static method for collections. Collectors.joining is a stream collector. Both use StringJoiner internally.)
  • Can joining produce JSON arrays? (For simple strings, yes. For proper JSON with escaping, use a JSON library.)
  • What happens if an element is null? (NullPointerException. Filter nulls before joining.)

Visual Overview

DIAGRAM
  Collectors.joining(", ", "[", "]")

  Stream: "SAV2001" → "CUR3001" → "FD4001"
              │            │           │
              ▼            ▼           ▼
  StringJoiner: add("SAV2001")
                add("CUR3001")
                add("FD4001")
              │
              ▼
  Result: "[SAV2001, CUR3001, FD4001]"

  Edge cases:
  Empty stream → "[]"
  One element  → "[SAV2001]"
  Many         → "[SAV2001, CUR3001, FD4001]"

Key Takeaway

Collectors.joining concatenates stream elements into a single String with three overloads: no-arg for direct concatenation, one-arg for delimiter-separated, and three-arg for delimiter with prefix and suffix. The three-argument form handles all edge cases automatically: empty streams return prefix+suffix only, single elements get no delimiter, and multiple elements get delimiters only between elements.

The manual StringBuilder approach requires explicit index tracking and conditional delimiter insertion in 5 lines. Collectors.joining eliminates this boilerplate in one line with no off-by-one errors. Internally it uses StringJoiner which uses StringBuilder, so performance is identical.

As a downstream collector inside groupingBy, joining produces per-group summary strings: groupingBy(side, mapping(tradeId, joining(", ", "(", ")"))) produces {BUY: "(TRD-001, TRD-002)", SELL: "(TRD-003)"} in a single collect call. For parallel streams, joining's combiner correctly merges partial results without explicit synchronization.

Remember one sentence: Collectors.joining concatenates stream elements with delimiter, prefix, and suffix, handling empty and single-element edge cases automatically in one line.

---

Share
Use ← → arrow keys to navigate