Code
library(tidyverse)The intro chapter raised a question and then we moved on: how does the computer actually store a date? For most of the book the answer does not matter, because for the data I give you read_csv hands you a proper date column and a tsibble keeps it visible. But two kinds of data force you to look under the hood, and a lot of environmental work is one or the other. Data from a logger arrives stamped with a clock time, and clocks carry time zones and daylight saving. Output from a climate model arrives on a calendar that may not have leap days at all, or may give every month thirty days. Both will trip you up if you assume R’s defaults match your data. This aside is the reference for when that happens.
Date Class: A Count of DaysR’s Date class is simpler than it looks. A date is stored as a single number: the count of days since January 1, 1970. Strip the class away and you can see it.
One day after the epoch is the integer 1. That is all a Date is. Because it is a count, arithmetic just works, and subtracting two dates gives you a difftime measured in days.
A Date has no notion of time of day, and no time zone. For daily, monthly, or annual data, which is most of what we handle, that is the right tool and you never need anything fancier.
POSIXct and POSIXltThe moment your data are finer than a day, hourly logger readings, say, you need to store a time of day, and that pulls in a time zone whether you want one or not. R has two classes for this.
POSIXct is the workhorse. It stores an instant as a single number, the count of seconds since the same 1970 epoch, with a time zone recorded as an attribute.
[1] 1577836800
attr(,"tzone")
[1] "UTC"
[1] "UTC"
POSIXlt is the other form. Instead of one number it keeps a list of broken-out pieces: year, month, day, hour, minute, second, and a few more. It is handy when you want to pull a component out by hand, but it is bulky, so most code converts to it briefly and works in POSIXct otherwise.
$year
[1] 120
$mon
[1] 0
$mday
[1] 1
$hour
[1] 13
Note the quirks that list exposes: the year is counted from 1900, and the month from zero. You will not touch those directly often, but it explains some otherwise baffling off-by-one bugs.
Date and POSIXct use 1970 as an origin and POSIXlt uses 1900. Why?
They are software conventions. The 1970 epoch comes from Unix, which was taking shape at Bell Labs at the turn of the 1970s, and its designers picked the start of that decade as a convenient recent origin to count from. R sits on the same C library and POSIX standard, so it inherited the choice. Nothing is special about the date itself except that a great deal of software agreed to start counting there.
The 1900 in POSIXlt has the same heritage. POSIXlt is a near-copy of C’s broken-out time structure, struct tm, which stores the year as a count from 1900. It was a small space saving back when bytes were scarce, since a year in the 1900s then fit in a single byte. The month counts from zero for the same lineage reason.
So why do the two classes disagree at all? Because R is wrapping two separate C constructs that were never meant to match. POSIXct is C’s time_t, a single count of seconds for fast arithmetic. POSIXlt is C’s struct tm, the broken-out form built for reading off components. Those are different jobs and standardized at different times. The names spell it out once you know the C: ct is calendar time, the seconds count, and lt is local time, the broken-out form C’s localtime() hands back. None of this matters until it produces an off-by-one bug you cannot explain, and now you can.
Does any of this matter? Not for day-to-day use. But it is a good reminder that a choice made for coding convenience gets enshrined and long outlives its reason. The byte someone saved in the 1970s is still shaping the year field you read today. Worth keeping in mind the next time you reach for a convenient shortcut of your own.
The usual way a date enters R is as text (a character), and the job is to convert that text into a Date or a POSIXct. The lubridate package makes this painless: you name the order of the parts and it does the rest. The function is named for the order, so ymd is year-month-day, mdy is the American month-day-year, and the _hms suffix adds a time.
[1] "2024-03-09"
[1] "2024-03-09"
[1] "2024-03-09 14:30:00 UTC"
When you read a CSV, read_csv quietly runs a parser like this for you, which is why the DATE column in our weather data came in already typed as a Date. It only needs help when the format is unusual or ambiguous.
The one parsing horror worth warning you about is Excel. Like an awkward teenager, Excel is convinced that everything is a date. Type a gene name like SEPT2 and it swoons into the 2nd of September; geneticists eventually renamed a batch of genes just to stop it. When it does store a date on purpose, it uses a serial number counting days from its own origin, and that origin is not 1970. On Windows it counts from December 30, 1899 (which is infuriating). So a date pasted out of a spreadsheet can arrive as a bare integer, and you have to tell R the origin to recover it.
If a column of dates comes out of Excel looking like five-digit numbers, this is why, and now you know the fix.
Here is where logger data gets tricky. A POSIXct is a fixed instant, but how it prints depends on the time zone you view it through. The same instant shown in two zones is two different clock readings.
There are two operations people constantly confuse, and the lubridate functions name them clearly. with_tz keeps the instant fixed and changes the clock you read it on. force_tz keeps the clock reading fixed and changes which instant it refers to. One is a conversion; the other is a relabeling, and they give different answers.
[1] "2026-06-01 19:00:00 UTC"
[1] "2026-06-01 12:00:00 UTC"
Noon in Los Angeles is 19:00 UTC. with_tz reports that. force_tz instead insists the clock said 12:00 UTC, which is a different moment by seven hours. If a logger writes local time but you tell R it was UTC, you have done a silent force_tz and every timestamp is now wrong by the offset.
Daylight saving makes it worse, because a local day is not always twenty-four hours long. On the spring-forward day the clocks skip an hour, and on the fall-back day they repeat one. Watch what the length of a day does.
Time difference of 23 hours
Time difference of 1.041667 days
Twenty-three hours, then twenty-five. There is also an hour in spring that does not exist at all: 2:30 AM never happened on March 14, 2021 in Los Angeles, so asking R for it produces something that is not what you typed.
The same two dates in UTC are exactly twenty-four hours apart, every time, because UTC has no daylight saving. That is the practical advice hiding in all of this: do your storage and arithmetic in UTC, and convert to local time only for display. A logger deployment that records UTC will save you a weekend of debugging that one recording local time will cost you.
Climate data brings the other hard case. R’s Date follows the ordinary Gregorian calendar, leap days and all, and it gets them right. The handy test is leap_year.
[1] TRUE FALSE FALSE FALSE
[1] "2020-02-29"
[1] "2021-03-01"
Add a day to February 28 and you land on the 29th in a leap year and on March 1 otherwise. R handles that for you, so ordinary date arithmetic across a leap day is safe.
Leap seconds are a different story. A body called the International Earth Rotation Service occasionally declares an extra second, slipped in to keep our clocks aligned with the Earth’s slightly irregular spin, and R, following the POSIX standard, ignores them in its counting. It even carries the table of them, but does not use it for arithmetic.
For environmental work that rounding error is harmless. But I’m mentioning it so you are not surprised when a timestamp is off by a handful of seconds from an atomic-clock reference (like from a GPS if you dig deep into the settings!).
It’s not rounding error with a model calendar though. Many climate models do not run on the Gregorian calendar at all. A common choice is the “no-leap” or 365-day calendar, where February 29 never exists in any year, and another is the 360-day calendar, where every month has thirty days so a year is a tidy 360. These are deliberate simplifications that make the model’s bookkeeping easier. The danger is that the time axis of such a file is usually written as something like “days since 2000-01-01,” and if you feed that through R’s Date, R counts in actual Gregorian days and your dates drift further off with every passing model year. That drift has driven me bonkers on more than one occasion.
The fix is to use a tool that understands the model’s calendar. The PCICt package handles 360-day and 365-day calendars directly, and if you are reading netCDF output the CFtime and ncdf4 packages decode the calendar from the file’s metadata for you.
If you work with model output, do not let R’s Date near the time axis until you have confirmed the calendar. It is the single most common way a climate time series gets silently mangled.
Back in the main flow, all of this is mostly handled for you. A tsibble’s index can be any of these types, and it picks up whatever your data already are. Daily data indexed by a Date, coarser data by tsibble’s own yearmonth, yearquarter, or yearweek, and sub-daily logger data by a POSIXct carrying its time zone. The old ts class, by contrast, knows none of this; it has only a start and a frequency, which is why it cannot represent a proper calendar and why we keep our data in tsibbles instead.
So the working rule for the book is short. Let read_csv and a tsibble handle dates for you, reach for lubridate when you have to parse something odd, store sub-daily data in UTC, and check the calendar before you trust any climate-model time axis. Come back to this aside when the data fight you, which, if you work with loggers or model output, they eventually will.