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

Date

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

POSIXct

a double: the number of seconds since 1970-01-01 00:00:00 UTC, with an optional tzone attribute

only how it is displayed (the instant is the same everywhere)

instants: timestamps, columns of a data frame

POSIXlt

a list of 11 fields (sec, min, hour, mday, mon, year, wday, yday, isdst, zone, gmtoff) with a tzone attribute

part of the value

reading or changing one field (the month, the day of the week) for a moment

difftime

a double with a units attribute (secs, mins, hours, days, weeks)

none

the result of subtracting two dates or times

ts

a numeric vector or matrix with a tsp attribute (start, end, frequency)

none

regularly spaced observations: loans per month

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"

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: %Y takes 30, the month is 06, the day is 20 and the trailing 26 is ignored. The result is the date 0030-06-20, with no error and no warning. Give the format whenever 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 not NA as 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 just NA. With a format, the impossible date is NA wherever 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"

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

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"

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

sec

seconds, possibly with a fraction

0 to 61 (leap seconds), double

min

minutes

0 to 59

hour

hours

0 to 23

mday

day of the month

1 to 31

mon

months after the first of the year

0 to 11, so January is 0

year

years since 1900

2026 is 126

wday

day of the week, starting on Sunday

0 to 6

yday

day of the year

0 to 365, so 1 January is 0

isdst

daylight-saving flag

positive in force, 0 not, negative unknown

zone

abbreviation of the time zone in force

"CEST", "UTC", or "" if unknown

gmtoff

offset from UTC in seconds, positive east of Greenwich

NA if unknown

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, and lt[["min"]] is an error with the text shown on the script. Use lt$min or lt[, "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 mday and converting with as.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 set isdst to -1 (unknown), as the daylight-saving script does, because a stale flag moves the result by an hour.

  • The fields wday and yday are 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

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

as.POSIXct("2026-06-30 09:30:00", tz = "Europe/Madrid")

the zone in which the text is written

chooses the instant

format(x, tz = "Europe/Madrid", usetz = TRUE)

the zone in which to show an existing instant

chooses the clock reading; the instant is unchanged

attr(x, "tzone") <- "Europe/Madrid"

the zone in which to show the object from now on

same as above, stored in the object

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"

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 unless tz is given (R documentation: as.Date). Going the other way, a Date converted to a date-time is midnight UTC (R documentation: Dates), and since R 4.3.0 as.POSIXlt(<Date>) and as.POSIXct(<Date>) apply a tz argument, which changes only the display: midnight UTC is 02:00 in Madrid in June.

  • Comparing times whose tzone attributes 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 the Continent/City and Etc/... 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, POSIXlt objects carry three values (the zone name and the standard and daylight abbreviations), which is why attr(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")

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:30 on 2026-03-29 in Madrid, which does not exist, became 03:30 CEST; as.POSIXlt() of the same text keeps the fields 02:30 with an unknown isdst, so it prints without a zone.

  • 02:30 on 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

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

%Y

year with century (on input only years 0 to 9999)

2026

%y

year without century; on input 00-68 mean 20xx and 69-99 mean 19xx

26

%C

century

20

%m

month as 01-12

06

%d

day of the month as 01-31

05

%e

day of the month as 1-31, a single digit padded with a blank

" 5"

%H

hour, 00-23

14

%k

hour 0-23, a single digit padded with a blank (not in the standards)

14

%I

hour 01-12, ambiguous without %p

02

%l

hour 1-12 padded with a blank (not in the standards)

2

%p

AM or PM in the locale; the input match is case-insensitive

PM

%M

minute 00-59

05

%S

second as an integer (00-61 allows for two leap seconds); does not read or write fractions

09

%OS3

R-specific: seconds truncated to exactly 3 decimals; %OS alone reads fractions on input

09.000

%z

signed offset from UTC as hours and minutes (-0800 is 8 hours behind); accepted on input

+0200

%Z

time zone abbreviation; output only

CEST

%j

day of the year, 001-366

156

%U

week of the year, Sunday as the first day of the week (US convention)

22

%W

week of the year, Monday as the first day of the week (UK convention)

22

%u

weekday 1-7, Monday is 1

5

%w

weekday 0-6, Sunday is 0

5

%a, %A

abbreviated and full weekday name in the locale (case-insensitive on input)

Fri, Friday

%b, %B, %h

abbreviated and full month name in the locale; %h is the same as %b

Jun, June, Jun

%F

equivalent to %Y-%m-%d, the ISO 8601 date

2026-06-05

%T

equivalent to %H:%M:%S

14:05:09

%R

equivalent to %H:%M

14:05

%D

%m/%d/%y, the US form

06/05/26

%G, %g, %V

ISO 8601 week-based year, its last two digits, and ISO week number 01-53; accepted but ignored on input

2026, 26, 23

%s

seconds since 1970-01-01 UTC; output only, not in the standards

1780664709

%%

a literal %

%

# 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"

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"

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"

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:

  • %OSn in a format (n from 0 to 6) prints exactly n decimals, truncating and not rounding; %OS alone uses up to digits decimals, and only one %OS per format string is handled on output. On input, %OS reads fractions and %S does not.

  • format(x, digits = n) has the same effect, and options(digits.secs = n) sets the default of the digits argument for both print() and format(). A format that contains %OSn takes precedence over digits.

  • Since R 4.5.0 the print() method for date-times has its own digits argument, so print(x, digits = 3) shows milliseconds without changing the option; another 4.5.0 entry made format(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

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

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 - d1 on two Date values is a difftime in "days". On two POSIXct values 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; pass units = "weeks" to difftime().

  • 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 of d2 - d1 for date-times depends on the data.

  • units(dt) reads the unit and units(dt) <- "weeks" changes it, scaling the number.

  • There is no "months" or "years": they have no fixed length. The help page says to use seq.Date() or seq.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.0 diff() of dates and date-times accepts a units argument, and since R 4.5.0 difftime has a summary() method and a format() option with.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

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

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"

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'"

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"

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

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 message as.character(td, ..) no longer obeys a 'format' argument; use format(td, ..) ?. Older code that used as.character() to format dates returns unformatted text now. The same warning appears for a Date: the warning is raised by the POSIXt method, to which the Date method passes the call. Use format(x, "%Y").

  • A second positional argument is now digits, so as.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, whereas format() uses one format for the whole vector. That is also how paste() converts a date-time.

  • as.character() ignores options(digits.secs); use format(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

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"

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

as.Date(x), as.POSIXct(x, tz = )

ymd(), ymd_hms()

parse another day-month-year order

as.Date(x, "%d/%m/%Y")

dmy(), mdy(), parse_date_time()

first day of the month

trunc(d, "months")

floor_date(d, "month")

add one calendar month

seq(d, by = "month", length.out = 2)[2], or POSIXlt$mon

d %m+% period(1, "month")

show an instant in another zone

format(x, tz = )

with_tz(x, tz)

reuse the clock reading in another zone

as.POSIXct(format(x), tz = )

force_tz(x, tz)

difference in days

as.numeric(d2 - d1, units = "days")

interval(d1, d2) / ddays(1)

# 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

character string is not in a standard unambiguous format

as.Date() or as.POSIXct() of text that matches no default layout (also of a vector whose first non-NA element is an impossible date)

give format, or optional = TRUE

binary + is not defined for "Date" objects

adding two dates

add a number or a difftime to one date

* not defined for "Date" objects

multiplying a date

operate on as.numeric(d) and convert back, if you really need it

unary - is not defined for "Date" objects

-d

d - d2 for a difference

non-numeric argument to binary operator

subtracting text from a date

convert with as.Date() first

'arg' should be one of “auto”, “secs”, “mins”, “hours”, “days”, “weeks”

difftime(units = "months")

use "days" or "weeks"; for months use seq() or lubridate

invalid string for 'by'

seq(d, by = "fortnight")

use "2 weeks"

wrong sign in 'by' argument

a positive by with to before from, or the reverse

use "-1 month" when going backwards

given 'by', exactly two of 'to', 'from' and 'length.out' / 'along.with' must be specified

seq(d, by = "month") without an end

add to or length.out

invalid specification of 'breaks'

cut(d, "hour") on a Date

use an interval valid for dates ("day", "week", "month", "quarter", "year")

non-numeric argument to mathematical function

round(d, "months") on a Date

use trunc(d, "months")

No element named "min" found in x, did you mean x[, "min"] instead?

lt[["min"]] on a POSIXlt

lt$min or lt[, "min"]

as.character(td, ..) no longer obeys a 'format' argument; use format(td, ..) ? (warning)

as.character(x, format = )

format(x, format)

'digits' must be numeric, integer valued

as.character(x, "%Y")

format(x, "%Y")

'tzone' attributes are inconsistent (warning)

comparing date-times with different tzone

convert with attr<- or compare as.numeric()

'start' cannot be after 'end'

window() with start after end

check the time range

'ts' object must have one or more observations

ts(numeric(0))

supply data

non-intersecting series (warning)

arithmetic on two ts with no common time range

align with window() or ts.union()

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

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

trunc(<Date>) supports units = "months" and "years"; format() and print() of non-finite Date and POSIXt values show Inf or NaN instead of NA

R 4.3.0

as.Date(<numeric>), as.POSIXct(<numeric>) and as.POSIXlt(<numeric>) work without origin; POSIXlt objects always have 11 components and are validated on conversion; experimental balancePOSIXlt(); as.character(<POSIXt>) and as.character(<Date>) ignore options() and warn when given format; as.POSIXlt(<Date>) and as.POSIXct(<Date>) apply tz; a formatted string longer than 2048 bytes is an error (it was truncated silently at 255 bytes before); TZ overrides the Windows zone mapping

R 4.4.0

diff() for Date, POSIXct and POSIXlt accepts a units argument

R 4.5.0

head() and tail() methods for ts; the print() method for date-times has a digits argument; seq.Date() and seq.POSIXt() work with from missing and seq.Date(from, to) defaults to one day; difftime gets a summary() method and the with.units option of format(); format(dtime, digits = , format = ) is consistent for fractional seconds

R 4.6.1

as.matrix(<POSIXlt>) again produces a numeric matrix; the c() and subassignment methods for POSIXlt work correctly more often

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

as.Date(19904, origin = "1970-01-01") is required

as.Date(19904)

origin is optional for numbers; it is still needed for other epochs (spreadsheets, SAS)

R 4.3.0

as.character(x, format = "%d/%m/%Y") formats a date-time

format(x, "%d/%m/%Y")

the argument is no longer obeyed, a warning is given

R 4.3.0

as.character(Sys.time()) honours options(digits.secs)

format(x, digits = 3)

as.character() no longer depends on options()

R 4.3.0

a POSIXlt has nine fields (isdst last), and zone and gmtoff may be missing

11 fields, always present

objects created since R 4.3.0 have all components and are validated

R 4.3.0

head(ts_object) drops the time axis, use window()

head() and tail() keep the ts time axis

methods for class "ts" were added; before them ordinary subsetting applied

R 4.5.0

seq(d1, d2) needs by, and from is mandatory

seq(d1, d2) and seq(to = d2, by = "day", length.out = 3) work

the defaults and argument rules were relaxed

R 4.5.0

diff(dates) is always in days

diff(d, units = "weeks")

units is passed to the difftime method

R 4.4.0

"use POSIXlt for data frames" or "convert with strptime() first"

store Date or POSIXct

POSIXlt is a list; data.frame() converts it to POSIXct, assignment does not

always

as.Date(Sys.time()) is "today"

as.Date(x, tz = "your zone")

as.Date() of a date-time uses UTC unless tz is given

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

difftime(units = "months") or d + months(1)

seq(d, by = "month"), POSIXlt$mon, or lubridate

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