Dates and Times in R
You run a small online plant shop, Fern & Co. Six orders came in during March 2026, and your order log wrote each order's date the way logs do: as plain text, like "2026-03-02". You want simple answers. How many days from the first order to the last? Which weekday do people actually shop on? When a customer in New York orders at 9:30 in the morning, what time is that on your clock in Bengaluru?
You cannot answer any of those while the date is just text. The fix is to turn that text into a real date, and then R can count, compare and convert for you. That is what the lubridate package is for. By the end of this lesson you will be able to:
- Turn date text in any layout into a real date you can compute with
- Add, subtract and compare dates, and pull out the year, month or weekday
- Read a time correctly across time zones
Prerequisites: you can run a line of R and store a result with <- (Your First R Session), and you know what a vector and the character type are (Atomic Vectors and Data Types). You just finished matching text with regular expressions; a date is text too, so this is the natural next step. Press Run to see the whole payoff at once; the rest of the lesson builds it up one piece at a time.
A date is a number, not text
Here is the thing that trips everyone up at the start. To you, "2026-03-02" clearly means the 2nd of March. To R, it is just six characters between quotes, the same character type you used for names and cities. You cannot do arithmetic on it any more than you can on "hello".
A real date is different. When you parse that text into a Date, R stores it as a single number: the count of days since 1 January 1970 (a fixed reference point called the epoch). That is the whole trick. Because a date is really a number underneath, R can subtract two of them, add seven to one, or sort them, just like ordinary numbers.
So 2026-03-02 is day number 20514. You will almost never look at that number, but knowing it is there explains everything that follows. Once a date is a real Date, there are four jobs you will do with it, and this lesson walks through them in order:
Parse: turn text into a real date
Parsing is where lubridate shines. Base R makes you spell out a format with cryptic codes like %Y-%m-%d. lubridate asks one easy question instead: what order are the parts in? You pick the function whose name is that order, year, month, day, and it figures out the separators (dashes, slashes, spaces, even month names) for you.
Three different-looking strings, one real date. Notice every result prints back in the same tidy YYYY-MM-DD form: that is just how a Date displays, regardless of how it came in. Now let's parse the real Fern & Co. log. The website recorded all six orders the same way, so one call to ymd() handles the whole vector at once:
dmy() reads "02/03/2026", "02-03-2026" and "2 March 2026" all the same way, because all three are day, then month, then year.Read the order right
A European supplier emails you an order dated 04/03/2026. You know from the email that it was placed on the 4th of March, 2026. Which call returns that exact date?
dmy() reads the parts as day, month, year, so 04/03/2026 becomes 4 March 2026. The order of the parts is what you declare, and it matches what the supplier meant.ymd() expects the year first, but this text starts with the day. It cannot line the parts up, so it returns NA with a warning rather than the date you want.Calculate: add, subtract and compare
This is the payoff for parsing. Because a Date is a number of days, arithmetic just works, and the answers come back in friendly date units. Adding a plain number adds that many days:
Subtracting one date from another tells you the gap between them. R returns a difftime, a labelled difference that says what the units are:
The same subtraction works against any reference date. Say the spring sale opened on 1 March; how many days into the sale was each order placed? Just subtract the start date:
And when you need now rather than a fixed date, lubridate reads the computer clock for you:
today() is perfect for "how many days until this subscription renews?", measured from whatever day the code actually runs.
When does the refund window close?
Fern & Co. gives every order a 30-day refund window: it closes 30 days after the order date. The order_date vector is ready below. Replace the blank with a single expression that returns the closing date of each window. (Adding a plain number to a date adds that many days.)
Show answer
order_date <- ymd(c("2026-03-02","2026-03-07","2026-03-08","2026-03-14","2026-03-15","2026-03-21"))
order_date + 30
#> [1] "2026-04-01" "2026-04-06" "2026-04-07" "2026-04-13" "2026-04-14" "2026-04-20"Extract: pull out the parts
Often you do not want the whole date, just one piece of it: the year for a report, the month for a chart, the weekday to spot a pattern. lubridate has a small, predictable verb for each part. Give it a date vector and it pulls that part out of every entry:
By default month() and wday() return a number (March is 3, Sunday is 1). Add label = TRUE and you get readable names instead, which is almost always what you want when reading or plotting:
Now answer the question we opened with: which weekday do people buy plants on? Count the weekdays with table():
Five of the six orders landed on a Saturday or Sunday. The weekend is when Fern & Co. should run its ads, and you could not see that until the dates were real and the weekday was pulled out.
Name each order's weekday
Using the same order_date vector, return the weekday name of each order, so the result reads Mon Sat Sun Sat Sun Sat rather than a column of numbers. Reach for wday(), and remember the argument that switches numbers to names.
Show answer
order_date <- ymd(c("2026-03-02","2026-03-07","2026-03-08","2026-03-14","2026-03-15","2026-03-21"))
wday(order_date, label = TRUE)
#> [1] Mon Sat Sun Sat Sun Sat
#> Levels: Sun < Mon < Tue < Wed < Thu < Fri < SatTime zones: the same instant, different clocks
So far every order has been a plain date. But a real timestamp carries a time of day and a time zone, and time zones are where dates quietly go wrong. A date-time is built with ymd_hms() (year-month-day, then hour-minute-second), and you tell it which zone the clock reading belongs to:
That single instant exists everywhere at once; different places just read a different clock for it. with_tz() re-displays the same moment on another zone's clock. Maria's 9:30am in New York is the same instant as 7:00pm on your Bengaluru clock:
There is a second, very different operation, and mixing them up is the classic time-zone bug. force_tz() does not convert the moment; it keeps the clock reading 09:30 exactly and just slaps a new zone label on it, which points at a completely different instant:
with_tz() when you want to know what time it was somewhere else for a moment that already happened (almost always). Reach for force_tz() only to fix a timestamp that was recorded with the wrong zone label in the first place.with_tz or force_tz?
Maria's order is stored as 2026-03-15 09:30:00 EDT (New York time). You run with_tz(maria, "Asia/Kolkata") to see it on your Bengaluru clock. What comes back?
with_tz() holds the exact moment fixed and only changes the clock it is displayed on. 9:30am in New York is 7:00pm the same day in Bengaluru, so the reading moves but the instant does not.with_tz() re-displays an existing instant in any zone you ask for; it is one of lubridate's most-used functions.References
Four trustworthy, free places to take dates and times further:
- lubridate (tidyverse) - official site - the home page and full reference for every parser and accessor you used here.
- R for Data Science (2e): Dates and times - the canonical, example-led chapter, including spans and periods we only touched on.
- Posit lubridate cheatsheet - a one-page visual map of parsing, components and time-zone tools, worth keeping open.
- Do more with dates and times (vignette) - the package authors' own tour, with the difference between durations, periods and intervals.
Lesson 3 complete
You took a log of plain-text order dates and made them genuinely useful. The key was the first move: a Date is a number of days since 1970, so once you parse text into one, everything else is ordinary arithmetic and lookup. You ran the four jobs: parse with ymd(), dmy() and mdy() (choose the function by the order of the parts), calculate by adding days and subtracting dates, extract the year, month and weekday with year(), month() and wday(), and handled time zones with with_tz() (same instant, new clock) versus force_tz() (same clock, new instant).
That weekend finding, five of six orders on a Saturday or Sunday, came from turning a date into a weekday. It is a hint of what is next: the weekday is a category, one of a fixed set of values with a natural order. In Lesson 4: Factors with forcats, you will learn to store and reorder categories like these so your counts and charts come out in the order you want, not alphabetical.