Dates with lubridate

Reviewed & published by Brayan K

lubridate is the tidyverse package that makes working with dates and times painless — it parses messy date strings, pulls out components like year or weekday, and does calendar-aware arithmetic without fiddly format codes.

Part of the free R course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll parse dates with ymd()/mdy()/dmy(), extract parts with year() and wday(), add durations and periods, snap dates with floor_date(), and handle time zones.

What You'll Learn in This Lesson

1️⃣ Parsing & Accessing Components

The parser functions are named for the order the parts appear in your text: ymd() for year-month-day, mdy() for US month/day/year, dmy() for day-month-year. They're forgiving about separators and even accept month names.

# Plain R reads dates only in one rigid format. lubridate gives you
# parsers NAMED for the order the parts appear in your text.
library(lubridate)

ymd("2024-03-15")        # year-month-day  -> "2024-03-15"
mdy("03/15/2024")        # month/day/year  -> "2024-03-15"
dmy("15 March 2024")     # day-month-year  -> "2024-03-15"

# They are forgiving about separators — slashes, dashes, words, none:
ymd("20240315")          # -> "2024-03-15"
mdy("March 15, 2024")    # -> "2024-03-15"

# All return a real Date object, so they SORT and SUBTRACT correctly
# (unlike a plain "03/15/2024" character string).

With a real Date in hand, accessor functions read off each component. With label = TRUE, weekday and month come back as readable names.

# Once you have a Date, accessor functions pull out each component.
library(lubridate)

d <- ymd("2024-03-15")   # a Friday

year(d)    # 2024
month(d)   # 3
day(d)     # 15
wday(d, label = TRUE)    # Fri   (a labelled weekday)
yday(d)    # 75   (the 75th day of the year)

# month() can also return the name:
month(d, label = TRUE)   # Mar

2️⃣ Date Arithmetic: Durations & Periods

Subtracting two dates gives a difftime (a span). To add time, lubridate offers two flavours: durations (exact seconds) and periods (human, calendar-aware units). The difference shows up around leap days and month ends.

# Because Dates are numbers under the hood, you can do arithmetic.
library(lubridate)

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

end - start              # a difftime: "Time difference of 74 days"

# durations measure EXACT seconds; periods measure HUMAN units that
# respect calendar quirks like leap years and daylight saving:
start + days(30)         # exactly 30 calendar days later
start + months(2)        # "2024-03-01" (calendar-aware)
start + years(1)         # "2025-01-01"

# duration vs period across a leap day shows the difference:
ymd("2024-02-28") + days(1)      # "2024-02-29" (2024 is a leap year)
ymd("2023-02-28") + days(1)      # "2023-03-01"

Reach for periods (months(), years()) when you mean "same day next month/year," and durations (ddays(), dyears()) when you need an exact physical elapsed time.

3️⃣ Rounding Dates & Time Zones

floor_date() snaps a date down to a unit boundary — the heart of turning fine-grained timestamps into weekly or monthly groups. Time zones, meanwhile, live on date-times (POSIXct): attach one when parsing, then shift the wall-clock with with_tz().

# floor_date()/ceiling_date() snap a date to a unit boundary — perfect
# for grouping daily data into weeks, months, or quarters.
library(lubridate)

d <- ymd("2024-03-15")

floor_date(d, "month")   # "2024-03-01"  (start of its month)
ceiling_date(d, "month") # "2024-04-01"  (start of NEXT month)
floor_date(d, "week")    # the week's start date
round_date(d, "month")   # nearest month boundary

# Time zones live on date-TIMES (POSIXct). Attach one when parsing,
# and convert the wall-clock with with_tz():
t <- ymd_hms("2024-03-15 14:30:00", tz = "America/New_York")
with_tz(t, "Europe/London")   # same instant, London wall-clock time

A key distinction: with_tz() keeps the same instant in time and changes only how it's displayed, whereas force_tz() keeps the displayed clock and changes the underlying instant. Mixing these up is the most common time-zone bug.

Your turn. Fill in the ___ blanks and run it.

# 🎯 YOUR TURN — replace each ___ then run it.
library(lubridate)

# A vector of messy date strings (different formats per source):
raw <- c("2023-12-25", "2024-01-01", "2024-07-04")

# 1) Parse them all to real Dates with the right parser.
dates <- ymd(raw)   # ymd() matches the year-month-day order of these strings

# 2) Extract the weekday NAME of each date.
weekdays <- wday(dates, label = TRUE)
print(weekdays)

# 3) How many days are there from the first to the last date?
span <- ___   # 👉 replace ___ with dates[3] - dates[1]
print(span)

# 4) Snap each date down to the first of its month.
print(floor_date(dates, "month"))

Mini-Challenge: Events Report

Parse timestamps with the right combined parser, label the weekdays, measure the span, and group by week — the full lifecycle of date wrangling in one task.

# 🎯 MINI-CHALLENGE: a simple events report.
#
# You have three event timestamps as text:
#   raw <- c("Jan 5, 2024 09:15", "Feb 20, 2024 13:45", "Mar 2, 2024 08:00")
#
# 1. Parse them with the correct lubridate parser (hint: month name,
#    day, year, then hour:minute -> mdy_hm()).
# 2. Add a column/vector of the weekday name for each (wday label).
# 3. Compute how many DAYS passed between the first and last event.
# 4. Bonus: use floor_date(..., "week") to find the start of the week
#    each event falls in, then count events per week.
#
# Write it yourself, then run in RStudio or rdrr.io/snippets.

# your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — lubridate

TaskR Syntax
Parse Y-M-Dymd("2024-03-15")
Parse with timeymd_hms("2024-03-15 14:30:00")
Extract weekdaywday(d, label = TRUE)
Add calendar timed + months(2)
Snap to monthfloor_date(d, "month")
Convert time zonewith_tz(t, "Europe/London")

🎉 Lesson Complete!

Practice quiz

Which parser reads "03/15/2024" as month/day/year?

  • ymd()
  • mdy()
  • dmy()
  • ydm()

Answer: mdy(). lubridate parsers are named for component order; mdy() matches month/day/year.

Which parser handles "2024-03-15" (year-month-day)?

  • dmy()
  • mdy()
  • ymd()
  • myd()

Answer: ymd(). ymd() parses year-month-day order into a real Date.

What does wday(d, label = TRUE) return?

  • The day number only
  • The labelled weekday name
  • The year
  • The week of the year

Answer: The labelled weekday name. With label = TRUE, wday() returns the weekday as a readable name like Fri.

What is the difference between a period and a duration?

  • Periods are always shorter
  • There is no difference
  • Periods are exact seconds
  • Periods are calendar-aware; durations are exact seconds

Answer: Periods are calendar-aware; durations are exact seconds. Periods (months(), years()) respect the calendar; durations (ddays()) are fixed seconds.

Which adds exactly 30 calendar days to a date?

  • start + days(30)
  • start + 30s
  • start * 30
  • start + month(30)

Answer: start + days(30). days(30) is a period adding 30 calendar days.

What does floor_date(d, "month") do?

  • Adds a month
  • Removes the day
  • Snaps the date down to the first of its month
  • Rounds to the nearest year

Answer: Snaps the date down to the first of its month. floor_date snaps a date down to the start of the chosen unit, e.g. the month.

Which parser handles a date that also includes a time?

  • ymd_hms()
  • ymd()
  • hms()
  • time()

Answer: ymd_hms(). Add the time order to the name: ymd_hms() parses date plus hour-minute-second.

What does with_tz() change?

  • The underlying instant
  • Only the displayed wall-clock time zone
  • The date's year
  • Nothing

Answer: Only the displayed wall-clock time zone. with_tz() keeps the same instant and only changes how it is displayed.

Why parse a date string instead of leaving it as text?

  • It uses less memory
  • Text sorts and subtracts correctly
  • Parsing capitalizes months
  • So it sorts and subtracts correctly as a Date

Answer: So it sorts and subtracts correctly as a Date. A plain string sorts alphabetically; a real Date sorts and subtracts correctly.

What does ceiling_date(d, "month") return?

  • The start of the NEXT month
  • The end of the year
  • The same date
  • The previous month

Answer: The start of the NEXT month. ceiling_date snaps up to the next unit boundary, here the first of next month.

Continue this course

Frequently asked questions

Why use lubridate instead of base as.Date()?

Base as.Date() works, but you must spell out the exact format string (e.g. format = "%m/%d/%Y") and it's easy to get wrong. lubridate's parsers are named for the order of the components — ymd, mdy, dmy — so you read your data and pick the matching function. They also tolerate varied separators, accept month names, and pair with intuitive helpers like year(), days(), and floor_date(). For date arithmetic that respects leap years and daylight saving, lubridate's periods are far clearer than rolling your own.

What's the difference between a duration and a period?

A duration is an exact span of seconds — dseconds(), ddays(), dyears() — so adding dyears(1) always advances by 31,557,600 seconds regardless of the calendar. A period is a human unit — seconds(), days(), months(), years() — that respects calendar rules: months(1) lands on the same day next month, and years(1) accounts for leap years. Use periods for 'same time next month/year' logic and durations when you need a precise physical elapsed time.

How do I parse dates that include a time?

Use the parsers that add the time order to the name: ymd_hms() for year-month-day hour-minute-second, mdy_hm() for month/day/year hour:minute, and so on. These return a POSIXct date-time rather than a plain Date. You can attach a time zone with the tz argument when parsing, then shift the displayed wall-clock time to another zone with with_tz() — the underlying instant stays the same, only the local representation changes.

Why does floor_date matter for grouping data?

Real timestamps are too granular to group on directly — no two are identical to the second. floor_date(x, "month") collapses every date in March to 2024-03-01, giving you a clean grouping key so you can then group_by() and summarise monthly (or weekly, quarterly) totals. ceiling_date() and round_date() snap to the next or nearest boundary instead. This is the standard way to turn fine-grained event logs into period-level reports.