joining and counting collectors in Java 8
-
Last Updated: September 27, 2026
-
By: javahandson
-
Series

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().
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:
No heavy theory is needed here. If you can write a basic stream with map and filter, you are ready to follow along.
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.
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.
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); // JavaisfunNotice 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.
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, funNow 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,DeveloperThe 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.
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.
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, 103The 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. |
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, sqlNotice the second java is gone. The distinct step removed it before the join. Then sorted lined up the rest in order.
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, -, RaviHere the null becomes a dash. So the join stays safe and readable. Always guard your data when a null is possible.
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
// DeployEach step now sits on its own line. The newline delimiter did all the work. This is handy for checklists, logs, and simple reports.
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.
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.
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); // 4Here 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.
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.
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.
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 compileIf you declare the value as Integer, the code breaks. The compiler expects Long from counting. So always use Long for these counts.
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); // 3Here 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.
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); // 3We 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. |
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"));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.
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.
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, BagHere 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.
Both collectors are light and fast. Still, a couple of points are worth knowing. They help you reason about big streams.
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.
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.
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.
The choice usually comes down to your goal. So ask what you want out of the stream. The answer points to the right collector.
So the rule of thumb is clear. Text summaries call for joining. Tallies and frequency maps call for counting.
The table below sums up the forms at a glance. Keep it handy while the syntax is still new.
| What you want | Use this | Result |
|---|---|---|
| Glue strings, no gaps | Collectors.joining() | One string, no separator |
| Strings with a separator | Collectors.joining(“, “) | Delimited string |
| Wrapped, delimited string | Collectors.joining(“, “, “[“, “]”) | Prefix + items + suffix |
| Count as a downstream step | Collectors.counting() | A Long total |
| Plain total, no collector | Stream.count() | A primitive long |
| Count per group | groupingBy(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.
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.
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.
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.
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.
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.
A few traps catch developers with these collectors. Knowing them early saves you real debugging time.
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.
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.
Collectors.counting returns a Long, not an Integer. Declaring an Integer value breaks the build. So match the type with Long every time.
For a plain total, the collector is overkill. Stream.count reads cleaner and skips the boxing. Save counting for downstream use inside another collector.
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.
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.