Time spans come in two flavors, and Java models them with two separate types. Duration measures machine time in seconds and nanoseconds, suited to cache TTLs, flash-sale countdowns, and SLA windows. Period measures calendar time in years, months, and days, suited to free-trial lengths, subscription terms, and return windows. This lesson covers both, then walks through the daylight saving time trap that exists precisely because they are not interchangeable.
A "day" looks like a single concept until a daylight saving boundary appears. On the spring-forward day in most US time zones, the clock jumps from 02:00 straight to 03:00. That day has 23 hours, not 24. On the fall-back day, the clock falls from 02:00 back to 01:00 and that day has 25 hours. If someone places an order on March 8 and shipping "one day later" means "the same wall-clock time tomorrow" (which is 23 hours away), that's different from "exactly 86,400 seconds from now" (which would land at a different wall-clock time). Both are legitimate answers to different questions.
Java's solution is two different types that force an explicit choice.
| Type | Models | Units | Use When |
|---|---|---|---|
Duration | Machine-time amount | seconds, nanoseconds | Cache TTL, countdown, SLA, "X seconds elapsed", anything below a day |
Period | Calendar-time amount | years, months, days | Subscription length, free-trial days, age, return window, anything dated |
Both live in java.time, both are immutable, and both have nothing to do with the legacy java.util.Date API.
The split looks pedantic at first, but it eliminates a whole class of bugs. The compiler cannot add a Duration to a LocalDate (which has no time-of-day), and a Period cannot go where a millisecond-precision amount is expected. The types push toward the correct option.
A Duration always holds two fields internally: a long count of seconds and an int of nanoseconds within that second. Every factory method ends up populating those two fields.
The PT... format is ISO-8601 for time durations. P is the prefix for any period of time, T separates the date part from the time part, and the units are H, M, S (with fractional seconds allowed). Duration.ofDays(2) printed as PT48H, not P2D. That isn't a bug. A Duration doesn't know what a calendar day is; it converts every day to exactly 86,400 seconds and prints in time units.
A Duration can also be built from two Instant or LocalDateTime values:
Duration.between works on any pair of Temporal values that carry time-of-day information. It does not work on two LocalDate instances because a date alone has no time component. Calling it that way throws DateTimeException: Unable to obtain Duration from TemporalAmount.
For reading a duration from a config file or user input, Duration.parse accepts ISO-8601:
Duration is immutable and cheap to allocate, but every arithmetic operation creates a new object. In a tight loop that builds millions of durations, prefer working with raw long nanoseconds and converting to Duration once at the boundary.
A Duration exposes both its raw fields (seconds plus nanos) and a set of convenience converters. The convenience methods handle unit conversion.
| Method | Returns |
|---|---|
getSeconds() | The raw seconds field (signed long) |
getNano() | Nanoseconds within that second (0-999999999) |
toNanos() | Total nanoseconds as a long |
toMillis() | Total milliseconds as a long |
toSeconds() | Total whole seconds (Java 9+) |
toMinutes() | Total whole minutes |
toHours() | Total whole hours |
toDays() | Total whole days (24-hour days) |
The to... methods truncate toward zero. They don't round.
A common gotcha: getSeconds() returns the internal field, which is the total count of whole seconds. It's often mistaken for "the seconds part of an HMS breakdown" (like 30 in 1H 2M 30S). It isn't. Use modular arithmetic on toSeconds() to get the breakdown.
For comparisons, Duration implements Comparable<Duration> and offers state checks:
compareTo returns the usual negative, zero, or positive int. isZero and isNegative are the readable spellings of compareTo(Duration.ZERO) == 0 and compareTo(Duration.ZERO) < 0. abs() returns the positive magnitude, which is handy when subtracting a deadline from "now" and only the gap matters.
Duration is immutable, so every arithmetic method returns a new instance.
The fluent methods come in two shapes. plus(Duration) and minus(Duration) take another duration. plusSeconds, plusMinutes, plusHours, plusDays, plusMillis, plusNanos take a long. Use whichever reads better at the call site.
Combine a Duration with an Instant to compute a future or past point in time:
Instant.plus(Duration) and Instant.minus(Duration) both return a new Instant. The Duration doesn't change. This is the pattern for computing a deadline: take a starting Instant, add a Duration, get back the deadline.
Period works the same way conceptually, but it holds years, months, and days separately instead of collapsing everything to seconds. There's no time-of-day component at all.
The ISO-8601 format for periods is P{years}Y{months}M{days}D. There's no T because there's no time component. Period.ofWeeks(n) exists too, but it just calls Period.ofDays(n * 7); the result prints as days, not weeks.
Period.between(LocalDate, LocalDate) returns the gap between two calendar dates as a years-months-days breakdown:
The breakdown is calendar-aware. Period.between walks the calendar respecting month lengths (28-31 days) and leap years. This is why it returns three separate fields: there's no single integer that means "two years, three months, and 29 days" because months don't have a fixed length.
Period.parse reads ISO-8601:
Period exposes its three fields directly. The only convenience converter is toTotalMonths(), which combines years and months into a single long.
Notice what's missing: there is no toTotalDays(). A period of P1M has no fixed day count (a January period is 31 days, a February period is 28 or 29). The API refuses to lie about it. For a day count between two specific dates, use ChronoUnit.DAYS.between(start, end) instead.
A subtle property: Period doesn't normalize on its own. Period.of(0, 15, 0) prints as P15M, not P1Y3M. For the canonical years/months form, call normalized(), which rolls 12 months into a year but leaves the day field alone.
The 45 days stay as 45 days. The library can't safely convert days to months without a reference date because months aren't a fixed number of days.
Period arithmetic mirrors Duration arithmetic. Every method returns a new Period.
That last result, P3M-5D, looks odd. It is valid ISO-8601, but it's a sign that the fields haven't been normalized against a real date. Until this Period is applied to a LocalDate, the library doesn't know whether 3 months minus 5 days is "still 3 months back" or "2 months and 26 days back". It defers the math.
Applying a Period to a LocalDate returns a new date:
Now consider a calendar-aware case: adding one month to January 31.
Adding one month to January 31 gives February 28 (the last valid day of February), not February 31. From there, adding another month gives March 28, not March 31. The library doesn't try to "remember" that the start was the 31st. For "the last day of the month" behavior, use TemporalAdjusters.lastDayOfMonth() instead. That's a separate API and outside the scope of this lesson.
This example shows why the split matters. Take a ZonedDateTime the night before daylight saving begins, then add "one day" with each type. The results are different.
Look at the two results. Duration.ofDays(1) added exactly 86,400 seconds. Because the spring-forward day lost an hour, the wall clock advanced by 25 hours of clock time to cover 24 hours of elapsed time. The result is 19:00 on March 8.
Period.ofDays(1) added one calendar day. The wall clock stays at 18:00. The amount of physical time elapsed was only 23 hours, not 24, because the day itself was 23 hours long.
Both answers are correct for different questions:
| Question | Use |
|---|---|
| "Show this banner for exactly 24 hours" | Duration |
| "Renew this subscription one day from now (same time tomorrow)" | Period |
| "Expire this cache after 1 hour" | Duration |
| "Give the customer a 7-day free trial" | Period |
| "Bill the customer monthly" | Period |
| "Time out this API call after 30 seconds" | Duration |
The same trap exists in reverse on the fall-back day. Duration.ofDays(1) advances the wall clock by 23 hours of physical time spread across a 25-hour calendar day. Period.ofDays(1) advances the wall clock by exactly one calendar day. The difference is always one hour around a DST boundary.
The DST adjustment only happens when the time math is bound to a real zone. LocalDateTime.plus(Duration.ofDays(1)) and LocalDateTime.plus(Period.ofDays(1)) produce the same result because a LocalDateTime has no zone. The trap only fires on ZonedDateTime.
Period.between returns years, months, and days. Sometimes the answer should be just one number: "how many days until the trial ends" or "how many hours since the order was placed". ChronoUnit.between(start, end) answers that question.
ChronoUnit.DAYS.between returned 850, where Period.between for the same dates returns P2Y3M29D. Same range, different shape. Use whichever fits the display.
One rule to remember: ChronoUnit.MONTHS.between and ChronoUnit.YEARS.between only work on temporal types that carry date information. ChronoUnit.HOURS.between and below only work on temporal types that carry time information. The compiler permits any combination, but a runtime exception fires if the underlying temporal can't supply the unit. LocalDate plus ChronoUnit.HOURS.between throws.
Use LocalDateTime or Instant for hour, minute, or second granularity from ChronoUnit.between.
Three tools, three questions:
| Question | Tool |
|---|---|
| "How much physical time passed?" (seconds, minutes, hours) | Duration |
| "How many calendar units passed?" (years + months + days breakdown) | Period |
| "How many of one specific unit passed?" (just days, or just hours) | ChronoUnit.UNIT.between |
Practical examples from e-commerce code:
| Scenario | Code |
|---|---|
| Cache product details for 5 minutes | Duration.ofMinutes(5) |
| Flash sale lasts 2 hours | Duration.ofHours(2) |
| 30-day return window from order date | orderDate.plus(Period.ofDays(30)) |
| Monthly subscription renewal | lastBilling.plus(Period.ofMonths(1)) |
| Time since order was placed, in minutes | ChronoUnit.MINUTES.between(placedAt, Instant.now()) |
| Banner runs for exactly 24 hours, ignore DST | bannerStart.plus(Duration.ofHours(24)) |
| Banner runs until same time tomorrow, respect DST | bannerStart.plus(Period.ofDays(1)) |
Period.between walks the calendar and is O(1) but allocates a Period object. ChronoUnit.DAYS.between returns a primitive long and skips the allocation. In hot paths, prefer the ChronoUnit approach when a single unit is enough.
10 quizzes