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.
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Text first. Dates arrive as text. The standard form, year, month and day joined by hyphens, needs no format to be read.
- 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.
- 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.
- 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.
- 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.
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 |
Checkpoint
Check: Date, POSIXct and POSIXlt
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:
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
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Written in UTC. The server writes the log at 18:45 on a Monday, UTC, the zone servers usually keep.
- 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.
- 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.
- The same moment. with_tz() changed only the clock that is shown. The moment did not change, so the two times compare equal.
- 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.
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.
Checkpoint
Check: Time zones and the day a moment falls on
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:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Before the change. At 00:30 London is on GMT, the same as UTC.
- 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.
- 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.
- 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.
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
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:
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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.
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
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
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.
Checkpoint
Check: Indian financial years and quarters
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.
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.
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.
-
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.
-
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.
Key takeaways
- A
Datecounts days and aPOSIXctcounts seconds from the start of 1970, whilePOSIXltholds 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
NAinstead of an error or a silent midnight, and count the newNAvalues as the check. - A time zone changes how a moment is shown, not the moment: convert with
with_tz()before grouping by day, passtztoas.Date(), and keepforce_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 asddays(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
- Date Class (Dates) (The R Core Team)
- Date-Time Classes (DateTimeClasses: POSIXct and POSIXlt) (The R Core Team)
- Date-time Conversion Functions to and from Character (strptime, format codes) (The R Core Team)
- Time Zones (timezones) (The R Core Team)
- Generate Regular Sequences of Dates (seq.Date) (The R Core Team)
- Generate Regular Sequences of Times (seq.POSIXt: steps of a month) (The R Core Team)
- Date Conversion Functions to and from Character (as.Date) (The R Core Team)
- lubridate: make dealing with dates a little easier (The lubridate authors (tidyverse))
- lubridate reference: add and subtract months to a date without exceeding the last day (%m+%) (The lubridate authors (tidyverse))
- lubridate reference: get the fiscal quarter (quarter) (The lubridate authors (tidyverse))
- lubridate reference: get date-time in a different time zone (with_tz) and replace it (force_tz) (The lubridate authors (tidyverse))
- Time Zone Database (Internet Assigned Numbers Authority (IANA))
- When do the clocks change? (United Kingdom) (GOV.UK)
- The General Clauses Act, 1897, section 3(21) (financial year) (Legislative Department, Government of India (India Code))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress