 doc/src/sgml/func.sgml                    |  65 ++++-
 src/backend/utils/adt/timestamp.c         | 440 ++++++++++++++++++++++++++++++
 src/include/catalog/pg_proc.dat           |  30 ++
 src/include/datatype/timestamp.h          |   8 +
 src/test/regress/expected/timestamp.out   | 209 ++++++++++++++
 src/test/regress/expected/timestamptz.out | 149 ++++++++++
 src/test/regress/sql/timestamp.sql        | 118 ++++++++
 src/test/regress/sql/timestamptz.sql      |  90 ++++++
 8 files changed, 1108 insertions(+), 1 deletion(-)

diff --git a/doc/src/sgml/func.sgml b/doc/src/sgml/func.sgml
index 7a0bb0c70a..68afdb9ee4 100644
--- a/doc/src/sgml/func.sgml
+++ b/doc/src/sgml/func.sgml
@@ -6976,6 +6976,42 @@ SELECT regexp_match('abc01234xyz', '(?:(.*?)(\d+)(.*)){1,1}');
         <entry><literal>2 days 03:00:00</literal></entry>
        </row>
 
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified interval; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40')</literal></entry>
+        <entry><literal>2001-02-16 20:30:00</literal></entry>
+       </row>
+
+      <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp</type>, <type>timestamp</type>)</function></literal></entry>
+        <entry><type>timestamp</type></entry>
+        <entry>Truncate to specified interval aligned with specified origin; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('15 minutes', timestamp '2001-02-16 20:38:40', timestamp '2001-02-16 20:05:00')</literal></entry>
+        <entry><literal>2001-02-16 20:35:00</literal></entry>
+       </row>
+
+       <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp with time zone</type>, <optional><type>text</type></optional>)</function></literal></entry>
+        <entry><type>timestamp with time zone</type></entry>
+        <entry>Truncate to specified interval in the specified time zone; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('6 hours', timestamptz '2001-02-16 20:38:40+00', 'Australia/Sydney')</literal></entry>
+        <entry><literal>2001-02-16 19:00:00+00</literal></entry>
+       </row>
+
+      <row>
+        <entry><literal><function>date_trunc_interval(<type>interval</type>, <type>timestamp with time zone</type>, <type>timestamp with time zone</type>, <optional><type>text</type></optional>)</function></literal></entry>
+        <entry><type>timestamp with time zone</type></entry>
+        <entry>Truncate to specified interval aligned with specified origin in the specified time zone; see <xref linkend="functions-datetime-trunc"/>
+        </entry>
+        <entry><literal>date_trunc_interval('6 hours', timestamptz '2001-02-16 20:38:40+00', timestamptz '2001-02-16 01:00:00+00', 'Australia/Sydney')</literal></entry>
+        <entry><literal>2001-02-16 20:00:00+00</literal></entry>
+       </row>
+
        <row>
         <entry>
          <indexterm>
@@ -7845,7 +7881,7 @@ SELECT date_part('hour', INTERVAL '4 hours 3 minutes');
   </sect2>
 
   <sect2 id="functions-datetime-trunc">
-   <title><function>date_trunc</function></title>
+   <title><function>date_trunc</function>, <function>date_trunc_interval</function></title>
 
    <indexterm>
     <primary>date_trunc</primary>
@@ -7929,6 +7965,33 @@ SELECT date_trunc('hour', INTERVAL '3 days 02:47:33');
 <lineannotation>Result: </lineannotation><computeroutput>3 days 02:00:00</computeroutput>
 </screen>
    </para>
+
+   <para>
+    The function <function>date_trunc_interval</function> is 
+    similar to the <function>date_trunc</function>, except that it
+    truncates to an arbitrary interval.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 minutes', TIMESTAMP '2001-02-16 20:38:40');
+<lineannotation>Result: </lineannotation><computeroutput>2001-02-16 20:35:00</computeroutput>
+</screen>
+   </para>
+
+   <para>
+    The boundaries of the interval to truncate on can be controlled by setting the optional origin parameter. If not specfied, the default origin is January 1st, 2001.
+   </para>
+
+   <para>
+    Example:
+<screen>
+SELECT date_trunc_interval('5 years'::interval, TIMESTAMP '2020-02-01', TIMESTAMP '2012-01-01');
+<lineannotation>Result: </lineannotation><computeroutput>2017-01-01 00:00:00</computeroutput>
+</screen>
+   </para>
+
   </sect2>
 
   <sect2 id="functions-datetime-zoneconvert">
diff --git a/src/backend/utils/adt/timestamp.c b/src/backend/utils/adt/timestamp.c
index 4caffb5804..a6af9217d5 100644
--- a/src/backend/utils/adt/timestamp.c
+++ b/src/backend/utils/adt/timestamp.c
@@ -45,6 +45,13 @@
 
 #define SAMESIGN(a,b)	(((a) < 0) == ((b) < 0))
 
+/*
+ * The default origin is 2001-01-01, which matches date_trunc as far as
+ * aligning on ISO weeks. This numeric value should match the internal
+ * value of SELECT make_timestamp(2001, 1, 1, 0, 0, 0);
+ */
+#define DATE_TRUNC_DEFAULT_ORIGIN 31622400000000
+
 /* Set at postmaster start */
 TimestampTz PgStartTime;
 
@@ -3804,6 +3811,146 @@ timestamptz_age(PG_FUNCTION_ARGS)
  *---------------------------------------------------------*/
 
 
+static Timestamp
+timestamp_trunc_interval_internal(Interval *stride,
+								  Timestamp timestamp,
+								  Timestamp origin)
+{
+	Timestamp	result,
+				tm_diff,
+				tm_usecs,
+				tm_delta;
+	fsec_t		ofsec,
+				tfsec;
+	int 		origin_months,
+				tm_months,
+				result_months,
+				month_diff,
+				month_delta = 0;
+
+	struct pg_tm ot, /* origin */
+				 tt, /* input */
+				 rt; /* result */
+
+	tm_diff = timestamp - origin;
+
+	/*
+	 * For strides measured in days or smaller units, do one simple calculation
+	 * on the time in microseconds.
+	 */
+	if (stride->month == 0)
+	{
+		tm_usecs = stride->day * USECS_PER_DAY + stride->time;
+
+		/* trivial case of 1 usec */
+		if (tm_usecs == 1)
+			return timestamp;
+
+		tm_delta = tm_diff - tm_diff % tm_usecs;;
+		if (tm_diff < 0)
+			tm_delta -= tm_usecs;
+
+		result = origin + tm_delta;
+	}
+	else
+	{
+		/*
+		 * For strides measured in years and/or months, convert origin and
+		 * input timestamps to months.
+		 */
+		if (stride->day != 0 || stride->time != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("cannot mix year or month interval units with day units or smaller")));
+
+		if (timestamp2tm(timestamp, NULL, &tt, &tfsec, NULL, NULL) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+
+		if (timestamp2tm(origin, NULL, &ot, &ofsec, NULL, NULL) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+
+
+		origin_months = ot.tm_year * 12 + ot.tm_mon - 1;
+		tm_months = tt.tm_year * 12 + tt.tm_mon - 1;
+		month_diff = tm_months - origin_months;
+
+		/* take the origin's smaller units into account */
+		if (Minor_Units(tt, tfsec) < Minor_Units(ot, ofsec))
+			month_diff--;
+
+		month_delta = month_diff - month_diff % stride->month;
+
+		/*
+		 * Make sure truncation happens in the right direction.
+		 * XXX This is a bit of a hack.
+		 */
+		if (month_diff < 0 && stride->month != 1)
+			month_delta -= stride->month;
+
+		result_months = origin_months + month_delta;
+
+		/* compute result fields of pg_tm struct */
+		rt.tm_year = result_months / 12;
+		rt.tm_mon = (result_months % 12) + 1;
+		/* align on origin's smaller units */
+		rt.tm_mday = ot.tm_mday;
+		rt.tm_hour = ot.tm_hour;
+		rt.tm_min = ot.tm_min;
+		rt.tm_sec = ot.tm_sec;
+
+		/* Note using the origin's fsec directly */
+		if (tm2timestamp(&rt, ofsec, NULL, &result) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+	}
+	return result;
+}
+
+/* timestamp_trunc_interval()
+ * Truncate timestamp to specified interval.
+ */
+Datum
+timestamp_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	Timestamp	timestamp = PG_GETARG_TIMESTAMP(1);
+	Timestamp	result;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	result = timestamp_trunc_interval_internal(stride, timestamp,
+											   DATE_TRUNC_DEFAULT_ORIGIN);
+
+	PG_RETURN_TIMESTAMP(result);
+}
+
+Datum
+timestamp_trunc_interval_origin(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	Timestamp	timestamp = PG_GETARG_TIMESTAMP(1);
+	Timestamp	origin = PG_GETARG_TIMESTAMP(2);
+	Timestamp	result;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	if (TIMESTAMP_NOT_FINITE(origin))
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	result = timestamp_trunc_interval_internal(stride, timestamp, origin);
+
+	PG_RETURN_TIMESTAMP(result);
+}
+
 /* timestamp_trunc()
  * Truncate timestamp to specified units.
  */
@@ -3938,6 +4085,132 @@ timestamp_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMP(result);
 }
 
+/*
+ * Common code for timestamptz_trunc_interval*()
+ *
+ * tzp identifies the zone to truncate with respect to.  We assume
+ * infinite timestamps have already been rejected.
+ */
+static TimestampTz
+timestamptz_trunc_interval_internal(Interval *stride,
+									TimestampTz timestamp,
+									pg_tz *tzp,
+									TimestampTz origin)
+{
+	TimestampTz	result,
+				tm_diff,
+				tm_usecs,
+				tm_delta;
+	fsec_t		ofsec,
+				tfsec;
+	int 		origin_months,
+				tm_months,
+				result_months,
+				month_diff,
+				month_delta = 0,
+				tz;
+
+	struct pg_tm ot, /* origin */
+				 tt, /* input */
+				 rt; /* result */
+
+	/* Convert input to pg_tm with timezone. */
+	if (timestamp2tm(timestamp, &tz, &tt, &tfsec, NULL, tzp) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	// XXX it seems like we should pass TZ here, but it seems to break
+	// things
+	if (timestamp2tm(origin, NULL, &ot, &ofsec, NULL, tzp) != 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	/*
+	 * For strides measured in days or smaller units, do one simple calculation
+	 * on the time in microseconds.
+	 */
+	if (stride->month == 0)
+	{
+		tm_usecs = stride->day * USECS_PER_DAY + stride->time;
+
+		/* trivial case of 1 usec */
+		if (tm_usecs == 1)
+			return timestamp;
+
+		// got the idea from timestamp_zone() to just recovert to timestamp
+		// with null tz.
+		if (tm2timestamp(&tt, tfsec, NULL, &timestamp) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+
+		// WIP
+		// if (tm2timestamp(&ot, ofsec, NULL, &origin) != 0)
+		// 	ereport(ERROR,
+		// 			(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+		// 			 errmsg("timestamp out of range")));
+
+		tm_diff = timestamp - origin;
+
+		tm_delta = tm_diff - tm_diff % tm_usecs;;
+		if (tm_diff < 0)
+			tm_delta -= tm_usecs;
+
+
+		// XXX this seems mysterious, hopefully there's a more principled way
+		result = dt2local(origin + tm_delta, -tz);
+	}
+	else
+	{
+		/*
+		 * For strides measured in years and/or months, convert origin and
+		 * input timestamps to months.
+		 */
+		if (stride->day != 0 || stride->time != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("cannot mix year or month interval units with day units or smaller")));
+
+		origin_months = ot.tm_year * 12 + ot.tm_mon - 1;
+		tm_months = tt.tm_year * 12 + tt.tm_mon - 1;
+		month_diff = tm_months - origin_months;
+
+		/* take the origin's smaller units into account */
+		if (Minor_Units(tt, tfsec) < Minor_Units(ot, ofsec))
+			month_diff--;
+
+		month_delta = month_diff - month_diff % stride->month;
+
+		/*
+		 * Make sure truncation happens in the right direction.
+		 * XXX This is a bit of a hack.
+		 */
+		if (month_diff < 0 && stride->month != 1)
+			month_delta -= stride->month;
+
+		result_months = origin_months + month_delta;
+
+		/* compute result fields of pg_tm struct */
+		rt.tm_year = result_months / 12;
+		rt.tm_mon = (result_months % 12) + 1;
+		/* align on origin's smaller units */
+		rt.tm_mday = ot.tm_mday;
+		rt.tm_hour = ot.tm_hour;
+		rt.tm_min = ot.tm_min;
+		rt.tm_sec = ot.tm_sec;
+
+		/* Note using the origin's fsec directly */
+		if (tm2timestamp(&rt, ofsec, &tz, &result) != 0)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+					 errmsg("timestamp out of range")));
+	}
+
+	return result;
+}
+
 /*
  * Common code for timestamptz_trunc() and timestamptz_trunc_zone().
  *
@@ -4085,6 +4358,52 @@ timestamptz_trunc_internal(text *units, TimestampTz timestamp, pg_tz *tzp)
 	return result;
 }
 
+/* timestamptz_trunc_interval()
+ * Truncate timestamptz to specified interval in session timezone.
+ */
+Datum
+timestamptz_trunc_interval(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz result;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMPTZ(timestamp);
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 session_timezone,
+												 DATE_TRUNC_DEFAULT_ORIGIN);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
+/* timestamptz_trunc_interval_origin()
+ * Truncate timestamptz to specified interval in session timezone.
+ */
+Datum
+timestamptz_trunc_interval_origin(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz result;
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	if (TIMESTAMP_NOT_FINITE(origin))
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 session_timezone,
+												 origin);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /* timestamptz_trunc()
  * Truncate timestamptz to specified units in session timezone.
  */
@@ -4103,6 +4422,127 @@ timestamptz_trunc(PG_FUNCTION_ARGS)
 	PG_RETURN_TIMESTAMPTZ(result);
 }
 
+/* timestamptz_trunc_interval_zone()
+ * Truncate timestamptz to specified interval in specified timezone.
+ */
+Datum
+timestamptz_trunc_interval_zone(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	text	   *zone = PG_GETARG_TEXT_PP(2);
+	TimestampTz result;
+	char		tzname[TZ_STRLEN_MAX + 1];
+	char	   *lowzone;
+	int			type,
+				val;
+	pg_tz	   *tzp;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMPTZ(timestamp);
+
+	/*
+	 * Look up the requested timezone (see notes in timestamptz_zone()).
+	 */
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+
+	/* DecodeTimezoneAbbrev requires lowercase input */
+	lowzone = downcase_truncate_identifier(tzname,
+										   strlen(tzname),
+										   false);
+
+	type = DecodeTimezoneAbbrev(0, lowzone, &val, &tzp);
+
+	if (type == TZ || type == DTZ)
+	{
+		/* fixed-offset abbreviation, get a pg_tz descriptor for that */
+		tzp = pg_tzset_offset(-val);
+	}
+	else if (type == DYNTZ)
+	{
+		/* dynamic-offset abbreviation, use its referenced timezone */
+	}
+	else
+	{
+		/* try it as a full zone name */
+		tzp = pg_tzset(tzname);
+		if (!tzp)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("time zone \"%s\" not recognized", tzname)));
+	}
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 tzp,
+												 DATE_TRUNC_DEFAULT_ORIGIN);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
+/* timestamptz_trunc_interval_origin_zone()
+ * Truncate timestamptz to specified interval in specified timezone,
+ * aligned to the specified origin.
+ */
+Datum
+timestamptz_trunc_interval_origin_zone(PG_FUNCTION_ARGS)
+{
+	Interval   *stride = PG_GETARG_INTERVAL_P(0);
+	TimestampTz timestamp = PG_GETARG_TIMESTAMPTZ(1);
+	TimestampTz	origin = PG_GETARG_TIMESTAMPTZ(2);
+	text	   *zone = PG_GETARG_TEXT_PP(3);
+	TimestampTz result;
+	char		tzname[TZ_STRLEN_MAX + 1];
+	char	   *lowzone;
+	int			type,
+				val;
+	pg_tz	   *tzp;
+
+	if (TIMESTAMP_NOT_FINITE(timestamp))
+		PG_RETURN_TIMESTAMP(timestamp);
+
+	if (TIMESTAMP_NOT_FINITE(origin))
+		ereport(ERROR,
+				(errcode(ERRCODE_DATETIME_VALUE_OUT_OF_RANGE),
+				 errmsg("timestamp out of range")));
+
+	/*
+	 * Look up the requested timezone (see notes in timestamptz_zone()).
+	 */
+	text_to_cstring_buffer(zone, tzname, sizeof(tzname));
+
+	/* DecodeTimezoneAbbrev requires lowercase input */
+	lowzone = downcase_truncate_identifier(tzname,
+										   strlen(tzname),
+										   false);
+
+	type = DecodeTimezoneAbbrev(0, lowzone, &val, &tzp);
+
+	if (type == TZ || type == DTZ)
+	{
+		/* fixed-offset abbreviation, get a pg_tz descriptor for that */
+		tzp = pg_tzset_offset(-val);
+	}
+	else if (type == DYNTZ)
+	{
+		/* dynamic-offset abbreviation, use its referenced timezone */
+	}
+	else
+	{
+		/* try it as a full zone name */
+		tzp = pg_tzset(tzname);
+		if (!tzp)
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+					 errmsg("time zone \"%s\" not recognized", tzname)));
+	}
+
+	result = timestamptz_trunc_interval_internal(stride, timestamp,
+												 tzp,
+												 origin);
+
+	PG_RETURN_TIMESTAMPTZ(result);
+}
+
 /* timestamptz_trunc_zone()
  * Truncate timestamptz to specified units in specified timezone.
  */
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 87d25d4a4b..e8aeb4801c 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -5663,6 +5663,36 @@
 { oid => '2020', descr => 'truncate timestamp to specified units',
   proname => 'date_trunc', prorettype => 'timestamp',
   proargtypes => 'text timestamp', prosrc => 'timestamp_trunc' },
+
+{ oid => '8989', descr => 'truncate timestamp to specified interval',
+  proname => 'date_trunc_interval', prorettype => 'timestamp',
+  proargtypes => 'interval timestamp', prosrc => 'timestamp_trunc_interval' },
+{ oid => '8990',
+  descr => 'truncate timestamp to specified interval and origin',
+  proname => 'date_trunc_interval', prorettype => 'timestamp',
+  proargtypes => 'interval timestamp timestamp',
+  prosrc => 'timestamp_trunc_interval_origin' },
+
+{ oid => '8991',
+  descr => 'truncate timestamp with time zone to specified interval',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz', prosrc => 'timestamptz_trunc_interval' },
+{ oid => '8992',
+  descr => 'truncate timestamp with time zone to specified interval, in specified time zone',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz text',
+  prosrc => 'timestamptz_trunc_interval_zone' },
+
+{ oid => '8993',
+  descr => 'truncate timestamp with time zone to specified interval and origin',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz', prosrc => 'timestamptz_trunc_interval_origin' },
+{ oid => '8994',
+  descr => 'truncate timestamp with time zone to specified interval and origin, in specified time zone',
+  proname => 'date_trunc_interval', prorettype => 'timestamptz',
+  proargtypes => 'interval timestamptz timestamptz text',
+  prosrc => 'timestamptz_trunc_interval_origin_zone' },
+
 { oid => '2021', descr => 'extract field from timestamp',
   proname => 'date_part', prorettype => 'float8',
   proargtypes => 'text timestamp', prosrc => 'timestamp_part' },
diff --git a/src/include/datatype/timestamp.h b/src/include/datatype/timestamp.h
index 6be6d35d1e..2f897b7575 100644
--- a/src/include/datatype/timestamp.h
+++ b/src/include/datatype/timestamp.h
@@ -93,6 +93,14 @@ typedef struct
 #define USECS_PER_MINUTE INT64CONST(60000000)
 #define USECS_PER_SEC	INT64CONST(1000000)
 
+/* compute total of non-month, non-year units in a pg_tm struct + fsec */
+#define Minor_Units(tm, usec) \
+	(tm.tm_mday - 1) * USECS_PER_DAY + \
+	tm.tm_hour * USECS_PER_HOUR + \
+	tm.tm_min * USECS_PER_MINUTE + \
+	tm.tm_sec * USECS_PER_SEC + \
+	usec
+
 /*
  * We allow numeric timezone offsets up to 15:59:59 either way from Greenwich.
  * Currently, the record holders for wackiest offsets in actual use are zones
diff --git a/src/test/regress/expected/timestamp.out b/src/test/regress/expected/timestamp.out
index 5f97505a30..2fd9deae62 100644
--- a/src/test/regress/expected/timestamp.out
+++ b/src/test/regress/expected/timestamp.out
@@ -545,6 +545,215 @@ SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17
                  | Mon Feb 23 00:00:00 2004
 (1 row)
 
+-- verify date_trunc_interval behaves the same as date_trunc (excluding decade)
+-- case 1: AD dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts) AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '2020-02-29 15:44:17.71393') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ millennium  | 1000 y   | t
+ century     | 100 y    | t
+ year        | 1 y      | t
+ quarter     | 3 mon    | t
+ month       | 1 mon    | t
+ week        | 7 d      | t
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(12 rows)
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts, timestamp '2000-01-01 BC') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '0055-6-10 15:44:17.71393 BC') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ millennium  | 1000 y   | t
+ century     | 100 y    | t
+ year        | 1 y      | t
+ quarter     | 3 mon    | t
+ month       | 1 mon    | t
+ week        | 7 d      | t
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(12 rows)
+
+-- case 3: AD dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts) AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '1999-12-31 15:44:17.71393') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ millennium  | 1000 y   | t
+ century     | 100 y    | t
+ year        | 1 y      | t
+ quarter     | 3 mon    | t
+ month       | 1 mon    | t
+ week        | 7 d      | t
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(12 rows)
+
+-- case 4: BC dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts) AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '0055-6-07 15:44:17.71393 BC') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ millennium  | 1000 y   | t
+ century     | 100 y    | t
+ year        | 1 y      | t
+ quarter     | 3 mon    | t
+ month       | 1 mon    | t
+ week        | 7 d      | t
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(12 rows)
+
+-- truncate timestamps on arbitrary intervals
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('50 years'),
+  ('1.5 years'),
+  ('18 months'),
+  ('6 months'),
+  ('15 days'),
+  ('2 hours'),
+  ('15 minutes'),
+  ('10 seconds'),
+  ('100 milliseconds'),
+  ('250 microseconds')
+) intervals (interval),
+(SELECT TIMESTAMP '2020-02-11 15:44:17.71393') ts (ts);
+     interval     |      date_trunc_interval       
+------------------+--------------------------------
+ 50 years         | Mon Jan 01 00:00:00 2001
+ 1.5 years        | Tue Jan 01 00:00:00 2019
+ 18 months        | Tue Jan 01 00:00:00 2019
+ 6 months         | Wed Jan 01 00:00:00 2020
+ 15 days          | Thu Feb 06 00:00:00 2020
+ 2 hours          | Tue Feb 11 14:00:00 2020
+ 15 minutes       | Tue Feb 11 15:30:00 2020
+ 10 seconds       | Tue Feb 11 15:44:10 2020
+ 100 milliseconds | Tue Feb 11 15:44:17.7 2020
+ 250 microseconds | Tue Feb 11 15:44:17.71375 2020
+(10 rows)
+
+-- shift bins using the origin parameter:
+SELECT date_trunc_interval('5 min'::interval, timestamp '2020-02-1 01:01:01', timestamp '2020-02-01 00:02:30');
+   date_trunc_interval    
+--------------------------
+ Sat Feb 01 00:57:30 2020
+(1 row)
+
+SELECT date_trunc_interval('5 years'::interval, timestamp '2020-02-1 01:01:01', timestamp '2012-01-01');
+   date_trunc_interval    
+--------------------------
+ Sun Jan 01 00:00:00 2017
+(1 row)
+
+SELECT date_trunc_interval('3 year', timestamp '2015-01-14 20:38:40', timestamp '2012-01-15 01:01:01.123');
+     date_trunc_interval      
+------------------------------
+ Sun Jan 15 01:01:01.123 2012
+(1 row)
+
+SELECT date_trunc_interval('3 year', timestamp '2015-01-15 20:38:40', timestamp '2012-01-15 01:01:01.123');
+     date_trunc_interval      
+------------------------------
+ Thu Jan 15 01:01:01.123 2015
+(1 row)
+
+-- not defined
+SELECT date_trunc_interval('1 month 1 day', timestamp '2001-02-16 20:38:40.123456');
+ERROR:  cannot mix year or month interval units with day units or smaller
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
diff --git a/src/test/regress/expected/timestamptz.out b/src/test/regress/expected/timestamptz.out
index 639b50308e..c2f3a55e62 100644
--- a/src/test/regress/expected/timestamptz.out
+++ b/src/test/regress/expected/timestamptz.out
@@ -663,6 +663,155 @@ SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-
                   | Thu Feb 15 20:00:00 2001 PST
 (1 row)
 
+-- verify date_trunc_interval behaves the same as date_trunc (excluding decade)
+-- case 1: AD dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '2020-02-29 15:44:17.71393+00') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ millennium  | 1000 y   | t
+ century     | 100 y    | t
+ year        | 1 y      | t
+ quarter     | 3 mon    | t
+ month       | 1 mon    | t
+ week        | 7 d      | t
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(12 rows)
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, timestamptz '2000-01-01+00 BC', 'Australia/Sydney') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '0055-6-10 15:44:17.71393+00 BC') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ millennium  | 1000 y   | t
+ century     | 100 y    | t
+ year        | 1 y      | t
+ quarter     | 3 mon    | t
+ month       | 1 mon    | t
+ week        | 7 d      | t
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(12 rows)
+
+-- case 3: AD dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '1999-12-31 15:44:17.71393+00') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ millennium  | 1000 y   | t
+ century     | 100 y    | t
+ year        | 1 y      | t
+ quarter     | 3 mon    | t
+ month       | 1 mon    | t
+ week        | 7 d      | t
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(12 rows)
+
+-- case 4: BC dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '0055-6-07 15:44:17.71393+00 BC') ts (ts);
+     str     | interval | equal 
+-------------+----------+-------
+ millennium  | 1000 y   | t
+ century     | 100 y    | t
+ year        | 1 y      | t
+ quarter     | 3 mon    | t
+ month       | 1 mon    | t
+ week        | 7 d      | t
+ day         | 1 d      | t
+ hour        | 1 h      | t
+ minute      | 1 m      | t
+ second      | 1 s      | t
+ millisecond | 1 ms     | t
+ microsecond | 1 us     | t
+(12 rows)
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
diff --git a/src/test/regress/sql/timestamp.sql b/src/test/regress/sql/timestamp.sql
index 7b58c3cfa5..d171bf95ec 100644
--- a/src/test/regress/sql/timestamp.sql
+++ b/src/test/regress/sql/timestamp.sql
@@ -166,6 +166,124 @@ SELECT '' AS "54", d1 - timestamp without time zone '1997-01-02' AS diff
 
 SELECT '' AS date_trunc_week, date_trunc( 'week', timestamp '2004-02-29 15:44:17.71393' ) AS week_trunc;
 
+-- verify date_trunc_interval behaves the same as date_trunc (excluding decade)
+
+-- case 1: AD dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts) AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '2020-02-29 15:44:17.71393') ts (ts);
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts, timestamp '2000-01-01 BC') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '0055-6-10 15:44:17.71393 BC') ts (ts);
+
+-- case 3: AD dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts) AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '1999-12-31 15:44:17.71393') ts (ts);
+
+-- case 4: BC dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts) = date_trunc_interval(interval::interval, ts) AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamp '0055-6-07 15:44:17.71393 BC') ts (ts);
+
+-- truncate timestamps on arbitrary intervals
+SELECT
+  interval,
+  date_trunc_interval(interval::interval, ts)
+FROM (
+  VALUES
+  ('50 years'),
+  ('1.5 years'),
+  ('18 months'),
+  ('6 months'),
+  ('15 days'),
+  ('2 hours'),
+  ('15 minutes'),
+  ('10 seconds'),
+  ('100 milliseconds'),
+  ('250 microseconds')
+) intervals (interval),
+(SELECT TIMESTAMP '2020-02-11 15:44:17.71393') ts (ts);
+
+-- shift bins using the origin parameter:
+SELECT date_trunc_interval('5 min'::interval, timestamp '2020-02-1 01:01:01', timestamp '2020-02-01 00:02:30');
+SELECT date_trunc_interval('5 years'::interval, timestamp '2020-02-1 01:01:01', timestamp '2012-01-01');
+SELECT date_trunc_interval('3 year', timestamp '2015-01-14 20:38:40', timestamp '2012-01-15 01:01:01.123');
+SELECT date_trunc_interval('3 year', timestamp '2015-01-15 20:38:40', timestamp '2012-01-15 01:01:01.123');
+
+-- not defined
+SELECT date_trunc_interval('1 month 1 day', timestamp '2001-02-16 20:38:40.123456');
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp without time zone '1997-01-02' AS diff
   FROM TIMESTAMP_TBL
diff --git a/src/test/regress/sql/timestamptz.sql b/src/test/regress/sql/timestamptz.sql
index 300302dafd..f25451fd0f 100644
--- a/src/test/regress/sql/timestamptz.sql
+++ b/src/test/regress/sql/timestamptz.sql
@@ -193,6 +193,96 @@ SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-
 SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'GMT') as gmt_trunc;  -- fixed-offset abbreviation
 SELECT '' AS date_trunc_at_tz, date_trunc('day', timestamp with time zone '2001-02-16 20:38:40+00', 'VET') as vet_trunc;  -- variable-offset abbreviation
 
+-- verify date_trunc_interval behaves the same as date_trunc (excluding decade)
+
+-- case 1: AD dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '2020-02-29 15:44:17.71393+00') ts (ts);
+
+-- case 2: BC dates, origin < input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, timestamptz '2000-01-01+00 BC', 'Australia/Sydney') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '0055-6-10 15:44:17.71393+00 BC') ts (ts);
+
+-- case 3: AD dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '1999-12-31 15:44:17.71393+00') ts (ts);
+
+-- case 4: BC dates, origin > input
+SELECT
+  str,
+  interval,
+  date_trunc(str, ts, 'Australia/Sydney') = date_trunc_interval(interval::interval, ts, 'Australia/Sydney') AS equal
+FROM (
+  VALUES
+  ('millennium', '1000 y'),
+  ('century', '100 y'),
+  ('year', '1 y'),
+  ('quarter', '3 mon'),
+  ('month', '1 mon'),
+  ('week', '7 d'),
+  ('day', '1 d'),
+  ('hour', '1 h'),
+  ('minute', '1 m'),
+  ('second', '1 s'),
+  ('millisecond', '1 ms'),
+  ('microsecond', '1 us')
+) intervals (str, interval),
+(SELECT timestamptz '0055-6-07 15:44:17.71393+00 BC') ts (ts);
+
 -- Test casting within a BETWEEN qualifier
 SELECT '' AS "54", d1 - timestamp with time zone '1997-01-02' AS diff
   FROM TIMESTAMPTZ_TBL
