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
- How ymd/mdy/dmy parse strings into real Date objects
- How to extract year, month, day, and weekday
- The difference between durations and periods
- Calendar-aware date arithmetic across leap years
- How floor_date() groups data into weeks or months
- How to attach and convert time zones
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) # Mar2️⃣ 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 timeA 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 hereCommon Errors (and the fix)
- ❌ All formats failed to parse. No formats found. — ✅ you used the wrong parser; match it to the order in your string (use mdy() for "03/15/2024", not ymd()).
- ❌ Dates sort like text — ✅ you forgot to parse; a plain string sorts alphabetically. Convert with a lubridate parser first.
- ❌ months(1) on Jan 31 gives NA — ✅ Feb has no 31st; use %m+% months(1) to roll back to the last valid day.
- ❌ Time-zone confusion — ✅ with_tz() changes display only; force_tz() changes the instant. Pick deliberately.
Pro Tips
- 💡 Today and now: today() returns the current Date and now() the current date-time — handy for "days until" calculations.
- 💡 Safe month math: use date %m+% months(1) to avoid NA at month ends.
- 💡 Build from parts: make_date(2024, 3, 15) assembles a Date from numeric year/month/day columns.
📋 Quick Reference — lubridate
| Task | R Syntax |
|---|---|
| Parse Y-M-D | ymd("2024-03-15") |
| Parse with time | ymd_hms("2024-03-15 14:30:00") |
| Extract weekday | wday(d, label = TRUE) |
| Add calendar time | d + months(2) |
| Snap to month | floor_date(d, "month") |
| Convert time zone | with_tz(t, "Europe/London") |
🎉 Lesson Complete!
- ✅ ymd/mdy/dmy parse text into real Dates
- ✅ year(), month(), wday() read off components
- ✅ Periods are calendar-aware; durations are exact seconds
- ✅ floor_date() groups timestamps into weeks/months
- ✅ with_tz() redisplays an instant in another time zone
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
- Previous: Functional Programming with purrr
- Next: ggplot2: Facets, Themes & Scales — Small multiples with facets, custom themes, and scale control
- Quick reference: R cheat sheet
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.