A Java Stream has no index, so to iterate over a stream with indices we stream the index numbers with IntStream.range(0, list.size()) and look up each element with list.get(i). For sources without positions, such as a Set or the lines of a file, we attach a counter to each element, for example with a stream gatherer.
We need the index to number the lines of a report, to pick every second element, to find the position of the first match or to put a line number into a validation error. A plain for loop also works, but the index-based stream keeps the rest of the pipeline in stream style.
The following example numbers the tracks of a playlist and keeps the tracks at even positions.
List<String> tracks = List.of("Intro", "Blue", "Echo", "Rain", "Outro");
List<String> numbered = IntStream.range(0, tracks.size()).mapToObj(i -> (i + 1) + ". " + tracks.get(i)).toList(); // [1. Intro, 2. Blue, 3. Echo, 4. Rain, 5. Outro]
List<String> evenPositions = IntStream.range(0, tracks.size()).filter(i -> i % 2 == 0).mapToObj(tracks::get).toList(); // [Intro, Echo, Outro]
The stream itself runs over the numbers 0 to 4, and the list supplies the element for each number. We cover why streams have no index, pairing elements with their index in a record, a Java 24 gatherer for any stream, the AtomicInteger counter and its limits, and the Guava and StreamEx helpers.
1. Why a Stream Has No Index
A List stores its elements at positions, so get(2) returns the third element. A Stream only passes elements one by one from a source to the next operation, and many sources have no positions at all. A HashSet, a generated stream or Files.lines() produce elements without a number attached, so the Stream interface has no forEachIndexed() or get(int) method.

So we either stream the positions of a source that has them, or we count the elements as they pass. The first option needs a List or an array, and the second works for any stream as long as it runs in order.
2. Using IntStream.range() Over a List or an Array
The method IntStream.range(start, end) produces the numbers from start up to end – 1. For an ArrayList or an array, get(i) and array[i] take constant time, so the index stream does the same lookup work as a for loop. With the index available, we can filter on it, map with it or search for a position.
List<String> tracks = List.of("Intro", "Blue", "Echo", "Rain", "Outro");
List<String> lastTwo = IntStream.range(tracks.size() - 2, tracks.size()).mapToObj(tracks::get).toList(); // [Rain, Outro]
int firstWithE = IntStream.range(0, tracks.size()).filter(i -> tracks.get(i).startsWith("E")).findFirst().orElse(-1); // 2
int missing = IntStream.range(0, tracks.size()).filter(i -> tracks.get(i).equals("Jazz")).findFirst().orElse(-1); // -1
Arrays work the same way, with array.length as the end. The IntStream method rangeClosed() includes the end value, and the three-argument iterate() handles a step other than one, for example to visit every third index.
String[] genres = {"rock", "jazz", "pop", "folk"};
List<String> labels = IntStream.range(0, genres.length).mapToObj(i -> i + "=" + genres[i]).toList(); // [0=rock, 1=jazz, 2=pop, 3=folk]
List<String> everyThird = IntStream.iterate(0, i -> i < genres.length, i -> i + 3).mapToObj(i -> genres[i]).toList(); // [rock, folk]
We use IntStream.range() only for lists with fast random access, such as ArrayList and List.of(). On a LinkedList, each get(i) traverses the list from the start, so the whole stream becomes quadratic.
3. Pairing Each Element With Its Index in a Record
When later steps need both the index and the element, a small record carries the two values through the pipeline. A record is clearer than Map.Entry or a two-element array, because the fields have names.
record Indexed<T>(int index, T value) {}
List<String> tracks = List.of("Intro", "Blue", "Echo", "Rain", "Outro");
List<Indexed<String>> pairs = IntStream.range(0, tracks.size()).mapToObj(i -> new Indexed<>(i, tracks.get(i))).toList();
List<String> shortAtOdd = pairs.stream()
.filter(p -> p.index() % 2 == 1 && p.value().length() <= 4)
.map(p -> p.index() + ":" + p.value())
.toList(); // [1:Blue, 3:Rain]
A music app that shows the label “Track 3 of 5” uses the same pairing. The index is computed once, before filtering, so it keeps the original position even after other tracks are filtered out.
4. Adding Indexes to Any Stream With a Gatherer
Since Java 24, the Gatherer API (JEP 485) lets us write our own intermediate operations. A sequential gatherer keeps a counter as its state and wraps each element in an Indexed record, so it works for sources without positions, such as a Set, String.lines() or Files.lines().
static <T> Gatherer<T, ?, Indexed<T>> withIndex() {
return Gatherer.ofSequential(
() -> new int[1],
(counter, element, downstream) -> downstream.push(new Indexed<>(counter[0]++, element)));
}
String setlist = "Intro\nBlue\nEcho";
List<String> lines = setlist.lines().gather(withIndex()).map(p -> "line " + (p.index() + 1) + ": " + p.value()).toList(); // [line 1: Intro, line 2: Blue, line 3: Echo]
The method Gatherer.ofSequential() creates a gatherer that processes elements one at a time in encounter order, even when the stream is parallel. The int[] array holds the counter, because a lambda can change the contents of an array but not reassign a local variable.
5. Using an AtomicInteger as a Counter
Many older answers use an AtomicInteger outside the stream and call getAndIncrement() inside map(). It works for a sequential stream with an ordered source, because elements pass the lambda in order.
List<String> tracks = List.of("Intro", "Blue", "Echo", "Rain", "Outro");
AtomicInteger counter = new AtomicInteger();
List<String> counted = tracks.stream().map(t -> counter.getAndIncrement() + "-" + t).toList(); // [0-Intro, 1-Blue, 2-Echo, 3-Rain, 4-Outro]
The counter gives wrong numbers in a parallel stream. Threads take elements in any order, so the counter values no longer match the positions. In test runs on Java 25 with 1,000 names in a parallelStream(), about half of the elements got a wrong number, and the result changed from run to run. A lambda that updates shared state like this is called a stateful lambda, so we prefer the index stream or the gatherer, which stay correct in parallel streams.
6. Using Guava Streams.mapWithIndex()
If the project already uses Guava, the method Streams.mapWithIndex() passes each element and its long index to a function. It works for any stream, the indexes stay correct in parallel streams, and it splits the work well when the source is a sized collection such as a list.
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.7.2-jre</version>
</dependency>
List<String> tracks = List.of("Intro", "Blue", "Echo", "Rain", "Outro");
List<String> withGuava = Streams.mapWithIndex(tracks.stream(), (track, index) -> index + ":" + track).toList(); // [0:Intro, 1:Blue, 2:Echo, 3:Rain, 4:Outro]
To filter by index, we map to an Indexed record first and filter after that, instead of returning null for the unwanted indexes as some examples do.
7. Using StreamEx EntryStream
The StreamEx library has EntryStream.of(list), which turns a list into a stream of index-element entries with methods that take both values.
<dependency>
<groupId>one.util</groupId>
<artifactId>streamex</artifactId>
<version>0.9.0</version>
</dependency>
List<String> tracks = List.of("Intro", "Blue", "Echo", "Rain", "Outro");
List<String> evenTracks = EntryStream.of(tracks).filterKeyValue((index, track) -> index % 2 == 0).values().toList(); // [Intro, Echo, Outro]
List<String> exNumbered = EntryStream.of(tracks).mapKeyValue((index, track) -> (index + 1) + ". " + track).toList(); // [1. Intro, 2. Blue, 3. Echo, 4. Rain, 5. Outro]
StreamEx is convenient when a project uses many of its extra operations. For indexes alone, IntStream.range() or the gatherer from section 4 do the same without a dependency.
8. Picking a Way to Iterate Over a Stream With Indices
The source decides the approach. Lists and arrays have positions we can stream, and every other source needs a counter that runs in encounter order.
| Approach | Works on | Parallel-safe | Dependency |
|---|---|---|---|
| IntStream.range() + get(i) | Lists with random access, arrays | Yes | None |
| Indexed record | Same as above | Yes | None |
| Gatherer withIndex() | Any ordered stream | Yes, runs sequentially | None, Java 24+ |
| AtomicInteger counter | Any ordered stream | No | None |
| Guava Streams.mapWithIndex() | Any stream | Yes | Guava |
| StreamEx EntryStream.of() | Lists and arrays | Yes | StreamEx |
9. Real-World Example With Line Numbers in Validation Errors
An import screen accepts a pasted list of songs with a duration per line. When a line is invalid, the error message must name the line number, so the user can find it. The text comes from a text area, not a list, so the gatherer adds the numbers.
String pasted = "Intro,3:10\nBlue,abc\nEcho,4:05\nRain";
List<String> errors = pasted.lines()
.gather(withIndex())
.filter(p -> !p.value().matches("[^,]+,\\d+:\\d{2}"))
.map(p -> "Line " + (p.index() + 1) + " is invalid: " + p.value())
.toList(); // [Line 2 is invalid: Blue,abc, Line 4 is invalid: Rain]
The index is attached before the filter(), so the line numbers refer to the original input. Adding the counter after filtering would number only the invalid lines, which is a common bug in this kind of code.
10. Stream Index FAQs
Index questions mostly concern forEach(), finding a position, indexOf() inside a pipeline and the effect of filtering or sorting.
10.1. Can Stream.forEach() Give Us the Index?
The forEach() method passes only the element. We loop over the indexes instead, for example IntStream.range(0, list.size()).forEach(i -> System.out.println(i + ” ” + list.get(i))).
10.2. How do we find the index of an element with streams?
We stream the indexes and filter by the element, as in section 2. For a plain equality check, list.indexOf(element) is shorter and returns -1 when the element is missing.
10.3. Why not call list.indexOf() inside map()?
Because indexOf() searches the list from the start for every element, so the pipeline becomes quadratic. It also returns the first position for duplicates, so a playlist with the same song twice gets the same index twice.
10.4. Do indexes change after filter() or sorted()?
The answer depends on where we attach them. An index attached before filter() or sorted() keeps the original position, whereas a counter attached after them numbers the new order. We decide which of the two the output needs and place the index step accordingly.
11. Conclusion
Streams pass elements without positions, so the index has to come from somewhere else. For lists and arrays, IntStream.range() with get(i) is the simplest option, and an Indexed record carries the index along when later steps need it.
For sources without positions, a sequential gatherer adds a correct index on Java 24 and later, and Guava or StreamEx offer the same as library methods. The AtomicInteger counter works only in sequential streams, so we keep it out of code that might run in parallel.
12. References
- IntStream Javadoc
- Gatherer Javadoc
- JEP 485, Stream Gatherers
- Guava Streams Javadoc
- StreamEx on GitHub
Happy Learning !!