177

I'm dealing with dates and times in Rails and Postgres and running into this issue:

The database is in UTC.

The user sets a time-zone of choice in the Rails app, but it's only to be used when getting the users local time for comparing times.

User stores a time, say March 17, 2012, 7pm. I don't want timezone conversions or the timezone to be stored. I just want that date and time saved. That way if the user changed their time zone, It would still show March 17, 2012, 7pm.

I only use the users specified time zone to get records 'before' or 'after' the current time in the users local time zone.

I'm currently using 'timestamp without time zone' but when I retrieve the records, rails (?) converts them to the time zone in the app, which I don't want.

Appointment.first.time
 => Fri, 02 Mar 2012 19:00:00 UTC +00:00 

Because the records in the database seem to come out as UTC, my hack is to take the current time, remove the time zone with 'Date.strptime(str, "%m/%d/%Y")' and then do my query with that:

.where("time >= ?", date_start)

It seems like there must be an easier way to just ignore time zones all around. Any ideas?

Erwin Brandstetter
  • 605,456
  • 145
  • 1,078
  • 1,228
99miles
  • 10,942
  • 18
  • 78
  • 123

4 Answers4

389

Postgres has two different timestamp data types:

timestamptz is the preferred type in the date/time family, literally. It has typispreferred set in pg_type, which can be relevant:

Internal storage and epoch

Internally, timestamps occupy 8 bytes of storage on disk and in RAM. It is an integer value representing the count of microseconds from the Postgres epoch, 2000-01-01 00:00:00 UTC.

Postgres also has built-in knowledge of the commonly used UNIX time counting seconds from the UNIX epoch, 1970-01-01 00:00:00 UTC, and uses that in functions to_timestamp(double precision) or EXTRACT(EPOCH FROM timestamptz).

The source code:

* Timestamps, as well as the h/m/s fields of intervals, are stored as
* int64 values with units of microseconds.  (Once upon a time they were  
* double values with units of seconds.)

And:

/* Julian-date equivalents of Day 0 in Unix and Postgres reckoning */  
#define UNIX_EPOCH_JDATE        2440588 /* == date2j(1970, 1, 1) */  
#define POSTGRES_EPOCH_JDATE    2451545 /* == date2j(2000, 1, 1) */  

The microsecond resolution translates to a maximum of 6 fractional digits for seconds.

timestamp

For timestamp no time zone is provided explicitly. Postgres ignores any time zone modifier added to input literals by mistake!

Nothing is shifted for display. With everything happening in the same time zone this is fine. For a different time zone the meaning changes, but value and display stay the same.

timestamptz

Handling of timestamptz is subtly different. The manual:

For timestamp with time zone, the internally stored value is always in UTC (Universal Coordinated Time ...)

Bold emphasis mine. The time zone itself is never stored. It is an input modifier used to compute the according UTC timestamp, which is stored. Or an output decorator, the time zone offset according to the timezone setting of the current session.

For input literals without appended offset, the timezone setting of the current session is assumed. All computations are done with UTC timestamp values. If more than one time zone may be involved, or if there can be any doubt or misunderstanding, go with timestamptz. Applies in most use cases.

Clients like psql or pgAdmin or any application communicating via libpq (like Ruby with the pg gem) are presented with the timestamp plus offset for the current time zone or according to a requested time zone (see below). It is always the same point in time, only the display format varies. Or, as the manual puts it:

All timezone-aware dates and times are stored internally in UTC. They are converted to local time in the zone specified by the TimeZone configuration parameter before being displayed to the client.

Example in psql:

db=# SELECT timestamptz '2012-03-05 20:00+03';
      timestamptz
------------------------
 2012-03-05 18:00:00+01

What happened here?
The input literal with (arbitrary) time zone offset +03 is just another way to input the UTC timestamp 2012-03-05 17:00:00. The result of the query is displayed for the current time zone setting Vienna/Austria in my test, which has an offset +01 during winter and +02 during summer time ("daylight saving time", DST). So 2012-03-05 18:00:00+01 as DST only kicks in later in the year.

Postgres forgets the input literal immediately. All it remembers is the value for the data type. Just like with a decimal number. numeric '003.4' or numeric '+3.4' - both result in the exact same internal value.

AT TIME ZONE

To interpret or represent timestamp literals according to a specific time zone, use the AT TIME ZONE construct. timestamptz is converted to timestamp and vice versa.

To get UTC 2012-03-05 17:00:00+0 as timestamptz:

SELECT timestamp '2012-03-05 17:00:00' AT TIME ZONE 'UTC'

... which is equivalent to:

SELECT timestamptz '2012-03-05 17:00:00 UTC'

To display the same point in time as EST timestamp (Eastern Standard Time):

SELECT timestamp '2012-03-05 17:00:00' AT TIME ZONE 'UTC' AT TIME ZONE 'EST'

That's right, AT TIME ZONE 'UTC' twice. The first interprets the timestamp value as (given) UTC timestamp returning the type timestamptz. The second converts timestamptz to timestamp in the given time zone 'EST' - what a wall clock displays in the time zone EST at this point in time.

Examples

SELECT ts AT TIME ZONE 'UTC'
FROM  (
   VALUES
      (1, timestamptz '2012-03-05 17:00:00+0')
    , (2, timestamptz '2012-03-05 18:00:00+1')
    , (3, timestamptz '2012-03-05 17:00:00 UTC')
    , (4, timestamp   '2012-03-05 11:00:00' AT TIME ZONE '+6') 
    , (5, timestamp   '2012-03-05 17:00:00' AT TIME ZONE 'UTC') 
    , (6, timestamp   '2012-03-05 07:00:00' AT TIME ZONE 'US/Hawaii')  -- ①
    , (7, timestamptz '2012-03-05 07:00:00 US/Hawaii')                  -- ①
    , (8, timestamp   '2012-03-05 07:00:00' AT TIME ZONE 'HST')        -- ①
    , (9, timestamp   '2012-03-05 18:00:00+1')  -- ② loaded footgun!
      ) t(id, ts);

Returns 8 (or 9) identical rows with a timestamptz column holding the same UTC timestamp 2012-03-05 17:00:00. The 9th row sort of happens to work in my time zone, but is an evil trap.

① Rows 6 - 8 with time zone name and time zone abbreviation for Hawaii time are subject to DST (daylight saving time) and might differ, though not currently. A time zone name like 'US/Hawaii' is aware of DST rules and all historic shifts automatically, while an abbreviation like HST is just a dumb code for a fixed offset. You may need to append a different abbreviation for summer / standard time. The name correctly interprets any timestamp at the given time zone. An abbreviation is cheap, but needs to be the right one for the given timestamp:

Daylight Saving Time is not among the brightest ideas humanity ever came up with.

② Row 9, marked as loaded footgun happens to work for me. For timestamp [without time zone] input, any time zone offset is ignored! Only the bare timestamp is used. The value is then coerced to timestamptz in the example to match the column type. For this step, the timezone setting of the current session is assumed, which happens to be Europe/Vienna for me and matches +1. But probably not in your case - which will result in a different value. In short: Don't cast timestamptz literals to timestamp or you lose the time zone offset.

Your questions

User stores a time, say March 17, 2012, 7pm. I don't want timezone conversions or the timezone to be stored.

Time zone itself is never stored. Use one of the methods above to enter a UTC timestamp.

I only use the users specified time zone to get records 'before' or 'after' the current time in the users local time zone.

You can use one query for all clients in different time zones.
For absolute global time:

SELECT * FROM tbl WHERE time_col > (now() AT TIME ZONE 'UTC')::time

For time according to the local clock:

SELECT * FROM tbl WHERE time_col > now()::time

Not tired of background information, yet? There is more in the manual.

Erwin Brandstetter
  • 605,456
  • 145
  • 1,078
  • 1,228
  • 2
    Minor detail, but I think timestamps are stored internally as the number of microseconds since 2000-01-01 - see [date/time datatype](http://www.postgresql.org/docs/9.2/static/datatype-datetime.html) section of the manual. My own inspections of the source seem to confirm it. Strange to use a different origin for the epoch! – harmic Nov 29 '13 at 02:57
  • 3
    @harmic As for different epoch… Actually not so strange. This [Wikipedia page](https://en.wikipedia.org/wiki/Epoch_%28reference_date%29) lists two dozen epochs used by various computer systems. While the [Unix epoch](https://en.wikipedia.org/wiki/Unix_time) is common, it is not the only one. – Basil Bourque Feb 15 '14 at 10:04
  • 4
    @ErwinBrandstetter This is a *great* answer, except for one serious flaw. As harmic commented, Postgres does *not* use Unix time. According to [the doc](http://www.postgresql.org/docs/current/interactive/datatype-datetime.html#DATATYPE-TIMEZONES): (a) The epoch is 2001-01-01 rather than Unix’ 1970-01-01, and (b) While Unix time has a resolution of whole seconds, Postgres keeps fractions of seconds. The number of fractional digits depends on the compile time option: 0 to 6 when eight-byte integer storage (default) is used, or from 0 to 10 when floating-point storage (deprecated) is used. – Basil Bourque Feb 15 '14 at 10:16
  • 2
    **CORRECTION:** On my earlier comment, I incorrectly cited the Postgres epoch as 2001. Actually it is **2000**. – Basil Bourque Feb 16 '14 at 03:35
  • Is there a way to have the `AT TIME ZONE` statement for `SELECT p.*` like queries when the timestamp column is one of the columns of the table `p`. http://stackoverflow.com/questions/39211953/sql-at-time-zone-query-wide-and-with-a-select-all-columns-tablename-exp – sçuçu Aug 29 '16 at 18:07
  • @ErwinBrandstetter, isn't epcoh 1970-01-01T00:00:00Z? From doc: "epoch date, timestamp 1970-01-01 00:00:00+00 (Unix system time zero)" epoch date, timestamp 1970-01-01 00:00:00+00 (Unix system time zero) – ALZ May 12 '20 at 16:46
  • @ALZ: There are different "epochs" floating around. I clarified above. – Erwin Brandstetter May 13 '20 at 00:00
  • @ErwinBrandstetter, sorry, but I didn't got it. In docs I see 1970. When PG will use 2000? – ALZ May 13 '20 at 10:26
  • @ALZ: Internal storage is based on the Postgres epoch 2000-01-01 00:00:00 UTC. – Erwin Brandstetter May 13 '20 at 23:16
  • how about store and transmit UTC only, display according to user's time zone ? – WestFarmer May 23 '23 at 11:19
1

If you want to deal in UTC by default:

In config/application.rb, add:

config.time_zone = 'UTC'

Then, if you store the current user timezone name is current_user.timezone you can say.

post.created_at.in_time_zone(current_user.timezone)

current_user.timezone should be a valid timezone name, otherwise you will get ArgumentError: Invalid Timezone, see full list.

Dorian
  • 22,759
  • 8
  • 120
  • 116
1

Dunno if Erwin's answer contains a solution of the problem (still it contains tons of useful info), but I have a

shorter solution:

(at least shorter for reading)

.where("created_at > ?", (YOUR_DATE_IN_THE_TIMEZONE).iso8601)

Why all the mess happens

When you try to implement something like .where("created_at > ?", YOUR_DATE_IN_THE_TIMEZONE) Rails steps in and converts the time to server time (most likely UTC) to convert your date to timestamp (timestamp without timezone format). That's why all the dancing with in_time_zone and around is senseless.

Why iso8601 works

When you call iso8601 your date is converted into string which Rails can't "brake" and has to pass to Postgres as it is.

Don't forget to upvote!

Alexander Gorg
  • 1,049
  • 16
  • 32
0

I had similar puzzle plus timestamp precision in my Angular/Typescript/Node API/PostgreSQL environment, here is complete answer and solution

Jeb50
  • 6,272
  • 6
  • 49
  • 87