rguides

Using lubridate to Work with Dates and Times in R

Using lubridate to handle dates and times in R replaces the awkward base R date functions with intuitive, verb-shaped alternatives. Functions like ymd(), dmy(), and mdy() parse dates by guessing the format from the order of components, while period(), duration(), and interval() model time intervals correctly across DST transitions and leap days. This tutorial covers parsing, arithmetic, and time zone handling with practical examples you can apply immediately.

What you’ll learn

This tutorial covers the key concepts and practical techniques for working with Working with dates in R using lubridate. By the end, you will know how to apply the core functions in real data analysis workflows.

Installing and loading lubridate

First, install and load lubridate from CRAN:

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

lubridate is part of the tidyverse, so if you have tidyverse loaded, you already have lubridate:

library(tidyverse)

Parsing dates and times

The hardest part of working with dates is often just getting R to recognize them. lubridate makes this straightforward with functions that match common date formats.

Automatic parsing functions

lubridate provides functions named after the order of date components:

FunctionFormatExample
ymd()Year-Month-Day2024-03-15
mdy()Month-Day-YearMarch 15, 2024
dmy()Day-Month-Year15 March 2024
ymd_hms()With time2024-03-15 14:30:00
# Parse dates from different formats
ymd("2024-03-15")
# [1] "2024-03-15"

mdy("March 15, 2024")
# [1] "2024-03-15"

dmy("15-03-2024")
# [1] "2024-03-15"

# Parse datetime with time
ymd_hms("2024-03-15 14:30:45")
# [1] "2024-03-15 14:30:45 UTC"

# Shorter datetime formats
ymd_hm("2024-03-15 14:30")
ymd_h("2024-03-15 14")

Parsing from character vectors

All lubridate parsing functions are vectorized — pass a character vector and you get back a vector of Date or POSIXct values. This is the standard pattern for converting a date column in a data frame with mutate(). The function automatically detects separators (dashes, slashes, dots, or no separator at all) within each call. Non-parseable strings return NA with a warning.

dates <- c("2024-01-15", "2024-02-20", "2024-03-15")
ymd(dates)
#> [1] "2024-01-15" "2024-02-20" "2024-03-15"

datetimes <- c("2024-03-15 09:30:00", "2024-03-15 10:45:00")
ymd_hms(datetimes)
#> [1] "2024-03-15 09:30:00 UTC" "2024-03-15 10:45:00 UTC"

Handling mixed formats

When a single column contains dates in different formats — common in datasets assembled from multiple sources — parse_date_time() accepts a vector of format orders and tries each one until parsing succeeds. This is more flexible than the single-format ymd() family and handles the messy reality of real-world data. For custom formats, parse_date_time(x, orders = "%B %d, %Y") also accepts strptime-style format codes.

mixed <- c("2024-01-15", "15/03/2024", "2024.03.20")
parse_date_time(mixed, orders = c("ymd", "dmy", "ymd"))
#> [1] "2024-01-15 UTC" "2024-03-15 UTC" "2024-03-20 UTC"

Extracting date components

Accessor functions return individual components as numeric values. These are the building blocks for time-based analysis: filter by year, group by month, compute day-of-week patterns, or extract hour-of-day for traffic analysis. Adding label = TRUE returns ordered factors with human-readable abbreviations, and these accessors also work as assignment targets — you can modify a date component in place without reconstructing the entire date.

date <- ymd_hms("2024-03-15 14:30:45")

year(date)                    #> 2024
month(date)                   #> 3
month(date, label = TRUE)     #> Mar  (ordered factor)
day(date)                     #> 15
hour(date)                    #> 14
minute(date)                  #> 30
second(date)                  #> 45
wday(date)                    #> 6  (1=Sunday)
wday(date, label = TRUE)      #> Fri

# Accessors also allow assignment to modify components
date <- ymd("2024-03-15")
month(date) <- 6
date
#> [1] "2024-06-15"

Date arithmetic

lubridate makes date arithmetic intuitive with functions like days(), weeks(), months(), and years():

Adding and subtracting time

Date arithmetic uses helper functions — days(), weeks(), months(), years() — that produce objects lubridate understands. Adding them to a date or datetime shifts the value forward by the specified amount, handling month boundaries and leap years correctly:

# Start date
start <- ymd("2024-03-15")

# Add time
start + days(10)
# [1] "2024-03-25"

start + weeks(2)
# [1] "2024-03-29"

start + months(1)
# [1] "2024-04-15"

start + years(1)
# [1] "2025-03-15"

# Subtract time
start - days(5)
# [1] "2024-03-10"

Difference between dates

Use %--% to create an interval between two dates, then convert it to a duration or period to express the span in the units you need. The interval stores both endpoints, so conversions to different units are always computed from the exact start and end dates:

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

# Calculate difference
interval <- start %--% end
interval
# [1] 2024-01-01 UTC--2024-03-15 UTC

# Get the span in different units
as.duration(interval)
# [1] "74d 0H 0M 0S"

as.period(interval)
# [1] "2 months and 14 days"

# Quick calculations
end - start
# Time difference of 74 days

Rounding dates

Round dates to nearby boundaries with floor_date(), ceiling_date(), and round_date(). These functions take a datetime and a unit (like “day”, “hour”, or “month”) and snap the value to the nearest boundary in that unit:

datetime <- ymd_hms("2024-03-15 14:30:45")

floor_date(datetime, "day")   # Round down to day
# [1] "2024-03-15 UTC"

ceiling_date(datetime, "day") # Round up to day
# [1] "2024-03-16 UTC"

round_date(datetime, "hour")  # Round to nearest hour
# [1] "2024-03-15 15:00:00 UTC"

Handling time zones

Time zones can be tricky. lubridate makes them manageable:

Setting time zones

Use tz in the parsing function to set the timezone at creation, then with_tz() to convert between timezones. The underlying instant (seconds since 1970-01-01) never changes during conversion — only the displayed clock time adjusts to the target timezone:

# Parse with specific timezone
datetime <- ymd_hms("2024-03-15 14:30:00", tz = "America/New_York")
datetime
# [1] "2024-03-15 14:30:00 EST"

# Convert to another timezone
with_tz(datetime, "UTC")
# [1] "2024-03-15 19:30:00 UTC"

with_tz(datetime, "Europe/London")
# [1] "2024-03-15 18:30:00 GMT"

Common time zone operations

Common time zone operations include checking the system timezone, listing available timezone names, and forcing a timezone onto a naive datetime. force_tz() is the dangerous version — it changes the clock’s interpretation without adjusting the instant:

# Get current timezone
Sys.timezone()
# [1] "UTC"

# See available timezones
length(OlsonNames())
# [1] 599

# Parse and force timezone (even if string has no tz)
force_tz(ymd_hms("2024-03-15 14:30:00"), tzone = "America/Los_Angeles")
# [1] "2024-03-15 14:30:00 PST"

Practical examples

Example 1: calculate age

calculate_age <- function(birth_date) {
  birth <- ymd(birth_date)
  today <- today()
  age <- year(today) - year(birth)
  # Subtract 1 if birthday hasn't occurred this year
  month(today) < month(birth) | 
    (month(today) == month(birth) & day(today) < day(birth))
  age - 1
}

calculate_age("1990-05-15")
# [1] 35

Example 2: business days between dates

library(lubridate)

# Create date range
start <- ymd("2024-03-01")
end <- ymd("2024-03-31")

# Get all days in March
all_days <- seq(start, end, by = "day")

# Remove weekends
business_days <- all_days[!wday(all_days) %in% c(1, 7)]
length(business_days)
# [1] 21

Example 3: find last day of month

# Last day of March 2024
ceiling_date(ymd("2024-03-15"), "month") - days(1)
# [1] "2024-03-31"

# Or use the dedicated function
ceiling_date(ymd("2024-03-15"), "month") - ddays(1)
# [1] "2024-03-31 23:59:59 UTC"

Example 4: parse dates from real data

# Simulating reading from a CSV
library(dplyr)

sample_data <- tibble(
  date_str = c("2024-01-15", "2024-02-20", "2024-03-10", "2024-04-05")
)

# Parse and extract components
sample_data %>%
  mutate(
    date = ymd(date_str),
    year = year(date),
    month = month(date, label = TRUE),
    day = day(date),
    quarter = quarter(date),
    week = week(date)
  )
# # A tibble: 4 × 7
#   date_str  date        year month   day quarter  week
#   <chr>     <date>     <dbl> <ord> <int>   <int> <int>
# 1 2024-01-15 2024-01-15  2024 Jan    15       1     3
# 2 2024-02-20 2024-02-20  2024 Feb    20       1     8
# 3 2024-03-10 2024-03-10  2024 Mar    10       1    11
# 4 2024-04-05 2024-04-05  2024 Apr     5       2    14

Extracting components

year(dt), month(dt), day(dt), hour(dt), minute(dt), second(dt) extract components. These also work as assignment: month(dt) <- 6 changes the month to June. All are vectorized.

wday(dt, label = TRUE) returns the day of week as an ordered factor (“Mon”, “Tue”, …). wday(dt, label = FALSE) returns an integer (1 = Sunday by default; set week_start = 1 for Monday). yday(dt) returns the day of year (1-366). week(dt) returns the week number; isoweek(dt) uses ISO 8601 week numbering.

quarter(dt) returns the quarter (1-4). semester(dt) returns 1 or 2. floor_date(dt, unit = "month") rounds down to the start of the month; ceiling_date(dt, unit = "week") rounds up. round_date(dt, unit = "hour") rounds to the nearest hour.

Arithmetic with periods and durations

Lubridate distinguishes periods (human-time units) from durations (physical time). months(3) is a period (the next calendar quarter, variable in seconds). dmonths(3) is a duration (exactly 3 * 30.4375 * 86400 seconds). The distinction matters around DST transitions and month boundaries.

dt + years(1) adds one calendar year, preserving the day of month. dt + dyears(1) adds exactly 365.25 * 86400 seconds. For business applications (billing dates, contract renewals), use periods. For physics and data science (elapsed time, timestamps), use durations.

difftime(end, start, units = "days") computes differences, returning a difftime object. as.numeric(difftime(...)) extracts the numeric value. interval(start, end) creates an interval object; as.duration(interval(start, end)) converts to seconds; as.period(interval(start, end), unit = "month") converts to a period.

Time zones

with_tz(dt, tzone = "America/New_York") changes the display time zone without changing the underlying instant. force_tz(dt, tzone = "America/Chicago") changes the interpretation: it reinterprets the displayed time as being in Chicago, changing the underlying instant.

Sys.timezone() returns the system time zone. OlsonNames() lists all valid time zone names. Always store timestamps in UTC internally and convert to local time for display: with_tz(utc_dt, tzone = user_timezone).

lubridate::now(tzone = "UTC") returns the current time in UTC. lubridate::today() returns today’s date with no time component.

Working with date sequences

seq(from = ymd("2024-01-01"), to = ymd("2024-12-31"), by = "month") generates a monthly sequence. by = "week", by = "quarter", by = "year" work similarly. by = 7 (integer) generates a sequence with 7-day steps.

%within% tests whether a date falls within an interval: ymd("2024-06-15") %within% interval(ymd("2024-01-01"), ymd("2024-12-31")) returns TRUE.

For business days, bizdays::create.calendar() with a holiday list enables bizdays::add.bizdays(date, n) to add n business days. The timeDate package also provides holiday calendars for many countries.

Why date handling is hard

Dates and times are more complex than they appear. A date string like “01/02/03” is ambiguous, is it January 2, 2003, or February 1, 2003, or February 3, 2001? Different regions use different date formats. Times complicate matters further: UTC offset rules change, daylight saving time creates discontinuities, and some timezone conversions involve non-integer hour offsets. Doing date arithmetic correctly requires awareness of all these complications.

lubridate provides a consistent interface that isolates you from most of this complexity. Its parsing functions accept format strings that match common date representations. Its arithmetic functions handle the edge cases of month and year arithmetic. Its timezone functions use the IANA timezone database to apply the correct DST rules for each location. You still need to know the right function to call, but the functions behave correctly once you do.

Parsing ambiguous dates

The order of year, month, and day in a date string determines which lubridate parsing function to use. The function names encode the parsing order: ymd parses year-month-day, mdy parses month-day-year, dmy parses day-month-year. These functions are forgiving about separators — slashes, dashes, spaces, and periods all work. The parser infers the separator from the string.

When a dataset contains date strings in multiple formats, or the format is unknown, use parse_date_time with a list of possible format strings. It tries each format in order and returns the first successful parse. For large datasets with inconsistent dates, this is slower than a single-format parser but catches formats that the default parsers would miss. Always check parsed dates by plotting them on a timeline or looking at min/max — parsing errors often produce implausible values like dates far in the future or past.

Month and year arithmetic

Adding months or years to a date requires care because calendar months have different lengths. Adding one month to January 31 cannot return February 31 — that date does not exist. lubridate’s %m+% operator handles this by rolling back to the last day of the month: January 31 %m+% months(1) returns February 28 (or 29 in a leap year). Using the plus operator directly does not handle this edge case and returns NA.

Year arithmetic has the same edge case for February 29 in leap years. The rollback behavior of %m+% applies consistently. For financial and business date calculations where end-of-month dates are common, using %m+% instead of + months() prevents hard-to-reproduce bugs that only appear at the end of short months.

Summary

lubridate transforms date handling from a headache into a pleasure:

  • Parsing: Use ymd(), mdy(), dmy() and their datetime variants
  • Extraction: year(), month(), day(), hour(), wday()
  • Arithmetic: Add/subtract with days(), months(), years()
  • Intervals: Use %--% to calculate spans between dates
  • Time zones: with_tz() and force_tz() for timezone conversion
  • Rounding: floor_date(), ceiling_date(), round_date()

With these tools, you’ll handle any date-related task with confidence. In the next tutorial, we’ll cover forcats for handling categorical variables—another common data wrangling challenge.

Next steps

Now that you understand working with dates in r using lubridate, explore these related topics to deepen your knowledge and apply these techniques in more complex scenarios.

See also