1

I'm working on a Spring Boot application which queries a PostgreSQL database.

I have a timestamptz column. The end users of my application would like to view these timestamps in their own timezone, with the offset included. For example,

Given a row containing 2023-08-10T12:00:00.000+00

When the user queries from time zone 'US/Eastern'

Then the output they should see is 2023-08-10T08:00:00.000-04

Sounds simple enough, but I'm having trouble figuring out how to handle this given that there will be multiple concurrent requests coming from different time zones.

If I execute this in a SQL console, it gives me the exact result I want,

SET LOCAL TIME ZONE 'US/Eastern';
SELECT to_char('2023-08-10T12:00:00.000+00'::TIMESTAMPTZ, 'YYYY-MM-DD"T"HH24:MI:SS.MSOF');

2023-08-10T08:00:00.000000-04

However, I do not know how to use this from within my Spring Boot application.

  1. According to my benchmarks, if I try to do this in code, it comes with a ~30% performance penalty, which is significant in this case, so I would like to do it entirely in SQL if possible.
  2. I cannot execute two separate statements separated by ; in a PreparedStatement
  3. I do not want to change the default time zone for the database, since there may be multiple concurrent requests.
  4. For the same reason, I do not want to change the default time zone for the JVM.

So, an answer to either of these questions would be a huge help:

  • In jdbc, is there a way to set the time zone for only a single query, without affecting other concurrent queries?
  • In PostgreSQL, is there a way I can express the above SQL in a single line (no semi colons)?
Jake
  • 321
  • 3
  • 12

3 Answers3

1

The function you posted depends on the timezone setting of the current session, and shows an incorrect offset if that doesn't happen to be UTC.

Also, it's more convoluted than it needs to be. Try instead:

CREATE OR REPLACE FUNCTION display_at_zone(tstz timestamptz, display_zone text)
  RETURNS text
  LANGUAGE plpgsql STABLE PARALLEL SAFE AS   -- not IMMUTABLE!
$func$
DECLARE
   at_zone timestamp := tstz AT TIME ZONE display_zone;
BEGIN
   RETURN to_char(at_zone, 'YYYY-MM-DD"T"HH24:MI:SS.MS')
       || to_char(at_zone - tstz AT TIME ZONE 'UTC', 'HH:MI');   -- FIX!
END
$func$;

This simpler equivalent is about twice as fast as your function in my tests.

Note how I work with at_zone - tstz AT TIME ZONE 'UTC' instead of at_zone - tstz to get the correct offset, independent of the current timezone setting.

Make it STABLE, not IMMUTABLE, because to_char() is only STABLE. So it can be "inlined" in an outer query. See:

Make it PARALLEL SAFE (because it is) so it does not stand in the way of parallelization. See:

Don't use more assignments than needed in PL/pgSQL, where those are comparatively expensive.

Or shorter, yet, with plain SQL:

CREATE OR REPLACE FUNCTION display_at_zone_sql(tstz timestamptz, display_zone text)
  RETURNS text
  LANGUAGE sql STABLE PARALLEL SAFE AS
$func$
SELECT to_char(tstz AT TIME ZONE display_zone, 'YYYY-MM-DD"T"HH24:MI:SS.MS')
    || to_char(tstz AT TIME ZONE display_zone
             - tstz AT TIME ZONE 'UTC', 'HH:MI');
$func$;

PL/pgSQL and SQL functions have slightly different performance characteristics. This SQL function can be inlined and is noticeably faster when nested in a bigger query. See:

Maybe, a plain cast to text is good enough? Differs slightly from your format:

CREATE OR REPLACE FUNCTION display_at_zone_sql3(tstz timestamptz, display_zone text)
  RETURNS text
  LANGUAGE sql STABLE PARALLEL SAFE AS
$func$
SELECT (tstz AT TIME ZONE display_zone)::text       -- does the job?
    || to_char(tstz AT TIME ZONE display_zone
             - tstz AT TIME ZONE 'UTC', 'HH:MI');
$func$;

Faster, yet.

Erwin Brandstetter
  • 605,456
  • 145
  • 1,078
  • 1,228
0

Just use the clause at time zone to convert server's timestamptz to user's time zone, here an example:

select '2023-08-10T12:00:00.000+00' original_timestamptz
,'2023-08-10T12:00:00.000+00' at time zone 'US/Eastern' converted,
split_part(to_char('2023-08-10T12:00:00.000+00' at time zone 'US/Eastern', 
'YYYY-MM-DD"T"HH24:MI:SS.MSOF'),'+',1)||
(select split_part(utc_offset::text,':',1) from pg_timezone_names where name = 'US/Eastern')
with_offset;

It's necessary to "manually" add the offset to the desired result since the expression timestamp with time zone AT TIME ZONE zone returns timestamp without time zone as can be seen in official documentation

Pepe N O
  • 1,678
  • 1
  • 7
  • 11
  • This is very close, but it has a major bug: if the timezone is DST-aware, and it is currently DST in that timezone, and the date being converted is not during DST, then the offset will be incorrect by one hour. There are similar bugs for all other less known non-linearities that make up the rules of a timezone. – Jake Aug 10 '23 at 18:26
  • This is an example based on your question, in the postgres system view pg_timezone_names your could query if that time zone has DST i.e. `select name, abbrev, utc_offset, is_dst from pg_catalog.pg_timezone_names where name = 'US/Eastern';` [also check](https://www.enterprisedb.com/postgres-tutorials/postgres-time-zone-explained) – Pepe N O Aug 10 '23 at 18:37
  • By the way PostgreSQL automatically takes into account DST, but i don't know if historically also (for not current datimes) probably don't. – Pepe N O Aug 10 '23 at 18:48
  • Sorry for any confusion, but the one date in the example is just an arbitrary date, not the only date that I want to solve for. In general it could be any date from the last 100 years. But even so, this exact code with this exact example will start giving a different, incorrect result in about 3 months time from now. It's not accurate to say that it "automatically takes into account DST" in this case because it is applying the DST-adjusted offset *as it is now*, not *as it was on the date we are converting*. To see this, replace `2023-08-10` with for example `2023-01-01` – Jake Aug 10 '23 at 18:53
  • It does apply the correct DST offset for historical dates, for example `SELECT '1980-01-01T12:00' AT TIME ZONE 'US/Eastern', '1980-08-01T12:00' AT TIME ZONE 'US/Eastern';`. The problem is that `pg_timezone_names.utc_offset` only contains the current offset. Now sure how I can turn that into a workable solution for any arbitrary date. – Jake Aug 10 '23 at 19:00
  • If your historical timestamps are in UTC you could get the offset applied by a simple subtraction e.g. `SELECT '1980-01-01T12:00'-'1980-01-01T12:00' AT TIME ZONE 'US/Eastern' utc_offset, '1980-08-01T12:00' - '1980-08-01T12:00' AT TIME ZONE 'US/Eastern' utc_offset;` however the result doesn't looks accurate. – Pepe N O Aug 10 '23 at 20:41
  • However this way `select '1980-01-01T12:00' at time zone 'UTC' - '1980-01-01T12:00' AT TIME ZONE 'US/Eastern' utc_offset, '1980-08-01T12:00' at time zone 'UTC' - '1980-08-01T12:00' AT TIME ZONE 'US/Eastern' utc_offset;` looks more accurate. – Pepe N O Aug 10 '23 at 21:02
  • Interesting. We're definitely getting closer, but this opens up another question: how to convert an interval into a UTC offset? I'll post another answer with what I've come up with after combing all of your ideas into a function. – Jake Aug 10 '23 at 21:59
0

Turns out this is quite complicated. Here is a function I've come up with which meets the original requirements by combining all of Pepe's ideas from his answer and comments:

CREATE OR REPLACE FUNCTION display_at_zone(
    tstz         TIMESTAMPTZ,
    display_zone VARCHAR
) RETURNS VARCHAR
AS
$$
DECLARE
    at_zone        TIMESTAMP;
    utc_offset     INTERVAL;
    hours          INT;
    minutes        INT;
    string_no_zone VARCHAR;
BEGIN

    at_zone = tstz AT TIME ZONE display_zone;
    utc_offset = at_zone - tstz;
    hours = EXTRACT(HOURS FROM utc_offset);
    minutes = ABS(EXTRACT(MINUTES FROM utc_offset));
    string_no_zone = TO_CHAR(at_zone, 'YYYY-MM-DD"T"HH24:MI:SS.MS');

    RETURN string_no_zone || TO_CHAR(hours, 'SG00:') || TO_CHAR(minutes, 'FM00');

END
$$ IMMUTABLE LANGUAGE plpgsql;

This has (at least) two problems:

  1. Compared to doing the exact same operation using java.time, the performance is worse, not better.
  2. Does not work if the display_timezone parameter is an offset, but this can be easily worked around.

Another option is to do my original suggestion in a function, but the performance of this is even worse.

BEGIN
    PERFORM set_config('timezone', display_zone, true /* local */);
    RETURN to_char(tstz, 'YYYY-MM-DD"T"HH24:MI:SS.MSOF');
END
Jake
  • 321
  • 3
  • 12