joining and counting collectors in Java 8

  • Last Updated: September 27, 2026
  • By: javahandson
  • Series
img

joining and counting collectors in Java 8

Master the joining and counting collectors in Java 8. Learn Collectors.joining() with delimiter, prefix and suffix, plus counting() and how it differs from Stream.count().

1. Introduction

You have a stream of data, and you need a quick summary from it. Maybe you want to glue all the names into one line. Or maybe you just want a count of matching items. Both jobs are a perfect fit for the joining and counting collectors in Java 8.

Both tools sit in the Collectors class. The joining collector stitches your stream into a single string. The counting collector, on the other hand, tells you how many elements flowed through.

Neither one is complicated. Yet each saves you from writing a loop by hand. So they show up in real code all the time.

In this guide we stay tight on these two collectors only. We will not wander into summing, grouping, or partitioning. Those have their own guides, so here we go deep on join and count alone.

Here is what we will cover:

  • What Collectors.joining does, with and without a delimiter
  • Adding a prefix and suffix around the joined text
  • Why joining works on strings, and how to handle other types
  • What Collectors.counting does, and what it returns
  • The key difference between counting() and Stream.count()
  • Using counting as a downstream collector, plus common mistakes

No heavy theory is needed here. If you can write a basic stream with map and filter, you are ready to follow along.

2. A Quick Recap on Collectors

Before we dive in, let us set the scene. A collector is a recipe that gathers stream elements into a result. You pass it to the collect method at the end of a stream.

The Collectors class holds many ready-made recipes. You have likely met toList and toSet already. Now joining and counting join that same family.

If you want the full picture of the Collectors class, see the intro guide in this series. Here, though, we zoom in on just two members. So let us start with joining.

One quick note before we begin. Both collectors are terminal helpers, so they run at the end of a pipeline. You can still filter, map, or sort earlier in the chain. Then the collector wraps up the final result.

3. Collectors.joining(): Building One String

The joining collector takes many strings and makes one. It walks the stream in order and sticks the pieces together. The result is a single, tidy string.

This is handy for logs, labels, and CSV-style lines. Instead of a manual loop with a StringBuilder, you write one clean call. As a result, your code stays short and readable.

3.1 The Plain joining()

The simplest form takes no arguments. It just concatenates every element back to back. There is no space or comma between them.

List<String> words = List.of("Java", "is", "fun");
 
String result = words.stream()
        .collect(Collectors.joining());
 
System.out.println(result); // Javaisfun

Notice there are no gaps in the output. The words run straight into each other. So the plain form is rarely what you want on its own.

3.2 joining() with a Delimiter

Most of the time you want a separator between items. The one-argument form takes a delimiter for exactly this. It slots that delimiter between each pair of elements.

List<String> words = List.of("Java", "is", "fun");
 
String result = words.stream()
        .collect(Collectors.joining(", "));
 
System.out.println(result); // Java, is, fun

Now the output reads cleanly. A comma and a space sit between each word. Also note the delimiter does not appear at the very start or end.

You can pass any string as the delimiter. A pipe, a dash, or a newline all work fine. So this form covers most everyday joining needs.

A CSV-style line is a great example. You keep the values in a stream and join them with a comma. As a result, you get a ready row for a file or a report.

List<String> row = List.of("Amit", "Pune", "Developer");
 
String csvLine = row.stream()
        .collect(Collectors.joining(","));
 
System.out.println(csvLine); // Amit,Pune,Developer

The same trick builds breadcrumbs and paths too. Just swap the comma for a slash or an arrow. So one method covers many text-joining tasks.

3.3 joining() with Delimiter, Prefix, and Suffix

The richest form takes three arguments. Along with the delimiter, you pass a prefix and a suffix. These wrap the whole joined string.

List<String> words = List.of("Java", "is", "fun");
 
String result = words.stream()
        .collect(Collectors.joining(", ", "[", "]"));
 
System.out.println(result); // [Java, is, fun]

Here the square brackets wrap the result. This shape looks just like a printed list. Therefore, it is great for readable debug output.

The prefix and suffix appear only once each. They sit at the two ends, not between items. So you get a clean wrapper around the joined text.

3.4 joining() Works on Strings Only

Here is a limit that trips people up. The joining collector only accepts CharSequence values, like String. It cannot join numbers or objects directly.

So you must map your elements to strings first. A quick map step does the job. After that, joining works as normal.

List<Integer> ids = List.of(101, 102, 103);
 
String result = ids.stream()
        .map(String::valueOf)   // convert int to String first
        .collect(Collectors.joining(", "));
 
System.out.println(result); // 101, 102, 103

The map call turns each number into text. Then joining glues those strings together. Skip that map step, and the code will not even compile.

💡 Interview Insight
A common question is how joining works under the hood. It uses a StringBuilder internally, so it stays efficient even for many elements. Another favourite: joining accepts only CharSequence, so you must map non-string values to strings first, or the code fails to compile.

3.5 Sorting or De-duplicating Before You Join

The joining collector keeps the stream order as-is. But you often want the pieces sorted or unique first. So you add a stream step before the collect call.

A sorted call orders the elements alphabetically. A distinct call drops any repeats. Both run before joining, so the final string reflects them.

List<String> tags = List.of("java", "sql", "java", "api");
 
String result = tags.stream()
        .distinct()   // remove duplicates
        .sorted()     // order A to Z
        .collect(Collectors.joining(", "));
 
System.out.println(result); // api, java, sql

Notice the second java is gone. The distinct step removed it before the join. Then sorted lined up the rest in order.

3.6 Handling null Elements Safely

A null value in the stream is a real risk. The joining collector cannot append a null. So it throws a NullPointerException on the spot.

The fix is a small map step. You replace each null with a safe fallback, like a dash. After that, joining runs without a hitch.

List<String> data = Arrays.asList("Amit", null, "Ravi");
 
String result = data.stream()
        .map(s -> s == null ? "-" : s)  // guard against null
        .collect(Collectors.joining(", "));
 
System.out.println(result); // Amit, -, Ravi

Here the null becomes a dash. So the join stays safe and readable. Always guard your data when a null is possible.

3.7 Joining into Multiple Lines

The delimiter does not have to be a comma. A newline works just as well. So you can turn a stream into a neat block of lines.

List<String> steps = List.of("Clone repo", "Run build", "Deploy");
 
String block = steps.stream()
        .collect(Collectors.joining("\n"));
 
System.out.println(block);
// Clone repo
// Run build
// Deploy

Each step now sits on its own line. The newline delimiter did all the work. This is handy for checklists, logs, and simple reports.

3.8 joining() vs String.join() and StringBuilder

You might ask why not just use String.join. That method is great when you already hold a collection. But joining shines inside a stream pipeline.

Picture a stream that filters and maps before the join. With joining, the whole flow stays in one chain. With String.join, you would break the chain and build a list first.

  • Use Collectors.joining when you are already in a stream pipeline.
  • Use String.join for a plain collection you have in hand.
  • Skip a manual StringBuilder loop, since both options are cleaner.

4. Collectors.counting(): Counting the Elements

The counting collector does one simple job. It counts how many elements the stream produced. The result comes back as a Long.

On its own, counting feels a bit plain. After all, a stream already has a count method. But counting truly earns its keep as a downstream collector, which we will see soon.

4.1 The Basic counting()

Used alone, counting just returns the size of the stream. You pass it to collect, and you get a Long back.

List<String> names = List.of("Amit", "Sara", "Ravi", "Neha");
 
long total = names.stream()
        .filter(n -> n.length() == 4)
        .collect(Collectors.counting());
 
System.out.println(total); // 4

Here we count the four-letter names. The filter narrows the stream first. Then counting tallies whatever survives.

The result type here is worth a pause. Even though we store it in a long, counting hands back a Long object. Java then unboxes it for us. We will come back to this type detail shortly.

4.2 counting() vs Stream.count()

This difference is a classic interview point. Both give you a count of elements. Yet they are not the same tool.

Stream.count is a terminal operation. You call it directly, and it ends the stream. It returns a primitive long.

Collectors.counting is a collector. You hand it to collect, and it returns a boxed Long. On its own, count is simpler and clearer.

So why does counting even exist? The reason is composition. A terminal call like count cannot nest inside another collector. A collector like counting can, and that is exactly what groupingBy needs.

// Simpler when you just need a total
long a = names.stream().filter(n -> n.length() == 4).count();
 
// The collector form, same result here
long b = names.stream().filter(n -> n.length() == 4)
        .collect(Collectors.counting());

So for a plain total, reach for Stream.count. It reads better and skips the boxing. Save counting for when you need a collector, not a terminal call.

4.3 counting() as a Downstream Collector

This is where counting really matters. You can nest it inside another collector. The most common pair is with groupingBy, to count items per group.

The idea is simple. You group elements by some key, then count each group. The result is a frequency map, like a word count.

List<String> items = List.of("apple", "banana", "apple", "cherry", "banana", "apple");
 
Map<String, Long> frequency = items.stream()
        .collect(Collectors.groupingBy(
                item -> item,            // group by the value
                Collectors.counting())); // count each group
 
System.out.println(frequency); // {banana=2, cherry=1, apple=3}

So apple shows up three times, banana twice, and cherry once. The groupingBy call builds the buckets, while counting fills in the totals. This pattern is a go-to for tallies.

We keep the grouping part light here on purpose. The full story on groupingBy and its downstream collectors lives in its own guide. For now, just see how counting slots in as the downstream step.

4.4 The Return Type Is Always Long

One small detail catches people out. Collectors.counting always returns a Long, never an int. So your map value or variable must match that type.

// This compiles fine
Map<String, Long> counts = items.stream()
        .collect(Collectors.groupingBy(x -> x, Collectors.counting()));
 
// Map<String, Integer> here would NOT compile

If you declare the value as Integer, the code breaks. The compiler expects Long from counting. So always use Long for these counts.

4.5 Counting Only What Matches

Often you want a count of items that pass a test. The trick is to filter first, then count. So the filter trims the stream before the tally runs.

List<Integer> scores = List.of(45, 82, 91, 30, 76);
 
long passCount = scores.stream()
        .filter(s -> s >= 50)   // keep passing scores
        .collect(Collectors.counting());
 
System.out.println(passCount); // 3

Here we count only the scores of fifty or more. The filter drops the low ones first. Then counting tallies the three that remain.

This same shape works inside groupingBy too. You can filter the stream, group the rest, and count each group. As a result, you get a tally of just the items you care about.

4.6 Counting Distinct Values

Sometimes you want the number of unique items, not the total. The counting collector alone does not remove repeats. So you add a distinct step before it.

List<String> visits = List.of("Amit", "Sara", "Amit", "Ravi", "Sara");
 
long uniqueVisitors = visits.stream()
        .distinct()   // drop repeats first
        .collect(Collectors.counting());
 
System.out.println(uniqueVisitors); // 3

We had five visits but only three people. The distinct call removes the repeats first. Then counting reports the three unique names.

💡 Interview Insight
Interviewers love the counting() versus count() split. Stream.count() is a terminal operation returning a primitive long. Collectors.counting() is a collector returning a boxed Long, and its real power is as a downstream collector inside groupingBy. Naming that downstream use case scores extra points.

5. Using joining and counting Together

These two collectors often appear in the same task. So let us build one small example that uses both. We will summarise a list of orders.

record Order(String customer, String product) {}
 
List<Order> orders = List.of(
        new Order("Amit", "Pen"),
        new Order("Sara", "Book"),
        new Order("Amit", "Bag"));

5.1 Joining the Product Names

First, we want a single line of all products. We map each order to its product, then join them. A comma keeps the line readable.

String productLine = orders.stream()
        .map(Order::product)
        .collect(Collectors.joining(", ", "Products: ", "."));
 
System.out.println(productLine); // Products: Pen, Book, Bag.

The prefix labels the line, and the suffix ends it with a full stop. So one call gives us a clean, printable summary. That is joining doing its job.

5.2 Counting Orders per Customer

Next, we want how many orders each customer placed. We group by customer, then count each group. The counting collector handles the tally.

Map<String, Long> ordersPerCustomer = orders.stream()
        .collect(Collectors.groupingBy(
                Order::customer,
                Collectors.counting()));
 
System.out.println(ordersPerCustomer); // {Amit=2, Sara=1}

Amit placed two orders, and Sara placed one. The two collectors solve two different needs from the same data. Together they cover both text and tallies.

5.3 Building a Small Report

Let us take it one step further. We can fold both results into a short report string. So a reader sees the products and the totals in one place.

String products = orders.stream()
        .map(Order::product)
        .collect(Collectors.joining(", "));
 
long orderCount = orders.stream()
        .collect(Collectors.counting());
 
String report = "Total orders: " + orderCount + " | Items: " + products;
System.out.println(report);
// Total orders: 3 | Items: Pen, Book, Bag

Here joining builds the item list, while counting gives the total. We then stitch both into one line. That is a common shape for a quick summary or a log entry.

Notice we ran two streams over the same list. That is fine for a small list like this. For a very large source, though, you may prefer a single pass with a custom collector, which is a topic for another guide.

6. Performance Notes

Both collectors are light and fast. Still, a couple of points are worth knowing. They help you reason about big streams.

6.1 joining() Uses a StringBuilder

The joining collector builds its result with a StringBuilder. So it avoids the cost of creating many throwaway strings. This keeps it fast, even for long streams.

Compare that with adding strings in a loop using the plus sign. That approach makes a new string every time, which is slow. Therefore, joining is the better choice inside a pipeline.

6.2 counting() Is a Simple Tally

The counting collector just adds one per element. It does no heavy work beyond that. So its cost grows in a straight line with the stream size.

For a plain total, though, remember Stream.count can be smarter. On some sources it skips walking every element. As a result, it may finish faster than the collector form.

6.3 Both Work with Parallel Streams

Both collectors are safe to run in parallel. Each thread builds a partial result, and Java merges the parts at the end. So you can add parallel and still get a correct answer.

The joining collector merges the partial strings in the right order. Meanwhile, counting just adds the partial totals together. Still, a parallel stream only pays off for a very large source, so test before you switch.

7. When to Use joining and counting

The choice usually comes down to your goal. So ask what you want out of the stream. The answer points to the right collector.

7.1 Reach for joining When

  • You want one string built from many elements.
  • You need a delimiter, or a prefix and suffix wrapper.
  • You are already inside a stream pipeline with map or filter.

7.2 Reach for counting When

  • You need a count as a downstream collector, most often with groupingBy.
  • You are building a frequency map or a per-group tally.
  • A plain total is fine too, but Stream.count is simpler for that.

So the rule of thumb is clear. Text summaries call for joining. Tallies and frequency maps call for counting.

7.3 Quick Reference

The table below sums up the forms at a glance. Keep it handy while the syntax is still new.

What you wantUse thisResult
Glue strings, no gapsCollectors.joining()One string, no separator
Strings with a separatorCollectors.joining(“, “)Delimited string
Wrapped, delimited stringCollectors.joining(“, “, “[“, “]”)Prefix + items + suffix
Count as a downstream stepCollectors.counting()A Long total
Plain total, no collectorStream.count()A primitive long
Count per groupgroupingBy(k, counting())Map of key to Long

So each row maps a goal to the right call. Over time these choices become second nature. Until then, this table is a quick memory aid.

8. FAQ’s on joining and counting collectors in Java 8

Q: What does Collectors.joining() do in Java 8?

A: Collectors.joining() combines the elements of a stream into a single String. The plain form concatenates them with no separator. You can also pass a delimiter, and a delimiter with a prefix and suffix, to control how the final string looks. It works only on CharSequence values like String, so map other types to strings first.

Q: What is the difference between Collectors.counting() and Stream.count()?

A: Stream.count() is a terminal operation that you call directly, and it returns a primitive long. Collectors.counting() is a collector that you pass to collect(), and it returns a boxed Long. For a plain total, Stream.count() is simpler. The real value of counting() is as a downstream collector inside groupingBy() to count items per group.

Q: How do you build a frequency map with Collectors.counting()?

A: Use groupingBy() with counting() as the downstream collector: items.stream().collect(Collectors.groupingBy(x -> x, Collectors.counting())). This groups the elements by value and counts each group, giving a Map from each value to its count as a Long.

Q: Why does Collectors.joining() throw a NullPointerException?

A: The joining collector cannot append a null element, so a null in the stream triggers a NullPointerException. Guard against it with a map step that replaces each null with a safe fallback, such as an empty string or a dash, before the collect() call.

Q: What does the three-argument joining() return for an empty stream?

A: The plain and delimiter forms return an empty string for an empty stream. The three-argument form, which takes a prefix and suffix, still returns those two parts. So joining(“, “, “[“, “]”) on an empty stream returns “[]”, just the wrapper.

9. Common Mistakes and Pitfalls

A few traps catch developers with these collectors. Knowing them early saves you real debugging time.

9.1 Passing Non-Strings to joining()

The joining collector rejects numbers and objects. Forget the map to string, and the code fails to compile. Always convert to text before you join.

9.2 Expecting a Delimiter at the Ends

The delimiter sits only between elements. It never appears at the start or the end. If you need edges wrapped, use the prefix and suffix form instead.

9.3 Declaring the Count as Integer

Collectors.counting returns a Long, not an Integer. Declaring an Integer value breaks the build. So match the type with Long every time.

9.4 Reaching for counting() When count() Fits

For a plain total, the collector is overkill. Stream.count reads cleaner and skips the boxing. Save counting for downstream use inside another collector.

9.5 Joining an Empty Stream

An empty stream is a fair edge case here. The plain and delimiter forms return an empty string. The three-argument form, however, still returns the prefix and suffix.

String empty = List.<String>of().stream()
        .collect(Collectors.joining(", ", "[", "]"));
 
System.out.println(empty); // []  (just the wrapper)

So an empty input still gives you the brackets. This surprises people who expect a blank string. Keep it in mind when the source might be empty.

10. Conclusion

The joining and counting collectors solve two neat everyday needs. With joining you turn a stream into one string. With counting you tally how many elements passed through.

The joining collector shines inside a pipeline. You get a delimiter, plus an optional prefix and suffix. Just remember it works on strings, so map other types first.

The counting collector is best as a downstream step. Paired with groupingBy, it builds frequency maps with ease. For a plain total, though, Stream.count is the simpler pick.

So keep both in your toolkit and choose by intent. Text summaries lean on joining. Tallies lean on counting.

Now open your editor and try each form yourself. Change the delimiter, wrap the output, and build a small word count. That hands-on time is what makes the ideas stick.

Further Reading

 

Leave a Comment