Dates and Times
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. This page explains how R represents calendar dates (Date), instants (POSIXct), broken-down times (POSIXlt) and intervals (difftime), how to read and write them as text, how time zones and daylight saving time change what a clock reading means, how to do arithmetic, sequences and counting by period, and how the ts class of the stats package stores regular time series. Everything is base R (the base, utils and stats packages), except one short section that runs lubridate 1.9.5 next to the base functions; the lintr 3.4.0 default linters were used only to check the scripts.
Every example below is a complete script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines starting with #> are what it printed. The scripts pass the default lintr linters. Errors and warnings that the page wants to show are caught with tryCatch() and printed with conditionMessage(), so the text is exactly what R says and the script still ends normally. The examples use the library scenario of the section: loans$loan_date, loans$return_date, shelf_today (the fixed "today", 2026-06-30) and loan_days (21) come from the scenario.R file described on the section index, and the scripts that use them start with source("scenario.R"). Two rules keep the output reproducible: the scripts never print Sys.time() or Sys.Date() (they change on every run, so only their classes are shown) and they always name the time zone they mean, either with tz = or by setting the TZ environment variable, so nothing depends on the zone of the machine that ran them. A few behaviours are decided by the operating system rather than by R (what happens inside a daylight-saving gap, what a misspelt zone name does, the number of names in the time zone database); the page says so where they appear and shows what the Linux container used for checking produced.
The classes at a glance
R has no single "date-time type". It has two classes for calendar days and instants that a vector can hold, one class that exposes the fields of a calendar time, one for the difference between two of them, and a separate class for regularly spaced numeric series. The help page DateTimeClasses states the design in one sentence: POSIXct is more convenient for including in data frames, and POSIXlt is closer to human-readable forms (R documentation: DateTimeClasses).
| Class | What it stores | Time zone | Use it for |
|---|---|---|---|
|
a double: the number of days since 1970-01-01, negative before it |
none: arithmetic is as if in UTC |
calendar days: loan dates, due dates, birthdays |
|
a double: the number of seconds since 1970-01-01 00:00:00 UTC, with an optional |
only how it is displayed (the instant is the same everywhere) |
instants: timestamps, columns of a data frame |
|
a list of 11 fields ( |
part of the value |
reading or changing one field (the month, the day of the week) for a moment |
|
a double with a |
none |
the result of subtracting two dates or times |
|
a numeric vector or matrix with a |
none |
regularly spaced observations: loans per month |
Source: R documentation: DateTimeClasses
A date has no time of day and no zone, so it is the right type for something that happens "on 2026-06-30" wherever you are. An instant is a point on the time line, so it needs a zone only to be shown or to be built from a clock reading. Mixing the two ideas is the source of most date bugs, and the rest of the page keeps them apart: Dates first, then instants and time zones.
Dates
The help page for Dates says that dates are represented as the number of days since 1970-01-01, with negative values for earlier dates, and that they are always printed following the rules of the current Gregorian calendar even when that calendar was not yet in use (R documentation: Dates). It also says that the value is intended to be an integer but that this is not enforced, so a Date is stored as a double and a fraction of a day is kept but ignored when printing. Two consequences are on the first script: unclass() shows the number behind the class, and identical() should not be used on dates, because a date stored as an integer and the same date stored as a double are different objects (the help page warns about this; use == or all.equal()).
Numbers become dates with as.Date(x). Before R 4.3.0 this needed an origin argument; the 4.3.0 NEWS says that as.POSIXct(<numeric>) and as.POSIXlt() without an origin now work, and "So does as.Date(<numeric>)" (R NEWS: Changes in R 4.3.0). origin is still the way to read a number counted from another epoch, such as a spreadsheet serial number.
source("scenario.R")
# A Date is a double: the number of days since 1970-01-01 (UTC).
d <- as.Date("2026-06-30")
d
#> [1] "2026-06-30"
class(d)
#> [1] "Date"
typeof(d)
#> [1] "double"
unclass(d)
#> [1] 20634
as.Date(0)
#> [1] "1970-01-01"
as.Date(-1)
#> [1] "1969-12-31"
# Numbers become dates without an origin since R 4.3.0.
as.Date(20634)
#> [1] "2026-06-30"
# A spreadsheet serial number needs the origin of that program.
as.Date(46203, origin = "1899-12-30")
#> [1] "2026-06-30"
# The fixed "today" of the shelf scenario is such a Date.
shelf_today
#> [1] "2026-06-30"
shelf_today == d
#> [1] TRUE
# Fractions of a day are stored but ignored when a Date is printed.
half <- d + 0.5
half
#> [1] "2026-06-30"
unclass(half)
#> [1] 20634.5
format(half)
#> [1] "2026-06-30"
# Pieces of a date (the names follow the LC_TIME locale).
weekdays(d)
#> [1] "Tuesday"
months(d)
#> [1] "June"
quarters(d)
#> [1] "Q2"
julian(d)
#> [1] 20634
#> attr(,"origin")
#> [1] "1970-01-01"
format(d, "%A %d %B %Y")
#> [1] "Tuesday 30 June 2026"
format(d, "%d/%m/%Y")
#> [1] "30/06/2026"
Source: R documentation: Dates
weekdays(), months(), quarters() and julian() are documented together on one help page (R documentation: weekdays). The names they return and the names that format() produces for %A and %B follow the LC_TIME locale category, so on a machine configured for another language weekdays(d) is in that language; the section Encodings, Locales and Internationalization explains the locale categories. julian(d) returns the number of days since the origin attribute, 1970-01-01 unless you pass another.
Reading dates with as.Date
as.Date() on text takes a format (the conversion codes of the table below) or, when none is given, tries the two ISO layouts in tryFormats = c("%Y-%m-%d", "%Y/%m/%d") on the first element that is not NA, and signals an error if neither matches. Three details of that rule cause most trouble, and the script shows each one:
-
The text is processed only as far as the format needs, so anything after the date is ignored:
"2026-06-30abc"and"2026-06-30 23:59:59"both give 2026-06-30. -
A text that fits the first layout is accepted whatever it was meant to say.
"30/06/2026"is read with%Y/%m/%d:%Ytakes30, the month is06, the day is20and the trailing26is ignored. The result is the date 0030-06-20, with no error and no warning. Give theformatwhenever the text is not an ISO date. -
The layout is guessed from the first element that is not
NA. An impossible date such as"2025-02-30"is notNAas text, but it does not parse, so a vector that starts with one fails as a whole, while the same date later in the vector is justNA. With aformat, the impossible date isNAwherever it is.
The error text for a vector that matches no layout is character string is not in a standard unambiguous format. optional = TRUE returns NA instead of signalling that error (R documentation: as.Date).
# ISO layouts need no format: "%Y-%m-%d" and "%Y/%m/%d" are tried in turn.
as.Date("2026-06-30")
#> [1] "2026-06-30"
as.Date("2026/06/30")
#> [1] "2026-06-30"
as.Date("2026-6-3")
#> [1] "2026-06-03"
# Anything else needs a format.
as.Date("30/06/2026", format = "%d/%m/%Y")
#> [1] "2026-06-30"
as.Date("June 30, 2026", format = "%B %d, %Y")
#> [1] "2026-06-30"
as.Date("30 Jun 2026", format = "%d %b %Y")
#> [1] "2026-06-30"
as.Date("20260630", format = "%Y%m%d")
#> [1] "2026-06-30"
as.Date("30.06.26", format = "%d.%m.%y")
#> [1] "2026-06-30"
# Without a format a string that matches the first layout is accepted
# whatever it was meant to say: this is year 30, month 6, day 20.
wrong <- as.Date("30/06/2026")
wrong
#> [1] "30-06-20"
as.POSIXlt(wrong)$year + 1900L
#> [1] 30
as.POSIXlt(wrong)$mon + 1L
#> [1] 6
as.POSIXlt(wrong)$mday
#> [1] 20
# A string that matches neither layout is an error ...
tryCatch(as.Date("June 30, 2026"), error = \(e) conditionMessage(e))
#> [1] "character string is not in a standard unambiguous format"
# ... unless optional = TRUE asks for NA instead.
as.Date("June 30, 2026", optional = TRUE)
#> [1] NA
# Characters after the date are ignored.
as.Date("2026-06-30 23:59:59")
#> [1] "2026-06-30"
as.Date("2026-06-30abc")
#> [1] "2026-06-30"
# An impossible date is NA when the format is given ...
as.Date("2025-02-30", format = "%Y-%m-%d")
#> [1] NA
# ... but without a format all candidates fail, and that is an error.
tryCatch(as.Date("2025-02-30"), error = \(e) conditionMessage(e))
#> [1] "character string is not in a standard unambiguous format"
# The layout is guessed from the first element that is not NA, so one
# impossible date first in the vector makes the whole call fail ...
tryCatch(
as.Date(c("2025-02-30", "2025-03-01")),
error = \(e) conditionMessage(e)
)
#> [1] "character string is not in a standard unambiguous format"
# ... while the same date later in the vector is just NA.
as.Date(c("2025-03-01", "2025-02-30"))
#> [1] "2025-03-01" NA
# Giving the format avoids the guess.
as.Date(c("2025-02-30", "2025-03-01"), format = "%Y-%m-%d")
#> [1] NA "2025-03-01"
Source: R documentation: as.Date
Printing the wrong-layout date gives 30-06-20 on the Linux container, without the zero padding of the year: how R prints a year below 1000 depends on the operating system (the Date help page does not promise a layout), which is one more reason to give a format. For two-digit years (%y), values 00 to 68 are read as 20xx and 69 to 99 as 19xx, the behaviour specified by the 2018 POSIX standard, as the parsing script shows.
The loans: an impossible date
The shelf scenario contains one deliberate date problem: row 20 of loans.csv has the loan date 2025-02-30, which does not exist. read.csv() returns every column as text (colClasses = "character" makes that explicit) and scenario.R converts the two date columns with as.Date(format = "%Y-%m-%d"), so the impossible date becomes NA and the rest of the column converts normally. Empty return_date fields, the seven loans still out, are NA as well. Because an NA is present, range() needs na.rm = TRUE, as do most of the summaries below. read.csv() can also convert while reading, with colClasses = c(loan_date = "Date"); it applies as.Date() to the text, so it gives the same result here. The section Reading and Writing Text Data covers colClasses in detail.
source("scenario.R")
# loans.csv holds text. The scenario converts it with an explicit format.
raw <- read.csv("loans.csv", colClasses = "character")
raw$loan_date[18:22]
#> [1] "2025-07-01" "2025-07-02" "2025-02-30" "2025-07-05" "2025-07-06"
loans$loan_date[18:22]
#> [1] "2025-07-01" "2025-07-02" NA "2025-07-05" "2025-07-06"
str(loans[c("loan_date", "return_date")])
#> 'data.frame': 60 obs. of 2 variables:
#> $ loan_date : Date, format: "2025-01-14" "2025-01-16" ...
#> $ return_date: Date, format: "2025-02-04" "2025-01-26" ...
# Row 20 holds the impossible date 2025-02-30, which became NA.
sum(is.na(loans$loan_date))
#> [1] 1
which(is.na(loans$loan_date))
#> [1] 20
raw$loan_date[is.na(loans$loan_date)]
#> [1] "2025-02-30"
# Empty return_date fields (loans still out) are NA too.
sum(is.na(loans$return_date))
#> [1] 7
# range() and friends need na.rm = TRUE once an NA is present.
range(loans$loan_date)
#> [1] NA NA
range(loans$loan_date, na.rm = TRUE)
#> [1] "2025-01-14" "2026-05-28"
# read.csv can convert at read time with colClasses = "Date".
dated <- read.csv(
"loans.csv",
colClasses = c(loan_date = "Date", return_date = "Date")
)
class(dated$loan_date)
#> [1] "Date"
sum(is.na(dated$loan_date))
#> [1] 1
all(dated$loan_date == loans$loan_date, na.rm = TRUE)
#> [1] TRUE
Source: R documentation: as.Date
POSIXct and POSIXlt
An instant needs a finer unit than a day. POSIXct ("calendar time", from the C type time_t) stores the number of seconds since 1970-01-01 00:00:00 UTC as a double. POSIXlt ("local time", from the C structure struct tm) stores the same instant as a list of broken-down fields. Both inherit from the virtual class POSIXt, which lets operations such as subtraction mix them, and both may carry a tzone attribute (R documentation: DateTimeClasses). Because a POSIXct is just a number, it is fast, it sorts and subsets like a number, and it is one atomic column in a data frame. Because a POSIXlt is a list, a field such as the month is available directly.
# POSIXct: seconds since 1970-01-01 00:00:00 UTC, plus a tzone attribute.
x <- as.POSIXct("2026-06-30 09:30:00", tz = "UTC")
x
#> [1] "2026-06-30 09:30:00 UTC"
class(x)
#> [1] "POSIXct" "POSIXt"
typeof(x)
#> [1] "double"
unclass(x)
#> [1] 1782811800
#> attr(,"tzone")
#> [1] "UTC"
as.numeric(x)
#> [1] 1782811800
# Numbers become date-times without an origin since R 4.3.0.
as.POSIXct(1782811800, tz = "UTC")
#> [1] "2026-06-30 09:30:00 UTC"
# POSIXlt: a list of broken-down time fields.
lt <- as.POSIXlt(x)
class(lt)
#> [1] "POSIXlt" "POSIXt"
typeof(lt)
#> [1] "list"
names(unclass(lt))
#> [1] "sec" "min" "hour" "mday" "mon" "year" "wday" "yday"
#> [9] "isdst" "zone" "gmtoff"
str(unclass(lt))
#> List of 11
#> $ sec : num 0
#> $ min : int 30
#> $ hour : int 9
#> $ mday : int 30
#> $ mon : int 5
#> $ year : int 126
#> $ wday : int 2
#> $ yday : int 180
#> $ isdst : int 0
#> $ zone : chr "UTC"
#> $ gmtoff: int 0
#> - attr(*, "tzone")= chr "UTC"
#> - attr(*, "balanced")= logi TRUE
# One field per element: mon counts from 0, year from 1900.
lt$hour
#> [1] 9
lt$mon
#> [1] 5
lt$mon + 1L
#> [1] 6
lt$year + 1900L
#> [1] 2026
lt$wday
#> [1] 2
lt$yday
#> [1] 180
lt$zone
#> [1] "UTC"
lt$gmtoff
#> [1] 0
# length() counts times, not fields.
length(lt)
#> [1] 1
length(unclass(lt))
#> [1] 11
# as.matrix() gives the fields as numbers (numeric again since R 4.6.1).
as.matrix(as.POSIXlt(c(x, x + 60)))
#> sec min hour mday mon year wday yday isdst gmtoff
#> [1,] 0 30 9 30 5 126 2 180 0 0
#> [2,] 0 31 9 30 5 126 2 180 0 0
# [[ with a field name is an error; use $ or [ with two indices.
tryCatch(lt[["min"]], error = \(e) conditionMessage(e))
#> [1] "No element named \"min\" found in x, did you mean x[, \"min\"] instead?"
lt$min
#> [1] 30
lt[, "min"]
#> [1] 30
# Out-of-range fields are normalised on conversion: day 70 of June.
lt$mday <- lt$mday + 40L
as.POSIXct(lt)
#> [1] "2026-08-09 09:30:00 UTC"
format(lt)
#> [1] "2026-08-09 09:30:00"
Source: R documentation: DateTimeClasses
The fields of a POSIXlt are, in this fixed order (several C routines rely on it and the help page says the order must not be changed):
| Field | Meaning | Range or type |
|---|---|---|
|
seconds, possibly with a fraction |
0 to 61 (leap seconds), double |
|
minutes |
0 to 59 |
|
hours |
0 to 23 |
|
day of the month |
1 to 31 |
|
months after the first of the year |
0 to 11, so January is 0 |
|
years since 1900 |
2026 is 126 |
|
day of the week, starting on Sunday |
0 to 6 |
|
day of the year |
0 to 365, so 1 January is 0 |
|
daylight-saving flag |
positive in force, 0 not, negative unknown |
|
abbreviation of the time zone in force |
|
|
offset from UTC in seconds, positive east of Greenwich |
|
Source: R documentation: DateTimeClasses
Until R 4.3.0 the last two fields were optional and platform dependent. The 4.3.0 NEWS says that all objects created in that version have 11 components, that zone is always set, that gmtoff is set for times in UTC and usually on platforms with C-level support, and that there are now comprehensive validity checks on the structure of a POSIXlt when converting, formatting and printing, which avoids mis-conversions of hand-crafted objects. The same entry introduced the experimental balancePOSIXlt(), which makes "ragged" objects (fields of different lengths, or out-of-range values) balanced (R NEWS: Changes in R 4.3.0). The 4.6.1 NEWS records that as.matrix(<POSIXlt>) again produces a numeric matrix, as the script shows, and that the c() and subassignment methods work correctly more often (R NEWS: Changes in R 4.6.1).
Three points about POSIXlt are easy to miss:
-
length(lt)is the number of times, not the number of fields, andlt[["min"]]is an error with the text shown on the script. Uselt$minorlt[, "min"](the help page says single components can be extracted and replaced with[and two indices). -
Fields may be set to out-of-range values and normalised on conversion. Adding 40 to
mdayand converting withas.POSIXct()gives the date 40 days later. This is how the "one month later" calculation below is done in base R. After changing a field in a zone with daylight saving, also setisdstto-1(unknown), as the daylight-saving script does, because a stale flag moves the result by an hour. -
The fields
wdayandydayare informative: they are not used for conversion, printing or formatting.
Which one to store
Store POSIXct. The help page for DateTimeClasses says that data.frame() and the as.data.frame method convert a POSIXlt to a one-column POSIXct data frame, so that date-time objects are treated like atomic vectors. The conversion is applied by data.frame(), not by df$col <- value, which stores the list as it is and leaves the data frame with a column of a different kind. Convert explicitly with as.POSIXct() when you assign. Arithmetic and comparison on either class are done after coercion to POSIXct, so nothing is gained by keeping the list.
x <- as.POSIXct("2026-06-30 09:30:00", tz = "UTC")
times <- as.POSIXlt(c(x, x + 3600))
# data.frame() converts a POSIXlt column to POSIXct ...
df <- data.frame(id = 1:2, when = times)
class(df$when)
#> [1] "POSIXct" "POSIXt"
# ... but assigning a column does not, which keeps the list structure.
df$other <- times
class(df$other)
#> [1] "POSIXlt" "POSIXt"
str(df)
#> 'data.frame': 2 obs. of 3 variables:
#> $ id : int 1 2
#> $ when : POSIXct, format: "2026-06-30 09:30:00" "2026-06-30 10:30:00"
#> $ other: POSIXlt, format: "2026-06-30 09:30:00" "2026-06-30 10:30:00"
# Convert explicitly before storing.
df$other <- as.POSIXct(times)
class(df$other)
#> [1] "POSIXct" "POSIXt"
# Arithmetic and comparison go through POSIXct anyway.
df$when + 60
#> [1] "2026-06-30 09:31:00 UTC" "2026-06-30 10:31:00 UTC"
df$when > x
#> [1] FALSE TRUE
Source: R documentation: DateTimeClasses
For the data frame functions used here see Data Frames; the attributes (class, tzone, units, tsp) that make these classes work are explained on Attributes and Names.
Time zones
A POSIXct is one point on the time line. A time zone is a rule that turns that point into a clock reading, and the other way round. Three different operations use the word tz, and mixing them up is the usual mistake:
| Call | What tz means |
Effect |
|---|---|---|
|
the zone in which the text is written |
chooses the instant |
|
the zone in which to show an existing instant |
chooses the clock reading; the instant is unchanged |
|
the zone in which to show the object from now on |
same as above, stored in the object |
Source: R documentation: as.POSIXlt
The tzone attribute of a POSIXct is what print() and format() use. If it is the empty string (what you get when tz is not given), the object is shown in the current zone of the session, so the same object prints differently after TZ changes. The help page adds that c() on POSIXct objects drops tzone if they do not all have the same one, and that c() on POSIXlt objects converts them to the current zone (R documentation: DateTimeClasses).
x <- as.POSIXct("2026-06-30 09:30:00", tz = "UTC")
attr(x, "tzone")
#> [1] "UTC"
# tz in as.POSIXct says in which zone the *text* is written.
madrid <- as.POSIXct("2026-06-30 09:30:00", tz = "Europe/Madrid")
madrid
#> [1] "2026-06-30 09:30:00 CEST"
as.numeric(madrid) - as.numeric(x)
#> [1] -7200
# The same instant shown in other zones: only the display changes.
format(x, tz = "Europe/Madrid", usetz = TRUE)
#> [1] "2026-06-30 11:30:00 CEST"
format(x, tz = "Asia/Tokyo", usetz = TRUE)
#> [1] "2026-06-30 18:30:00 JST"
format(x, "%H:%M", tz = "America/New_York")
#> [1] "05:30"
as.POSIXlt(x, tz = "Asia/Tokyo")$hour
#> [1] 18
attr(as.POSIXlt(x, tz = "Europe/Madrid"), "tzone")
#> [1] "Europe/Madrid" "CET" "CEST"
# Setting the tzone attribute changes the display of the object itself.
y <- x
attr(y, "tzone") <- "America/New_York"
y
#> [1] "2026-06-30 05:30:00 EDT"
as.numeric(y) == as.numeric(x)
#> [1] TRUE
# To keep the clock reading and change the instant, re-parse the text.
as.POSIXct(format(x, tz = "UTC"), tz = "Asia/Tokyo")
#> [1] "2026-06-30 09:30:00 JST"
# Comparing times with different tzone attributes warns.
tokyo <- as.POSIXct("2026-06-30 18:30:00", tz = "Asia/Tokyo")
tryCatch(x == tokyo, warning = \(w) conditionMessage(w))
#> [1] "'tzone' attributes are inconsistent"
as.numeric(x) == as.numeric(tokyo)
#> [1] TRUE
# The zone names come from the IANA database that the system provides.
tz_names <- OlsonNames()
length(tz_names) > 400
#> [1] TRUE
head(tz_names, 3)
#> [1] "Africa/Abidjan" "Africa/Accra" "Africa/Addis_Ababa"
c("Europe/Madrid", "Nowhere/Land") %in% tz_names
#> [1] TRUE FALSE
# A misspelt zone is not an error here: it is treated as UTC.
nowhere <- as.POSIXct("2026-06-30 09:30:00", tz = "Nowhere/Land")
as.numeric(nowhere) == as.numeric(x)
#> [1] TRUE
# A Date becomes midnight UTC, shown in the zone requested.
day <- as.Date("2026-06-30")
as.POSIXct(day, tz = "UTC")
#> [1] "2026-06-30 UTC"
as.POSIXct(day, tz = "Europe/Madrid")
#> [1] "2026-06-30 02:00:00 CEST"
# as.Date() on a date-time uses UTC unless told otherwise.
late <- as.POSIXct("2026-06-30 23:30:00", tz = "America/New_York")
as.Date(late)
#> [1] "2026-07-01"
as.Date(late, tz = "America/New_York")
#> [1] "2026-06-30"
Source: R documentation: timezones
Notes on the script:
-
as.Date()of a date-time is documented to convert "by ignoring the time after midnight in the representation of the time in specified time zone, default UTC", so a late evening in New York is already tomorrow’s date unlesstzis given (R documentation: as.Date). Going the other way, aDateconverted to a date-time is midnight UTC (R documentation: Dates), and since R 4.3.0as.POSIXlt(<Date>)andas.POSIXct(<Date>)apply atzargument, which changes only the display: midnight UTC is 02:00 in Madrid in June. -
Comparing times whose
tzoneattributes differ gives the warning shown ('tzone' attributes are inconsistent); the comparison itself is still made on the instants. -
The names come from the IANA time zone database (the "Olson" database) installed on the system.
OlsonNames()lists the ones available; the number depends on the installed version of the database and on the system, so the script tests it against a bound instead of printing it. The help page says that only theContinent/CityandEtc/...forms are fully portable, that"UTC"and"GMT"are accepted on every platform, and that traditional names need care:"EST"is a zone in Canada without daylight saving, not the same as"EST5EDT"nor Australian Eastern Standard Time (R documentation: timezones). -
An invalid name is not an error. The help page says invalid values are most commonly treated as UTC, on some platforms with a warning. On the Linux container used here there was no warning, so the safest checks are
tz %in% OlsonNames()or a test with a known instant. -
When there is a
tzone,POSIXltobjects carry three values (the zone name and the standard and daylight abbreviations), which is whyattr(as.POSIXlt(x, "Europe/Madrid"), "tzone")has length 3.
The TZ environment variable
With tz = "", the zone is the current one: the value of the TZ environment variable if it is set, otherwise the system zone. Sys.timezone() returns the name of the current zone; its value is operating-system specific and can be NA or an empty string, so this page prints it only after setting TZ itself. The help page says an invalid TZ makes most platforms use UTC, and recommends setting TZ before R starts (or in .Rprofile) when the system value is wrong. The 4.3.0 NEWS adds that on Windows TZ can be set to the desired Olson name to override the automatic mapping from Windows zone names (R NEWS: Changes in R 4.3.0). Sys.setenv() changes it for the running session (R documentation: Sys.setenv). The environment variables used by R in general are listed on Sessions, Workspaces and Startup.
# With tz = "" the zone comes from the TZ environment variable.
Sys.setenv(TZ = "Europe/Madrid")
Sys.timezone()
#> [1] "Europe/Madrid"
local_time <- as.POSIXct("2026-06-30 09:30:00")
local_time
#> [1] "2026-06-30 09:30:00 CEST"
attr(local_time, "tzone")
#> [1] ""
Sys.setenv(TZ = "Asia/Tokyo")
tokyo_time <- as.POSIXct("2026-06-30 09:30:00")
tokyo_time
#> [1] "2026-06-30 09:30:00 JST"
as.numeric(local_time) - as.numeric(tokyo_time)
#> [1] 25200
# The stored instant does not change when the display zone does.
local_time
#> [1] "2026-06-30 16:30:00 JST"
# c() on date-times with different zones drops the tzone attribute,
# so the result is shown in the session zone (here Asia/Tokyo).
utc <- as.POSIXct("2026-06-30 09:30:00", tz = "UTC")
both <- c(utc, as.POSIXct("2026-06-30 09:30:00", tz = "Europe/Madrid"))
both
#> [1] "2026-06-30 18:30:00 JST" "2026-06-30 16:30:00 JST"
attr(both, "tzone")
#> NULL
attr(c(utc, utc), "tzone")
#> [1] "UTC"
# Back to the system zone for the rest of the session.
Sys.unsetenv("TZ")
Source: R documentation: timezones
Daylight saving time
Two kinds of clock reading cannot be turned into an instant without a rule: a reading that never happened (the clocks jumped forward) and a reading that happened twice (the clocks went back). The help page for as.POSIXlt says that what happens in such cases is operating-system specific: for the first one should expect NA, and the second could be read as either of the two instants (R documentation: as.POSIXlt). The Linux container used for this page chose these answers, which you cannot rely on elsewhere:
-
02:30on 2026-03-29 in Madrid, which does not exist, became03:30CEST;as.POSIXlt()of the same text keeps the fields02:30with an unknownisdst, so it prints without a zone. -
02:30on 2026-10-25, which occurs twice, was read as the second one (CET).
The portable approach is to avoid constructing clock readings in the gap and to be explicit when adding "a day": the by units of seq.POSIXt() distinguish "day" from "DSTday". "day" is 24 hours and ignores changes to or from daylight saving; "DSTday" takes the same clock time each day (R documentation: seq.POSIXt). Likewise a difftime in "days" is a period of 24 hours, not a calendar day, and so takes no account of daylight saving (R documentation: difftime). The next script computes a due date for a loan taken at 18:00 on 2026-03-20, nine days before the clocks go forward in Madrid, with loans lasting loan_days.
source("scenario.R")
# Europe/Madrid: clocks went forward at 02:00 on 2026-03-29 (CET to CEST)
# and back at 03:00 on 2026-10-25 (CEST to CET).
before <- as.POSIXct("2026-03-29 01:30:00", tz = "Europe/Madrid")
before + 3600
#> [1] "2026-03-29 03:30:00 CEST"
seq(before, by = "hour", length.out = 3)
#> [1] "2026-03-29 01:30:00 CET" "2026-03-29 03:30:00 CEST"
#> [3] "2026-03-29 04:30:00 CEST"
# The wall-clock time 02:30 does not exist on 2026-03-29.
gap <- as.POSIXct("2026-03-29 02:30:00", tz = "Europe/Madrid")
gap
#> [1] "2026-03-29 03:30:00 CEST"
is.na(gap)
#> [1] FALSE
as.POSIXlt("2026-03-29 02:30:00", tz = "Europe/Madrid")
#> [1] "2026-03-29 02:30:00"
# 02:30 on 2026-10-25 occurs twice; as.POSIXct picks one of the two.
twice <- as.POSIXct("2026-10-25 02:30:00", tz = "Europe/Madrid")
format(twice, "%H:%M %Z")
#> [1] "02:30 CET"
utc_start <- as.POSIXct("2026-10-25 00:30:00", tz = "UTC")
format(utc_start + 0:2 * 3600, "%H:%M %Z", tz = "Europe/Madrid")
#> [1] "02:30 CEST" "02:30 CET" "03:30 CET"
# A loan at 18:00 on 2026-03-20 is due loan_days later.
start <- as.POSIXct("2026-03-20 18:00:00", tz = "Europe/Madrid")
start + loan_days * 86400
#> [1] "2026-04-10 19:00:00 CEST"
seq(start, by = "21 DSTdays", length.out = 2)[[2L]]
#> [1] "2026-04-10 18:00:00 CEST"
# Same answer through the broken-down fields: reset isdst to "unknown".
lt <- as.POSIXlt(start)
lt$mday <- lt$mday + loan_days
lt$isdst <- -1L
as.POSIXct(lt)
#> [1] "2026-04-10 18:00:00 CEST"
# A difference of "days" is a multiple of 24 hours.
due <- as.POSIXct("2026-04-10 18:00:00", tz = "Europe/Madrid")
difftime(due, start)
#> Time difference of 20.95833 days
difftime(due, start, units = "hours")
#> Time difference of 503 hours
Source: R documentation: seq.POSIXt
The library’s rule is "due 21 days later at closing time", so the right answer is 18:00 on 2026-04-10, which by = "21 DSTdays" and the POSIXlt field arithmetic give; adding loan_days * 86400 seconds gives 19:00 because one of those 21 days has 23 hours. Dates avoid the problem altogether: for a rule that is about days, store a Date.
Conversion codes: format, strptime and strftime
Four functions convert between text and date-time objects, and they share one set of conversion specifications: format() (methods for Date, POSIXct and POSIXlt) and its wrapper strftime() produce text, strptime() parses text into a POSIXlt, and as.Date() and as.POSIXct() take a format that they hand to strptime() (R documentation: strptime). A specification is % followed by a letter, sometimes with an O or E modifier. Anything else in the format is copied (and %% is a literal percent sign). The following table lists the ones most used; the numbers come from the first script below, which formats 2026-06-05 14:05:09 in Europe/Madrid (a Friday).
| Code | Meaning | Output |
|---|---|---|
|
year with century (on input only years 0 to 9999) |
|
|
year without century; on input 00-68 mean 20xx and 69-99 mean 19xx |
|
|
century |
|
|
month as 01-12 |
|
|
day of the month as 01-31 |
|
|
day of the month as 1-31, a single digit padded with a blank |
|
|
hour, 00-23 |
|
|
hour 0-23, a single digit padded with a blank (not in the standards) |
|
|
hour 01-12, ambiguous without |
|
|
hour 1-12 padded with a blank (not in the standards) |
|
|
AM or PM in the locale; the input match is case-insensitive |
|
|
minute 00-59 |
|
|
second as an integer (00-61 allows for two leap seconds); does not read or write fractions |
|
|
R-specific: seconds truncated to exactly 3 decimals; |
|
|
signed offset from UTC as hours and minutes ( |
|
|
time zone abbreviation; output only |
|
|
day of the year, 001-366 |
|
|
week of the year, Sunday as the first day of the week (US convention) |
|
|
week of the year, Monday as the first day of the week (UK convention) |
|
|
weekday 1-7, Monday is 1 |
|
|
weekday 0-6, Sunday is 0 |
|
|
abbreviated and full weekday name in the locale (case-insensitive on input) |
|
|
abbreviated and full month name in the locale; |
|
|
equivalent to |
|
|
equivalent to |
|
|
equivalent to |
|
|
|
|
|
ISO 8601 week-based year, its last two digits, and ISO week number 01-53; accepted but ignored on input |
|
|
seconds since 1970-01-01 UTC; output only, not in the standards |
|
|
a literal |
|
Source: R documentation: strptime
# A Friday in June at 14:05:09 in Madrid; the day is 5 to show padding.
x <- as.POSIXct("2026-06-05 14:05:09", tz = "Europe/Madrid")
codes <- c(
"%Y", "%y", "%C", "%m", "%d", "%e", "%H", "%k", "%I", "%l", "%p",
"%M", "%S", "%OS3", "%z", "%Z", "%j", "%U", "%W", "%u", "%w",
"%a", "%A", "%b", "%B", "%h", "%F", "%T", "%R", "%D", "%G", "%g",
"%V", "%s", "%%"
)
shown <- vapply(codes, \(code) format(x, code), character(1L))
data.frame(code = codes, output = shown, row.names = NULL)
#> code output
#> 1 %Y 2026
#> 2 %y 26
#> 3 %C 20
#> 4 %m 06
#> 5 %d 05
#> 6 %e 5
#> 7 %H 14
#> 8 %k 14
#> 9 %I 02
#> 10 %l 2
#> 11 %p PM
#> 12 %M 05
#> 13 %S 09
#> 14 %OS3 09.000
#> 15 %z +0200
#> 16 %Z CEST
#> 17 %j 156
#> 18 %U 22
#> 19 %W 22
#> 20 %u 5
#> 21 %w 5
#> 22 %a Fri
#> 23 %A Friday
#> 24 %b Jun
#> 25 %B June
#> 26 %h Jun
#> 27 %F 2026-06-05
#> 28 %T 14:05:09
#> 29 %R 14:05
#> 30 %D 06/05/26
#> 31 %G 2026
#> 32 %g 26
#> 33 %V 23
#> 34 %s 1780664709
#> 35 %% %
# Anything that is not a conversion specification is copied.
format(x, "Loan on %d.%m.%Y at %H:%M")
#> [1] "Loan on 05.06.2026 at 14:05"
# format() picks the shortest default that shows every element.
format(as.POSIXct("2026-06-05 00:00:00", tz = "UTC"))
#> [1] "2026-06-05"
format(as.POSIXct("2026-06-05 14:05:09", tz = "UTC"))
#> [1] "2026-06-05 14:05:09"
format(as.Date("2026-06-05"), "%d %B")
#> [1] "05 June"
Source: R documentation: strptime
Parsing text
strptime() is the strict reader: it returns a POSIXlt, and text that does not fit the format becomes NA in every field, with no error. The help page documents that an invalid time such as "2010-02-30 08:00" gives all components NA, that unspecified seconds, minutes and hours are zero, and that an unspecified year, month or day is the current one, with the exception that if a month is given the day of that month must be given too (R documentation: strptime). as.POSIXct(x, format = ) and as.Date(x, format = ) call it and convert the result. Without a format, as.POSIXct() tries a fixed list in turn ("%Y-%m-%d %H:%M:%OS", "%Y/%m/%d %H:%M:%OS", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M", "%Y-%m-%d", "%Y/%m/%d") and takes the first that works for all the non-NA inputs; this is slow on long inputs, so give the format when you know it.
The same rule that ignores trailing characters has a nasty consequence here: when the longer layouts fail, the date-only layout still matches the beginning of the text, so a time that is not valid, or that has a T in front of it, silently becomes a date at midnight (the last lines of the script, and the next section). A parse failure that is visible as NA is better than one that is not, so check the results of parsing a column with anyNA() or sum(is.na()) and compare the number of NA before and after.
# strptime() parses text into a POSIXlt; as.POSIXct() takes the same format.
txt <- "30/06/2026 09:30"
parsed <- strptime(txt, "%d/%m/%Y %H:%M", tz = "UTC")
class(parsed)
#> [1] "POSIXlt" "POSIXt"
parsed
#> [1] "2026-06-30 09:30:00 UTC"
as.POSIXct(parsed)
#> [1] "2026-06-30 09:30:00 UTC"
as.POSIXct(txt, format = "%d/%m/%Y %H:%M", tz = "UTC")
#> [1] "2026-06-30 09:30:00 UTC"
# Text that does not fit is NA, not an error.
strptime(
c("30/06/2026 09:30", "31/02/2026 09:30", "oops"),
"%d/%m/%Y %H:%M",
tz = "UTC"
)
#> [1] "2026-06-30 09:30:00 UTC" NA
#> [3] NA
# The 12-hour clock needs %I together with %p.
as.POSIXct(
"06/30/2026 09:30 PM",
format = "%m/%d/%Y %I:%M %p",
tz = "UTC"
)
#> [1] "2026-06-30 21:30:00 UTC"
as.POSIXct("06/30/2026 09:30", format = "%m/%d/%Y %I:%M", tz = "UTC")
#> [1] "2026-06-30 09:30:00 UTC"
# Two-digit years: 00-68 mean 20xx and 69-99 mean 19xx.
as.Date(c("01/02/68", "01/02/69"), format = "%d/%m/%y")
#> [1] "2068-02-01" "1969-02-01"
# Day of the year, and a numeric UTC offset (%z is accepted on input).
as.Date("2026-181", format = "%Y-%j")
#> [1] "2026-06-30"
as.POSIXct(
"2026-06-30 09:30:00 +0200",
format = "%Y-%m-%d %H:%M:%S %z",
tz = "UTC"
)
#> [1] "2026-06-30 07:30:00 UTC"
# Unspecified seconds, minutes and hours are zero ...
format(strptime("2026-06-30", "%Y-%m-%d", tz = "UTC"), "%H:%M:%S")
#> [1] "00:00:00"
# ... but a month without a day cannot be completed: the result is NA.
strptime("2026-06", "%Y-%m", tz = "UTC")
#> [1] NA
# The standard layouts accept a date, optionally followed by a time.
as.POSIXct("2026-06-30", tz = "UTC")
#> [1] "2026-06-30 UTC"
as.POSIXct("2026-06-30 09:30", tz = "UTC")
#> [1] "2026-06-30 09:30:00 UTC"
as.POSIXct("2026/06/30 09:30:15", tz = "UTC")
#> [1] "2026-06-30 09:30:15 UTC"
# Anything after the part that matches is silently ignored.
as.POSIXct("2026-06-30 25:00:00", tz = "UTC")
#> [1] "2026-06-30 UTC"
Source: R documentation: strptime
Locale-dependent codes need care on input too: %a, %A, %b, %B and %p are matched against the names of the LC_TIME locale (case-insensitively), so "30 junio 2026" does not parse under an English locale. The help page for as.Date suggests setting Sys.setlocale("LC_TIME", "C") around the call when reading English month abbreviations in an unknown locale. The %c, %x and %X codes are locale-specific on output and are not shown in the table because their output depends on the machine.
ISO 8601
ISO 8601 writes a day as 2026-06-30, a time as 09:30:05 and a combined moment as 2026-06-30T09:30:05+02:00 or 2026-06-30T07:30:05Z. R’s default text is the same except for a space instead of the T; the strptime help page notes that the ISO form uses no space, possibly T, to separate dates and times, and that R uses a space by default (R documentation: strptime). The output codes are %F (date), %T (time), %z (offset +hhmm, with no colon) and %G-W%V-%u for the week-based form. The week-based year can differ from the calendar year near 1 January (the ISO week 53 of 2026 contains the last days of the year and days of 2027), which is why %G and %Y are different codes.
Reading ISO text needs more care than writing it. The default layouts of as.POSIXct() have no T, so "2026-06-30T09:30:05" matches only as a date and the time is lost without any warning, as the script shows. Give a format ("%Y-%m-%dT%H:%M:%S", or "%FT%T") or tryFormats. A trailing Z is just a literal character in the format, so it does not set the zone: pass tz = "UTC" as well. A numeric offset is read with %z, which accepts +hhmm or -hhmm (values from -1400 to +1400); +02:00 with a colon does not match and the result is NA, so remove the colon first, as the script does with sub() (see Strings and Regular Expressions). The help page says %z on input makes most sense with tz = "UTC": the result is the instant converted to UTC.
ISOdate() and ISOdatetime() build a POSIXct from numeric year, month, day, hour, minute and second. ISOdate() defaults to 12:00 and to the time zone "GMT", which R treats as UTC, and an impossible combination such as 30 February gives NA (R documentation: ISOdatetime).
x <- as.POSIXct("2026-06-30 09:30:05", tz = "UTC")
# ISO 8601 output.
format(x, "%F")
#> [1] "2026-06-30"
format(x, "%T")
#> [1] "09:30:05"
format(x, "%FT%T%z")
#> [1] "2026-06-30T09:30:05+0000"
format(x, "%Y-%m-%dT%H:%M:%S%z")
#> [1] "2026-06-30T09:30:05+0000"
format(x, "%Y-%m-%dT%H:%M:%SZ")
#> [1] "2026-06-30T09:30:05Z"
format(as.Date("2026-06-30"), "%G-W%V-%u")
#> [1] "2026-W27-2"
format(as.Date("2027-01-01"), "%Y-%m-%d is in %G-W%V")
#> [1] "2027-01-01 is in 2026-W53"
# The standard input layouts have no "T": the time is lost without a warning.
as.POSIXct("2026-06-30T09:30:05", tz = "UTC")
#> [1] "2026-06-30 UTC"
as.POSIXct("2026-06-30T09:30:05", format = "%Y-%m-%dT%H:%M:%S", tz = "UTC")
#> [1] "2026-06-30 09:30:05 UTC"
as.POSIXct("2026-06-30T09:30:05", format = "%FT%T", tz = "UTC")
#> [1] "2026-06-30 09:30:05 UTC"
as.POSIXct(
"2026-06-30T09:30:05",
tryFormats = "%Y-%m-%dT%H:%M:%OS",
tz = "UTC"
)
#> [1] "2026-06-30 09:30:05 UTC"
# A literal Z, and a numeric offset without a colon.
as.POSIXct(
"2026-06-30T09:30:05Z",
format = "%Y-%m-%dT%H:%M:%SZ",
tz = "UTC"
)
#> [1] "2026-06-30 09:30:05 UTC"
as.POSIXct(
"2026-06-30T09:30:05+0200",
format = "%Y-%m-%dT%H:%M:%S%z",
tz = "UTC"
)
#> [1] "2026-06-30 07:30:05 UTC"
# With a colon in the offset, %z does not match: the result is NA.
as.POSIXct(
"2026-06-30T09:30:05+02:00",
format = "%Y-%m-%dT%H:%M:%S%z",
tz = "UTC"
)
#> [1] NA
# Remove the colon first.
iso <- "2026-06-30T09:30:05+02:00"
no_colon <- sub("([+-][0-9]{2}):([0-9]{2})$", "\\1\\2", iso)
no_colon
#> [1] "2026-06-30T09:30:05+0200"
as.POSIXct(no_colon, format = "%Y-%m-%dT%H:%M:%S%z", tz = "UTC")
#> [1] "2026-06-30 07:30:05 UTC"
# ISOdate() and ISOdatetime() build times from numeric parts.
ISOdate(2026, 6, 30)
#> [1] "2026-06-30 12:00:00 GMT"
ISOdatetime(2026, 6, 30, 9, 30, 5, tz = "UTC")
#> [1] "2026-06-30 09:30:05 UTC"
ISOdate(2026, 2, 30)
#> [1] NA
class(ISOdate(2026, 6, 30))
#> [1] "POSIXct" "POSIXt"
Source: R documentation: ISOdatetime
Fractional seconds
A POSIXct can hold fractions of a second, and a POSIXlt holds them in sec with better accuracy; the help page says that conversion between the two may not be exact but is better than a microsecond (R documentation: DateTimeClasses). Fractions are shown only when asked for:
-
%OSnin a format (nfrom 0 to 6) prints exactlyndecimals, truncating and not rounding;%OSalone uses up todigitsdecimals, and only one%OSper format string is handled on output. On input,%OSreads fractions and%Sdoes not. -
format(x, digits = n)has the same effect, andoptions(digits.secs = n)sets the default of thedigitsargument for bothprint()andformat(). A format that contains%OSntakes precedence overdigits. -
Since R 4.5.0 the
print()method for date-times has its owndigitsargument, soprint(x, digits = 3)shows milliseconds without changing the option; another 4.5.0 entry madeformat(dtime, digits = , format = )more consistent for fractional seconds (R NEWS: Changes in R 4.5.0).
x <- as.POSIXct("2026-06-30 09:30:05.123456", tz = "UTC")
# Fractions are stored but not shown by default.
x
#> [1] "2026-06-30 09:30:05 UTC"
format(x, "%OS3")
#> [1] "05.123"
format(x, "%H:%M:%OS3")
#> [1] "09:30:05.123"
format(x, digits = 3)
#> [1] "2026-06-30 09:30:05.123"
# The print method accepts digits since R 4.5.0.
print(x, digits = 3)
#> [1] "2026-06-30 09:30:05.123 UTC"
as.POSIXlt(x)$sec
#> [1] 5.123456
# options(digits.secs) changes what print() and format() show.
old <- options(digits.secs = 3)
x
#> [1] "2026-06-30 09:30:05.123 UTC"
format(x)
#> [1] "2026-06-30 09:30:05.123"
options(old)
x
#> [1] "2026-06-30 09:30:05 UTC"
# %OSn truncates, it does not round.
y <- as.POSIXct("2026-06-30 09:30:05.9996", tz = "UTC")
format(y, "%OS3")
#> [1] "05.999"
format(y, "%OS2")
#> [1] "05.99"
# Fractional seconds can be read with %OS (but not with %S).
strptime("09:30:05.250", "%H:%M:%OS", tz = "UTC")$sec
#> [1] 5.25
strptime("09:30:05.250", "%H:%M:%S", tz = "UTC")$sec
#> [1] 5
Source: R documentation: options
The current date and time
Sys.Date() returns the current day as a Date, and Sys.time() returns the current instant as a POSIXct that on almost all systems has sub-second accuracy (R documentation: Sys.time). date() returns the current time as a character string in a fixed format (R documentation: date). Their value changes on every call, so a result that depends on them cannot be compared with a previous run, and this page does not print them. Code that needs the current time reads it once, at the edge of the program, and passes it down as an argument (the shelf scenario does the same with shelf_today). Timing code is a different job: Sys.time() differences are not the right tool for it, see Performance, Profiling and Benchmarking.
# Sys.Date() and Sys.time() read the clock, so a printed result would
# change on every run. Only their classes are shown on this page.
class(Sys.Date())
#> [1] "Date"
class(Sys.time())
#> [1] "POSIXct" "POSIXt"
class(date())
#> [1] "character"
# A function that takes "today" as an argument can be tested and re-run.
days_out <- function(loan_date, return_date, today = Sys.Date()) {
end <- return_date
end[is.na(end)] <- today
end - loan_date
}
loan <- as.Date("2026-06-09")
days_out(loan, as.Date(NA), today = as.Date("2026-06-30"))
#> Time difference of 21 days
days_out(loan, as.Date("2026-06-20"))
#> Time difference of 11 days
Source: R documentation: Sys.time
Arithmetic and difftime
Dates and date-times support a deliberately small set of arithmetic operations. The help page Ops.Date and the one for DateTimeClasses say that you can add or subtract a number (days for Date, seconds for POSIXct) or a difftime, that subtracting two objects gives a difftime, that comparisons work, and that adding two dates or times makes no sense (R documentation: Ops.Date). The errors for the rest, such as binary + is not defined for "Date" objects, are listed in Common errors and warnings.
The difftime class (R documentation: difftime) stores a number and the unit it is expressed in:
-
d2 - d1on twoDatevalues is adifftimein"days". On twoPOSIXctvalues the unit is chosen automatically: the largest of"secs","mins","hours"or"days"in which all the absolute differences are greater than one."weeks"is never chosen automatically; passunits = "weeks"todifftime(). -
difftime(time1, time2, units = )makes the unit explicit.as.numeric(dt, units = "days")returns the number in the unit you ask for and is the safe way to compare with a number, because the unit ofd2 - d1for date-times depends on the data. -
units(dt)reads the unit andunits(dt) <- "weeks"changes it, scaling the number. -
There is no
"months"or"years": they have no fixed length. The help page says to useseq.Date()orseq.POSIXt()to create intervals of months, quarters or years, and the error text for the attempt is in the errors section. -
sum(),mean(),diff()and arithmetic with a number keep the unit. Since R 4.4.0diff()of dates and date-times accepts aunitsargument, and since R 4.5.0difftimehas asummary()method and aformat()optionwith.units(R NEWS: Changes in R 4.4.0 and 4.5.0).
The Ops.Date page says that character vectors in a comparison are converted with as.Date(), which is why a date compares with ISO text; subtracting text is an error.
d1 <- as.Date("2026-06-09")
d2 <- as.Date("2026-06-30")
# Adding a number adds days; subtracting two dates gives a difftime.
d1 + 21
#> [1] "2026-06-30"
d2 - 7
#> [1] "2026-06-23"
dt <- d2 - d1
dt
#> Time difference of 21 days
class(dt)
#> [1] "difftime"
units(dt)
#> [1] "days"
typeof(dt)
#> [1] "double"
as.numeric(dt)
#> [1] 21
# Other units: as.numeric() and difftime() take a units argument.
as.numeric(dt, units = "weeks")
#> [1] 3
as.numeric(dt, units = "hours")
#> [1] 504
difftime(d2, d1, units = "weeks")
#> Time difference of 3 weeks
difftime(d2, d1)
#> Time difference of 21 days
format(dt)
#> [1] "21 days"
format(dt, with.units = FALSE)
#> [1] "21"
# Changing units scales the value.
units(dt) <- "weeks"
dt
#> Time difference of 3 weeks
# A difftime can be built from a number, and added to a date-time.
as.difftime(90, units = "mins")
#> Time difference of 90 mins
t1 <- as.POSIXct("2026-06-30 09:30:00", tz = "UTC")
t1 + 3600
#> [1] "2026-06-30 10:30:00 UTC"
t1 + as.difftime(2, units = "hours")
#> [1] "2026-06-30 11:30:00 UTC"
t1 - as.POSIXct("2026-06-30 08:00:00", tz = "UTC")
#> Time difference of 1.5 hours
t1 - as.POSIXct("2026-06-28 08:00:00", tz = "UTC")
#> Time difference of 2.0625 days
# Several differences: sum(), mean() and diff() keep the units.
lengths_min <- as.difftime(c(30, 45), units = "mins")
sum(lengths_min)
#> Time difference of 75 mins
mean(lengths_min)
#> Time difference of 37.5 mins
diff(c(d1, d2, as.Date("2026-07-15")))
#> Time differences in days
#> [1] 21 15
diff(c(d1, d2, as.Date("2026-07-15")), units = "weeks")
#> Time differences in weeks
#> [1] 3.000000 2.142857
# Dates compare with each other and with ISO text.
d1 < d2
#> [1] TRUE
d1 > "2026-01-01"
#> [1] TRUE
d1 == "2026-06-09"
#> [1] TRUE
Source: R documentation: difftime
Overdue loans
The shelf scenario defines a returned loan as overdue when return_date - loan_date > loan_days, and a loan still out as overdue when shelf_today - loan_date > loan_days. Both are the same expression once the missing return dates are replaced by "today". The script computes the lengths, notes the data error in row 12 (a loan returned three days before it was loaned, which shows as a negative difftime), and wraps the rule in a function that takes today as an argument so that it never reads the clock. The comparison lent > loan_days compares the number of days with the integer 21, since the unit of a Date difference is always days. The second comparison states the unit with as.numeric(lent, units = "days"), which stays correct if the same code is later used with date-times. The counts are 25 returned loans longer than 21 days, 7 loans out for longer than 21 days, and 32 together. The loan with the missing date (row 20) has no answer and stays NA, which sum(na.rm = TRUE) skips. See Missing and Special Values.
source("scenario.R")
# How long each loan lasted: a difftime with one value per row.
lent <- loans$return_date - loans$loan_date
class(lent)
#> [1] "difftime"
units(lent)
#> [1] "days"
head(lent)
#> Time differences in days
#> [1] 21 10 1 26 26 17
range(lent, na.rm = TRUE)
#> Time differences in days
#> [1] -3 37
# Returned loans that took longer than loan_days; NA stays NA.
late <- lent > loan_days
sum(late, na.rm = TRUE)
#> [1] 25
# The robust form states the unit instead of relying on units(lent).
late_days <- as.numeric(lent, units = "days") > loan_days
all(late == late_days, na.rm = TRUE)
#> [1] TRUE
# One loan was returned before it was loaned (a data error).
which(lent < 0)
#> [1] 12
loans[which(lent < 0), c("loan_id", "loan_date", "return_date")]
#> loan_id loan_date return_date
#> 12 L012 2025-04-23 2025-04-20
# Loans still out are overdue when shelf_today is too far from loan_date.
still_out <- is.na(loans$return_date)
out_days <- shelf_today - loans$loan_date[still_out]
out_days
#> Time differences in days
#> [1] 84 81 71 65 58 44 33
sum(out_days > loan_days)
#> [1] 7
# One function for both cases; today is an argument.
is_overdue <- function(loan_date, return_date, today, days = 21L) {
end <- return_date
end[is.na(end)] <- today
end - loan_date > days
}
overdue <- is_overdue(loans$loan_date, loans$return_date, shelf_today)
sum(overdue, na.rm = TRUE)
#> [1] 32
loans$loan_id[which(overdue)][1:6]
#> [1] "L004" "L005" "L007" "L009" "L011" "L015"
# Row 20 has no loan date, so nothing can be said about it.
overdue[20]
#> [1] NA
Source: R documentation: difftime
Sequences, periods and cut
seq() on dates dispatches to seq.Date(), and on date-times to seq.POSIXt(). The by argument is a number (days for Date, seconds for POSIXct), a difftime, or a character string with one of "day", "week", "month", "quarter" or "year" (for date-times also "sec", "min", "hour" and "DSTday"), optionally preceded by a positive or negative integer and a space, or followed by s, and abbreviated to any unambiguous prefix (R documentation: seq.Date). Two of to, from and length.out (or along.with) must be given with by. R 4.5.0 made from optional when enough other arguments are supplied, and made seq(from, to) default to one day (R NEWS: Changes in R 4.5.0).
"month" first advances the month without changing the day and, if that is not a valid day, counts forward into the next month: January 31 plus a month is "February 31", which is March 3 (the seq.POSIXt help page says so and points to the examples). The script shows the rollover and two ways to avoid it: build month ends as "the day before the first of the next month", and add a month through POSIXlt fields. Base R has no month arithmetic with +: d + months(1) fails because months() extracts the month name from a date (see the error text on the script). lubridate has a period class for that; see lubridate in one paragraph.
# seq() by day, week, month, quarter or year; "by" may carry a count.
seq(as.Date("2026-01-01"), by = "month", length.out = 6)
#> [1] "2026-01-01" "2026-02-01" "2026-03-01" "2026-04-01" "2026-05-01"
#> [6] "2026-06-01"
seq(as.Date("2026-01-01"), as.Date("2026-12-31"), by = "quarter")
#> [1] "2026-01-01" "2026-04-01" "2026-07-01" "2026-10-01"
seq(as.Date("2026-01-01"), by = "2 weeks", length.out = 3)
#> [1] "2026-01-01" "2026-01-15" "2026-01-29"
seq(as.Date("2026-06-30"), by = "-1 week", length.out = 4)
#> [1] "2026-06-30" "2026-06-23" "2026-06-16" "2026-06-09"
seq(as.Date("2026-01-01"), as.Date("2026-01-10"), length.out = 4)
#> [1] "2026-01-01" "2026-01-04" "2026-01-07" "2026-01-10"
# Since R 4.5.0 "from" may be missing, and "by" defaults to one day.
seq(to = as.Date("2026-06-30"), by = "day", length.out = 3)
#> [1] "2026-06-28" "2026-06-29" "2026-06-30"
seq(as.Date("2026-01-01"), as.Date("2026-01-04"))
#> [1] "2026-01-01" "2026-01-02" "2026-01-03" "2026-01-04"
# Months have different lengths, so seq() from the 31st rolls over.
seq(as.Date("2026-01-31"), by = "month", length.out = 4)
#> [1] "2026-01-31" "2026-03-03" "2026-03-31" "2026-05-01"
# Month ends: the day before the first of each following month.
firsts <- seq(as.Date("2026-02-01"), by = "month", length.out = 4)
firsts - 1
#> [1] "2026-01-31" "2026-02-28" "2026-03-31" "2026-04-30"
# The first of the month of any date, and "one month later".
d <- as.Date("2026-01-31")
as.Date(format(d, "%Y-%m-01"))
#> [1] "2026-01-01"
lt <- as.POSIXlt(d)
lt$mon <- lt$mon + 1L
as.Date(lt)
#> [1] "2026-03-03"
# Base R has no months(1) to add to a date.
tryCatch(d + months(1), error = \(e) conditionMessage(e))
#> [1] "no applicable method for 'months' applied to an object of class \"c('double', 'numeric')\""
# Wrong arguments give errors with fixed texts.
tryCatch(
seq(as.Date("2026-01-01"), by = "fortnight", length.out = 3),
error = \(e) conditionMessage(e)
)
#> [1] "invalid string for 'by'"
tryCatch(
seq(as.Date("2026-01-01"), by = "month"),
error = \(e) conditionMessage(e)
)
#> [1] "given 'by', exactly two of 'to', 'from' and 'length.out' / 'along.with' must be specified"
tryCatch(
seq(as.Date("2026-01-01"), as.Date("2026-03-01"), by = "-1 month"),
error = \(e) conditionMessage(e)
)
#> [1] "wrong sign in 'by' argument"
Source: R documentation: seq.Date
cut() on dates (cut.Date) and date-times (cut.POSIXt) turns them into a factor of periods, which is how to count per week, month, quarter or year. The labels are the first day of each period; breaks can be an interval specification ("day", "week", "month", "quarter", "year", optionally preceded by an integer; for date-times also "sec", "min", "hour" and "DSTday"), a number of intervals, or a vector of cut points. Weeks start on Monday unless start.on.monday = FALSE. Unlike numeric cut(), the default is right = FALSE, so each interval includes its start. A quarter starts on 1 January, 1 April, 1 July or 1 October. Values outside the breaks and NA become NA (R documentation: cut.POSIXt). The levels cover the whole range, so a period with no observation is a level with count zero, which is what a monthly time series needs (see the time-series section). The result is a factor, so Factors applies to it, for example as.Date(cut(d, "month")) converts the labels back to dates.
source("scenario.R")
# cut() on dates makes a factor of periods labelled by their first day.
d <- as.Date(c("2026-06-01", "2026-06-07", "2026-06-08", "2026-06-14"))
cut(d, "week")
#> [1] 2026-06-01 2026-06-01 2026-06-08 2026-06-08
#> Levels: 2026-06-01 2026-06-08
cut(d, "week", start.on.monday = FALSE)
#> [1] 2026-05-31 2026-06-07 2026-06-07 2026-06-14
#> Levels: 2026-05-31 2026-06-07 2026-06-14
cut(as.Date(c("2026-01-15", "2026-05-07", "2026-08-01")), "quarter")
#> [1] 2026-01-01 2026-04-01 2026-07-01
#> Levels: 2026-01-01 2026-04-01 2026-07-01
# Empty periods between the first and the last date are kept as levels.
table(cut(as.Date(c("2026-01-15", "2026-04-02")), "month"))
#>
#> 2026-01-01 2026-02-01 2026-03-01 2026-04-01
#> 1 0 0 1
# Loans per quarter and per month.
per_quarter <- table(cut(loans$loan_date, "quarter"))
per_quarter
#>
#> 2025-01-01 2025-04-01 2025-07-01 2025-10-01 2026-01-01 2026-04-01
#> 9 8 12 13 10 7
per_month <- table(cut(loans$loan_date, "month"))
head(per_month, 4)
#>
#> 2025-01-01 2025-02-01 2025-03-01 2025-04-01
#> 4 2 3 3
length(per_month)
#> [1] 17
sum(per_month)
#> [1] 59
# Labels and codes.
cut(d, "week", labels = c("first", "second"))
#> [1] first first second second
#> Levels: first second
cut(d, "week", labels = FALSE)
#> [1] 1 1 2 2
# A sequence of breaks works as for numbers; right = FALSE by default.
breaks <- as.Date(c("2025-01-01", "2025-07-01", "2026-01-01", "2026-07-01"))
table(cut(loans$loan_date, breaks))
#>
#> 2025-01-01 2025-07-01 2026-01-01
#> 17 25 17
halves <- c("H1 2025", "H2 2025", "H1 2026")
table(cut(loans$loan_date, breaks, labels = halves))
#>
#> H1 2025 H2 2025 H1 2026
#> 17 25 17
# Intervals that do not apply to dates are errors.
tryCatch(cut(d, "hour"), error = \(e) conditionMessage(e))
#> [1] "invalid specification of 'breaks'"
Source: R documentation: cut.POSIXt
trunc() and round() on date-times take a units argument: one of "secs", "mins", "hours", "days", "months" or "years", which can be abbreviated, and the result is a POSIXlt (convert it back with as.POSIXct() for storage). The help page says time zones matter only for days or more, when midnight in the current zone is used. For Date, trunc() has the same units and so gives the first of the month or the year, while round() has no units argument (its ... carries digits) and rounds the number of days, which is why passing a unit name to it fails with the message shown (R documentation: round.POSIXt).
x <- as.POSIXct("2026-06-30 09:47:31", tz = "UTC")
trunc(x, "days")
#> [1] "2026-06-30 UTC"
trunc(x, "hours")
#> [1] "2026-06-30 09:00:00 UTC"
round(x, "hours")
#> [1] "2026-06-30 10:00:00 UTC"
round(x, "mins")
#> [1] "2026-06-30 09:48:00 UTC"
trunc(x, "months")
#> [1] "2026-06-01 UTC"
trunc(x, "years")
#> [1] "2026-01-01 UTC"
# The result is a POSIXlt; convert it back for storage.
class(trunc(x, "days"))
#> [1] "POSIXlt" "POSIXt"
class(as.POSIXct(trunc(x, "days")))
#> [1] "POSIXct" "POSIXt"
# Dates: trunc() has units, round() only rounds the number of days.
d <- as.Date("2026-06-30")
trunc(d, "months")
#> [1] "2026-06-01"
trunc(d, "years")
#> [1] "2026-01-01"
round(d + 0.6)
#> [1] "2026-07-01"
tryCatch(round(d, "months"), error = \(e) conditionMessage(e))
#> [1] "non-numeric argument to mathematical function"
Source: R documentation: round.POSIXt
Summaries and counting
The usual vector tools work on dates because a Date is a classed number: range(), min(), max(), sort(), rev(), order(), diff(), which.max(), median() and mean() keep the class where it makes sense (a diff() is a difftime). Counting per period is a table() over a text key made with format(), over a cut() factor, or over a POSIXlt field. Numeric keys come from as.integer(format(d, "%Y")) or as.POSIXlt(d)$year + 1900L. Do not extract text and compare it as dates, and do not sort text: "2026-06-09" sorts correctly only because ISO text happens to sort like dates.
The weekday names come from the locale and are alphabetical in a table, so the script builds the levels of a factor from a known Monday to get calendar order (see Factors). For statistical summaries of the lengths of loans see Descriptive Statistics and Tests, and for grouping a data frame by period see Data Frames and The tidyverse and dplyr.
source("scenario.R")
d <- loans$loan_date
range(d, na.rm = TRUE)
#> [1] "2025-01-14" "2026-05-28"
diff(range(d, na.rm = TRUE))
#> Time difference of 499 days
head(sort(d, decreasing = TRUE), 3)
#> [1] "2026-05-28" "2026-05-17" "2026-05-03"
rev(head(sort(d), 3))
#> [1] "2025-01-21" "2025-01-16" "2025-01-14"
d[which.max(d)]
#> [1] "2026-05-28"
loans$loan_id[which.max(d)]
#> [1] "L060"
median(d, na.rm = TRUE)
#> [1] "2025-10-02"
# Gaps between consecutive loans, in days.
gaps <- diff(sort(d))
head(gaps)
#> Time differences in days
#> [1] 2 5 5 24 9 11
max(gaps)
#> Time difference of 28 days
# Counting per period with format().
table(format(d, "%Y"))
#>
#> 2025 2026
#> 42 17
head(table(format(d, "%Y-%m")), 4)
#>
#> 2025-01 2025-02 2025-03 2025-04
#> 4 2 3 3
as.integer(format(d, "%Y"))[1:4]
#> [1] 2025 2025 2025 2025
# Weekdays in calendar order, from a known Monday.
monday <- as.Date("2026-06-01")
days <- weekdays(monday + 0:6)
table(factor(weekdays(d), levels = days))
#>
#> Monday Tuesday Wednesday Thursday Friday Saturday Sunday
#> 9 15 7 10 6 5 7
# Longest loan and who borrowed it.
lent <- loans$return_date - loans$loan_date
loans[which.max(lent), c("loan_id", "member", "loan_date", "return_date")]
#> loan_id member loan_date return_date
#> 15 L015 Ana Ruiz 2025-06-04 2025-07-11
Source: R documentation: Dates
as.character() and the format warning
as.character() is the function that every other function calls to turn a value into text, and R 4.3.0 changed what it does for dates and times. The NEWS entry says that as.character(<POSIXt>) now behaves more in line with the methods for atomic vectors such as numbers and is no longer influenced by options(), the same for as.character(<Date>), and gives the method arguments digits and OutDec with defaults that do not depend on options(): Use of as.character(*, format = .) now warns. (R NEWS: Changes in R 4.3.0). The consequences, all in the script:
-
as.character(x, format = "%Y")still returns the full ISO text and warns with the messageas.character(td, ..) no longer obeys a 'format' argument; use format(td, ..) ?. Older code that usedas.character()to format dates returns unformatted text now. The same warning appears for aDate: the warning is raised by thePOSIXtmethod, to which theDatemethod passes the call. Useformat(x, "%Y"). -
A second positional argument is now
digits, soas.character(x, "%Y")is an error,'digits' must be numeric, integer valued. -
as.character()converts element by element, so a vector with a midnight and a non-midnight time has a text without a time for the first and with a time for the second, whereasformat()uses one format for the whole vector. That is also howpaste()converts a date-time. -
as.character()ignoresoptions(digits.secs); useformat(x, digits = ), which obeys it (see fractional seconds).
x <- as.POSIXct("2026-06-30 09:30:00", tz = "UTC")
# as.character() gives the ISO text; it ignores options() since R 4.3.0.
as.character(x)
#> [1] "2026-06-30 09:30:00"
as.character(as.Date("2026-06-30"))
#> [1] "2026-06-30"
# Elements are converted one by one: a midnight loses its time.
midnight <- as.POSIXct("2026-06-30 00:00:00", tz = "UTC")
as.character(c(midnight, x))
#> [1] "2026-06-30" "2026-06-30 09:30:00"
format(c(midnight, x))
#> [1] "2026-06-30 00:00:00" "2026-06-30 09:30:00"
paste("Loan at", x)
#> [1] "Loan at 2026-06-30 09:30:00"
# A format argument is not obeyed, and R warns.
tryCatch(
as.character(x, format = "%Y"),
warning = \(w) conditionMessage(w)
)
#> [1] "as.character(td, ..) no longer obeys a 'format' argument; use format(td, ..) ?"
withCallingHandlers(
as.character(x, format = "%Y"),
warning = \(w) invokeRestart("muffleWarning")
)
#> [1] "2026-06-30 09:30:00"
tryCatch(
as.character(as.Date("2026-06-30"), format = "%d/%m/%Y"),
warning = \(w) conditionMessage(w)
)
#> [1] "as.character(td, ..) no longer obeys a 'format' argument; use format(td, ..) ?"
# A second positional argument is digits, not format.
tryCatch(as.character(x, "%Y"), error = \(e) conditionMessage(e))
#> [1] "'digits' must be numeric, integer valued"
# format() takes the format.
format(x, "%Y")
#> [1] "2026"
format(as.Date("2026-06-30"), "%d/%m/%Y")
#> [1] "30/06/2026"
Time-series objects: ts
The ts class of the stats package represents numeric observations sampled at equally spaced points in time. The language definition describes the underlying attribute: tsp holds the parameters of a time series (start, end and frequency) and is used to handle series with periodic substructure such as monthly or quarterly data (The R Language Definition: Time series attributes). ts(data, start, frequency) creates one, where start is a single number or a pair c(unit, position) (for example c(2025, 1) is January 2025 for frequency = 12) and frequency is the number of observations per unit of time (R documentation: ts).
For the shelf, loans per month is a regular series: one count per calendar month, from January 2025 (the first loan) to May 2026 (the last), without the loan that has the impossible date (59 of the 60 loans are counted). cut(loans$loan_date, "month") gives the months from the first to the last date, including empty ones, so table() of it is exactly 17 counts in order. A series is irregular when observations are not equally spaced, and then a data frame with a Date column is the right structure, not a ts. The accessors frequency(), deltat(), start(), end(), time() and cycle() read the attribute (R documentation: time), and tsp() gets or sets all three (R documentation: tsp). Printing uses a calendar layout for frequencies 4 and 12 (quarters and months) and a plain listing otherwise; print(x, calendar = TRUE) forces the calendar layout.
source("scenario.R")
# Loans per month as a monthly time series starting in January 2025.
per_month <- table(cut(loans$loan_date, "month"))
monthly <- ts(as.vector(per_month), start = c(2025, 1), frequency = 12)
monthly
#> Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
#> 2025 4 2 3 3 2 3 7 1 4 7 3 3
#> 2026 4 3 3 4 3
class(monthly)
#> [1] "ts"
unclass(monthly)
#> [1] 4 2 3 3 2 3 7 1 4 7 3 3 4 3 3 4 3
#> attr(,"tsp")
#> [1] 2025.000 2026.333 12.000
# tsp is c(start, end, frequency) in units of "years".
tsp(monthly)
#> [1] 2025.000 2026.333 12.000
attributes(monthly)
#> $tsp
#> [1] 2025.000 2026.333 12.000
#>
#> $class
#> [1] "ts"
#>
# Accessors.
frequency(monthly)
#> [1] 12
deltat(monthly)
#> [1] 0.08333333
start(monthly)
#> [1] 2025 1
end(monthly)
#> [1] 2026 5
time(monthly)
#> Jan Feb Mar Apr May Jun Jul Aug
#> 2025 2025.000 2025.083 2025.167 2025.250 2025.333 2025.417 2025.500 2025.583
#> 2026 2026.000 2026.083 2026.167 2026.250 2026.333
#> Sep Oct Nov Dec
#> 2025 2025.667 2025.750 2025.833 2025.917
#> 2026
cycle(monthly)
#> Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
#> 2025 1 2 3 4 5 6 7 8 9 10 11 12
#> 2026 1 2 3 4 5
# Other frequencies: quarterly data, and a cycle of 7 (days in weeks).
ts(1:10, start = c(2025, 2), frequency = 4)
#> Qtr1 Qtr2 Qtr3 Qtr4
#> 2025 1 2 3
#> 2026 4 5 6 7
#> 2027 8 9 10
ts(1:6, start = c(1, 3), frequency = 7)
#> Time Series:
#> Start = c(1, 3)
#> End = c(2, 1)
#> Frequency = 7
#> [1] 1 2 3 4 5 6
# The time axis as dates: the first of each month.
months_start <- seq(as.Date("2025-01-01"), by = "month", length.out = 17L)
head(data.frame(month = months_start, loans = as.vector(monthly)), 3)
#> month loans
#> 1 2025-01-01 4
#> 2 2025-02-01 2
#> 3 2025-03-01 3
Source: R documentation: ts
window(x, start, end) extracts a time range and keeps the class (R documentation: window). It is the right way to subset a ts by time, because x[i] is a plain subset: the ts help page says that subsetting the first dimension returns a vector or matrix. Since R 4.5.0 head() and tail() have methods for ts that keep the time attributes; the 4.5.0 NEWS lists them as new head() and tail() methods for class "ts" proposed by Spencer Graves, and the examples of the ts help page use them (R NEWS: Changes in R 4.5.0). Before R 4.5.0 there was no such method, so head() applied ordinary subsetting, which drops the time attributes; older answers that say to use window() for that are only half right today.
Other operations align the time axes: lag(x, k) shifts the time base, diff(x, lag = ) takes differences (with lag = 12 for a year-on-year change of a monthly series), and arithmetic between two series is done on the common time range, with the warning non-intersecting series when there is none. aggregate(x, nfrequency = , FUN = ) changes the frequency: months to quarters or to years; periods that are not complete at the end are dropped, so 2026 does not appear in the yearly total below (R documentation: aggregate). For plotting a series, see Base Graphics and Devices. Models for time series, and the other packages for them, are covered by the CRAN task view Time Series that the ts help page points to (CRAN Task View: Time Series).
source("scenario.R")
per_month <- table(cut(loans$loan_date, "month"))
monthly <- ts(as.vector(per_month), start = c(2025, 1), frequency = 12)
# window() cuts by time and keeps the ts attributes.
window(monthly, start = c(2025, 7), end = c(2025, 12))
#> Jul Aug Sep Oct Nov Dec
#> 2025 7 1 4 7 3 3
window(monthly, start = c(2026, 1))
#> Jan Feb Mar Apr May
#> 2026 4 3 3 4 3
# head() and tail() have ts methods since R 4.5.0 and keep the time axis.
head(monthly, 3)
#> Jan Feb Mar
#> 2025 4 2 3
tail(monthly, 3)
#> Mar Apr May
#> 2026 3 4 3
tail(monthly, -14)
#> Mar Apr May
#> 2026 3 4 3
tsp(tail(monthly, 3))
#> [1] 2026.167 2026.333 12.000
# [ drops the time attributes.
monthly[1:3]
#> [1] 4 2 3
class(monthly[1:3])
#> [1] "integer"
# lag(), diff() and arithmetic work on the time axis.
lag(monthly, -1)
#> Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
#> 2025 4 2 3 3 2 3 7 1 4 7 3
#> 2026 3 4 3 3 4 3
diff(monthly)
#> Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
#> 2025 -2 1 0 -1 1 4 -6 3 3 -4 0
#> 2026 1 -1 0 1 -1
diff(monthly, lag = 12)
#> Jan Feb Mar Apr May
#> 2026 0 1 0 1 1
monthly + lag(monthly, -1)
#> Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
#> 2025 6 5 6 5 5 10 8 5 11 10 6
#> 2026 7 7 6 7 7
# aggregate() changes the frequency; incomplete periods at the end are
# dropped.
aggregate(monthly, nfrequency = 4, FUN = sum)
#> Qtr1 Qtr2 Qtr3 Qtr4
#> 2025 9 8 12 13
#> 2026 10
aggregate(monthly, nfrequency = 1, FUN = sum)
#> Time Series:
#> Start = 2025
#> End = 2025
#> Frequency = 1
#> [1] 42
# Printing as a calendar, and summaries.
print(monthly, calendar = TRUE)
#> Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
#> 2025 4 2 3 3 2 3 7 1 4 7 3 3
#> 2026 4 3 3 4 3
summary(monthly)
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 1.000 3.000 3.000 3.471 4.000 7.000
sum(monthly)
#> [1] 59
# Errors and warnings with fixed texts.
tryCatch(
window(monthly, start = c(2026, 6), end = c(2025, 1)),
error = \(e) conditionMessage(e)
)
#> [1] "'start' cannot be after 'end'"
tryCatch(ts(numeric(0)), error = \(e) conditionMessage(e))
#> [1] "'ts' object must have one or more observations"
tryCatch(
ts(1:3, start = 2020) + ts(1:3, start = 2030),
warning = \(w) conditionMessage(w)
)
#> [1] "non-intersecting series"
Source: R documentation: window
lubridate in one paragraph
lubridate 1.9.5, an add-on package from the tidyverse family, is a convenience layer over the same classes: its parsers (ymd(), dmy(), ymd_hms()) guess the separators and return ordinary Date or POSIXct objects, so the results work with every function on this page; period() and %m+% add calendar months with the end-of-month rule that base R lacks; floor_date(), ceiling_date() and round_date() round to a unit; interval() and %--% build intervals; with_tz() keeps the instant and changes the display zone (the same as format(tz = ) or setting tzone) and force_tz() keeps the clock reading and changes the instant (the same as re-parsing the text with the new tz). Nothing on this page requires it, but it shortens the common cases; attaching it masks date(), intersect(), setdiff() and union() (R prints that list when the package is attached), which is why the script calls everything with lubridate::. See The tidyverse and dplyr for the surrounding ecosystem.
| Task | Base R | lubridate 1.9.5 |
|---|---|---|
parse an ISO date or date-time |
|
|
parse another day-month-year order |
|
|
first day of the month |
|
|
add one calendar month |
|
|
show an instant in another zone |
|
|
reuse the clock reading in another zone |
|
|
difference in days |
|
|
# lubridate is called with its namespace so that nothing is attached
# (attaching it masks date(), intersect(), setdiff() and union()).
packageVersion("lubridate")
#> [1] ‘1.9.5’
d <- lubridate::ymd("2026-06-30")
class(d)
#> [1] "Date"
lubridate::ymd(c("2026-06-30", "2026/07/01", "20260702"))
#> [1] "2026-06-30" "2026-07-01" "2026-07-02"
lubridate::dmy("30/06/2026")
#> [1] "2026-06-30"
lubridate::ymd_hms("2026-06-30 09:30:00", tz = "UTC")
#> [1] "2026-06-30 09:30:00 UTC"
# Months arithmetic that base R lacks.
jan31 <- lubridate::ymd("2026-01-31")
one_month <- lubridate::period(1, "month")
lubridate::`%m+%`(jan31, one_month)
#> [1] "2026-02-28"
jan31 + one_month
#> [1] NA
lubridate::floor_date(d, "month")
#> [1] "2026-06-01"
lubridate::floor_date(d, "quarter")
#> [1] "2026-04-01"
# Intervals, and zones: with_tz() keeps the instant, force_tz() the clock.
iv <- lubridate::interval(lubridate::ymd("2026-06-09"), d)
iv
#> [1] 2026-06-09 UTC--2026-06-30 UTC
iv / lubridate::ddays(1)
#> [1] 21
x <- lubridate::ymd_hms("2026-06-30 09:30:00", tz = "UTC")
lubridate::with_tz(x, "Europe/Madrid")
#> [1] "2026-06-30 11:30:00 CEST"
lubridate::force_tz(x, "Europe/Madrid")
#> [1] "2026-06-30 09:30:00 CEST"
# Parse failures are NA with a warning.
tryCatch(lubridate::ymd("2026-02-30"), warning = \(w) conditionMessage(w))
#> [1] " 1 failed to parse."
Note that jan31 + one_month is NA, as "February 31" does not exist, whereas %m+% rolls back to the last valid day of the month (2026-02-28). A parse that fails is NA with a warning whose text is a blank followed by 1 failed to parse..
Common errors and warnings
| Message | Cause | Fix |
|---|---|---|
|
|
give |
|
adding two dates |
add a number or a |
|
multiplying a date |
operate on |
|
|
|
|
subtracting text from a date |
convert with |
|
|
use |
|
|
use |
|
a positive |
use |
|
|
add |
|
|
use an interval valid for dates ( |
|
|
use |
|
|
|
|
|
|
|
|
|
|
comparing date-times with different |
convert with |
|
|
check the time range |
|
|
supply data |
|
arithmetic on two |
align with |
Source: R documentation: as.Date
d1 <- as.Date("2026-06-09")
d2 <- as.Date("2026-06-30")
x <- as.POSIXct("2026-06-30 09:30:00", tz = "UTC")
msg <- function(expr) {
tryCatch(expr, error = \(e) conditionMessage(e))
}
# Dates cannot be added or multiplied, and text is not a number.
msg(d1 + d2)
#> [1] "binary + is not defined for \"Date\" objects"
msg(d1 * 2)
#> [1] "* not defined for \"Date\" objects"
msg(x + x)
#> [1] "binary '+' is not defined for \"POSIXt\" objects"
msg(d1 - "2026-01-01")
#> [1] "non-numeric argument to binary operator"
msg(-d1)
#> [1] "unary - is not defined for \"Date\" objects"
# A text that is not a date at all.
msg(as.Date("hello"))
#> [1] "character string is not in a standard unambiguous format"
msg(as.POSIXct("hello", tz = "UTC"))
#> [1] "character string is not in a standard unambiguous format"
msg(as.Date(c("hello", "2026-06-30")))
#> [1] "character string is not in a standard unambiguous format"
msg(as.Date(TRUE))
#> [1] "do not know how to convert 'TRUE' to class “Date”"
# difftime has no months or years (the length of a month varies).
msg(difftime(d2, d1, units = "months"))
#> [1] "'arg' should be one of “auto”, “secs”, “mins”, “hours”, “days”, “weeks”"
# Wrong units and arguments of the rounding functions.
msg(trunc(x, "fortnights"))
#> [1] "'arg' should be one of “secs”, “mins”, “hours”, “days”, “months”, “years”"
msg(round(d1, "days"))
#> [1] "non-numeric argument to mathematical function"
# Integer and double dates differ for identical() but not for all.equal().
identical(as.Date(20630L), as.Date(20630))
#> [1] FALSE
all.equal(as.Date(20630L), as.Date(20630))
#> [1] TRUE
Source: R documentation: Ops.Date
Some mistakes produce no message at all, and are worth a test in any date-handling code: the wrong layout of an ISO-looking text ("30/06/2026"), an ISO text with a T that loses its time, a time such as 25:00 that falls back to a date, %I without %p (read as the morning), a time in a daylight-saving gap that moves by an hour, and as.Date() of a late-evening instant that gives tomorrow’s date in UTC.
Version notes
| Version | Change |
|---|---|
R 4.2.0 |
|
R 4.3.0 |
|
R 4.4.0 |
|
R 4.5.0 |
|
R 4.6.1 |
|
Where older tutorials differ
Many pages on the web predate these changes, or describe the classes as they were in R 3.x. When one of them disagrees with this page, the table says why.
| Older tutorial | Current practice | Why | Version |
|---|---|---|---|
|
|
|
R 4.3.0 |
|
|
the argument is no longer obeyed, a warning is given |
R 4.3.0 |
|
|
|
R 4.3.0 |
a |
11 fields, always present |
objects created since R 4.3.0 have all components and are validated |
R 4.3.0 |
|
|
methods for class |
R 4.5.0 |
|
|
the defaults and argument rules were relaxed |
R 4.5.0 |
|
|
|
R 4.4.0 |
"use |
store |
|
always |
|
|
|
always |
"dates are limited to 1902-2037 (32-bit time)" |
dates and times cover billions of years on 64-bit platforms |
only 32-bit systems are limited to that range; not every operating system converts times outside it correctly |
always |
|
|
months have no fixed length; base R has no month arithmetic |
always |
For the full list of changes by release see What’s New and Migrating Older Code.
Where to go next
-
Numbers and Arithmetic: floating-point behaviour behind fractional days and seconds.
-
Strings and Regular Expressions: cleaning text before it is parsed.
-
Encodings, Locales and Internationalization: the
LC_TIMElocale behind month and weekday names. -
Data Frames and Factors: date columns and period factors.
-
Attributes and Names:
class,tzone,unitsandtspare attributes. -
Descriptive Statistics and Tests and The tidyverse and dplyr.
References
-
strptime (including format, strftime and as.character methods)
-
The R Language Definition: Time series attributes
-
R NEWS: Changes in R 4.2.0, 4.3.0, 4.4.0, 4.5.0 and 4.6.1