From 69692f295d4575c3d713f07cc521d7d3250574b0 Mon Sep 17 00:00:00 2001
From: Alexandre Felipe <o.alexandre.felipe@gmail.com>
Date: Sun, 23 Aug 2026 20:53:01 +0100
Subject: [PATCH-v15 6/6] [SLOPE] Timezone

Add slope support to functions that use a `timestamp` computed from
a `timestamp with timezone`. Time in certain time zones might have
discontinuities, mostly due to dailight saving time (DST), but maybe
others. The `timestamp with timezone` is represented with resolution
of 1 microsecond at UTC, when converted to a `timestamp` the session
timezone offset of the timezone at the given time is computed and
applied, the offset varies with time, and the two common discontinuities
are a spring-forward when the clock advances creating effectively
skipping a range of timestamps, or a fall-back when the clock moves
backward, creating a range of ambiguous timestamps. The fall-back
discontinuities cause the timestamp to be discontinuous, but certain
parts of the timestamp might remain monotonic. The most relevant
example is the date of a timestamp, in most of the timezones, a
fall back stays within the same day.

Follows a query to the discontinuities discussed above.

    WITH RECURSIVE
    bin AS (
        -- search a intervals of duration t_step
    SELECT n.name AS tz,
            t AS lo,
            t + t_step AS hi,
            t_step
    FROM pg_timezone_names n,
        (VALUES (timestamptz '1900-01-01',
                    timestamptz '2026-01-01',
                    interval '7 days'))
            AS range(t_from, t_to, t_step),
        generate_series(t_from, t_to, t_step) AS t
    WHERE
        -- keep only those having a discontinuity
        timezone(name, t + t_step) - timezone(name, t) <> t_step

    UNION ALL
    SELECT tz,
            CASE WHEN pick_hi
                THEN mid ELSE lo END,
            CASE WHEN pick_hi
                THEN hi ELSE mid END,
            t_step / 2
    FROM bin,
            LATERAL (SELECT (lo + t_step / 2) AS mid,
                    timezone(tz, lo + t_step / 2) - timezone(tz, lo) =
                                    t_step / 2 as pick_hi
                )
    ),
    discontinuities AS (
    SELECT tz,
            hi               AS utc,
            timezone(tz, lo) AS before,
            timezone(tz, hi) AS after
    FROM bin
    WHERE hi - lo < interval '2 microseconds'
    )
    SELECT *
    FROM discontinuities
    -- Having a list of discontinuities, it is easier to respond
    -- questions such as which discontinuities caused the date to
    -- move back
    WHERE after::date < before::date

The functions supported now are 
 - date_trunc(unit text, timestamptz)
 - date_trunc(unit text, timestamptz, timezone text)
 - timezone(timestamptz)
 - timezone(timezone text, timestamptz)
 - date(timestamptz)

The timezone monotonicity is performed once when the timezone
is loaded and checked when the monotonicity of one of those
functions is requested.

---
 src/backend/utils/adt/timestamp.c           |   8 +-
 src/backend/utils/fmgr/slopesupport.c       | 166 +++++++++++++++++++-
 src/include/c.h                             |   5 +
 src/include/catalog/pg_proc.dat             |  24 ++-
 src/include/pgtime.h                        |  12 ++
 src/test/regress/expected/slope.out         | 125 +++++++++++----
 src/test/regress/expected/slope_catalog.out |  15 +-
 src/test/regress/sql/slope.sql              |  82 ++++++++--
 src/timezone/localtime.c                    |  11 ++
 src/timezone/pgtz.c                         |  96 ++++++++++-
 src/timezone/pgtz.h                         |   2 +
 11 files changed, 484 insertions(+), 62 deletions(-)

diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 9c17ba2f905..de596eefc67 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -1921,10 +1921,6 @@ timestamp2tm(Timestamp dt, int *tzp, struct pg_tm *tm, fsec_t *fsec, const char
 	Timestamp	time;
 	pg_time_t	utime;
 
-	/* Use session timezone if caller asks for default */
-	if (attimezone == NULL)
-		attimezone = session_timezone;
-
 	time = dt;
 	TMODULO(time, date, USECS_PER_DAY);
 
@@ -1970,7 +1966,9 @@ timestamp2tm(Timestamp dt, int *tzp, struct pg_tm *tm, fsec_t *fsec, const char
 	utime = (pg_time_t) dt;
 	if ((Timestamp) utime == dt)
 	{
-		struct pg_tm *tx = pg_localtime(&utime, attimezone);
+		/* Use session timezone doesn't pass one */
+		struct pg_tm *tx = pg_localtime(&utime, 
+			attimezone == NULL ? session_timezone : attimezone);
 
 		tm->tm_year = tx->tm_year + 1900;
 		tm->tm_mon = tx->tm_mon + 1;
diff --git a/src/backend/utils/fmgr/slopesupport.c b/src/backend/utils/fmgr/slopesupport.c
index d14e1f8dcb4..d24115d2a5a 100644
--- a/src/backend/utils/fmgr/slopesupport.c
+++ b/src/backend/utils/fmgr/slopesupport.c
@@ -1,9 +1,14 @@
 #include "postgres.h"
 
-#include "c.h"
+#include "catalog/pg_type.h"
 #include "nodes/supportnodes.h"
+#include "parser/scansup.h"
+#include "pgtime.h"
+#include "utils/builtins.h"
+#include "utils/datetime.h"
 #include "utils/fmgrprotos.h"
 #include "utils/numeric.h"
+#include "varatt.h"
 
 /**
  * Extended numeric sign, the usual -1, 0, 1,
@@ -358,3 +363,162 @@ divide_slope_support(PG_FUNCTION_ARGS)
 	}
 	PG_RETURN_POINTER(NULL);
 }
+
+/*
+ * Look up a timezone from a constant text argument.
+ */
+static pg_tz *
+get_const_timezone_arg(List *args, int argno)
+{
+	Node	   *tz_arg_node;
+	Const	   *tz_const;
+	text	   *zone;
+	char		tzname[TZ_STRLEN_MAX + 1];
+
+	if (args == NULL || list_length(args) <= argno)
+		return NULL;
+
+	tz_arg_node = list_nth(args, argno);
+	if (!IsA(tz_arg_node, Const))
+		return NULL;
+
+	tz_const = (Const *) tz_arg_node;
+
+	if (tz_const->constisnull || tz_const->consttype != TEXTOID)
+		return NULL;
+
+	zone = DatumGetTextPP(tz_const->constvalue);
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+	return DecodeTimezoneNameToTz(tzname);
+}
+
+/*
+ * Slope support for date(timestamptz)
+ */
+Datum
+timestamptz_date_slope_support(PG_FUNCTION_ARGS)
+{
+	Node	   *rawreq = (Node *) PG_GETARG_POINTER(0);
+
+	if (pg_timezone_is_monotonic(session_timezone, TZ_MONOTONIC_DAY))
+		return monotonic_slope_support(rawreq, 1, asc0_slope);
+	else
+		PG_RETURN_POINTER(NULL);
+}
+
+static Datum
+timestamptz_support_lookup(Node* rawreq, Datum fixed_unit, pg_tz *tzp)
+{
+	text	   *units;
+	int			val;
+	char	   *lowunits;
+	int			type;
+	TZMonotonicityBits tz_unit;
+	
+	units = DatumGetTextPP(fixed_unit);
+
+	lowunits = downcase_truncate_identifier(VARDATA_ANY(units),
+											VARSIZE_ANY_EXHDR(units),
+											false);
+
+	type = DecodeUnits(0, lowunits, &val);
+
+	if (type != UNITS)
+		PG_RETURN_POINTER(NULL);
+
+	switch (val)
+	{
+		case DTK_WEEK:
+			PG_RETURN_POINTER(NULL);
+		case DTK_MILLENNIUM:
+		case DTK_CENTURY:
+		case DTK_DECADE:
+		case DTK_YEAR:
+			tz_unit = TZ_MONOTONIC_YEAR;
+			break;
+		case DTK_QUARTER:
+		case DTK_MONTH:
+			tz_unit = TZ_MONOTONIC_MONTH;
+			break;
+		case DTK_DAY:
+			tz_unit = TZ_MONOTONIC_DAY;
+			break;
+		case DTK_HOUR:
+			tz_unit = TZ_MONOTONIC_HOUR;
+			break;
+		case DTK_MINUTE:
+			tz_unit = TZ_MONOTONIC_MINUTE;
+			break;
+		case DTK_SECOND:
+		case DTK_MILLISEC:
+		case DTK_MICROSEC:
+			tz_unit = TZ_MONOTONIC_SECOND;
+			break;
+		default:
+			PG_RETURN_POINTER(NULL);
+	}
+	if(pg_timezone_is_monotonic(tzp, tz_unit))
+		return monotonic_slope_support(rawreq, 2, asc1_slope);
+	else
+		PG_RETURN_POINTER(NULL);
+}
+
+Datum
+timestamptz_trunc_slope_support(PG_FUNCTION_ARGS)
+{
+	Node	   *rawreq = (Node *) PG_GETARG_POINTER(0);
+	List	   *args = get_arg_list(rawreq);
+	Const	   *unit_arg;
+	pg_tz	   *tzp;
+
+	if (args == NULL)
+		PG_RETURN_POINTER(NULL);
+
+	
+	unit_arg = (Const *) linitial(args);
+	if (list_length(args) > 2)
+		tzp = get_const_timezone_arg(args, 2);
+	else
+		tzp = session_timezone;
+	if (!IsA(unit_arg, Const) || unit_arg->constisnull || tzp == NULL)
+		PG_RETURN_POINTER(NULL);
+
+	return timestamptz_support_lookup(rawreq, unit_arg->constvalue, tzp);
+}
+
+/*
+ * Slope support for timezone(text, timestamptz).
+ */
+Datum
+timestamptz_zone_slope_support(PG_FUNCTION_ARGS)
+{
+	Node	   *rawreq = (Node *) PG_GETARG_POINTER(0);
+	List	   *args = get_arg_list(rawreq);
+	pg_tz	   *tzp;
+
+	if (args == NULL || list_length(args) < 2)
+		PG_RETURN_POINTER(NULL);
+
+	tzp = get_const_timezone_arg(args, 0);
+	if (tzp == NULL)
+		PG_RETURN_POINTER(NULL);
+
+	if (pg_timezone_is_monotonic(tzp, TZ_MONOTONIC_SECOND))
+		return monotonic_slope_support(rawreq, 2, asc1_slope);
+	else
+		PG_RETURN_POINTER(NULL);
+}
+
+/*
+ * Slope support for timezone(timestamptz).
+ */
+Datum
+timestamptz_at_local_slope_support(PG_FUNCTION_ARGS)
+{
+	Node	   *rawreq = (Node *) PG_GETARG_POINTER(0);
+
+	if (pg_timezone_is_monotonic(session_timezone, TZ_MONOTONIC_SECOND))
+		return monotonic_slope_support(rawreq, 1, asc0_slope);
+	else
+		PG_RETURN_POINTER(NULL);
+}
diff --git a/src/include/c.h b/src/include/c.h
index 20cfbac54e7..6328dd1a39a 100644
--- a/src/include/c.h
+++ b/src/include/c.h
@@ -1285,6 +1285,9 @@ typedef struct PGAlignedXLogBlock PGAlignedXLogBlock;
 /* msb for char */
 #define HIGHBIT					(0x80)
 #define IS_HIGHBIT_SET(ch)		((unsigned char)(ch) & HIGHBIT)
+/* ____-___ */ #define BITWISE_HI_AT(n)		(1llu << n)
+/* _____--- */ #define BITWISE_HI_RIGHT(n)		((1llu << (n + 1)) - 1)
+/* -----___ */ #define BITWISE_LO_RIGHT(n)		~BIT_MASK_HI_RIGHT(n)
 
 /*
  * Support macros for escaping strings.  escape_backslash should be true
@@ -1565,6 +1568,8 @@ typedef uint32_t char32_t;
 #endif
 #endif
 
+
+
 /* IWYU pragma: end_exports */
 
 #endif							/* C_H */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index edc7dab98de..0295ec4ea8e 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -2471,10 +2471,12 @@
   prorettype => 'timestamptz',
   proargtypes => 'float8', prosrc => 'float8_timestamptz' },
 { oid => '1159', descr => 'adjust timestamp to new time zone',
-  proname => 'timezone', prorettype => 'timestamp',
+  proname => 'timezone', prosupport => 'timestamptz_zone_slope_support',
+  prorettype => 'timestamp',
   proargtypes => 'text timestamptz', prosrc => 'timestamptz_zone' },
 { oid => '6334', descr => 'adjust timestamp to local time zone',
-  proname => 'timezone', provolatile => 's', prorettype => 'timestamp',
+  proname => 'timezone', prosupport => 'timestamptz_at_local_slope_support',
+  provolatile => 's', prorettype => 'timestamp',
   proargtypes => 'timestamptz', prosrc => 'timestamptz_at_local' },
 
 { oid => '1160', descr => 'I/O',
@@ -2552,6 +2554,7 @@
   prosrc => 'see system_functions.sql' },
 { oid => '1178', descr => 'convert timestamp with time zone to date',
   proname => 'date', provolatile => 's', prorettype => 'date',
+  prosupport => 'timestamptz_date_slope_support',
   proargtypes => 'timestamptz', prosrc => 'timestamptz_date' },
 { oid => '1181',
   descr => 'age of a transaction ID, in transactions before current transaction',
@@ -2635,10 +2638,12 @@
 { oid => '1217',
   descr => 'truncate timestamp with time zone to specified units',
   proname => 'date_trunc', provolatile => 's', prorettype => 'timestamptz',
+  prosupport => 'timestamptz_trunc_slope_support',
   proargtypes => 'text timestamptz', prosrc => 'timestamptz_trunc' },
 { oid => '1284',
   descr => 'truncate timestamp with time zone to specified units in specified time zone',
   proname => 'date_trunc', prorettype => 'timestamptz',
+  prosupport => 'timestamptz_trunc_slope_support',
   proargtypes => 'text timestamptz text', prosrc => 'timestamptz_trunc_zone' },
 { oid => '1218', descr => 'truncate interval to specified units',
   proname => 'date_trunc', prorettype => 'interval',
@@ -6735,7 +6740,8 @@
   prosrc => 'see system_functions.sql' },
 
 { oid => '2069', descr => 'adjust timestamp to new time zone',
-  proname => 'timezone', prorettype => 'timestamptz',
+  proname => 'timezone', prosupport => 'arg1_asc_slope_support',
+  prorettype => 'timestamptz',
   proargtypes => 'text timestamp', prosrc => 'timestamp_zone' },
 { oid => '2070', descr => 'adjust timestamp to new time zone',
   proname => 'timezone', prorettype => 'timestamptz',
@@ -12865,6 +12871,18 @@
 { oid => '9960', descr => 'planner support for divide slope (sign-dependent)',
   proname => 'divide_slope_support', prorettype => 'internal',
   proargtypes => 'internal', prosrc => 'divide_slope_support' },
+{ oid => '9961', descr => 'planner support date(timestamptz)',
+  proname => 'timestamptz_date_slope_support', prorettype => 'internal',
+  proargtypes => 'internal', prosrc => 'timestamptz_date_slope_support' },
+{ oid => '9962', descr => 'planner support date_trunc(text, timestamptz)',
+  proname => 'timestamptz_trunc_slope_support', prorettype => 'internal',
+  proargtypes => 'internal', prosrc => 'timestamptz_trunc_slope_support' },
+{ oid => '9963', descr => 'planner support timezone(text, timestamptz)',
+  proname => 'timestamptz_zone_slope_support', prorettype => 'internal',
+  proargtypes => 'internal', prosrc => 'timestamptz_zone_slope_support' },
+{ oid => '9964', descr => 'planner support timezone(timestamptz)',
+  proname => 'timestamptz_at_local_slope_support', prorettype => 'internal',
+  proargtypes => 'internal', prosrc => 'timestamptz_at_local_slope_support' },
 
 # AIO related functions
 { oid => '6399', descr => 'information about in-progress asynchronous IOs',
diff --git a/src/include/pgtime.h b/src/include/pgtime.h
index ba6705f71bd..6c5a397487a 100644
--- a/src/include/pgtime.h
+++ b/src/include/pgtime.h
@@ -46,6 +46,16 @@ struct pg_tm
 	const char *tm_zone;
 };
 
+typedef enum TZMonotonicityBits {
+	TZ_MONOTONIC_YEAR  ,
+	TZ_MONOTONIC_MONTH ,
+	TZ_MONOTONIC_DAY   ,
+	TZ_MONOTONIC_HOUR  ,
+	TZ_MONOTONIC_MINUTE,
+	TZ_MONOTONIC_SECOND,
+	TZ_MONOTONIC_NUM_BITS
+} TZMonotonicityBits;
+
 /* These structs are opaque outside the timezone library */
 typedef struct pg_tz pg_tz;
 typedef struct pg_tzenum pg_tzenum;
@@ -56,6 +66,8 @@ typedef struct pg_tzenum pg_tzenum;
 /* these functions are in localtime.c */
 
 extern struct pg_tm *pg_localtime(const pg_time_t *timep, const pg_tz *tz);
+extern bool pg_timezone_is_monotonic(const pg_tz *tz, TZMonotonicityBits unit);
+
 extern struct pg_tm *pg_gmtime(const pg_time_t *timep);
 extern int	pg_next_dst_boundary(const pg_time_t *timep,
 								 long int *before_gmtoff,
diff --git a/src/test/regress/expected/slope.out b/src/test/regress/expected/slope.out
index ebc4bd9b9d2..33b82dcc4ff 100644
--- a/src/test/regress/expected/slope.out
+++ b/src/test/regress/expected/slope.out
@@ -252,21 +252,6 @@ select date_trunc('day', ts), count(*) from src group by 1;
          Output: date_trunc('day'::text, ts)
 (5 rows)
 
--- date_trunc on timestamptz should not use index
-explain (costs off, verbose)
-select date_trunc('day', tstz), count(*) from src group by 1;
-                         QUERY PLAN                          
--------------------------------------------------------------
- GroupAggregate
-   Output: (date_trunc('day'::text, tstz)), count(*)
-   Group Key: (date_trunc('day'::text, src.tstz))
-   ->  Sort
-         Output: (date_trunc('day'::text, tstz))
-         Sort Key: (date_trunc('day'::text, src.tstz))
-         ->  Index Only Scan using src_tstz_idx on slope.src
-               Output: date_trunc('day'::text, tstz)
-(8 rows)
-
 --
 -- Test arithmetic operations
 --
@@ -425,23 +410,6 @@ select -v_int4 from src order by 1;
          Output: (- v_int4)
 (5 rows)
 
---
--- Group and order
---
-explain (costs off, verbose)
-select tstz::date, count(*) from src group by 1 order by 1;
-                         QUERY PLAN                          
--------------------------------------------------------------
- GroupAggregate
-   Output: ((tstz)::date), count(*)
-   Group Key: ((src.tstz)::date)
-   ->  Sort
-         Output: ((tstz)::date)
-         Sort Key: ((src.tstz)::date)
-         ->  Index Only Scan using src_tstz_idx on slope.src
-               Output: (tstz)::date
-(8 rows)
-
 --
 -- Test nested monotonic function
 --
@@ -844,8 +812,98 @@ SELECT v_int4::oid FROM src ORDER BY 1;
    ->  Index Only Scan using src_v_int4_idx on src
 (3 rows)
 
+--
+-- Now that some plans were shown, and we see that in many cases what
+-- we care is whether a plan has a index scan or not. This function will
+-- check that for us and just return 
+--
+CREATE OR REPLACE FUNCTION index_plan (query text) RETURNS BOOLEAN LANGUAGE plpgsql AS $$
+DECLARE
+    plan json;
+BEGIN
+   EXECUTE 'EXPLAIN (COSTS OFF, FORMAT JSON) SELECT ' || query || ' ORDER BY 1' into plan;
+   RETURN  plan->0->'Plan'->>'Node Type' LIKE 'Index%Scan';
+END
+$$;
+--
+-- Functions that take a timezone argument
+--
+EXECUTE query;
+ERROR:  prepared statement "query" does not exist
+SELECT *
+FROM unnest(ARRAY[
+    'Africa/Ouagadougou', 'Europe/London', 'Antarctica/Troll', 'Pacific/Guam', 'America/Goose_Bay'
+]) AS timezones(tz_name),
+LATERAL (
+    SELECT
+        string_agg(unit, ', ') FILTER (
+            WHERE index_plan('date_trunc(''' || unit || ''', tstz, ''' || tz_name || ''') FROM src')
+         ) AS "monotonic date_trunc units"
+    FROM unnest(ARRAY['year', 'month', 'day', 'hour', 'minute', 'second']) AS s(unit)
+) AS trunc,
+LATERAL (
+    SELECT index_plan('timezone(''' || tz_name || ''', tstz) FROM src') AS "timezone"
+) AS tz;
+      tz_name       |       monotonic date_trunc units       | timezone 
+--------------------+----------------------------------------+----------
+ Africa/Ouagadougou | year, month, day, hour, minute, second | t
+ Europe/London      | year, month, day, hour                 | f
+ Antarctica/Troll   | year, month, day                       | f
+ Pacific/Guam       | year, month                            | f
+ America/Goose_Bay  | year                                   | f
+(5 rows)
+
+--
+-- Functions that depends on session timezone
+--
+PREPARE query AS 
+SELECT index_plan($$ tstz::date FROM src $$) date, 
+    (SELECT string_agg( unit, ' ') FILTER (
+        WHERE index_plan($$ date_trunc('$$ || unit || $$', tstz) FROM src $$)
+    ) FROM unnest(ARRAY['year', 'month', 'day', 'hour', 'minute', 'second']) AS s(unit)
+) as "monotonic date_turnc units";
+-- No DST at all
+SET timezone = 'Africa/Ouagadougou';
+EXECUTE query;
+ date |    monotonic date_turnc units     
+------+-----------------------------------
+ t    | year month day hour minute second
+(1 row)
+
+-- The common case: one hour DST
+SET timezone = 'Europe/London';
+EXECUTE query;
+ date | monotonic date_turnc units 
+------+----------------------------
+ t    | year month day hour
+(1 row)
+
+-- DST might affect the hour
+SET timezone = 'Antarctica/Troll';
+EXECUTE query;
+ date | monotonic date_turnc units 
+------+----------------------------
+ t    | year month day
+(1 row)
+
+-- A weird case: used to go back a day
+SET timezone = 'Pacific/Guam';
+EXECUTE query;
+ date | monotonic date_turnc units 
+------+----------------------------
+ f    | year month
+(1 row)
+
+-- Even weirder, went back a month
+SET timezone = 'America/Goose_Bay';
+EXECUTE query;
+ date | monotonic date_turnc units 
+------+----------------------------
+ f    | year
+(1 row)
+
 DROP SCHEMA slope CASCADE;
-NOTICE:  drop cascades to 14 other objects
+NOTICE:  drop cascades to 15 other objects
 DETAIL:  drop cascades to table t
 drop cascades to table u
 drop cascades to operator family test_int4_ops for access method btree
@@ -860,3 +918,4 @@ drop cascades to type non42
 drop cascades to type fp_real
 drop cascades to table numeric_corners
 drop cascades to table inc
+drop cascades to function index_plan(text)
diff --git a/src/test/regress/expected/slope_catalog.out b/src/test/regress/expected/slope_catalog.out
index 670f711466e..555a2419523 100644
--- a/src/test/regress/expected/slope_catalog.out
+++ b/src/test/regress/expected/slope_catalog.out
@@ -133,8 +133,8 @@ JOIN pg_proc sp ON sp.oid = p.prosupport
 WHERE sp.proname LIKE '%slope%'
   AND NOT EXISTS (SELECT 1 FROM pg_operator o WHERE o.oprcode = p.oid)
 ORDER BY sp.proname, p.proname, arguments;
- oid  |   function   |             arguments             |           returns           |       prosupport        
-------+--------------+-----------------------------------+-----------------------------+-------------------------
+ oid  |   function   |             arguments             |           returns           |           prosupport            
+------+--------------+-----------------------------------+-----------------------------+---------------------------------
  2025 | timestamp    | date, time without time zone      | timestamp without time zone | addition_slope_support
  2466 | acosh        | double precision                  | double precision            | arg0_asc_slope_support
  1600 | asin         | double precision                  | double precision            | arg0_asc_slope_support
@@ -184,7 +184,12 @@ ORDER BY sp.proname, p.proname, arguments;
  2732 | acosd        | double precision                  | double precision            | arg0_desc_slope_support
  6220 | erfc         | double precision                  | double precision            | arg0_desc_slope_support
  2020 | date_trunc   | text, timestamp without time zone | timestamp without time zone | arg1_asc_slope_support
-(49 rows)
+ 2069 | timezone     | text, timestamp without time zone | timestamp with time zone    | arg1_asc_slope_support
+ 1178 | date         | timestamp with time zone          | date                        | timestamptz_date_slope_support
+ 6334 | timezone     | timestamp with time zone          | timestamp without time zone | timestamptz_at_local_slope_support
+ 1217 | date_trunc   | text, timestamp with time zone    | timestamp with time zone    | timestamptz_trunc_slope_support
+ 1159 | timezone     | text, timestamp with time zone    | timestamp without time zone | timestamptz_zone_slope_support
+(54 rows)
 
 -- Operators whose name has slope support for some types but not others
 SELECT
@@ -280,9 +285,7 @@ WHERE (u.prosupport = 0 OR NOT EXISTS (
 ORDER BY u.proname, arguments;
  oid  |   function   |              arguments               |           returns           
 ------+--------------+--------------------------------------+-----------------------------
- 1178 | date         | timestamp with time zone             | date
  1218 | date_trunc   | text, interval                       | interval
- 1217 | date_trunc   | text, timestamp with time zone       | timestamp with time zone
  1284 | date_trunc   | text, timestamp with time zone, text | timestamp with time zone
  1736 | log          | numeric, numeric                     | numeric
  2027 | timestamp    | timestamp with time zone             | timestamp without time zone
@@ -292,5 +295,5 @@ ORDER BY u.proname, arguments;
  1778 | to_timestamp | text, text                           | timestamp with time zone
   753 | trunc        | macaddr                              | macaddr
  4112 | trunc        | macaddr8                             | macaddr8
-(12 rows)
+(10 rows)
 
diff --git a/src/test/regress/sql/slope.sql b/src/test/regress/sql/slope.sql
index 1defff4d955..b99f4428dc3 100644
--- a/src/test/regress/sql/slope.sql
+++ b/src/test/regress/sql/slope.sql
@@ -178,10 +178,6 @@ select ts::date, count(*) from src group by 1;
 explain (costs off, verbose)
 select date_trunc('day', ts), count(*) from src group by 1;
 
--- date_trunc on timestamptz should not use index
-explain (costs off, verbose)
-select date_trunc('day', tstz), count(*) from src group by 1;
-
 
 --
 -- Test arithmetic operations
@@ -203,7 +199,6 @@ select v_int4 * 2, count(*) from src group by 1;
 explain (costs off, verbose)
 select v_int4 / 2, count(*) from src group by 1;
 
-
 --
 -- Test decreasing functions
 -- These queries can't use the index order because group pathkeys
@@ -248,13 +243,6 @@ select -v_int4 from src order by 1 desc;
 explain (costs off, verbose)
 select -v_int4 from src order by 1;
 
---
--- Group and order
---
-
-explain (costs off, verbose)
-select tstz::date, count(*) from src group by 1 order by 1;
-
 --
 -- Test nested monotonic function
 --
@@ -536,4 +524,74 @@ from src;
 EXPLAIN (COSTS OFF)
 SELECT v_int4::oid FROM src ORDER BY 1;
 
+
+
+--
+-- Now that some plans were shown, and we see that in many cases what
+-- we care is whether a plan has a index scan or not. This function will
+-- check that for us and just return 
+--
+CREATE OR REPLACE FUNCTION index_plan (query text) RETURNS BOOLEAN LANGUAGE plpgsql AS $$
+DECLARE
+    plan json;
+BEGIN
+   EXECUTE 'EXPLAIN (COSTS OFF, FORMAT JSON) SELECT ' || query || ' ORDER BY 1' into plan;
+   RETURN  plan->0->'Plan'->>'Node Type' LIKE 'Index%Scan';
+END
+$$;
+
+
+--
+-- Functions that take a timezone argument
+--
+EXECUTE query;
+SELECT *
+FROM unnest(ARRAY[
+    'Africa/Ouagadougou', 'Europe/London', 'Antarctica/Troll', 'Pacific/Guam', 'America/Goose_Bay'
+]) AS timezones(tz_name),
+LATERAL (
+    SELECT
+        string_agg(unit, ', ') FILTER (
+            WHERE index_plan('date_trunc(''' || unit || ''', tstz, ''' || tz_name || ''') FROM src')
+         ) AS "monotonic date_trunc units"
+    FROM unnest(ARRAY['year', 'month', 'day', 'hour', 'minute', 'second']) AS s(unit)
+) AS trunc,
+LATERAL (
+    SELECT index_plan('timezone(''' || tz_name || ''', tstz) FROM src') AS "timezone"
+) AS tz;
+
+
+--
+-- Functions that depends on session timezone
+--
+PREPARE query AS 
+SELECT index_plan($$ tstz::date FROM src $$) date, 
+    (SELECT string_agg( unit, ' ') FILTER (
+        WHERE index_plan($$ date_trunc('$$ || unit || $$', tstz) FROM src $$)
+    ) FROM unnest(ARRAY['year', 'month', 'day', 'hour', 'minute', 'second']) AS s(unit)
+) as "monotonic date_turnc units";
+
+-- No DST at all
+SET timezone = 'Africa/Ouagadougou';
+EXECUTE query;
+
+-- The common case: one hour DST
+SET timezone = 'Europe/London';
+EXECUTE query;
+
+-- DST might affect the hour
+SET timezone = 'Antarctica/Troll';
+EXECUTE query;
+
+-- A weird case: used to go back a day
+SET timezone = 'Pacific/Guam';
+EXECUTE query;
+
+-- Even weirder, went back a month
+SET timezone = 'America/Goose_Bay';
+EXECUTE query;
+
+
+
+
 DROP SCHEMA slope CASCADE;
\ No newline at end of file
diff --git a/src/timezone/localtime.c b/src/timezone/localtime.c
index a3eb3bacb9b..98bbc20aac2 100644
--- a/src/timezone/localtime.c
+++ b/src/timezone/localtime.c
@@ -1769,6 +1769,17 @@ pg_next_dst_boundary(const pg_time_t *timep,
 	return 1;
 }
 
+/*
+ * Determine whether timestamps truncated to the specified
+ * unit is monotonic. The results are precomputed when loading
+ * a timezone
+ */
+bool
+pg_timezone_is_monotonic(const pg_tz *tz, TZMonotonicityBits unit)
+{
+	return tz->state.monotonicity & BITWISE_HI_AT(unit);
+}
+
 /*
  * Identify a timezone abbreviation's meaning in the given zone
  *
diff --git a/src/timezone/pgtz.c b/src/timezone/pgtz.c
index 9561d94a67d..001c018ba0e 100644
--- a/src/timezone/pgtz.c
+++ b/src/timezone/pgtz.c
@@ -216,6 +216,98 @@ init_timezone_hashtable(void)
 	return true;
 }
 
+/*
+ * Check every time transition that moves time backwards
+ * up to which part of the timestamp the effects are observable
+ * on a timestamp truncation fashion. This information will be stored
+ * in the timezone state to assist decisions related to monotonicity
+ * analysis.
+ * 2026-07-02 01:59:59.999999 to 
+ * 2026-07-02 01:00:00.000000
+ * ==== == == == << xx xxxxxx
+ *
+ * would not violate monotonicity for year, month, day, and hour
+ * but would break for minutes and seconds.
+ */
+static void
+inspect_monotonicity(struct pg_tz *tz)
+{
+	struct state	*sp;
+	struct pg_tm	tm_before;
+	struct pg_tm	*p;
+	pg_time_t		t_before;
+	pg_time_t		t_after;
+	int				result;
+
+	sp = &tz->state;
+
+	/* conservative initialization */
+	sp->monotonicity = 0;
+	if (sp == NULL || sp->typecnt <= 0)
+		return;
+	/* Fixed-offset zone: no transitions => fully monotonic */
+	if (sp->timecnt == 0)
+	{
+		sp->monotonicity = BITWISE_HI_RIGHT(TZ_MONOTONIC_NUM_BITS);
+		return;
+	}
+	result = BITWISE_HI_RIGHT(TZ_MONOTONIC_NUM_BITS);
+	for (int i = 0; i < sp->timecnt; i++)
+	{
+		int			old_type;
+		int			new_type;
+
+		t_after  = sp->ats[i];
+		t_before = t_after - 1;
+
+		/*
+		 * Only fall-back transitions can break truncation monotonicity.
+		 * Skip spring-forward and other offset-increase transitions.
+		 */
+		old_type = (i == 0) ? 0 : sp->types[i - 1];
+		new_type = sp->types[i];
+		if (sp->ttis[new_type].tt_utoff >= sp->ttis[old_type].tt_utoff)
+			continue;
+
+		/* determine local time parts at the transition */
+		p = pg_localtime(&t_before, tz);
+		if (p == NULL)
+		{
+			elog(NOTICE, "Failed to get local time on timezone %s", tz->TZname);
+			return;
+		}
+		tm_before = *p;
+		p = pg_localtime(&t_after, tz);
+		if (p == NULL)
+		{
+			elog(NOTICE, "Failed to get local time on timezone %s", tz->TZname);
+			return;
+		}
+		#define mark(field, unit) \
+		do { \
+			if (tm_before.tm_##field > p->tm_##field) \
+			{ \
+				if (TZ_MONOTONIC_##unit == 0) \
+					result = 0; \
+				else \
+					result &= BITWISE_HI_RIGHT(TZ_MONOTONIC_##unit - 1); \
+			} \
+			else if (tm_before.tm_##field < p->tm_##field) \
+				goto next_transition; \
+		} while (0)
+		mark(year, YEAR);
+		mark(mon, MONTH);
+		mark(mday, DAY);
+		mark(hour, HOUR);
+		mark(min, MINUTE);
+		mark(sec, SECOND);
+		#undef mark
+next_transition:
+		;
+	}
+	sp->monotonicity = result;
+}
+
 /*
  * Load a timezone from file or from cache.
  * Does not verify that the timezone is acceptable!
@@ -276,11 +368,11 @@ pg_tzset(const char *tzname)
 									  uppername,
 									  HASH_ENTER,
 									  NULL);
-
+	
 	/* hash_search already copied uppername into the hash key */
 	strcpy(tzp->tz.TZname, canonname);
 	memcpy(&tzp->tz.state, &tzstate, sizeof(tzstate));
-
+	inspect_monotonicity(&tzp->tz);
 	return &tzp->tz;
 }
 
diff --git a/src/timezone/pgtz.h b/src/timezone/pgtz.h
index d4ae94de74a..c721f846a9f 100644
--- a/src/timezone/pgtz.h
+++ b/src/timezone/pgtz.h
@@ -25,6 +25,7 @@
  */
 #define TZ_RUNTIME_LEAPS 1
 
+
 /*
  * Limit to time zone abbreviation length in proleptic TZ strings.
  * This is distinct from TZ_MAX_CHARS, which limits TZif file contents.
@@ -90,6 +91,7 @@ struct state
 	int			charcnt;
 	bool		goback;
 	bool		goahead;
+	int			monotonicity;
 	pg_time_t	ats[TZ_MAX_TIMES];
 	unsigned char types[TZ_MAX_TIMES];
 	struct ttinfo ttis[TZ_MAX_TYPES];
-- 
2.53.0

