Java Tutorial
🔍

Java Stream skip() Method

Java Stream skip() Method

skip() is the intermediate operation that discards a fixed number of elements from the start of a stream and returns everything after them. Where limit() keeps the front of a stream and throws away the rest, skip() does the opposite — it throws away the front and keeps the rest. It is the operation to reach for the moment "start from somewhere other than the beginning" is the actual requirement, whether that means skipping a header row, resuming a job partway through, or moving past earlier pages of results.

What Is skip()?

Stream<T> skip(long n) returns a new stream consisting of the remaining elements after discarding the first n. It throws IllegalArgumentException immediately if n is negative, and if n is larger than the number of elements available, it simply returns an empty stream rather than throwing anything.

skip() is stateful — it has to count how many elements have gone by before it can start passing anything through — but it is not short-circuiting the way limit() is. It cannot know in advance where the stream ends, so it still has to process and discard each of the first n elements one at a time rather than jumping directly past them. IntStream, LongStream, and DoubleStream each provide their own skip() as well, working identically on primitive values.

Why skip() Was Introduced

Discarding a known prefix from a collection used to mean a loop with a manually tracked index, incremented past the elements that should not be processed.

1// File: BeforeSkip.java 2import java.util.*; 3 4public class BeforeSkip { 5 public static void main(String[] args) { 6 List<String> importRows = List.of( 7 "sku,name,price", 8 "SKU-1,Mouse,799", 9 "SKU-2,Keyboard,1499", 10 "SKU-3,Monitor,8999" 11 ); 12 13 List<String> dataRows = new ArrayList<>(); 14 for (int i = 1; i < importRows.size(); i++) { 15 dataRows.add(importRows.get(i)); 16 } 17 18 System.out.println(dataRows); 19 } 20}
Output:
[SKU-1,Mouse,799, SKU-2,Keyboard,1499, SKU-3,Monitor,8999]

skip() expresses the exact same intent as a single value, without an index variable for a reader to verify starts and stops in the right place.

1// File: AfterSkip.java 2import java.util.*; 3import java.util.stream.*; 4 5public class AfterSkip { 6 public static void main(String[] args) { 7 List<String> importRows = List.of( 8 "sku,name,price", 9 "SKU-1,Mouse,799", 10 "SKU-2,Keyboard,1499", 11 "SKU-3,Monitor,8999" 12 ); 13 14 List<String> dataRows = importRows.stream() 15 .skip(1) 16 .collect(Collectors.toList()); 17 18 System.out.println(dataRows); 19 } 20}
Output:
[SKU-1,Mouse,799, SKU-2,Keyboard,1499, SKU-3,Monitor,8999]

Both versions keep the same three data rows and discard the header. The stream version has no loop counter for a reader to confirm starts at exactly the right place.

Syntax

skip() behaves consistently whether the amount to skip is small, exceeds the stream's size entirely, or is zero.

1// File: SkipSyntaxForms.java 2import java.util.*; 3import java.util.stream.*; 4 5public class SkipSyntaxForms { 6 public static void main(String[] args) { 7 List<String> queue = List.of("job-1", "job-2", "job-3", "job-4", "job-5"); 8 9 List<String> afterFirstTwo = queue.stream().skip(2).collect(Collectors.toList()); 10 List<String> skipAll = queue.stream().skip(10).collect(Collectors.toList()); 11 List<String> skipNone = queue.stream().skip(0).collect(Collectors.toList()); 12 13 // Combined with limit(), skip() selects a specific window of elements 14 List<String> middleWindow = queue.stream().skip(1).limit(2).collect(Collectors.toList()); 15 16 System.out.println("After first two: " + afterFirstTwo); 17 System.out.println("Skip all: " + skipAll); 18 System.out.println("Skip none: " + skipNone); 19 System.out.println("Middle window: " + middleWindow); 20 } 21}
Output:
After first two: [job-3, job-4, job-5]
Skip all: []
Skip none: [job-1, job-2, job-3, job-4, job-5]
Middle window: [job-2, job-3]

Common Use Cases

Skipping Already-Processed Records

skip() fits naturally whenever a job needs to continue from a known point instead of starting over from the beginning.

1// File: SkipAlreadyProcessedExample.java 2import java.util.*; 3import java.util.stream.*; 4 5public class SkipAlreadyProcessedExample { 6 public static void main(String[] args) { 7 List<String> records = List.of("REC-1", "REC-2", "REC-3", "REC-4", "REC-5", "REC-6"); 8 int alreadyProcessedCount = 3; 9 10 List<String> remaining = records.stream() 11 .skip(alreadyProcessedCount) 12 .collect(Collectors.toList()); 13 14 System.out.println(remaining); 15 } 16}
Output:
[REC-4, REC-5, REC-6]

Discarding a Header or Metadata Row

skip(1) is the standard way to bypass a header line before the actual data rows are processed further.

1// File: SkipHeaderExample.java 2import java.util.*; 3import java.util.stream.*; 4 5public class SkipHeaderExample { 6 public static void main(String[] args) { 7 List<String> csvLines = List.of("date,amount,status", "2026-01-01,450.0,DELIVERED", "2026-01-02,899.0,CANCELLED"); 8 9 long deliveredCount = csvLines.stream() 10 .skip(1) 11 .filter(line -> line.contains("DELIVERED")) 12 .count(); 13 14 System.out.println("Delivered rows: " + deliveredCount); 15 } 16}
Output:
Delivered rows: 1

Combining skip() With limit() for Pagination

skip() moves past earlier pages, and limit() caps the current one — together they express any bounded window over a larger source.

1// File: SkipWithLimitPaginationExample.java 2import java.util.*; 3import java.util.stream.*; 4 5public class SkipWithLimitPaginationExample { 6 public static void main(String[] args) { 7 List<String> results = List.of("R1", "R2", "R3", "R4", "R5", "R6", "R7"); 8 9 int pageNumber = 2; 10 int pageSize = 3; 11 12 List<String> page = results.stream() 13 .skip((long) (pageNumber - 1) * pageSize) 14 .limit(pageSize) 15 .collect(Collectors.toList()); 16 17 System.out.println(page); 18 } 19}
Output:
[R4, R5, R6]

Skipping a Dynamically Computed Offset

The value passed to skip() does not need to be a fixed constant — it can come from state computed elsewhere, such as how many items a job has already handled.

1// File: DynamicSkipOffsetExample.java 2import java.util.*; 3import java.util.stream.*; 4 5public class DynamicSkipOffsetExample { 6 static long lastCompletedId = 3; 7 8 public static void main(String[] args) { 9 List<Long> allIds = List.of(1L, 2L, 3L, 4L, 5L, 6L); 10 11 List<Long> pendingIds = allIds.stream() 12 .filter(id -> id > lastCompletedId) 13 .collect(Collectors.toList()); 14 15 // Equivalent here, expressed instead as a count-based skip 16 long alreadyDoneCount = allIds.stream().filter(id -> id <= lastCompletedId).count(); 17 List<Long> pendingViaSkip = allIds.stream() 18 .skip(alreadyDoneCount) 19 .collect(Collectors.toList()); 20 21 System.out.println("Pending via filter: " + pendingIds); 22 System.out.println("Pending via skip: " + pendingViaSkip); 23 } 24}
Output:
Pending via filter: [4, 5, 6]
Pending via skip: [4, 5, 6]

Real-World Example

A nightly batch import job processes a large file of records, and if the job crashes or gets restarted partway through, re-processing everything from the start wastes time and risks handling some records twice. Recording how many records were successfully completed and passing that count into skip() on the next run lets the job resume exactly where it left off instead of starting over.

1// File: ImportRecord.java 2 3public record ImportRecord(String id, String payload) {}
1// File: ImportJobState.java 2 3public class ImportJobState { 4 private long completedCount = 0; 5 6 public long getCompletedCount() { 7 return completedCount; 8 } 9 10 public void markCompleted(int count) { 11 completedCount += count; 12 } 13}
1// File: ImportJob.java 2import java.util.*; 3import java.util.stream.*; 4 5public class ImportJob { 6 7 public List<ImportRecord> run(List<ImportRecord> allRecords, ImportJobState state, int batchSize) { 8 List<ImportRecord> batch = allRecords.stream() 9 .skip(state.getCompletedCount()) 10 .limit(batchSize) 11 .collect(Collectors.toList()); 12 13 for (ImportRecord record : batch) { 14 System.out.println("Processing " + record.id()); 15 } 16 17 state.markCompleted(batch.size()); 18 return batch; 19 } 20}
1// File: ImportJobDemo.java 2import java.util.*; 3 4public class ImportJobDemo { 5 public static void main(String[] args) { 6 List<ImportRecord> allRecords = List.of( 7 new ImportRecord("REC-1", "payload-1"), 8 new ImportRecord("REC-2", "payload-2"), 9 new ImportRecord("REC-3", "payload-3"), 10 new ImportRecord("REC-4", "payload-4"), 11 new ImportRecord("REC-5", "payload-5") 12 ); 13 14 ImportJobState state = new ImportJobState(); 15 ImportJob job = new ImportJob(); 16 17 System.out.println("=== First run ==="); 18 job.run(allRecords, state, 3); 19 20 System.out.println("=== Job crashes here, restarts later ==="); 21 22 System.out.println("=== Resumed run ==="); 23 job.run(allRecords, state, 3); 24 25 System.out.println("Total completed: " + state.getCompletedCount()); 26 } 27}
Output:
=== First run ===
Processing REC-1
Processing REC-2
Processing REC-3
=== Job crashes here, restarts later ===
=== Resumed run ===
Processing REC-4
Processing REC-5
Total completed: 5

The second call to job.run() never reprocesses REC-1 through REC-3, because state.getCompletedCount() already reflects how many records the first run finished, and skip() uses that count directly as its offset. During code reviews, seniors commonly flag a resumable job that instead re-reads and re-checks every record from the start on each run just to figure out where it left off — tracking the completed count explicitly and passing it straight into skip() is simpler and avoids redoing work that was already done.

Combining skip() With Other Features

skip() and limit() together are the standard pagination pattern — skip() moves past earlier results, and limit() caps how many of the remaining elements make up the current page. Unlike limit(), skip() has no short-circuiting benefit of its own; it still processes and discards each skipped element rather than jumping ahead. skip() composes naturally with a dynamically computed offset, such as a count already tracked elsewhere in the program, rather than only a fixed constant.

Best Practices

Track and pass an explicit offset — a completed count, a page number translated to an offset, a cursor position — into skip() rather than recomputing it by re-scanning data that has already been handled elsewhere.

Validate that any computed offset is non-negative before calling skip(). A negative value throws IllegalArgumentException immediately, and a miscalculated offset is one of the more common sources of that exception in real code.

Remember that skip() still processes the elements it discards rather than jumping past them in constant time. For a source where skipping a very large offset happens often, a data source with genuine random access — a database query with its own OFFSET clause, for instance — is usually the better tool than skipping over an in-memory stream repeatedly.

Combine skip() with limit() whenever only a specific window of a larger result is needed, rather than collecting the entire result and manually slicing it afterward.

Common Mistakes

Passing a negative value to skip() throws IllegalArgumentException immediately, the same way limit() does.

1// File: NegativeSkipMistake.java 2import java.util.*; 3import java.util.stream.*; 4 5public class NegativeSkipMistake { 6 public static void main(String[] args) { 7 List<Integer> numbers = List.of(1, 2, 3); 8 9 try { 10 numbers.stream().skip(-1).collect(Collectors.toList()); 11 } catch (IllegalArgumentException e) { 12 System.out.println("IllegalArgumentException - skip() rejects a negative argument"); 13 } 14 } 15}
Output:
IllegalArgumentException - skip() rejects a negative argument

Recomputing an offset from scratch on every run, instead of persisting and reusing it, silently reprocesses records that were already handled the moment that recomputation gets the count wrong.

1// File: RecomputedOffsetMistake.java 2import java.util.*; 3import java.util.stream.*; 4 5public class RecomputedOffsetMistake { 6 public static void main(String[] args) { 7 List<String> records = List.of("REC-1", "REC-2", "REC-3", "REC-4", "REC-5"); 8 9 // WRONG - guessing the offset from an assumption about batch size, 10 // rather than the actual number of records truly completed so far 11 int assumedCompleted = 2; 12 List<String> guessedRemaining = records.stream() 13 .skip(assumedCompleted) 14 .collect(Collectors.toList()); 15 16 // CORRECT - the offset comes from an explicitly tracked, accurate count 17 int actualCompleted = 3; 18 List<String> accurateRemaining = records.stream() 19 .skip(actualCompleted) 20 .collect(Collectors.toList()); 21 22 System.out.println("Guessed remaining: " + guessedRemaining); 23 System.out.println("Accurate remaining: " + accurateRemaining); 24 } 25}
Output:
Guessed remaining: [REC-3, REC-4, REC-5]
Accurate remaining: [REC-4, REC-5]

Assuming skip() is a cheap, constant-time jump — the way an array index or a database OFFSET clause might behave — is a subtler mistake worth being aware of. On an in-memory stream, skip() still iterates past and discards each of the first n elements one at a time, and that cost grows along with n, which matters once the offset becomes large on a genuinely sizable source.

Interview Questions

Q1. What does skip() do, and how is it different from limit()?

skip(n) discards the first n elements of a stream and returns everything after them. limit(n) does the opposite — it keeps the first n elements and discards everything after. They are complementary operations, and combining them, skip(offset).limit(size), selects a specific window from anywhere in a stream.

Q2. Is skip() a short-circuiting operation the way limit() is?

No. skip() cannot know in advance where the stream ends, so it still has to process each of the first n elements to discard them, rather than jumping directly past that many. It is stateful in that it tracks a running count, but it does not share limit()'s ability to stop pulling from the source early.

Q3. What happens if you call skip() with a value larger than the number of elements in the stream?

Nothing exceptional. skip() simply consumes everything the stream has and returns an empty stream, with no error thrown for requesting more than is available. This mirrors limit()'s equally forgiving behavior when asked for more elements than exist.

Q4. How would you design a resumable batch job using skip()?

Track how many records the job has successfully completed, persist that count somewhere durable so it survives a restart, and pass it directly into skip() on the next run before applying limit() to bound the next batch. This lets the job pick up exactly where it left off instead of reprocessing records that already succeeded, exactly as the import job example in this article demonstrates.

Q5. Why is skip() considered more expensive than it might look, even though it produces no visible output for the elements it discards?

Because it still has to iterate through and discard each of the first n elements one at a time on an in-memory stream, rather than jumping past them the way a database OFFSET or a direct array index would. That cost scales with n, which is easy to overlook since skip() never actually adds anything to the visible result for those discarded elements.

Q6. What exception does skip() throw for a negative argument, and when is that thrown relative to the pipeline running?

skip() throws IllegalArgumentException for a negative argument, and the check happens immediately when skip() is called to build the pipeline, not deferred until a terminal operation actually runs it. This is a useful detail for debugging, since the exception's stack trace points directly at the skip() call itself rather than somewhere further downstream.

FAQs

Is skip() an intermediate or terminal operation?

Intermediate. It returns a new Stream, and like every intermediate operation, it does nothing on its own until a terminal operation such as collect or forEach triggers the pipeline.

Does skip() modify the original list the stream was built from?

No. skip() produces a new stream and never touches the collection or array the stream was originally built from, the same guarantee every intermediate stream operation provides.

Can skip() be used on primitive streams like IntStream?

Yes. IntStream, LongStream, and DoubleStream each provide their own skip(), behaving identically to the reference-type version without any boxing involved.

Does skip(0) do anything at all?

No, functionally. skip(0) is valid and simply returns a stream with the exact same elements as the original, in the same order, since there is nothing to discard.

Can skip() be used safely on an infinite stream?

Yes, as long as a terminal operation applied afterward is also bounded somehow, typically with limit(). skip() itself does not need to see the whole stream to work — it counts past the first n elements and then passes everything after them through, which continues indefinitely on a genuinely infinite source unless something downstream stops consuming it.

How does skip() behave on a parallel stream?

The set of elements that survive is the same as on a sequential stream, as long as the stream has a defined encounter order, but the mechanics differ — a parallel stream may compute which elements to discard across multiple threads rather than counting through them one at a time in a single thread.

Is there a way to skip elements based on a condition instead of a fixed count?

Not through skip() itself, which only accepts a count. Stream.dropWhile(Predicate), added in Java 9, discards elements as long as a condition holds true and then passes through everything from the first element that fails it onward — a conditional counterpart to skip()'s fixed-count approach.

Summary

skip() discards a known number of elements from the front of a stream and passes the rest along, which is exactly the half of pagination limit() does not handle on its own. It has no short-circuiting shortcut the way limit() does — it still processes what it throws away — but it composes with limit() to express any bounded window over a larger source, and it composes with a persisted, explicitly tracked offset to make a batch job genuinely resumable.

The habit worth keeping is treating the offset passed into skip() as something to track and trust, not something to recompute or guess at on every run, exactly the distinction the import job example and the recomputed-offset mistake both draw. limit() remains the natural partner for capping whatever skip() leaves behind.

What to Read Next