R lubridate - Intervals, Durations, Periods

Time math looks simple until it isn't. Adding 'one day' to a timestamp seems straightforward, but what happens when that day crosses a daylight saving boundary? Is a day 86,400 seconds, or is it 23...

Key Insights

  • Durations measure exact seconds and are ideal for scientific calculations, while periods respect calendar irregularities like DST and variable month lengths—choosing wrong will introduce subtle bugs
  • Intervals capture both the time span AND its context (start and end points), making them essential for date range queries and overlap detection
  • Use the %--% operator for readable interval creation and %within% for intuitive membership testing—lubridate’s operators are more expressive than base R alternatives

Introduction to Time Spans in lubridate

Time math looks simple until it isn’t. Adding “one day” to a timestamp seems straightforward, but what happens when that day crosses a daylight saving boundary? Is a day 86,400 seconds, or is it 23 hours (or 25 hours) on those transition days? What about adding “one month” to January 31st?

These questions reveal why lubridate provides three distinct time span types: durations, periods, and intervals. Each solves a different problem, and using the wrong one leads to bugs that only surface twice a year during DST transitions or on edge-case dates.

library(lubridate)
library(tidyverse)

Let’s break down when and why you need each type.

Durations: Exact Time Lengths

Durations represent an exact number of seconds. They’re the physicist’s view of time—precise, unambiguous, and completely ignorant of human calendar conventions.

Create durations with the d-prefixed functions:

# Creating durations
dseconds(30)
#> [1] "30s"

dminutes(5)
#> [1] "300s (~5 minutes)"

dhours(2)
#> [1] "7200s (~2 hours)"

ddays(1)
#> [1] "86400s (~1 days)"

dweeks(2)
#> [1] "1209600s (~2 weeks)"

dyears(1)
#> [1] "31557600s (~365.25 days)"

Notice that dyears(1) equals 365.25 days—it accounts for leap years by averaging. This is exact but not always what you want.

Duration arithmetic is predictable:

# Durations are always exact seconds
start <- ymd_hms("2024-03-09 12:00:00", tz = "America/New_York")

# Adding exactly 86400 seconds
start + ddays(1)
#> [1] "2024-03-10 13:00:00 EDT"

Wait—we added one day but the clock shows 1:00 PM instead of noon? That’s because March 10, 2024 is when DST begins in the US. The clocks “spring forward,” so exactly 86,400 seconds later lands at 1:00 PM. The duration did exactly what it promised: added a precise number of seconds.

Use durations when you need:

  • Scientific calculations where precision matters
  • Time differences for performance measurements
  • Any context where “one day = 86,400 seconds” is the correct interpretation

Periods: Human-Relative Time Spans

Periods represent time the way humans think about it. Adding “one day” means “same time tomorrow,” regardless of how many seconds that actually involves.

# Creating periods (no 'd' prefix)
seconds(30)
minutes(5)
hours(2)
days(1)
weeks(2)
months(1)
years(1)

Now let’s revisit that DST example:

start <- ymd_hms("2024-03-09 12:00:00", tz = "America/New_York")

# Period respects "same time next day"
start + days(1)
#> [1] "2024-03-10 12:00:00 EDT"

# Duration adds exact seconds
start + ddays(1)
#> [1] "2024-03-10 13:00:00 EDT"

The period days(1) gives us noon the next day—what a human would expect. The duration gives us exactly 86,400 seconds later.

Periods handle month arithmetic gracefully:

# Adding months to different starting points
ymd("2024-01-31") + months(1)
#> [1] "2024-02-29"

ymd("2024-01-31") + months(2)
#> [1] "2024-03-31"

# What about invalid dates?
ymd("2024-01-31") + months(1)  # Feb 31 doesn't exist
#> [1] "2024-02-29"  # lubridate rolls back to last valid day

For stricter handling, use %m+% to add months while preserving day-of-month validity:

ymd("2024-01-31") %m+% months(1)
#> [1] "2024-02-29"

# Or use add_with_rollback for explicit control
add_with_rollback(ymd("2024-01-31"), months(1), roll_to_first = TRUE)
#> [1] "2024-03-01"

Use periods when you need:

  • Calendar-based scheduling (“meeting every month”)
  • User-facing date calculations (“subscription renews in 30 days”)
  • Any context where human expectations trump physical precision

Intervals: Time Spans with Context

Intervals differ fundamentally from durations and periods: they’re anchored to specific start and end points. This context enables operations that would otherwise be ambiguous.

# Creating intervals
start <- ymd("2024-01-15")
end <- ymd("2024-03-15")

# Two equivalent ways
interval(start, end)
#> [1] 2024-01-15 UTC--2024-03-15 UTC

start %--% end
#> [1] 2024-01-15 UTC--2024-03-15 UTC

The %--% operator is more readable in pipelines and expressions.

Extract interval components:

meeting <- ymd_hms("2024-06-01 09:00:00") %--% ymd_hms("2024-06-01 10:30:00")

int_start(meeting)
#> [1] "2024-06-01 09:00:00 UTC"

int_end(meeting)
#> [1] "2024-06-01 10:30:00 UTC"

int_length(meeting)  # Returns seconds
#> [1] 5400

# Flip direction
int_flip(meeting)
#> [1] 2024-06-01 10:30:00 UTC--2024-06-01 09:00:00 UTC

Converting Between Types

Converting intervals to durations or periods answers different questions about the same time span.

# A two-month interval
jan_to_mar <- ymd("2024-01-15") %--% ymd("2024-03-15")

# As duration: exact seconds
as.duration(jan_to_mar)
#> [1] "5184000s (~8.57 weeks)"

# As period: calendar units
as.period(jan_to_mar)
#> [1] "2m 0d 0H 0M 0S"

The duration tells us exactly 5,184,000 seconds elapsed. The period tells us “2 months” passed. Both are correct; they answer different questions.

Be aware of precision loss:

# This interval spans a DST transition
dst_interval <- ymd_hms("2024-03-09 12:00:00", tz = "America/New_York") %--%
                ymd_hms("2024-03-10 12:00:00", tz = "America/New_York")

as.duration(dst_interval)
#> [1] "82800s (~23 hours)"  # Only 23 hours due to DST

as.period(dst_interval)
#> [1] "1d 0H 0M 0S"  # Shows "1 day" conceptually

Practical Operations and Comparisons

Intervals shine in real-world scheduling and filtering scenarios.

Checking overlaps:

meeting1 <- ymd_hms("2024-06-01 09:00:00") %--% ymd_hms("2024-06-01 10:00:00")
meeting2 <- ymd_hms("2024-06-01 09:30:00") %--% ymd_hms("2024-06-01 11:00:00")
meeting3 <- ymd_hms("2024-06-01 14:00:00") %--% ymd_hms("2024-06-01 15:00:00")

int_overlaps(meeting1, meeting2)
#> [1] TRUE

int_overlaps(meeting1, meeting3)
#> [1] FALSE

Testing membership with %within%:

q1_2024 <- ymd("2024-01-01") %--% ymd("2024-03-31")

ymd("2024-02-15") %within% q1_2024
#> [1] TRUE

ymd("2024-05-01") %within% q1_2024
#> [1] FALSE

# Works with vectors
dates <- ymd(c("2024-02-15", "2024-05-01", "2024-03-15"))
dates %within% q1_2024
#> [1]  TRUE FALSE  TRUE

Dividing intervals:

project_span <- ymd("2024-01-01") %--% ymd("2024-06-30")

# How many weeks?
project_span / weeks(1)
#> [1] 26

# How many months?
project_span / months(1)
#> [1] 6

# Partial units work too
project_span / days(1)
#> [1] 181

Choosing the Right Type: Decision Guide

Here’s a practical decision framework:

Scenario Use Why
Performance benchmarks Duration Need exact elapsed time
“Remind me in 2 weeks” Period Human expectation of “same time”
“Is this date in Q1?” Interval Need range membership
Scientific time series Duration Precision required
Subscription billing Period “Monthly” means calendar month
Meeting conflict detection Interval Need overlap checking

Real-world example: Subscription billing system

# Subscription management using all three types
subscriptions <- tibble(
 user_id = 1:3,
 start_date = ymd(c("2024-01-15", "2024-02-28", "2024-03-01")),
 plan_months = c(12, 6, 1)
)

subscriptions <- subscriptions %>%
 mutate(
   # Period: calendar-based renewal
   end_date = start_date + months(plan_months),
 
   # Interval: the active subscription window
   active_interval = start_date %--% end_date,
 
   # Duration: exact time for proration calculations
   exact_seconds = as.duration(active_interval),
   daily_rate = 29.99 / as.numeric(exact_seconds / ddays(1)) * plan_months
 )

# Check if a user is active on a given date
check_date <- ymd("2024-04-15")
subscriptions %>%
 mutate(is_active = check_date %within% active_interval)

This example demonstrates the practical interplay: periods handle the human concept of “monthly billing,” intervals enable active-status queries, and durations provide exact values for proration math.

The key insight is that these aren’t interchangeable—each encodes different assumptions about what “time span” means. Match your type to your domain’s requirements, and you’ll avoid the subtle bugs that plague date-time code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.