R lubridate - Date Arithmetic

Date arithmetic sounds simple until you actually try to implement it. Adding 30 days to January 15th is straightforward. Adding 'one month' is not—does that mean 28, 29, 30, or 31 days? What happens...

Key Insights

  • lubridate distinguishes between periods (human-readable units like “1 month”) and durations (exact seconds), and choosing the wrong one leads to subtle bugs in date arithmetic
  • The %m+% operator handles month-end edge cases gracefully, rolling back to valid dates instead of producing unexpected results like February 31st
  • Intervals are the proper way to calculate differences between dates—dividing an interval by a time unit gives you the exact count in that unit

Introduction to Date Arithmetic in R

Date arithmetic sounds simple until you actually try to implement it. Adding 30 days to January 15th is straightforward. Adding “one month” is not—does that mean 28, 29, 30, or 31 days? What happens when you add one month to January 31st?

Base R handles dates, but the syntax is clunky and the mental model is inconsistent. You end up writing code like as.Date("2024-01-15") + 30 for days, but calculating months requires wrestling with seq.Date() or manual arithmetic with as.POSIXlt components.

lubridate, part of the tidyverse ecosystem, provides a consistent grammar for date-time manipulation. It introduces three distinct concepts—instants, intervals, and spans (periods and durations)—that map directly to how humans think about time. Once you internalize these concepts, date arithmetic becomes predictable.

Setting Up: Installing and Loading lubridate

Install lubridate from CRAN if you haven’t already:

install.packages("lubridate")
library(lubridate)

The first step in any date arithmetic is parsing your dates into proper date-time objects. lubridate’s parsing functions are named after the order of components in your date string:

# Parse dates based on their format
ymd("2024-03-15")        # Year-Month-Day
mdy("03/15/2024")        # Month/Day/Year
dmy("15-03-2024")        # Day-Month-Year

# These all produce the same result
# [1] "2024-03-15"

# Add time components
ymd_hms("2024-03-15 14:30:00")
mdy_hm("03/15/2024 2:30 PM")

# Parse vectors
dates <- c("2024-01-01", "2024-06-15", "2024-12-31")
ymd(dates)

lubridate is forgiving about separators. ymd("2024/03/15"), ymd("2024.03.15"), and ymd("20240315") all work. The function infers the separator from context.

Adding and Subtracting Time Periods

Here’s where lubridate shines. Periods represent human-readable time spans—“one month,” “two weeks,” “three years.” They respect calendar conventions rather than counting exact seconds.

start_date <- ymd("2024-01-15")

# Adding periods
start_date + days(10)      # "2024-01-25"
start_date + weeks(2)      # "2024-01-29"
start_date + months(1)     # "2024-02-15"
start_date + years(1)      # "2025-01-15"

# Subtracting works the same way
start_date - days(15)      # "2023-12-31"
start_date - months(6)     # "2023-07-15"

# Combine multiple periods
start_date + years(1) + months(3) + days(5)  # "2025-04-20"

# Multiply periods
start_date + months(3) * 4  # Add 12 months: "2025-01-15"

Periods handle variable-length months correctly. Adding one month to January 15th gives you February 15th, not February 14th (which would be 31 days later in non-leap years).

# Months have different lengths, but periods handle this
ymd("2024-01-31") + months(1)  # "2024-03-02" - rolls over!
ymd("2024-03-31") + months(1)  # "2024-05-01" - April has 30 days

Wait—that rollover behavior might not be what you want. We’ll address this in the pitfalls section.

Working with Durations for Precise Calculations

Durations represent exact amounts of time in seconds. When you need precision—scientific calculations, measuring elapsed time, or working with time-series data—use durations.

# Duration functions start with 'd'
ddays(1)       # 86400 seconds (exactly 24 hours)
dweeks(1)      # 604800 seconds
dhours(12)     # 43200 seconds
dminutes(90)   # 5400 seconds
dseconds(3600) # 3600 seconds

# Apply to dates
start_date <- ymd("2024-03-10")  # Day before DST in US
start_date + ddays(1)   # Adds exactly 86400 seconds
start_date + days(1)    # Adds one calendar day

The difference matters around daylight saving time transitions. In timezones with DST, one calendar day isn’t always 86400 seconds.

# Durations for precise elapsed time
start_time <- ymd_hms("2024-01-15 08:00:00")
end_time <- ymd_hms("2024-01-15 17:30:00")

elapsed <- as.duration(end_time - start_time)
elapsed  # "34200s (~9.5 hours)"

# Convert to hours
as.numeric(elapsed, "hours")  # 9.5

There’s no dmonths() or dyears() function because months and years don’t have fixed lengths in seconds. If you need month-like durations, use ddays(30) or ddays(365) and accept the approximation.

Calculating Date Differences with Intervals

Intervals represent the span between two specific instants. They’re the proper tool for answering “how much time between A and B?”

# Create an interval
start <- ymd("2024-01-01")
end <- ymd("2024-12-31")

span <- interval(start, end)
span  # 2024-01-01 UTC--2024-12-31 UTC

# Alternative syntax
span <- start %--% end

Extract the length in various units by dividing by a period:

# How many days?
span / days(1)    # 365

# How many weeks?
span / weeks(1)   # 52.14286

# How many months?
span / months(1)  # 12 (approximate)

# Get exact length in seconds
int_length(span)  # 31536000

Intervals are directional. Swapping start and end produces a negative interval:

reverse_span <- end %--% start
reverse_span / days(1)  # -365

Check if a date falls within an interval:

test_date <- ymd("2024-06-15")
test_date %within% span  # TRUE

outside_date <- ymd("2025-03-01")
outside_date %within% span  # FALSE

Practical Applications

Let’s build some real-world utilities.

Age Calculator

calculate_age <- function(birthdate, on_date = today()) {
 birthdate <- ymd(birthdate)
 on_date <- ymd(on_date)
 
 # Create interval and extract years
 age_interval <- interval(birthdate, on_date)
 
 # floor() gives complete years
 floor(age_interval / years(1))
}

calculate_age("1990-05-20")  # Returns age as of today
calculate_age("1990-05-20", "2024-05-19")  # 33 (day before birthday)
calculate_age("1990-05-20", "2024-05-20")  # 34 (on birthday)

Days Until a Future Event

days_until <- function(target_date) {
 target <- ymd(target_date)
 current <- today()
 
 if (target <= current) {
   return(paste("Date has passed by", current - target, "days"))
 }
 
 remaining <- interval(current, target) / days(1)
 floor(remaining)
}

days_until("2024-12-25")  # Days until Christmas

Generate Date Sequences

# Weekly meetings for the next 3 months
meeting_start <- ymd("2024-01-08")  # First Monday
meetings <- meeting_start + weeks(0:11)
meetings

# Monthly report dates (15th of each month)
report_dates <- ymd("2024-01-15") + months(0:11)
report_dates

# Business quarters
quarter_starts <- ymd("2024-01-01") + months(c(0, 3, 6, 9))
quarter_starts

Find Next Occurrence of a Weekday

next_weekday <- function(target_wday, from_date = today()) {
 # wday: 1 = Sunday, 2 = Monday, ..., 7 = Saturday
 from_date <- ymd(from_date)
 current_wday <- wday(from_date)
 
 days_ahead <- (target_wday - current_wday) %% 7
 if (days_ahead == 0) days_ahead <- 7  # Next week if today
 
 from_date + days(days_ahead)
}

next_weekday(2)  # Next Monday
next_weekday(6)  # Next Friday

Common Pitfalls and Best Practices

The Month-End Problem

Adding months to end-of-month dates produces unexpected results:

ymd("2024-01-31") + months(1)  # "2024-03-02" - not February!
ymd("2024-01-30") + months(1)  # "2024-03-01"
ymd("2024-01-29") + months(1)  # "2024-02-29" (leap year)

lubridate rolls forward when the target day doesn’t exist. Use %m+% for rollback behavior:

# %m+% rolls back to the last valid day of the month
ymd("2024-01-31") %m+% months(1)  # "2024-02-29"
ymd("2024-01-31") %m+% months(2)  # "2024-03-31"
ymd("2024-03-31") %m+% months(1)  # "2024-04-30"

# Subtraction version
ymd("2024-03-31") %m-% months(1)  # "2024-02-29"

Timezone Awareness

lubridate defaults to UTC. If your dates need timezone context:

# Parse with timezone
ymd_hms("2024-03-10 02:30:00", tz = "America/New_York")

# Convert timezones
dt <- ymd_hms("2024-03-15 12:00:00", tz = "UTC")
with_tz(dt, "America/Los_Angeles")  # Same instant, different display
force_tz(dt, "America/Los_Angeles") # Changes the instant

Leap Year Edge Cases

February 29th requires special handling:

leap_day <- ymd("2024-02-29")
leap_day + years(1)  # "2025-03-01" - rolls forward

# Use %m+% for rollback
leap_day %m+% years(1)  # "2025-02-28"

Vector Operations

lubridate functions are vectorized. Use them on entire columns:

library(dplyr)

df <- tibble(
 start_date = ymd(c("2024-01-15", "2024-02-20", "2024-03-10")),
 duration_months = c(3, 6, 12)
)

df %>%
 mutate(
   end_date = start_date %m+% months(duration_months),
   days_span = interval(start_date, end_date) / days(1)
 )

Date arithmetic doesn’t have to be painful. lubridate’s consistent API—periods for human time, durations for precise time, intervals for spans—covers virtually every use case. Master these three concepts and you’ll handle dates confidently.

Liked this? There's more.

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