interval
Description
An Interval is an S4 class in lubridate that represents a span of time anchored to specific start and end moments. Unlike a Duration (which measures elapsed seconds regardless of calendar context), an Interval is calendar-aware: it preserves information about the real-world dates it spans, which matters when units like months or days are involved.
Intervals are defined by two POSIXct endpoints. The direction matters: an interval runs from start to end. If start is after end, the interval has negative length.
Creation
Use interval() with two date-time arguments, or the %--% operator as syntactic sugar:
interval(start, end, tzone = attr(start, "tzone"))
# [1] Interval, change: 183 days, 0 hours, 0 mins, 0 secs
start <- ymd_hms("2010-01-01 00:00:00")
end <- ymd_hms("2010-06-01 00:00:00")
int <- interval(start, end)
# [1] 2010-01-01 00:00:00 UTC--2010-06-01 00:00:00 UTC
The interval() function is the primary constructor, but lubridate also provides the %--% infix operator as a shorthand that reads more naturally when you want to emphasise the span between two known endpoints. The result is identical — both produce an Interval S4 object — so the choice between them is purely stylistic.
start %--% end
# [1] 2010-01-01 00:00:00 UTC--2010-06-01 00:00:00 UTC
Both start and end are coerced to POSIXct via force_tz(). The tzone argument sets the time zone of the resulting interval (defaults to the tzone attribute of start).
If start and end have different timezone attributes, both are converted to the timezone of start before the interval is constructed:
start <- ymd_hms("2010-01-01 00:00:00", tz = "America/New_York")
end <- ymd_hms("2010-01-02 00:00:00", tz = "Europe/London")
int <- interval(start, end)
# Both converted to America/New_York
# [1] 2010-01-01 00:00:00 EST--2010-01-02 05:00:00 EST
int_length(int)
# [1] 86400 # exactly 24 hours (5-hour NY/London difference at this point)
The timezone conversion happens silently and is worth keeping in mind when your data comes from multiple sources with different timezone attributes. A less subtle behaviour occurs when you pass endpoints in reverse chronological order: the constructor does not reorder them, so the resulting interval has negative length, which can cause unexpected results in downstream calculations.
If start > end, the interval has negative length:
interval(ymd_hms("2010-06-01"), ymd_hms("2010-01-01"))
# [1] 2010-06-01 00:00:00 UTC--2010-01-01 00:00:00 UTC
# negative length: -183 days
Negative intervals preserve the original endpoint order, which can be useful for computing backward spans, but most interval operations assume positive length. If you need to construct intervals from string representations of date ranges — a format common in APIs, databases, and log files — lubridate can parse ISO 8601 notation directly, producing an Interval object from a single character string.
Since lubridate 1.7.2, intervals can be parsed directly from ISO 8601 interval notation:
as.interval("2007-03-01T13:00:00Z/2008-05-11T15:30:00Z")
# [1] 2007-03-01 13:00:00 UTC--2008-05-11 15:30:00 UTC
Operators
Lubridate provides several operators and helper functions for working with intervals. The %--% operator is syntactic sugar for interval() and produces the same result with less typing. For containment checks, %within% tests whether a date-time or another interval falls completely inside the boundary of a target interval — useful for filtering events to a date range without writing explicit comparison logic.
# Construct an interval with the %--% operator
ymd("2010-01-01") %--% ymd("2010-06-01")
# [1] 2010-01-01 UTC--2010-06-01 UTC
# %within% checks containment inside a reference interval
int <- interval(ymd("2010-01-01"), ymd("2010-12-31"))
ymd("2010-06-01") %within% int # [1] TRUE
ymd("2011-01-01") %within% int # [1] FALSE
For comparing intervals, int_start() and int_end() extract the POSIXct endpoints as vectors. These can be compared with standard relational operators to sort intervals chronologically or filter overlapping periods. is.interval() provides a type check that returns TRUE only for Interval S4 objects — helpful when writing functions that accept multiple lubridate types.
# Compare interval ordering by their start times
int1 <- interval(ymd("2010-01-01"), ymd("2010-06-01"))
int2 <- interval(ymd("2010-03-01"), ymd("2010-09-01"))
int_start(int1) < int_start(int2)
# [1] TRUE
# Type check for Interval class
is.interval(interval(ymd("2010-01-01"), ymd("2010-06-01"))) # TRUE
is.interval(Sys.time()) # FALSE
The operator functions answer basic questions about interval membership and ordering, but once you have constructed an interval you often need to pull apart its components for further analysis. The accessor family — int_start(), int_end(), and int_length() — gives you programmatic access to the POSIXct endpoints and the elapsed seconds, which are the building blocks for custom temporal logic.
Accessors
Lubridate provides a family of accessor functions for inspecting and modifying intervals. int_start() and int_end() return the POSIXct endpoints, while int_length() computes elapsed seconds — a precise numeric value useful for arithmetic comparisons regardless of calendar quirks.
int <- interval(ymd_hms("2010-01-01 00:00:00"), ymd_hms("2010-06-01 12:00:00"))
int_start(int) # [1] "2010-01-01 00:00:00 UTC"
int_end(int) # [1] "2010-06-01 12:00:00 UTC"
int_length(int) # [1] 13089600 (seconds: 151 days + 12 hours)
The raw second count from int_length() is precise but hard to interpret at a glance. Converting to days by dividing by 86400 gives a more readable figure, and because the result is a fractional number, it preserves the sub-day precision that calendar-based period calculations would round away. This makes the seconds-to-days conversion the most reliable way to express an interval’s length when you need to compare spans across variable-length months or daylight saving boundaries.
For human-readable durations, divide int_length() by 86400 to get days. The result is a fractional number reflecting partial days, which is more precise than calendar-based period calculations that round to whole units.
int_length(int) / 86400 # [1] 151.5 days
Modification functions change the interval without creating new dates manually. int_flip() reverses direction by swapping endpoints, int_shift() slides the interval forward or backward by a period without changing its length, and int_standardize() ensures positive length by reordering endpoints when start > end.
int_flip(int)
# [1] 2010-06-01 12:00:00 UTC--2010-01-01 00:00:00 UTC
int <- interval(ymd("2010-01-01"), ymd("2010-03-01"))
int_shift(int, period = days(10))
# [1] 2010-01-11 UTC--2010-03-11 UTC
int <- interval(ymd("2010-06-01"), ymd("2010-01-01"))
int_standardize(int)
# [1] 2010-01-01 UTC--2010-06-01 UTC
int_diff() takes a sorted vector of date-times and returns the intervals between each consecutive pair. This is useful for computing gaps in event logs — the length of each resulting interval tells you how much time elapsed between consecutive events.
times <- ymd_hms(c("2010-01-01 00:00:00", "2010-01-01 01:00:00", "2010-01-01 03:00:00"))
int_diff(times)
# [1] 2010-01-01 00:00:00 UTC--2010-01-01 01:00:00 UTC
# [2] 2010-01-01 01:00:00 UTC--2010-01-01 03:00:00 UTC
Overlap and alignment
Testing interval relationships is a common task in temporal data analysis. int_overlaps() returns TRUE when two intervals share any part of the timeline, making it straightforward to find events that occur during the same period. Non-overlapping intervals — where one ends before the other begins — return FALSE.
int1 <- interval(ymd("2010-01-01"), ymd("2010-06-01"))
int2 <- interval(ymd("2010-03-01"), ymd("2010-09-01"))
int3 <- interval(ymd("2010-07-01"), ymd("2010-12-01"))
int_overlaps(int1, int2) # [1] TRUE (March–June overlap)
int_overlaps(int1, int3) # [1] FALSE (int1 ends before int3 starts)
int_aligns() is stricter: it returns TRUE only when two intervals share an exact endpoint, not just an overlapping interval. This is useful for detecting abutting periods — like when one contract ends on the same day the next begins. For extracting the shared portion, intersect() returns the overlapping sub-interval, or NA when the intervals are disjoint.
int1 <- interval(ymd("2010-01-01"), ymd("2010-06-01"))
int2 <- interval(ymd("2010-06-01"), ymd("2010-09-01"))
int_aligns(int1, int2) # [1] TRUE (share June 1st endpoint)
int2 <- interval(ymd("2010-03-01"), ymd("2010-09-01"))
intersect(int1, int2) # [1] 2010-03-01 UTC--2010-06-01 UTC
intersect(int1, int3) # [1] NA
Conversion
Intervals can be converted to lubridate’s two other time representations: Duration (exact elapsed seconds regardless of calendar context) and Period (calendar-aware units like months and days). The choice matters because a Period is affected by calendar irregularities while a Duration is not.
int <- interval(ymd("2010-01-01"), ymd("2010-01-02"))
as.duration(int) # [1] "86400s (~1 days)" — exact seconds
as.period(int) # [1] "1d 0h 0m 0s" — calendar-aware units
The distinction between a duration and a period is one of the most important concepts to internalise when working with lubridate. A duration is a stopwatch: it always counts the exact number of seconds that physically elapse between two moments, no matter what the calendar says. A period, by contrast, respects the human calendar with its variable-length units. For most business applications where you care about calendar months, billing cycles, or financial quarters, periods are the natural choice. The next example shows what happens to a period calculation when daylight saving time is in play.
Converting to a Period can produce surprising results when the interval spans a daylight saving transition. Because periods account for calendar hour-length changes, a one-day clock span that crosses a spring-forward DST boundary reports twenty-five hours rather than the twenty-four you would get from a plain duration conversion. This outcome is correct from a calendar perspective but may be unexpected if you were thinking in elapsed seconds alone.
# Spring-forward DST: clock skips from 2am to 3am
int <- interval(ymd_hms("2021-03-13 00:00:00", tz = "America/New_York"),
ymd_hms("2021-03-14 00:00:00", tz = "America/New_York"))
as.period(int)
# [1] "1d 1h 0m 0s" # calendar counts the missing hour
The period conversion reveals how the calendar distorts time, but sometimes you want to go the other direction — start with an exact duration in seconds and anchor it to a specific moment to produce an interval. The round-trip from Interval to Duration and back is lossless as long as you supply the original start point, which makes Duration a dependable intermediate when you need to shift or resize intervals with arithmetic operations.
The reverse path — converting a Duration back to an Interval — requires providing a start point to anchor the duration. The round-trip is exact and preserves the original span, making Duration a reliable intermediate format for interval arithmetic.
as.interval(as.duration(interval(ymd("2010-01-01"), ymd("2010-01-02"))),
ymd("2010-01-01"))
# [1] 2010-01-01 UTC--2010-01-02 UTC
Gotchas
Negative intervals when start > end. The interval constructor retains the original start-to-end order, so passing a later start with an earlier end produces an interval with negative length. Use the int_standardize() helper to swap the endpoints and obtain a positive-length interval automatically, or construct with the correct chronological order to begin with:
int <- interval(ymd("2010-06-01"), ymd("2010-01-01"))
int_length(int)
# [1] -15552000 # seconds
Negative-length intervals are easy to create by mistake when your date columns are not sorted, and they can silently corrupt comparisons and filtering logic. Before relying on any interval for analysis, check int_length() and verify the sign is what you expect. A different kind of surprise arises from daylight saving transitions: the same clock time on two consecutive days can span 25 real hours rather than 24, and int_length() faithfully reports the true elapsed seconds. The exact example is shown below. This behaviour is by design and reflects the reality of how clock changes work, but it routinely trips up analysts who assume that one calendar day always equals 86400 seconds.
DST gaps are not visible in interval length. At a spring-forward transition, clocks jump forward, the same clock hour is skipped. A 24-hour clock span that straddles this transition actually covers 25 hours of real elapsed time, and int_length() reports the true value of 90000 seconds rather than the 86400 you might naively expect. This discrepancy is not a bug; it reflects the physical reality that the day of the spring-forward shift is only 23 hours long on the clock.
int <- interval(ymd_hms("2010-03-14 01:00:00", tz = "America/New_York"),
ymd_hms("2010-03-15 01:00:00", tz = "America/New_York"))
int_length(int)
# [1] 90000 # 25 hours of real elapsed time (clocks spring forward)
Leap years with day units. Arithmetic on intervals using the days() function does not automatically account for leap days. An interval spanning February the twenty-ninth may not behave as expected when you add or subtract day-based periods, because the raw day count ignores whether the year actually contains that extra calendar day:
int <- interval(ymd("2020-02-28"), ymd("2020-03-01"))
# [1] 2020-02-28 UTC--2020-03-01 UTC (2 days in a leap year)
int <- interval(ymd("2019-02-28"), ymd("2019-03-01"))
# [1] 2019-02-28 UTC--2019-03-01 UTC (1 day in a non-leap year)
Leap years add an extra day to February, which means intervals that span the end of February can differ by a full day depending on whether the year is a leap year — something that days() arithmetic does not automatically account for. A related subtlety occurs at month boundaries in general: because calendar months have different lengths, an interval anchored to the same day numbers can produce very different day counts depending on which months are involved. The example below contrasts January-to-February against January-to-March to make this variability explicit. An interval of one month anchored to January 31 produces a different span than the same operation anchored to February 28, simply because the months themselves have different numbers of days. Use as.period() to inspect the calendar interpretation of an interval’s length when month boundaries matter.
interval(ymd("2021-01-31"), ymd("2021-02-28"))
# [1] 2021-01-31 UTC--2021-02-28 UTC (28 days)
interval(ymd("2021-01-31"), ymd("2021-03-31"))
# [1] 2021-01-31 UTC--2021-03-31 UTC (59 days)
Use as.period() to inspect the calendar interpretation of an interval’s length.
See also
ymd(), parse date strings without timeymd_hms(), parse date-time strings with timezone supportdplyr::filter()— filter rows by conditions (often used with interval checks)