4  Working with Dates and Times

4.1 Topics Covered

  1. How R stores dates and times: Date, POSIXct, and POSIXlt
  2. Creating, formatting, and extracting components of dates in base R
  3. Date arithmetic and sequences of dates
  4. Parsing and manipulating dates with lubridate
  5. Durations, periods, intervals, and time zones
  6. Working with time series data using ts objects and data frames

4.2 Introduction

Dates and times data are common; unfortunately, they are also one of the most error-prone kinds of data to work with. Months have different lengths, years can be leap years, clocks jump forward and back for daylight saving time, and the same moment in time has a different “clock time” depending on where you are in the world.

In this chapter, we discuss how R represents dates and times, how to convert text into proper date-time objects, how to perform arithmetic with them, and how to summarize and plot data that are ordered in time. We cover two toolkits:

  1. Base R: has everything you need built in and is worth understanding because every other package builds on it
  2. lubridate: a tidyverse package that makes the most common tasks shorter, more readable, and harder to get wrong

Before starting, we load in lubridate. We also load ggplot2 for graphing, which is discussed in more detail in Chapter 5 and Chapter 6.

4.3 How R Stores Dates and Times

R has three main classes for temporal data, which are described in Table 4.1.

Table 4.1. R date and time classes.
Class Stores Underlying Value
Date Calendar date, no time of day Days since 1970-01-01
POSIXct Date and time (an instant) Seconds since 1970-01-01 00:00:00 UTC
POSIXlt Date and time, broken into parts List of components (year, month, hour, etc.)

The key idea is that, under the hood, dates and date-times are just numbers with a class attached. This is what makes it possible to sort them, subtract them, and plot them on a continuous axis.

d <- as.Date("2024-03-15")
d
[1] "2024-03-15"
[1] "Date"
unclass(d)   # days since 1970-01-01
[1] 19797

For most work, you will use Date when you only care about the day and POSIXct when the time of day matters. POSIXlt is mostly useful as an intermediate step for pulling out components.

4.4 Dates and Times in Base R

4.4.1 Creating Dates from Text

as.Date() converts character strings into Date objects. If your strings are already in the international standard YYYY-MM-DD format (ISO 8601), no extra arguments are needed.

as.Date(c("2024-01-01", "2024-02-29", "2024-12-31"))
[1] "2024-01-01" "2024-02-29" "2024-12-31"

For any other layout, you must describe the format using format codes.

as.Date("15/03/2024", format = "%d/%m/%Y")
[1] "2024-03-15"
as.Date("March 15, 2024", format = "%B %d, %Y")
[1] "2024-03-15"
as.Date("15-Mar-24", format = "%d-%b-%y")
[1] "2024-03-15"

The most common format codes are listed in Table 4.2. See ?strptime for the complete list.

Table 4.2. Common date and time format codes.
Code Meaning Example
%Y 4-digit year 2024
%y 2-digit year 24
%m Month as number 03
%B / %b Full / abbreviated month name March / Mar
%d Day of month 15
%A / %a Full / abbreviated weekday Friday / Fri
%H Hour (00–23) 14
%I Hour (01–12) 02
%p AM/PM indicator PM
%M Minute 30
%S Second 05
%j Day of year 075

If a string does not match the format you provide, as.Date() silently returns NA rather than an error. Always check for unexpected NAs after parsing.

as.Date("2024/03/15", format = "%Y-%m-%d")
[1] NA

Month and weekday names (%B, %b, %A, %a) depend on your computer’s language settings, or locale. Code that parses "March" may fail on a machine set to French or German.

4.4.2 Creating Date-Times

as.POSIXct() works the same way but also handles the time of day. You should always supply a time zone using the tz argument. If you leave it out, R uses your computer’s local time zone, and your results may differ on someone else’s machine.

t1 <- as.POSIXct("2024-03-15 14:30:00", tz = "UTC")
t1
[1] "2024-03-15 14:30:00 UTC"
as.POSIXct("15/03/2024 2:30 PM", format = "%d/%m/%Y %I:%M %p", tz = "UTC")
[1] "2024-03-15 14:30:00 UTC"

4.4.3 Formatting Dates as Text

format() goes the other way, turning a date into a string. It uses the same format codes.

format(d, "%d %B %Y")
[1] "15 March 2024"
format(d, "%A")
[1] "Friday"
format(t1, "%H:%M")
[1] "14:30"

4.4.4 Extracting Components

The helper functions weekdays() and months() cover the most common cases. For anything else, you can convert to POSIXlt and pull out the needed parts.

[1] "Friday"
[1] "March"
lt <- as.POSIXlt(t1)
lt$hour
[1] 14
lt$year + 1900   # years are stored as years since 1900
[1] 2024
lt$mon + 1       # months are stored 0–11
[1] 3

The odd offsets on year and mon are a historical quirk inherited from the C language and a common source of bugs. This is one of the places where lubridate helps a great deal.

4.4.5 Date Arithmetic

Because dates are numbers, you can add and subtract days directly.

d + 30
[1] "2024-04-14"
as.Date("2024-12-25") - d
Time difference of 285 days

Subtracting two dates returns a difftime object. You can choose the units with difftime() and convert to a plain number with as.numeric().

gap <- difftime(as.Date("2024-12-25"), d, units = "weeks")
gap
Time difference of 40.71429 weeks
[1] 40.71429

For POSIXct objects, adding a number adds seconds.

t1 + 3600   # one hour later
[1] "2024-03-15 15:30:00 UTC"

4.4.6 Sequences of Dates

seq() understands dates and accepts readable step sizes.

seq(as.Date("2024-01-01"), by = "month", length.out = 6)
[1] "2024-01-01" "2024-02-01" "2024-03-01" "2024-04-01" "2024-05-01"
[6] "2024-06-01"
seq(as.Date("2024-01-01"), as.Date("2024-01-29"), by = "week")
[1] "2024-01-01" "2024-01-08" "2024-01-15" "2024-01-22" "2024-01-29"

4.5 Making Life Easier with lubridate

lubridate does not replace base R’s date classes. Instead, it produces ordinary Date and POSIXct objects. What it adds is a set of functions with consistent names that remove most of the friction discussed above.

4.5.1 Parsing without Format Codes

Instead of writing format strings, you name the order of the components using the letters y, m, d, h, m, and s. lubridate works out the separators and month names for you.

ymd("2024-03-15")
[1] "2024-03-15"
dmy("15/03/2024")
[1] "2024-03-15"
mdy("March 15, 2024")
[1] "2024-03-15"
ymd_hms("2024-03-15 14:30:00")
[1] "2024-03-15 14:30:00 UTC"
dmy_hm("15.03.2024 14:30", tz = "America/New_York")
[1] "2024-03-15 14:30:00 EDT"

These functions are also forgiving of messy input as long as the order is consistent.

messy <- c("2024-03-15", "2024/3/16", "20240317", "2024 March 18")
ymd(messy)
[1] "2024-03-15" "2024-03-16" "2024-03-17" "2024-03-18"

Note that the date-time parsers (ymd_hms() and related functions) default to UTC, which makes results reproducible across computers.

If you have the parts in separate columns, you can use make_date() or make_datetime().

make_date(year = 2024, month = 3, day = 15)
[1] "2024-03-15"
make_datetime(2024, 3, 15, 14, 30, tz = "UTC")
[1] "2024-03-15 14:30:00 UTC"

4.5.2 Getting and Setting Components

lubridate provides one function per component, so there are no offsets to remember.

x <- ymd_hms("2024-03-15 14:30:05")
year(x)
[1] 2024
[1] 3
month(x, label = TRUE)
[1] Mar
12 Levels: Jan < Feb < Mar < Apr < May < Jun < Jul < Aug < Sep < ... < Dec
day(x)
[1] 15
wday(x, label = TRUE)
[1] Fri
Levels: Sun < Mon < Tue < Wed < Thu < Fri < Sat
yday(x)
[1] 75
hour(x)
[1] 14
[1] 1

The same functions can be used to change a component.

month(x) <- 7
x
[1] "2024-07-15 14:30:05 UTC"

4.5.3 Rounding Dates

Rounding is very useful for grouping observations into periods, for example to sum sales by week or month.

x <- ymd_hms("2024-03-15 14:30:05")
floor_date(x, "month")
[1] "2024-03-01 UTC"
floor_date(x, "week", week_start = 1)  # weeks starting Monday
[1] "2024-03-11 UTC"
round_date(x, "hour")
[1] "2024-03-15 15:00:00 UTC"
ceiling_date(x, "day")
[1] "2024-03-16 UTC"

4.5.4 Durations, Periods, and Intervals

lubridate distinguishes between three kinds of time span. The difference matters whenever the calendar is irregular.

  • Duration: an exact number of seconds; used for physical elapsed time
  • Period: a span in human calendar units, such as “1 month” or “1 day”; its length in seconds depends on when it starts
  • Interval: a span with a specific start and end point

The difference is easiest to see around a daylight saving change. In New York, clocks jumped from 2:00 to 3:00 AM on 10 March 2024.

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

before + ddays(1)   # duration: exactly 86,400 seconds later
[1] "2024-03-10 13:00:00 EDT"
before + days(1)    # period: same clock time, next day
[1] "2024-03-10 12:00:00 EDT"

The duration lands at 1 PM because that day was only 23 hours long. The period keeps the clock time the same, which is usually what people mean by “one day later.”

Periods also handle months of different lengths. If adding a month would produce an invalid date, %m+% rolls back to the last valid day instead of returning NA.

jan31 <- ymd("2024-01-31")
jan31 + months(1)     # Feb 31 doesn't exist
[1] NA
jan31 %m+% months(1)  # rolls back to Feb 29
[1] "2024-02-29"

Intervals allow you to ask how many whole units fit between two points and whether a date falls inside a range.

birth <- ymd("1990-07-20")
today <- ymd("2024-03-15")
span  <- interval(birth, today)

span %/% years(1)                      # age in whole years
[1] 33
ymd("2000-01-01") %within% span
[1] TRUE

4.5.5 Time Zones

A POSIXct value is a single instant; the time zone only controls how it is displayed. lubridate provides two functions that look similar but do very different things:

  • with_tz(): keeps the instant the same and changes the display time zone
  • force_tz(): keeps the clock time the same and changes the instant
meeting <- ymd_hms("2024-03-15 09:00:00", tz = "America/New_York")

with_tz(meeting, "Europe/London")    # same moment, London clock
[1] "2024-03-15 13:00:00 GMT"
force_tz(meeting, "Europe/London")   # 9 AM in London instead
[1] "2024-03-15 09:00:00 GMT"

Use with_tz() when you want to know “what time is that over there?” Use force_tz() to fix data that were recorded with the wrong time zone label. OlsonNames() lists all valid time zone names.

4.6 Working with Time Series Data

A time series is a set of observations recorded in time order. In R, there are two common ways to store one:

  1. A built-in ts object, suited to regularly spaced data such as monthly or quarterly values
  2. A data frame with a date or date-time column, which is more flexible and is the approach you will use most often

4.6.1 The ts Class

ts() stores a numeric vector together with its start time and frequency, or the number of observations per unit time (12 for monthly data and 4 for quarterly data). R ships with several examples, including AirPassengers, which contains monthly airline passenger totals from 1949 to 1960.

class(AirPassengers)
[1] "ts"
start(AirPassengers)
[1] 1949    1
frequency(AirPassengers)
[1] 12
window(AirPassengers, start = c(1960, 1), end = c(1960, 6))
     Jan Feb Mar Apr May Jun
1960 417 391 419 461 472 535

Plotting a ts object using R’s built-in plot() function automatically places time on the x-axis. It is also possible to plot the data using ggplot2 after converting it to a data frame or tibble.

plot(AirPassengers, ylab = "Passengers (thousands)", xlab = "Year")

y <- AirPassengers
df <- data.frame(time = as.numeric(time(y)),  
                 value = as.numeric(y))

ggplot(df, aes(time, value)) + geom_line()

You can create your own ts object from a vector.

sales <- ts(c(120, 135, 150, 142, 160, 171, 180, 176),
            start = c(2023, 1), frequency = 4)
sales
     Qtr1 Qtr2 Qtr3 Qtr4
2023  120  135  150  142
2024  160  171  180  176

The ts class works well with R’s classical forecasting tools, but it can only represent evenly spaced observations and has no room for extra columns. For most data analysis, a data frame is more practical.

4.6.2 Time Series in a Data Frame

In the next code block, we simulate a year of daily temperature readings with a seasonal pattern.

set.seed(42)
weather <- data.frame(
  date = seq(ymd("2024-01-01"), ymd("2024-12-31"), by = "day")
)
weather$temp <- 12 - 10 * cos(2 * pi * yday(weather$date) / 366) +
  rnorm(nrow(weather), sd = 3)

head(weather)
        date      temp
1 2024-01-01 6.1143489
2 2024-01-02 0.3117991
3 2024-01-03 3.1026443
4 2024-01-04 3.9221555
5 2024-01-05 3.2496213
6 2024-01-06 1.7346277

Because date is a true Date column, plotting provides a properly labeled time axis.

plot(weather$date, weather$temp, type = "l",
     xlab = "Date", ylab = "Temperature (°C)")

4.6.3 Aggregating to a Coarser Time Scale

A common task is summarizing daily data by week, month, or year. With lubridate, this is a two-step process: round each date down to the start of its period, then summarize by group.

weather$month <- floor_date(weather$date, "month")

monthly <- aggregate(temp ~ month, data = weather, FUN = mean)
head(monthly)
       month      temp
1 2024-01-01  2.731130
2 2024-02-01  4.606292
3 2024-03-01 10.089835
4 2024-04-01 14.173596
5 2024-05-01 18.186860
6 2024-06-01 21.607406

If you use dplyr, the same pattern reads naturally.

library(dplyr)

weather |>
  group_by(month = floor_date(date, "month")) |>
  summarise(mean_temp = mean(temp))

4.6.4 Filtering by Date

Dates can be compared using the standard comparison operators, so filtering a range is straightforward.

summer <- subset(weather, date >= ymd("2024-06-01") & date < ymd("2024-09-01"))
range(summer$date)
[1] "2024-06-01" "2024-08-31"

Component functions make other filters easy to write too, for example selecting only weekends.

weekends <- subset(weather, wday(date, week_start = 1) >= 6)
nrow(weekends)
[1] 104

4.6.5 Lags, Differences, and Rolling Means

Time series analysis often compares each observation with earlier ones. Base R provides diff() for changes between consecutive values and stats::filter() for moving averages.

weather$change <- c(NA, diff(weather$temp))

# 7-day centered moving average
weather$temp_7d <- as.numeric(stats::filter(weather$temp, rep(1/7, 7), sides = 2))

head(weather[, c("date", "temp", "change", "temp_7d")], 8)
        date      temp     change  temp_7d
1 2024-01-01 6.1143489         NA       NA
2 2024-01-02 0.3117991 -5.8025497       NA
3 2024-01-03 3.1026443  2.7908452       NA
4 2024-01-04 3.9221555  0.8195112 3.577411
5 2024-01-05 3.2496213 -0.6725342 2.962531
6 2024-01-06 1.7346277 -1.5149936 4.085758
7 2024-01-07 6.6066835  4.8720558 3.922359
8 2024-01-08 1.8101825 -4.7965010 4.232391
plot(weather$date, weather$temp, type = "l", col = "grey70",
     xlab = "Date", ylab = "Temperature (°C)")
lines(weather$date, weather$temp_7d, lwd = 2)

These calculations assume your rows are sorted by date with no gaps. Always use order() or arrange() first, and check for missing dates if the data came from an external source.

4.7 Concluding Remarks

Dates and times are stored as numbers in R: Date objects as days and POSIXct objects as seconds since 1 January 1970. This is why sorting, arithmetic, and plotting all work. Base R provides everything you need to convert between text and dates, but format codes and component offsets can be error-prone. lubridate simplifies parsing, extracting components, and rounding, and it makes the distinction between durations and periods explicit. Whichever toolkit you use, always be explicit about time zones. For time series, ts objects work well for regularly spaced data and classical forecasting, while a data frame with a date column is more flexible for general data analysis.

4.8 Questions

  1. Explain how R stores Date and POSIXct objects internally.
  2. Explain the difference between the Date, POSIXct, and POSIXlt classes.
  3. What happens when a string does not match the format provided to as.Date()?
  4. Why should you always specify a time zone when creating a POSIXct object?
  5. Explain the difference between a duration and a period in lubridate.
  6. Explain the difference between the with_tz() and force_tz() functions.
  7. What is the purpose of the floor_date() function?
  8. Explain the difference between storing a time series as a ts object and as a data frame with a date column.

4.9 Exercises

Parsing and Manipulating Dates

Task 1

Parse the following strings into Date objects twice, once with base R and once with lubridate: "07/04/1776" (month first), "1 January 2000", and "2024.12.25".

Task 2

Determine what day of the week you were born on. How many days old are you today?

Task 3

Starting from ymd("2024-01-31"), create a sequence of the last day of each month in 2024. Hint: try ceiling_date() or %m+%.

Task 4

A flight leaves Sydney at 10:00 PM local time on 1 March 2024 and lands in Los Angeles 13.5 hours later. What is the local arrival time in Los Angeles? The time zones are "Australia/Sydney" and "America/Los_Angeles".

Working with Time Series Data

Task 1

Using the weather data from this chapter, find the week (starting on Monday) with the highest average temperature.

Task 2

Convert AirPassengers into a data frame with a Date column and a passengers column, then calculate the total number of passengers per year. Hint: time(AirPassengers) returns decimal years; lubridate’s date_decimal() can convert them.