Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

R (4.6)  Module 6 – Strings, regular expressions, dates and times

Dates, times, time zones and lubridate

Parse dates in R and lubridate, convert UTC to IST and to zones with daylight saving, add months without losing month ends, and label Indian financial years.

  • Intermediate
  • 15 minutes
  • Examples run with R 4.6.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Parse and format dates and date-times in base R and lubridate
  • Convert between time zones correctly, for IST, UTC and zones with daylight saving
  • Calculate due dates and durations that respect month ends, leap years and daylight saving
  • Compute Indian financial years and quarters, and explain the labelling conventions

Before you start

What you’ll learn · Before you start

What you will learn

  • Parse and format dates and date-times in base R and lubridate
  • Convert between time zones correctly, for IST, UTC and zones with daylight saving
  • Calculate due dates and durations that respect month ends, leap years and daylight saving
  • Compute Indian financial years and quarters, and explain the labelling conventions

Before you start

On this page

A server writes a log at 18:45 UTC; in India the same moment is 00:15 the next day, so a daily count by the UTC date puts it on the wrong day for every reader in India. Dates and times look simple until they cross a midnight, a time zone, a month end or a change of the clocks, and R gives a wrong answer at each of them, often without a warning.

On recorded runs of R 4.6.1 with lubridate 1.9.5 you will see what R stores for a date and a date-time, parse text with checks that catch impossible dates, convert between UTC, IST and a zone with daylight saving, add months to the last day of a month in three defensible ways, and label Indian financial years and quarters without mixing conventions.

A Date counts days and a POSIXct counts seconds

unclass(as.Date("2026-10-04")) is 20730: R’s help page for dates says a Date is stored as the number of days since the start of 1970, with a class that prints it as a date. A date-time has two classes. POSIXct, the one to use in data, is the number of seconds since the same moment in UTC; POSIXlt is a list of fields, year, month, day, hour and so on. Step through what each holds:

Interactive · stage by stage What R stores for a date and a date-time Step through the run. Each stage is one class or function, with the number or fields it holds.
What R stores for a date and a date-time, step 5 of 5. Showing the text you read, as.Date() (20730 days), as.POSIXct(tz = "UTC") (1789411500 seconds), as.POSIXlt() (mon 8, year 126), format() (any layout you ask for).the text you readyear, month and dayas.Date()20730 daysas.POSIXct(tz = "UTC")1789411500 secondsas.POSIXlt()mon 8, year 126format()any layout you ask for

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Text first. Dates arrive as text. The standard form, year, month and day joined by hyphens, needs no format to be read.
  2. A count of days. as.Date() stores the number of days since the start of 1970, 20730 for the date in the run, so adding or subtracting days is plain arithmetic.
  3. A count of seconds. A POSIXct stores the seconds since the same moment in UTC, 1789411500 for the log time in the run. Its time zone only says how to print it.
  4. A list of fields. A POSIXlt splits the moment into fields. Two of them surprise people. mon counts from 0, so September is 8, and year counts from 1900, so the year is 126.
  5. Text again. format() writes either class as text with codes, %d for the day, %B for the month's name, %Y for the year and %H:%M for the time.

date_classes.R, typed at the R console Python · date_classes_session.py
from session import run, version

version()
# R reads date_classes.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f date_classes.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f date_classes.R
> # A Date is a number of days since the start of 1970, with a class that prints it as a date.
> d <- as.Date("2026-10-04")
> unclass(d)
[1] 20730
> d - 30
[1] "2026-09-04"
> as.numeric(d - as.Date("2026-04-01"))
[1] 186
> # A POSIXct date-time is a number of seconds since the same moment, in UTC.
> t <- as.POSIXct("2026-09-14 18:45:00", tz = "UTC")
> unclass(t)
[1] 1789411500
attr(,"tzone")
[1] "UTC"
> # A POSIXlt is a list of fields; mon counts from 0 and year from 1900.
> lt <- as.POSIXlt(t)
> c(year = lt$year, mon = lt$mon, mday = lt$mday, hour = lt$hour, wday = lt$wday)
year  mon mday hour wday 
 126    8   14   18    1 
> # format() writes either as text, with codes for each part.
> format(d, "%d %B %Y")
[1] "04 October 2026"
> format(d, "%A, day %j of the year, ISO week %V")
[1] "Sunday, day 277 of the year, ISO week 40"
> format(d, "two-digit year %y, weekday %u counting from 1 for Monday")
[1] "two-digit year 26, weekday 7 counting from 1 for Monday"
> format(t, "%d/%m/%Y %H:%M")
[1] "14/09/2026 18:45"

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 date_classes_session.py

Because a Date is a count of days, d - 30 is 30 days earlier and the difference of two dates is a number of days, 186 from the first of April to d. The fields of POSIXlt follow C’s conventions, so mon counts from 0 and year from 1900: September is 8 and the year is 126. Read them with format() instead:

Code Means In the run
%d, %m, %Y day, month and year with century 04, 10, 2026
%y year without century 26
%B, %A month and weekday names, in the locale’s language October, Sunday
%j, %V, %u day of the year, ISO week, weekday from 1 for Monday 277, 40, 7
%H:%M hour and minute 18:45
Timestamp Converter Convert a count of seconds since 1970, the number inside a POSIXct, to a date and time in UTC or IST.

Checkpoint

Check: Date, POSIXct and POSIXlt

  1. Question 1 of 2 This line prints 20730. What does that number count?

    Read the code, then choose one answer.

    unclass(as.Date("2026-10-04"))
    Show the answer: Question 1 of 2

    Answer: The days since the start of 1970

    The help page for dates: Dates are represented as the number of days since the start of 1970, with negative values for earlier dates.

  2. Question 2 of 2 What does the last line print?

    Read the code, then choose one answer.

    lt <- as.POSIXlt(as.POSIXct("2026-09-14 18:45:00", tz = "UTC"))
    c(lt$mon, lt$year)
    Show the answer: Question 2 of 2

    Answer: 8 126

    Read parts with format(x, "%m") and format(x, "%Y"), or lubridate's month() and year(), to avoid both offsets.

Parsing needs a format, and a check of its own

as.Date("2026-02-30") stops with an error, but as.Date("30/02/2026", format = "%d/%m/%Y") returns NA without a word, and as.POSIXct("2026-10-04 25:00", tz = "UTC") returns midnight, dropping the impossible hour. Text becomes a date through a format: the codes of the table above, matched against the input. The strptime help page lists them, including the century rule of %y, which reads 00 to 68 as 20xx and 69 to 99 as 19xx. The run shows each trap and a check that catches it:

parsing.R, typed at the R console Python · parsing_session.py
from session import run, version

version()
# R reads parsing.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f parsing.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f parsing.R
> # The standard form, year-month-day, needs no format.
> as.Date("2026-09-03")
[1] "2026-09-03"
> # An impossible date in the standard form is an error ...
> tryCatch(as.Date("2026-02-30"), error = conditionMessage)
[1] "character string is not in a standard unambiguous format"
> # ... but with a format it quietly becomes NA.
> as.Date("30/02/2026", format = "%d/%m/%Y")
[1] NA
> # %y guesses the century: 68 is read as 2068, 69 as 1969.
> as.Date(c("01/01/68", "01/01/69"), format = "%d/%m/%y")
[1] "2068-01-01" "1969-01-01"
> # A date-time without a format: an hour of 25 is dropped without a word.
> as.POSIXct("2026-10-04 25:00", tz = "UTC")
[1] "2026-10-04 UTC"
> # With a format, the same text is NA, which a check can count.
> as.POSIXct("2026-10-04 25:00", format = "%Y-%m-%d %H:%M", tz = "UTC")
[1] NA
> # Check a parse: count the NA values and look at the inputs that made them.
> x <- c("31/01/2026", "30/02/2026", "1/3/2026")
> p <- as.Date(x, format = "%d/%m/%Y")
> p
[1] "2026-01-31" NA           "2026-03-01"
> x[is.na(p) & !is.na(x)]
[1] "30/02/2026"
> # Comparing with the input instead flags a correct date written without leading zeros.
> format(p, "%d/%m/%Y") == x
[1]  TRUE    NA FALSE
> # lubridate names the order of the parts and warns when some fail.
> library(lubridate)

Attaching package: ‘lubridate’

The following objects are masked from ‘package:base’:

    date, intersect, setdiff, union

> packageVersion("lubridate")
[1] ‘1.9.5’
> dmy(x)
[1] "2026-01-31" NA           "2026-03-01"
Warning message:
 1 failed to parse. 
> ymd_hms("2026-09-14 18:45:00", tz = "Asia/Kolkata")
[1] "2026-09-14 18:45:00 IST"

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parsing_session.py

Input Call Result
"2026-02-30" as.Date(x) an error: not a standard unambiguous format
"30/02/2026" as.Date(x, format = "%d/%m/%Y") NA, silently
"01/01/68" as.Date(x, format = "%d/%m/%y") 2068-01-01
"2026-10-04 25:00" as.POSIXct(x, tz = "UTC") midnight, the hour dropped
"2026-10-04 25:00" the same with format = "%Y-%m-%d %H:%M" NA

The rule that follows: always give a format, so that anything impossible becomes NA, then count the NA values that were not missing in the input, x[is.na(p) & !is.na(x)], which named "30/02/2026". lubridate’s dmy(), ymd() and ymd_hms() name the order of the parts instead of the codes, and warn with the number of inputs that failed, “1 failed to parse”.

Comparing a parse with its input

A check such as format(p, "%d/%m/%Y") == x looks safe and fails on correct dates: in the run, "1/3/2026" parsed to the first of March but formatted back as "01/03/2026", so the check said FALSE. Count the new NA values instead, or compare after normalising.

Checkpoint

Check: Parsing with a format, and checking it

  1. Question 1 of 2 What does this return?

    Read the code, then choose one answer.

    as.Date("31/04/2026", format = "%d/%m/%Y")
    Show the answer: Question 1 of 2

    Answer: NA, because April has 30 days

    Count the NA values that were not missing in the input to find such dates, as the run did with its impossible day in February.

  2. Question 2 of 2 In the run, a date-time text with the hour 25 became midnight with as.POSIXct(x, tz = "UTC"), but NA with format = "%Y-%m-%d %H:%M". Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Without a format R tries several formats, and the date-only one reads the start of the text and ignores the rest

    Always give a format when parsing date-times from data, so impossible values become NA that a check can count.

A time zone changes the clock, not the moment

The third log of the run was written at 18:45 UTC on a Monday; India’s clock read 00:15 on Tuesday. A time zone is a rule for showing a moment on a local clock, kept in the IANA time zone database: The IANA database of the world's time zones and their history of offsets and clock changes, with zones named by a city, such as Asia/Kolkata or Europe/London. R and most systems use it. under names such as Asia/Kolkata and Europe/London, a city for each zone, which R’s help page on time zones calls the preferred way to name one. India Standard Time is UTC plus 5 hours 30 minutes all year. Follow one log across the two clocks:

Interactive · message by message One log time on two clocks Step through the third log of the run, written by a server in UTC and read in India.
One log time on two clocks, step 5 of 5. Messages so far: The UTC clock to The IST clock: with_tz(x, "Asia/Kolkata"); The UTC clock to The IST clock: force_tz(): 18:45 IST.The UTC clock18:45, MondayThe IST clock18:45, a different momentwith_tz(x, "Asia/Kolkata")force_tz(): 18:45 IST

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Written in UTC. The server writes the log at 18:45 on a Monday, UTC, the zone servers usually keep.
  2. Read in India. India's clock is 5 hours 30 minutes ahead all year, so the same moment reads 00:15, and in India it is already Tuesday.
  3. Two different days. Counted by the UTC day the log belongs to Monday, and by the IST day to Tuesday. In the run, 4 logs fell on the first UTC day but only 2 on the first IST day.
  4. The same moment. with_tz() changed only the clock that is shown. The moment did not change, so the two times compare equal.
  5. A different moment. force_tz() keeps the reading 18:45 and calls it IST, which is another moment, 5 hours 30 minutes earlier. Use it only when a time was recorded with the wrong zone.

ist_logs.R, typed at the R console Python · ist_logs_session.py
from session import run, version

version()
# R reads ist_logs.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f ist_logs.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f ist_logs.R
> library(lubridate)

Attaching package: ‘lubridate’

The following objects are masked from ‘package:base’:

    date, intersect, setdiff, union

> # Six server log times, stored in UTC, as servers usually store them.
> logs <- as.POSIXct(c("2026-09-14 09:10:00", "2026-09-14 17:20:00", "2026-09-14 18:45:00",
+                      "2026-09-14 21:05:00", "2026-09-15 02:30:00", "2026-09-15 19:00:00"), tz = "UTC")
> # The same moments on an Indian clock: IST is UTC plus 5 hours 30 minutes.
> ist <- with_tz(logs, "Asia/Kolkata")
> format(ist, "%Y-%m-%d %H:%M %Z")
[1] "2026-09-14 14:40 IST" "2026-09-14 22:50 IST" "2026-09-15 00:15 IST"
[4] "2026-09-15 02:35 IST" "2026-09-15 08:00 IST" "2026-09-16 00:30 IST"
> # A moment does not change when its zone changes, so the two compare equal.
> all(ist == logs)
[1] TRUE
> # Counting per day: the days differ, because 18:30 UTC is already midnight in India.
> table(utc_day = as.Date(logs, tz = "UTC"))
utc_day
2026-09-14 2026-09-15 
         4          2 
> table(ist_day = as.Date(ist, tz = "Asia/Kolkata"))
ist_day
2026-09-14 2026-09-15 2026-09-16 
         2          3          1 
> # as.Date() on a date-time uses UTC unless you give tz: here it picks the wrong day.
> as.Date(ist[3])
[1] "2026-09-14"
> as.Date(ist[3], tz = "Asia/Kolkata")
[1] "2026-09-15"
> as_date(ist[3])
[1] "2026-09-15"
> # force_tz() keeps the clock reading and changes the moment: use it only to fix a wrong zone.
> force_tz(logs[3], "Asia/Kolkata")
[1] "2026-09-14 18:45:00 IST"
> force_tz(logs[3], "Asia/Kolkata") == logs[3]
[1] FALSE

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ist_logs_session.py

with_tz() showed the six moments on India’s clock, the code %Z adding the zone’s abbreviation, and all(ist == logs) was TRUE, because they are the same moments. Counted by day, they are not the same data: 4 logs fell on the first day in UTC and 2 in IST, because every moment from 18:30 UTC on is already the next day in India. Group by the day your readers live in, after converting.

as.Date() on a date-time

as.Date(ist[3]) returned the Monday although the time printed as Tuesday 00:15 IST: the help page of as.Date() gives its tz argument the default "UTC", whatever zone the date-time carries. Write as.Date(x, tz = "Asia/Kolkata"), or lubridate’s as_date(x), which uses the zone of x.

force_tz() is the opposite tool: it keeps the clock reading, 18:45, and changes the zone, which makes a different moment, as the last line showed. It repairs times that were recorded with the wrong zone; it never converts.

Time Zone Converter See one moment in IST and in any other zone, to check a conversion the run makes.

Checkpoint

Check: Time zones and the day a moment falls on

  1. Question 1 of 2 What is the difference between lubridate's with_tz() and force_tz()?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: with_tz() shows the same moment on another clock; force_tz() keeps the clock reading and changes the moment

    Use with_tz() to convert, and force_tz() only to repair times that were recorded with the wrong zone.

  2. Question 2 of 2 Which day does the last line return?

    Read the code, then choose one answer.

    x <- as.POSIXct("2026-09-14 20:00:00", tz = "UTC")
    as.Date(x, tz = "Asia/Kolkata")
    Show the answer: Question 2 of 2

    Answer: The 15th, the next day

    Every moment from 18:30 UTC on falls on the next calendar day in India.

Daylight saving makes some days 23 hours long

India keeps one offset all year, so noon in January and in July are both 12:00 IST. London does not: noon in July is BST, British Summer Time, one hour ahead of GMT. GOV.UK says the clocks go forward one hour at 1am on the last Sunday of March and back at 2am on the last Sunday of October. Step through one such night, the one the run uses:

Interactive · stage by stage The night the London clocks go forward Step through the hour that London skips at the end of March, as R saw it in the run.
The night the London clocks go forward, step 4 of 4. Showing 00:30 GMT, 01:00 becomes 02:00, 00:30 plus one hour (02:30 BST), the whole day (23 hours long).00:30 GMThalf an hour before01:00 becomes 02:00one hour is skipped00:30 plus one hour02:30 BSTthe whole day23 hours long

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. Before the change. At 00:30 London is on GMT, the same as UTC.
  2. The jump. At 01:00 GMT the clocks move to 02:00 BST, British Summer Time, one hour ahead. No London clock shows a time from 01:00 to 01:59 that night.
  3. One hour later. Adding 3600 seconds to 00:30 gives 02:30 BST. A clock time typed in the missing hour, 01:30, was moved to 02:30 without a warning.
  4. A short day. From one midnight to the next the day lasted 23 hours, which difftime() reported. days(1) keeps the clock time across it; ddays(1) adds 24 hours and lands an hour later.

dst.R, typed at the R console Python · dst_session.py
from session import run, version

version()
# R reads dst.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f dst.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f dst.R
> library(lubridate)

Attaching package: ‘lubridate’

The following objects are masked from ‘package:base’:

    date, intersect, setdiff, union

> # India has one offset all year; London moves its clocks twice a year.
> format(as.POSIXct(c("2026-01-15 12:00", "2026-07-15 12:00"), tz = "Asia/Kolkata"), "%H:%M %Z")
[1] "12:00 IST" "12:00 IST"
> format(as.POSIXct(c("2026-01-15 12:00", "2026-07-15 12:00"), tz = "Europe/London"), "%H:%M %Z")
[1] "12:00 GMT" "12:00 BST"
> # The night the London clocks go forward: one hour after 00:30 is 02:30.
> before <- as.POSIXct("2026-03-29 00:30", tz = "Europe/London")
> before + 3600
[1] "2026-03-29 02:30:00 BST"
> # A clock time that never happened is moved, silently, into the next hour.
> as.POSIXct("2026-03-29 01:30", tz = "Europe/London")
[1] "2026-03-29 02:30:00 BST"
> # That day lasted 23 hours.
> difftime(as.POSIXct("2026-03-30", tz = "Europe/London"), as.POSIXct("2026-03-29", tz = "Europe/London"))
Time difference of 23 hours
> # A period of one day keeps the clock time; a duration of one day adds 86,400 seconds.
> noon <- ymd_hms("2026-03-28 12:00:00", tz = "Europe/London")
> noon + days(1)
[1] "2026-03-29 12:00:00 BST"
> noon + ddays(1)
[1] "2026-03-29 13:00:00 BST"

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dst_session.py

One hour after 00:30 was 02:30 BST, and the clock time 01:30, which never happened that night, was moved to 02:30 without a warning. difftime() measured the day as 23 hours. lubridate names the difference that this makes: a period: In lubridate, a length of time in calendar units, such as days(1) or months(1): adding one keeps the clock time across a change of the clocks, but its length in seconds varies. such as days(1) adds one day on the calendar and keeps the clock at 12:00, while a duration: In lubridate, an exact length of time in seconds, such as ddays(1), which is always 86,400 seconds, so across a change of the clocks the clock time it lands on shifts by an hour. such as ddays(1) adds exactly 86,400 seconds and lands at 13:00. Use periods for calendar rules, such as the same time tomorrow, and durations for measured time, such as a 24-hour test.

Checkpoint

Check: Daylight saving, periods and durations

  1. Question 1 of 2 That night the London clocks went forward. What does this return?

    Read the code, then choose one answer.

    as.POSIXct("2026-03-29 01:30", tz = "Europe/London")
    Show the answer: Question 1 of 2

    Answer: 02:30 BST, moved an hour without a warning

    Check times in the hour of a clock change when your data comes from a zone with daylight saving.

  2. Question 2 of 2 noon is 12:00 in London on the Saturday before the clocks go forward. What do noon + days(1) and noon + ddays(1) give?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 12:00 BST and 13:00 BST on the Sunday

    Use periods for calendar rules, such as the same time tomorrow, and durations for measured time.

Adding a month has three defensible answers

A loan taken on the last day of January needs a due date every month, and February has no 31st. The help page of seq.POSIXt(), to which seq.Date() refers for months, says that a month step first advances the month without changing the day, and that a day the month lacks is counted forward into the next month. lubridate’s %m+% takes the other common rule and falls back to the last day of the month, and a schedule may need a third, a working day. The run puts the three side by side:

month_end.R, typed at the R console Python · month_end_session.py
from session import run, version

version()
# R reads month_end.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f month_end.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f month_end.R
> library(lubridate)

Attaching package: ‘lubridate’

The following objects are masked from ‘package:base’:

    date, intersect, setdiff, union

> # A loan taken on the last day of January: when are the next five instalments due?
> start <- as.Date("2026-01-31")
> # Rule 1, base R: seq() adds months to the day number, and February 31 rolls into March.
> rule_seq <- seq(start, by = "month", length.out = 6)[-1]
> # Rule 2, lubridate: adding months() gives NA for a day the month lacks; %m+% falls back to the month's last day.
> start + months(1)
[1] NA
> rule_m <- start %m+% months(1:5)
> # Rule 3: the month's last day, moved back to Friday when it falls on a weekend.
> last_business_day <- function(d) {
+   end <- ceiling_date(d, "month") - days(1)
+   dow <- as.integer(format(end, "%u"))
+   end - ifelse(dow > 5, dow - 5, 0)
+ }
> rule_biz <- last_business_day(start %m+% months(1:5))
> data.frame(instalment = 1:5, seq = rule_seq, m_plus = rule_m, business = rule_biz,
+            weekday = format(rule_biz, "%a"))
  instalment        seq     m_plus   business weekday
1          1 2026-03-03 2026-02-28 2026-02-27     Fri
2          2 2026-03-31 2026-03-31 2026-03-31     Tue
3          3 2026-05-01 2026-04-30 2026-04-30     Thu
4          4 2026-05-31 2026-05-31 2026-05-29     Fri
5          5 2026-07-01 2026-06-30 2026-06-30     Tue
> # A leap day: one year later there is no February 29.
> ymd("2024-02-29") + years(1)
[1] NA
> ymd("2024-02-29") %m+% years(1)
[1] "2025-02-28"

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 month_end_session.py

The base rule gave no date in February and two in March, 2026-03-03 and 2026-03-31, so a customer would pay twice in one month. Plain + months(1) gave NA, lubridate’s way of saying the date does not exist, and %m+% gave the month ends, 2026-02-28, 2026-03-31 and so on. The business-day rule moved the last day of May, a Sunday, back to Friday, 2026-05-29. A leap day behaves the same: + years(1) gave NA, %m+% gave 2025-02-28. Choose with your schedule’s questions:

Interactive · answer and compare Which rule for monthly due dates? Answer two questions about your schedule. The table rules out the rules that would break it.
Must every month have exactly one due date?
Must a due date fall on a working day?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to add a month to the last day of January, against what a schedule may need
Rule One date every month Never on a weekend Keeps the day number
Roll over Written as: seq(start, by = "month") Poor no date in February and two in March Poor falls on whatever weekday the date is Mixed keeps 31 by rolling into the next month
Month end Written as: start %m+% months(k) Good one a month, the last day when the 31st is missing Poor the last day of a month can be a Saturday or Sunday Mixed keeps the 31st in months that have one
Last business day Written as: a function of your own Good one a month Good a Saturday or Sunday moves back to Friday Poor the day changes from month to month
How the answers decide
  • “Must every month have exactly one due date?” Yes: rules out Roll over. In the run, seq() skipped February and gave March two due dates, so one customer would pay twice in a month.
  • “Must a due date fall on a working day?” Yes: rules out Roll over and Month end. A due date from either rule can fall on a weekend; the last days of February and May did in the run. Only a rule that moves it, such as the last business day, keeps every date on a working day.

Date Calculator Add days or months to a date and count the days between two dates, to check a schedule by hand.

Key idea

“One month later” is a rule, not a fact: choose rolling over, the month end or a working day on purpose, and test it on the last days of months and on a leap day.

Checkpoint

Check: Adding months to month ends

  1. Question 1 of 2 Which three days does this return?

    Read the code, then choose one answer.

    seq(as.Date("2026-01-31"), by = "month", length.out = 3)
    Show the answer: Question 1 of 2

    Answer: The last day of January, the third day of March and the last day of March

    The run's schedule had no date in February and two in March, so a customer would pay twice in one month.

  2. Question 2 of 2 A leap day, one year later. What does this return?

    Read the code, then choose one answer.

    ymd("2024-02-29") %m+% years(1)
    Show the answer: Question 2 of 2

    Answer: The last day of the next February, its 28th day

    Test date rules on the last days of months and on a leap day: that is where the rules differ.

Indian financial years run from April to March

A sale on the last day of March and one on the first of April fall in different financial years, 2025-26 and 2026-27. In India the financial year starts on the first day of April, as the General Clauses Act defines it, so it ends with the last day of March, and Indian accounts write it with both of its calendar years. Neither base R nor lubridate has a function for that label, so the run writes one. Follow it on a day in May:

Interactive · stage by stage The financial year of a day in May Step through fy_label() and fy_quarter() on the day in May from the run, then on the last day of March.
The financial year of a day in May, step 5 of 5. Showing a day in May (the last day of March), month 4 or later? (no, 3 is before 4), the starting year (2025), the label (2025-26), the quarter (Q4).a day in Maythe last day of Marchmonth 4 or later?no, 3 is before 4the starting year2025the label2025-26the quarterQ4

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The month decides. An Indian financial year starts in April, so the only question is whether the month is April or later. May is month 5.
  2. The starting year. For April to December the financial year starts in the same calendar year, 2026 here; for January to March it started the year before.
  3. Both years in the label. The label joins the starting year with the last two digits of the next one, written 2026-27, the convention of Indian accounts.
  4. The quarter. Quarters count from April, so April to June is Q1 and May is in it. lubridate writes the same quarter as 2027.1, naming the year in which this financial year ends.
  5. The last day of March. The last day of March is month 3, before April, so it belongs to the year that started the April before, 2025-26, and to its Q4.

fy.R, typed at the R console Python · fy_session.py
from session import run, version

version()
# R reads fy.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f fy.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f fy.R
> library(lubridate)

Attaching package: ‘lubridate’

The following objects are masked from ‘package:base’:

    date, intersect, setdiff, union

> # The Indian financial year runs from April 1 to March 31 and is written with both years.
> fy_label <- function(d) {
+   y <- as.integer(format(d, "%Y"))
+   m <- as.integer(format(d, "%m"))
+   start <- ifelse(m >= 4, y, y - 1)
+   ifelse(is.na(d), NA_character_, sprintf("%d-%02d", start, (start + 1) %% 100))
+ }
> # Quarter 1 is April to June.
> fy_quarter <- function(d) paste0("Q", (as.integer(format(d, "%m")) - 4) %% 12 %/% 3 + 1)
> days_seen <- as.Date(c("2025-12-31", "2026-01-15", "2026-03-31", "2026-04-01", "2026-05-10", "2026-09-30"))
> data.frame(date = days_seen, fy = fy_label(days_seen), quarter = fy_quarter(days_seen))
        date      fy quarter
1 2025-12-31 2025-26      Q3
2 2026-01-15 2025-26      Q4
3 2026-03-31 2025-26      Q4
4 2026-04-01 2026-27      Q1
5 2026-05-10 2026-27      Q1
6 2026-09-30 2026-27      Q2
> # lubridate labels a fiscal quarter by the year in which the fiscal year ends.
> quarter(days_seen, fiscal_start = 4, type = "year.quarter")
[1] 2026.3 2026.4 2026.4 2027.1 2027.1 2027.2
> quarter(days_seen, fiscal_start = 4)
[1] 3 4 4 1 1 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fy_session.py

fy_label() looks only at the month: April to December starts a financial year in the same calendar year, January to March belongs to the one that started the year before. fy_quarter() counts quarters from April, so Q1 is April to June and the last day of March is in Q4. The last line shows a convention to watch: lubridate’s quarter() with fiscal_start = 4 returns 2027.1 for the day in May, labelling the quarter by the year in which the financial year ends.

Writing the year of a quarter

Indian accounts write the quarter from April to June of the financial year 2026-27 as Q1 of FY 2026-27, while lubridate’s 2027.1 names the year in which that financial year ends. Both are correct inside their own convention; a report that mixes them puts a quarter in the wrong year. State the convention in the report, and convert lubridate’s number to the label your readers use.

Week Number Calculator Check the ISO week of a date, the %V code of the format table, when reports are grouped by week.

Checkpoint

Check: Indian financial years and quarters

  1. One question Which Indian financial year does this day, the last day of March, belong to?

    Read the code, then choose one answer.

    as.Date("2026-03-31")
    Show the answer: One question

    Answer: 2025-26

    fy_label() in the run gave 2025-26 for the last day of March and 2026-27 for the first of April.

Practise: predict, then check

Work out each answer on paper, minding the zone, the month and the format.

Your predictions

Predict the number of days from 2026-03-01 to 2026-04-01, the day and time in India of 2026-09-14 20:00 UTC, one month after the last day of March with %m+%, and what as.Date("2026/13/01", format = "%Y/%m/%d") returns. Then type the lines into R.

practice.R, typed at the R console Python · practice_session.py
from session import run, version

version()
# R reads practice.R as if each line were typed at the console.
run("""
R --vanilla --quiet -f practice.R
""")

Output

# R version 4.6.1 -- "Happy Hop"
$ R --vanilla --quiet -f practice.R
> library(lubridate)

Attaching package: ‘lubridate’

The following objects are masked from ‘package:base’:

    date, intersect, setdiff, union

> # 1. Days between two dates:
> as.numeric(as.Date("2026-04-01") - as.Date("2026-03-01"))
[1] 31
> # 2. A UTC moment in India:
> format(as.POSIXct("2026-09-14 20:00", tz = "UTC"), "%d %H:%M", tz = "Asia/Kolkata")
[1] "15 01:30"
> # 3. One month after the last day of March:
> as.Date("2026-03-31") %m+% months(1)
[1] "2026-04-30"
> # 4. A parse with a format:
> as.Date("2026/13/01", format = "%Y/%m/%d")
[1] NA

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

March has 31 days; 20:00 UTC is 01:30 IST on the 15th; %m+% gave the last day of April, 2026-04-30; and month 13 does not exist, so the parse gave NA.

Finish with the questions below, which come back first to the part of this lesson your answers show is weakest, with every choice explained by the clock, calendar or format rule it rests on:

Practice that adapts to you

Practise this lesson

18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 18 Which day does this return?

    Read the code, then choose one answer.

    as.Date("2026-03-01") + 31
    Show the answer: Question 1 of 18

    Answer: The first day of April

    The practice run computed the same distance the other way: 31 days from the first of March to the first of April.

  2. Question 2 of 18 Which call writes d as day, month and a two-digit year, separated by slashes?

    Read the code, then choose one answer.

    d <- as.Date("2026-10-04")
    Show the answer: Question 2 of 18

    Answer: format(d, "%d/%m/%y")

    The format codes are the same for writing with format() and for reading with as.Date(format = ).

  3. Question 3 of 18 A data frame will hold two million log times. Which class should the column have?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: POSIXct, one number per moment with one time zone for the column

    Keep POSIXlt for reading a field or two of a few values, and POSIXct for data.

  4. Question 4 of 18 Which year does as.Date("15/08/47", format = "%d/%m/%y") give?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: 2047

    Store and exchange years with four digits; when only two digits exist, decide the century in your own code.

  5. Question 5 of 18 You parse 50,000 dates from a form with as.Date(x, format = "%d/%m/%Y"). Which check finds the impossible ones?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: x[is.na(p) & !is.na(x)], the inputs that were present but did not parse

    The run listed its impossible day in February this way, while the comparison with the input wrongly flagged a correct date written without leading zeros.

  6. Question 6 of 18 Which lubridate function reads x as the third day of September?

    Read the code, then choose one answer.

    x <- "03-09-2026"
    Show the answer: Question 6 of 18

    Answer: dmy()

    The run's dmy() also warned "1 failed to parse" for the impossible date.

  7. Question 7 of 18 x prints as 01:30 IST on the 15th. Which day does the last line return?

    Read the code, then choose one answer.

    x <- lubridate::with_tz(as.POSIXct("2026-09-14 20:00:00", tz = "UTC"), "Asia/Kolkata")
    as.Date(x)
    Show the answer: Question 7 of 18

    Answer: The 14th, the day in UTC

    The run printed the Monday for ist[3] with as.Date() and the Tuesday with tz or as_date().

  8. Question 8 of 18 Sign-up times are stored in UTC, and the team in India wants counts per Indian day. Which line counts correctly?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: table(as.Date(signups, tz = "Asia/Kolkata"))

    In the run the same six logs gave 4 and 2 per UTC day, but 2, 3 and 1 per IST day.

  9. Question 9 of 18 Which time zone should code name for India?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: "Asia/Kolkata"

    OlsonNames() lists the names this R knows; the runs of this lesson used Asia/Kolkata and Europe/London.

  10. Question 10 of 18 How many hours long was the London day on which the clocks went forward, from one midnight to the next?

    Type a number.

    A hint

    The clocks skipped one hour that night.

    Show the answer: Question 10 of 18

    Answer: 23 hours

    difftime() between the two midnights reported 23 hours; the day the clocks go back lasts 25.

  11. Question 11 of 18 Does India Standard Time change for daylight saving?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: No, it is UTC plus 5 hours 30 minutes all year

    Code that handles India alone never meets a missing or repeated hour; code that handles other zones must.

  12. Question 12 of 18 A job must run at 09:00 London time every day. How should the next run time be computed from the last one?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: Add the period days(1), which keeps the clock at 09:00

    Choose the unit by the promise: "same clock time" is a period, "every 24 hours" is a duration.

  13. Question 13 of 18 Why does this return NA?

    Read the code, then choose one answer.

    ymd("2026-01-31") + months(1)
    Show the answer: Question 13 of 18

    Answer: February has no 31st day, and plain addition of months returns NA rather than choosing another day

    Choose %m+% for the month end, or seq() for rolling over, on purpose.

  14. Question 14 of 18 The last day of a month falls on a Sunday. What does the run's last_business_day() return for that month?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: The Friday two days earlier

    In the run the last day of May was a Sunday, and the due date became Friday the 29th.

  15. Question 15 of 18 A rent agreement says rent is due on the last day of every month. Which rule fits?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: The month end, for example ceiling_date(d, "month") - days(1) for each month

    The trade-off table rules out rolling over for any schedule that needs one date in every month.

  16. Question 16 of 18 With quarters counted from April, which quarter is a date in October?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: Q3

    fy_quarter() computes ((month - 4) mod 12) %/% 3 + 1, so October, month 10, gives 6 %/% 3 + 1, which is 3.

  17. Question 17 of 18 This returns 2027.1 for a day in May. What does 2027 stand for?

    Read the code, then choose one answer.

    quarter(ymd("2026-05-10"), fiscal_start = 4, type = "year.quarter")
    Show the answer: Question 17 of 18

    Answer: The year in which this financial year ends

    The same quarter is Q1 of FY 2026-27 in the Indian label.

  18. Question 18 of 18 One table of a report shows lubridate's 2027.1, another shows FY 2026-27 Q1. What do you do?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: Choose one convention for the whole report, state it, and convert the other with a small function

    Write the convention in the report's notes, for example "quarters of the financial year, April to March".

Interview questions

Warm-up: what is the difference between Date, POSIXct and POSIXlt in R?

A Date is a calendar day with no time of day, stored as the number of days since the start of 1970, so date arithmetic is arithmetic on days. A POSIXct is a moment, stored as the number of seconds since the same start in UTC, with a time zone attribute that only decides how it is printed; it is compact and the right class for a column of timestamps. A POSIXlt holds the same moment as a list of fields, such as year counted from 1900, mon from 0, mday, hour and wday, which makes single fields easy to read but the object large and slow in data frames. Use Date for days, POSIXct for moments, and format() or lubridate’s functions rather than POSIXlt fields to read parts.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A dashboard counts app sign-ups per day from timestamps stored in UTC. The team in India says early-morning sign-ups appear on the previous day, so Monday looks low and Sunday high. Explain why, fix the daily count in R, and say how you would test the fix.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A loan schedule computes due dates with seq(start, by = "month", length.out = 12). For loans that start on the last day of a month, one customer was charged twice in March and not at all in February. Explain what happened, compare the fixes, choose one, and say how you would test the schedule.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A Date counts days and a POSIXct counts seconds from the start of 1970, while POSIXlt holds fields with a month from 0 and a year from 1900; format() reads any part with its codes.
  • Give every parse a format, so impossible input becomes NA instead of an error or a silent midnight, and count the new NA values as the check.
  • A time zone changes how a moment is shown, not the moment: convert with with_tz() before grouping by day, pass tz to as.Date(), and keep force_tz() for repairing wrong zones.
  • Daylight saving makes days of 23 and 25 hours and moves missing clock times, so periods such as days(1) keep the clock and durations such as ddays(1) keep the seconds.
  • Adding a month to a month end has three defensible rules, rolling over, the month end with %m+%, or a working day, and each must be chosen and tested on month ends and leap days.
  • The Indian financial year runs from April to March and is labelled with both years, while lubridate labels a fiscal quarter by the year in which the financial year ends.

Terms in this lesson

duration
In lubridate, an exact length of time in seconds, such as ddays(1), which is always 86,400 seconds, so across a change of the clocks the clock time it lands on shifts by an hour.
period
In lubridate, a length of time in calendar units, such as days(1) or months(1): adding one keeps the clock time across a change of the clocks, but its length in seconds varies.
time zone database
The IANA database of the world's time zones and their history of offsets and clock changes, with zones named by a city, such as Asia/Kolkata or Europe/London. R and most systems use it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.