1. Overview
We often need to work with a slice of a dataset. Streams give us two operations for this: limit(n) and skip(n), which we can use on their own or together to describe the exact window of elements we want to process.
In this lesson, we will learn about these operations using real-world examples, such as offset-based pagination and trimming large collections of data.
The relevant module we need to import when starting this lesson is: limit-skip-start.
If we want to reference the fully implemented lesson, we can import: limit-skip-end.
2. Limiting Elements with limit()
In this section, we will explore the limit() operation from the Java Stream API.
This method is used to restrict the number of elements flowing through a stream pipeline. It’s handy when we want to sample data, preview large collections, or implement pagination efficiently.
Let’s look at a basic example that limits the output to just the first three elements of a list:
@Test
void givenTasks_whenLimit3Elements_thenReturn3Elements() {
List<Task> firstThreeElements = new ArrayList<>();
tasks.stream()
.limit(3)
.forEach(task -> {
System.out.println(task);
firstThreeElements.add(task);
});
assertEquals(3, firstThreeElements.size());
}
In this example, limit(3) short-circuits the pipeline so that at most three elements (in the source’s encounter order) are processed.
This is useful in REST APIs, UI table views, or any situation where data is fetched or processed in chunks.
2.1. Different Values for limit()
If the limit() operation receives a value greater than or equal to the number of elements in the source, it has no effect, and all elements are returned.
If limit(0) is used, it returns an empty stream, effectively discarding all the elements.
However, if limit() is given a negative value, it’ll throw an IllegalArgumentException.
Let’s take a look at a few examples that demonstrate how limit() behaves with values greater than, equal to, or less than the size of the source:
@Test
void givenTasks_whenLimitWasSetWithDifferentValues() {
List<Task> result = new ArrayList<>();
tasks.stream()
.limit(tasks.size() + 1)
.forEach(task -> {
System.out.println(task);
result.add(task);
});
assertEquals(result.size(), tasks.size());
List<Task> result1 = new ArrayList<>();
tasks.stream()
.limit(0)
.forEach(task -> {
System.out.println(task);
result1.add(task);
});
assertEquals(0, result1.size());
assertThrows(IllegalArgumentException.class, () -> tasks.stream().limit(-1).toList());
}
3. Skipping Elements with skip()
In contrast to limit(n), which restricts how many elements are allowed to pass through a stream, the skip(m) operation discards the first m elements and processes only the remainder. This is particularly useful when we’re dealing with large data collections and want to ignore a known number of initial items.
Let’s see how we can use skip() to process only the elements after a certain point:
@Test
void givenTasks_whenSkip2Elements_thenReturnElementsStartingAtIndex2() {
List<Task> elementsWithoutFirst2 = new ArrayList<>();
tasks.stream()
.skip(2)
.forEach(task -> {
System.out.println(task);
elementsWithoutFirst2.add(task);
});
assertEquals(tasks.get(2), elementsWithoutFirst2.get(0));
assertEquals(6, elementsWithoutFirst2.size());
}
Note that skip() removes the specified number of elements before operations process them, making the rest of the pipeline unaware of the discarded items.
3.1. Different Values for skip()
If the skip() operation receives a value greater than or equal to the number of elements in the source, it discards all elements and returns an empty stream.
In case skip(0) is used, it has no effect; the stream is unchanged.
However, if skip() is given a negative value, it will throw an IllegalArgumentException.
The following examples illustrate how the skip() operation behaves with different input values:
@Test
void givenTasks_whenSkipWasSetWithDifferentValues() {
List<Task> result = new ArrayList<>();
tasks.stream()
.skip(tasks.size() + 1)
.forEach(task -> {
System.out.println(task);
result.add(task);
});
assertEquals(0, result.size());
List<Task> result1 = new ArrayList<>();
tasks.stream()
.skip(0)
.forEach(task -> {
System.out.println(task);
result1.add(task);
});
assertEquals(tasks.size(), result1.size());
assertThrows(IllegalArgumentException.class, () -> tasks.stream().skip(-1).toList());
}
4. Combining limit() and skip() in One Pipeline
Combining limit() and skip() is especially useful in practical scenarios where we need to extract a specific slice from a data stream.
Let’s take some practical examples to show how these two operations work together.
A classic use case is implementing pagination, where we want to retrieve a fixed number of elements starting from a given offset. In this example, we will fetch page 2 of a list, with a page size of 3:
@Test void givenTasks_whenUsingLimitAndSkip_thenReturnPage2() { int page = 2; int pageSize = 3; List<Task> result = new ArrayList<>(); tasks.stream() .skip(offset(page, pageSize)) .limit(pageSize) .forEach(task -> { System.out.println(task); result.add(task); }); assertEquals(3, result.size()); }static long offset(int page, int size) { return (long) (page - 1) * size; }
To keep the fluent API clean, we extracted the calculation of the pagination into the offset method.
The behavior of skip() and limit() relies on the encounter order of the stream. For ordered sources (e.g., List, LinkedHashSet, TreeSet, LinkedHashMap), results are consistent and deterministic. However, if the source is unordered e.g., HashSet, HashMap, ConcurrentHashMap), the slice isn’t well-defined. For such cases, it’s recommended to force the order for collection first with the sorted() operation from the Java Stream API.
4.1. Why Does Order Matter?
When using limit() and skip() together, the order in which they are applied can change the outcome. These operations are evaluated in the order they appear in the pipeline, so swapping them yields a different slice.
Let’s consider two examples to see the effect of the order of operations for limit() and skip().
In the first example, we apply limit() and then skip():
@Test
void givenTasks_whenUsingLimitFirstAndSkip_thenReturn1Element() {
List<Task> result = new ArrayList<>();
tasks.stream()
.limit(3)
.skip(2)
.forEach(task -> {
System.out.println(task);
result.add(task);
});
assertEquals(1, result.size());
}
Step by step:
- The tasks variable is a List with 8 elements: T1, T2, T3, T4, T5, T6, T7, T8.
- limit(3) operation retains only the first three tasks, T1, T2, T3.
- skip(2) operation discards the first two of those and leaves T3.
- The final result for the above example will be T3.
In contrast to the previous example, we will exchange the skip() and limit(). skip() will be applied first, then limit().
@Test
void givenTasks_whenSkipFirstAndLimit_thenReturn3Elements() {
List<Task> result = new ArrayList<>();
tasks.stream()
.skip(2)
.limit(3)
.forEach(task -> {
System.out.println(task);
result.add(task);
});
assertEquals(3, result.size());
}
Again, let’s see it step by step:
- Starting with a list of: T1, T2, T3, T4, T5, T6, T7, T8.
- skip(2) operation discards the first two elements and will remain: T3, T4, T5, T6, T7, T8.
- limit(3) operation will retain only the first three tasks: T3, T4, T5.
- The final result for the above example will be T3, T4, T5.
As shown above, these operations are not commutative, as swapping their order changes the data we operate on. Both methods operate in the order they appear in the stream pipeline, and each step transforms what the next step sees.
When building pagination logic, the typical and correct order is skip() first, then limit().
Even though we won’t dive into parallel pipelines here, it’s worth noting (as pointed out by the methods’ Javadoc) that these operations can be costly for ordered parallel pipelines, because they must respect encounter order for the “first/next” elements.