INTERVAL Data Type in PostgreSQL

Learn how to store and manipulate time periods in PostgreSQL using the INTERVAL data type: syntax, input formats, date arithmetic, extraction, and display formatting.

5 min read · Back to overview

Quick Answer

The PostgreSQL INTERVAL type stores a period of time expressed in years, months, days, hours, minutes, and seconds. It uses 16 bytes of storage and supports arithmetic with DATE and TIMESTAMP values, making it essential for calculating expiry dates, durations, and elapsed time.

Spin up a Postgres database in 20 seconds with Vela.

Try Vela Sandbox

The INTERVAL data type stores a duration — a period of time rather than a specific point in time. Intervals are essential for date arithmetic: calculating expiry dates, measuring elapsed time, filtering recent records, and aggregating durations.

Syntax

column_name INTERVAL
column_name INTERVAL(precision)    -- limit fractional seconds (0–6)

PostgreSQL accepts several syntaxes for interval literals:

-- Verbose (postgres) format
INTERVAL '1 year 2 months 3 days'
INTERVAL '3 hours 20 minutes'
INTERVAL '30 days'

-- ISO 8601 designator format
INTERVAL 'P1Y2M3DT4H5M6S'

-- ISO 8601 alternative format
INTERVAL 'P0001-02-03T04:05:06'

Practical Example

Track subscription durations in an orders table:

CREATE TABLE subscriptions (
  id                SERIAL PRIMARY KEY,
  product_sku       VARCHAR(50)  NOT NULL,
  started_at        TIMESTAMPTZ  NOT NULL DEFAULT now(),
  billing_interval  INTERVAL     NOT NULL,
  expires_at        TIMESTAMPTZ  GENERATED ALWAYS AS
                    (started_at + billing_interval) STORED
);

INSERT INTO subscriptions (product_sku, billing_interval)
VALUES
  ('PLAN-MONTHLY', '1 month'),
  ('PLAN-ANNUAL',  '1 year'),
  ('PLAN-TRIAL',   '14 days');

SELECT
  product_sku,
  started_at::date   AS start_date,
  billing_interval,
  expires_at::date   AS expiry_date
FROM subscriptions;

Interval Arithmetic with Dates and Timestamps

Intervals are most useful combined with TIMESTAMP or DATE values:

-- Orders placed in the last 7 days
SELECT id, product_sku, placed_at
FROM orders
WHERE placed_at >= now() - INTERVAL '7 days';

-- Add 30 days to find the ship-by date
SELECT
  id,
  order_date,
  order_date + INTERVAL '30 days' AS ship_by_date
FROM orders;

-- Interval arithmetic
SELECT INTERVAL '2h 50m' + INTERVAL '10m';   -- 03:00:00
SELECT 600 * INTERVAL '1 minute';            -- 10:00:00

Extracting Interval Components

-- Extract hours and minutes from a stored interval
SELECT
  product_sku,
  billing_interval,
  EXTRACT(DAY    FROM billing_interval) AS days,
  EXTRACT(HOUR   FROM billing_interval) AS hours,
  EXTRACT(MINUTE FROM billing_interval) AS minutes
FROM subscriptions;

Normalizing and Formatting Intervals

-- Convert 30 days to "1 month"
SELECT justify_days(INTERVAL '30 days');     -- 1 mon

-- Convert 24 hours to "1 day"
SELECT justify_hours(INTERVAL '24 hours');   -- 1 day

-- Apply both normalizations
SELECT justify_interval(INTERVAL '1 year -1 hour');
-- 11 mons 29 days 23:00:00

-- Format as HH:MM:SS string
SELECT TO_CHAR(INTERVAL '2 hours 15 minutes 30 seconds', 'HH24:MI:SS');
-- 02:15:30

Controlling Interval Display

SET intervalstyle = 'iso_8601';
SELECT INTERVAL '6 years 5 months 4 days 3 hours 2 minutes 1 second';
-- P6Y5M4DT3H2M1S

SET intervalstyle = 'postgres';  -- default
SELECT INTERVAL '6 years 5 months 4 days 3 hours 2 minutes 1 second';
-- 6 years 5 mons 4 days 03:02:01

Testing with Vela

Interval-based expiry logic often breaks on edge cases: end-of-month boundaries ('1 month' from January 31 lands on March 2 or March 3), daylight saving transitions, and negative durations. Before deploying subscription billing logic to production, test on a database branch with a variety of boundary dates to catch month/year-end edge cases before they appear in billing.

Production Tips

  • Use INTERVAL literals instead of integer day counts for date arithmetic — they handle month and year boundaries correctly where plain integers do not.
  • Avoid storing durations as plain integers (e.g. seconds) — you lose PostgreSQL’s built-in interval functions and comparisons.
  • Use justify_interval() before displaying durations to users so “30 days” shows as “1 month” rather than an ambiguous day count.
  • When filtering by recency (WHERE placed_at >= now() - INTERVAL '30 days'), verify the query uses an index on the timestamp column with EXPLAIN ANALYZE.
  • Store billing intervals as INTERVAL columns rather than hardcoding them in application logic — this allows per-subscription billing cycles and database-level consistency.

Continue in PostgreSQL Data Types: TIME.

Related in this section: Boolean · CHAR, VARCHAR, and TEXT · NUMERIC

Frequently Asked Questions

What is the PostgreSQL INTERVAL data type?
INTERVAL stores a duration — a period of time rather than a point in time. It can represent periods like '2 months', '3 hours 20 minutes', or '1 year 4 days'. PostgreSQL stores intervals internally as months, days, and seconds. The type requires 16 bytes and supports a range of roughly -178,000,000 to +178,000,000 years.
Does arithmetic on INTERVAL columns lock the table?
No. INTERVAL arithmetic happens at query evaluation time without any table locks beyond normal read semantics. Queries that add or subtract INTERVAL values from TIMESTAMPTZ columns are pure computation.
What happens when I extract parts from an INTERVAL value?
EXTRACT(field FROM interval) returns the value of the specified component as a double precision number. For example, EXTRACT(MINUTE FROM INTERVAL '5 hours 21 minutes') returns 21. Only the specified component is returned — extracting HOUR from '5 hours 21 minutes' returns 5, not 321 total minutes.
Can I store an INTERVAL with sub-second precision?
Yes. INTERVAL supports microsecond precision by default. An optional precision parameter (0–6) limits the fractional digits retained in the seconds field: INTERVAL(0) stores whole seconds only.
What is the safest way to use INTERVAL for subscription expiry in production?
Store the expiry as a TIMESTAMPTZ column and calculate it as created_at + subscription_interval at write time. Index the expiry column and query WHERE expires_at < now() to find expired records efficiently. Avoid computing expiry dynamically at query time to keep the query plan stable.