Splitting a date-time range into equal intervals in Java means dividing the Duration between the start and the end by the number of parts and adding multiples of that piece to the start, which gives the start and end of each sub-range. For calendar parts such as days, weeks, months and years, we step from one boundary to the next with plusDays() or a TemporalAdjuster instead, because months and years do not have equal lengths.
We split ranges in batch jobs that process records one chunk at a time, in API clients that may ask for at most 31 days per request, in charts that need a fixed number of buckets, and in booking apps that offer 30-minute slots.
The following example splits ten hours into four equal parts with the splitIntoEqualParts() method from section 1.
LocalDateTime start = LocalDateTime.of(2026, 1, 1, 0, 0);
LocalDateTime end = LocalDateTime.of(2026, 1, 1, 10, 0);
List<Slot> parts = splitIntoEqualParts(start, end, 4);
int count = parts.size(); // 4
String first = parts.get(0).toString(); // "2026-01-01T00:00/2026-01-01T02:30"
String last = parts.get(3).toString(); // "2026-01-01T07:30/2026-01-01T10:00"
Notice that each part ends where the next one starts, and the last part ends at the original end. We look at equal parts first, split ranges by days, months, years and ISO weeks, cut a range into fixed-length chunks, handle daylight saving days, and build request windows for an API with a size limit.
1. Splitting Date-time Range to N Equal Intervals
Equal parts need an exact length, so we measure the range with Duration.between(), which counts seconds and nanoseconds. Each boundary is the start plus i/n of the total, so the parts cannot drift away from the end.

A small record holds one sub-range. Its toString() prints the ISO-8601 interval form start/end, which keeps log lines short.
record Slot(LocalDateTime start, LocalDateTime end) {
@Override
public String toString() {
return start + "/" + end;
}
}
static List<Slot> splitIntoEqualParts(LocalDateTime start, LocalDateTime end, int parts) {
if (parts < 1 || !start.isBefore(end)) {
throw new IllegalArgumentException("start must be before end and parts must be at least 1");
}
Duration total = Duration.between(start, end);
List<Slot> slots = new ArrayList<>(parts);
for (int i = 0; i < parts; i++) {
LocalDateTime from = start.plus(total.multipliedBy(i).dividedBy(parts));
LocalDateTime to = start.plus(total.multipliedBy(i + 1).dividedBy(parts));
slots.add(new Slot(from, to));
}
return slots;
}
The method takes three parameters, start, end and parts, the number of intervals we want. It returns parts intervals, which have parts + 1 boundary points, and it rejects an empty or reversed range instead of returning an empty list.
LocalDateTime start = LocalDateTime.of(2026, 1, 1, 0, 0);
LocalDateTime end = LocalDateTime.of(2026, 1, 1, 10, 0);
String middleThird = splitIntoEqualParts(start, end, 3).get(1).toString(); // "2026-01-01T03:20/2026-01-01T06:40"
int quarterYear = splitIntoEqualParts(LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 4, 1, 0, 0), 6).size(); // 6
List<Slot> reversed = splitIntoEqualParts(end, start, 3); // IllegalArgumentException
We multiply before we divide, because Duration.dividedBy() truncates to whole nanoseconds, and adding a truncated piece again and again ends short of the end. One day divided by 7 shows the effect.
Duration day = Duration.ofDays(1);
Duration piece = day.dividedBy(7); // PT3H25M42.857142857S
Duration sevenPieces = piece.multipliedBy(7); // PT23H59M59.999999999S
Duration exact = day.multipliedBy(7).dividedBy(7); // PT24H
The same method works for ZonedDateTime and Instant after changing the types, because Duration measures real elapsed time. On a day with a daylight saving change, equal parts are equal in elapsed hours, not in clock hours.
2. Splitting Date-time Range in Date Parts
Many jobs need each sub-range to cover one calendar day, week, month or year. These parts have different lengths, so we do not divide anything. We start at the start date, find the next boundary, cut the range there and repeat until we reach the end.
The date ranges we build are half-open, so the start date is included and the end date is excluded. A month is the range from its first day to the first day of the next month, adjacent ranges never share a date, and a database query becomes created_on >= :start AND created_on < :end.
record DateRange(LocalDate start, LocalDate end) {
@Override
public String toString() {
return start + "/" + end;
}
}
static List<DateRange> splitBy(LocalDate start, LocalDate end, UnaryOperator<LocalDate> nextBoundary) {
List<DateRange> ranges = new ArrayList<>();
LocalDate from = start;
while (from.isBefore(end)) {
LocalDate boundary = nextBoundary.apply(from);
LocalDate to = boundary.isBefore(end) ? boundary : end;
ranges.add(new DateRange(from, to));
from = to;
}
return ranges;
}
The function nextBoundary must return a date after its input, such as d -> d.plusDays(1) or an adjuster from TemporalAdjusters. The first and the last range can be partial, because the start and the end can fall in the middle of a month or week.
2.1. Split Date-time Range into Days
For single days, we do not need ranges at all. The method LocalDate.datesUntil(), added in Java 9, returns a stream of every date from the start up to the day before the end, and its second form takes a step as a Period.
LocalDate from = LocalDate.of(2026, 3, 1);
LocalDate to = LocalDate.of(2026, 3, 5);
List<LocalDate> days = from.datesUntil(to).toList(); // [2026-03-01, 2026-03-02, 2026-03-03, 2026-03-04]
List<LocalDate> everyOther = from.datesUntil(to, Period.ofDays(2)).toList(); // [2026-03-01, 2026-03-03]
long dayCount = from.datesUntil(to).count(); // 4
For an inclusive end, we pass to.plusDays(1). More stream operations on dates are in datesUntil() examples and all dates between two dates.
2.2. Split Date-time Range into Months
For months, the next boundary is the first day of the next month, which TemporalAdjusters.firstDayOfNextMonth() returns. A range from January 15 to April 10 gives a partial January, two full months and a partial April.
LocalDate from = LocalDate.of(2026, 1, 15);
LocalDate to = LocalDate.of(2026, 4, 10);
List<DateRange> months = splitBy(from, to, d -> d.with(TemporalAdjusters.firstDayOfNextMonth())); // [2026-01-15/2026-02-01, 2026-02-01/2026-03-01, 2026-03-01/2026-04-01, 2026-04-01/2026-04-10]
List<YearMonth> labels = months.stream().map(r -> YearMonth.from(r.start())).toList(); // [2026-01, 2026-02, 2026-03, 2026-04]
A common shortcut counts the months with ChronoUnit.MONTHS.between() and adds that many months to the start. It counts complete months only, so for the same range it returns 2, and the split stops at March 15 and loses the days up to April 10.
long completeMonths = ChronoUnit.MONTHS.between(LocalDate.of(2026, 1, 15), LocalDate.of(2026, 4, 10)); // 2
2.3. Split Date-time Range into Years
Years work the same way with firstDayOfNextYear(), and Year.from() turns each range into a label. A fiscal year that starts in April only needs a different boundary function, such as the next April 1.
LocalDate from = LocalDate.of(2025, 7, 1);
LocalDate to = LocalDate.of(2027, 3, 1);
List<DateRange> years = splitBy(from, to, d -> d.with(TemporalAdjusters.firstDayOfNextYear())); // [2025-07-01/2026-01-01, 2026-01-01/2027-01-01, 2027-01-01/2027-03-01]
List<Year> yearLabels = years.stream().map(r -> Year.from(r.start())).toList(); // [2025, 2026, 2027]
2.4. Split Date-time Range into Weeks
ISO weeks start on Monday, so the next boundary is TemporalAdjusters.next(DayOfWeek.MONDAY). Java has no class for a week of a year, so we create a YearWeek record from the ISO fields IsoFields.WEEK_BASED_YEAR and IsoFields.WEEK_OF_WEEK_BASED_YEAR.
record YearWeek(int year, int week) {
static YearWeek from(LocalDate date) {
return new YearWeek(date.get(IsoFields.WEEK_BASED_YEAR), date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR));
}
@Override
public String toString() {
return "%d-W%02d".formatted(year, week);
}
}
LocalDate from = LocalDate.of(2026, 12, 23);
LocalDate to = LocalDate.of(2027, 1, 6);
List<DateRange> weeks = splitBy(from, to, d -> d.with(TemporalAdjusters.next(DayOfWeek.MONDAY))); // [2026-12-23/2026-12-28, 2026-12-28/2027-01-04, 2027-01-04/2027-01-06]
List<YearWeek> weekLabels = weeks.stream().map(r -> YearWeek.from(r.start())).toList(); // [2026-W52, 2026-W53, 2027-W01]
int alignedWeek = LocalDate.of(2027, 1, 1).get(ChronoField.ALIGNED_WEEK_OF_YEAR); // 1
int isoWeek = LocalDate.of(2027, 1, 1).get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); // 53
The week-based year is not always the calendar year. January 1, 2027 is a Friday, so it belongs to week 53 of 2026, whereas ChronoField.ALIGNED_WEEK_OF_YEAR counts 7-day blocks from January 1 and returns week 1. Week counts between two dates are covered in weeks between two dates.
3. Splitting a Range into Fixed-Length Chunks
Sometimes the size of each piece is fixed and the number of pieces follows from it. A clinic that offers 30-minute appointments from 09:00 to 10:40 gets three full slots and one 10-minute slot at the end, which the app either keeps or drops.
static List<Slot> splitByDuration(LocalDateTime start, LocalDateTime end, Duration step) {
if (step.isZero() || step.isNegative()) {
throw new IllegalArgumentException("step must be positive");
}
List<Slot> slots = new ArrayList<>();
for (LocalDateTime from = start; from.isBefore(end); from = from.plus(step)) {
LocalDateTime to = from.plus(step);
slots.add(new Slot(from, to.isBefore(end) ? to : end));
}
return slots;
}
LocalDateTime opens = LocalDateTime.of(2026, 5, 4, 9, 0);
LocalDateTime closes = LocalDateTime.of(2026, 5, 4, 10, 40);
List<Slot> slots = splitByDuration(opens, closes, Duration.ofMinutes(30));
int slotCount = slots.size(); // 4
String shortSlot = slots.get(3).toString(); // "2026-05-04T10:30/2026-05-04T10:40"
long fullSlots = slots.stream().filter(s -> Duration.between(s.start(), s.end()).equals(Duration.ofMinutes(30))).count(); // 3
The check on step matters, because a zero Duration would never move from forward and the loop would never end.
4. Splitting by Days Across a Daylight Saving Change
With a ZonedDateTime range, a calendar day is not always 24 hours. If we cut the range every Duration.ofHours(24), the cuts move off midnight after a daylight saving change. To cut at local midnight, we take the next date and call atStartOfDay(zone).
static List<Duration> dayLengths(ZonedDateTime start, ZonedDateTime end) {
List<Duration> lengths = new ArrayList<>();
ZonedDateTime from = start;
while (from.isBefore(end)) {
ZonedDateTime midnight = from.toLocalDate().plusDays(1).atStartOfDay(from.getZone());
ZonedDateTime to = midnight.isBefore(end) ? midnight : end;
lengths.add(Duration.between(from, to));
from = to;
}
return lengths;
}
ZoneId newYork = ZoneId.of("America/New_York");
ZonedDateTime from = ZonedDateTime.of(2026, 3, 7, 0, 0, 0, 0, newYork);
ZonedDateTime to = ZonedDateTime.of(2026, 3, 10, 0, 0, 0, 0, newYork);
List<Duration> lengths = dayLengths(from, to); // [PT24H, PT23H, PT24H]
ZonedDateTime fixedCut = from.plus(Duration.ofHours(48)); // 2026-03-09T01:00-04:00[America/New_York]
New York moves its clocks forward on March 8, 2026, so that day has 23 hours. Two 24-hour cuts end at 01:00 on March 9 instead of at midnight, which would put one hour of March 9 data into the March 8 batch.
5. Request Windows for an API With a 31-Day Limit
A reporting service downloads ad statistics from a partner API that accepts at most 31 days per request. To fetch the first quarter of 2026, the client splits the quarter into windows of 31 days and sends one request per window, with the window start as from and the exclusive end as to.
LocalDate quarterStart = LocalDate.of(2026, 1, 1);
LocalDate quarterEnd = LocalDate.of(2026, 4, 1);
List<DateRange> windows = splitBy(quarterStart, quarterEnd, d -> d.plusDays(31));
int requests = windows.size(); // 3
String lastWindow = windows.get(2).toString(); // "2026-03-04/2026-04-01"
long longest = windows.stream().mapToLong(w -> ChronoUnit.DAYS.between(w.start(), w.end())).max().orElse(0); // 31
Because the windows are half-open, a statistic for March 4 lands in the third window only, and the client never fetches a day twice. If the partner API treats to as inclusive, we pass end().minusDays(1) instead and keep the split itself unchanged.
6. Date Range Splitting FAQs
Turning a split range into batches or reports raises questions about inclusive ends, whole-day parts, hourly cuts and library support.
6.1. Should the end of each interval be inclusive or exclusive?
Exclusive, in most cases. With half-open ranges, adjacent intervals meet at one boundary value without overlapping or leaving a gap, and the same rule works for dates, date-times and instants. An inclusive end such as 23:59:59 misses events in the last second.
6.2. How do we split a date range into N parts of whole days?
We count the days with ChronoUnit.DAYS.between(), divide by N and give the remainder to the first parts. For example, 10 days in 3 parts gives 4, 3 and 3 days, because a Period cannot be divided into fractions of a day.
6.3. How do we split a time range into hours?
We call splitByDuration() from section 3 with Duration.ofHours(1). When the cuts must fall on full hours, we start at start.truncatedTo(ChronoUnit.HOURS), or at the next full hour when the start is in the middle of one.
6.4. Is there a library class for date ranges in Java?
The JDK has none, but the ThreeTen-Extra library by the lead of the java.time specification adds LocalDateRange, Interval and YearWeek. For a few split methods, the records in this article avoid an extra dependency.
7. Conclusion
Equal intervals come from Duration.between(), and we compute each boundary as the start plus total x i / n, so truncation never adds up. Fixed-length chunks step by a Duration and cut the last chunk at the end.
Calendar parts step from boundary to boundary with datesUntil(), firstDayOfNextMonth(), firstDayOfNextYear() or next(MONDAY), with ISO week fields for week labels and local midnight for zoned days. Other recipes are in our Java date-time guides.
8. References
- Duration (Java SE 25 API)
- LocalDate (Java SE 25 API)
- TemporalAdjusters (Java SE 25 API)
- IsoFields (Java SE 25 API)
Happy Learning !!