|
| 1 | +--- |
| 2 | +title: "Postgres DATEDIFF: Calculate Date Differences" |
| 3 | +description: "PostgreSQL has no DATEDIFF. Calculate date difference with date subtraction, AGE(), EXTRACT(EPOCH), DATE_PART, business days and a custom datediff() function." |
| 4 | +image: "/blog/image/9908.jpg" |
| 5 | +category: "Technical Article" |
| 6 | +date: August 23, 2026 |
| 7 | +--- |
| 8 | +[](https://app.chat2db.ai/) |
| 9 | +# Postgres DATEDIFF: Calculate Date Differences |
| 10 | + |
| 11 | +import Authors, { Author } from "components/authors"; |
| 12 | + |
| 13 | +<Authors date="August 23, 2026"> |
| 14 | + <Author name="Chat2DB Team" link="https://chat2db.ai" /> |
| 15 | +</Authors> |
| 16 | + |
| 17 | +If you arrive in PostgreSQL from SQL Server or MySQL, one of the first things you type is `SELECT DATEDIFF(day, start_date, end_date)` and one of the first errors you see is `function datediff(unknown, date, date) does not exist`. PostgreSQL simply does not ship a `DATEDIFF` function. Instead it relies on operator arithmetic: subtracting two dates returns an integer number of days, subtracting two timestamps returns an `interval`, and a handful of functions (`AGE()`, `EXTRACT()`, `DATE_PART()`, `justify_interval()`) turn those results into whatever unit you need. |
| 18 | + |
| 19 | +This article covers every practical way to calculate a date difference in PostgreSQL 14 through 17: days, hours, minutes and seconds, months and years, business days, a drop-in `datediff(unit, start, end)` function that mirrors SQL Server semantics, and the two places where people get wrong answers, namely timezones with DST and non-sargable `WHERE` clauses. |
| 20 | + |
| 21 | +## Sample data |
| 22 | + |
| 23 | +Run the following in any SQL client (you can paste it into Chat2DB, a free AI-powered SQL client, at https://app.chat2db.ai or after downloading from https://chat2db.ai/download) to follow along: |
| 24 | + |
| 25 | +```sql |
| 26 | +CREATE TABLE orders ( |
| 27 | + order_id serial PRIMARY KEY, |
| 28 | + ordered_at timestamptz NOT NULL, |
| 29 | + shipped_at timestamptz, |
| 30 | + due_date date NOT NULL |
| 31 | +); |
| 32 | + |
| 33 | +INSERT INTO orders (ordered_at, shipped_at, due_date) |
| 34 | +SELECT ts, |
| 35 | + ts + (g * interval '7 hours 13 minutes'), |
| 36 | + (ts + interval '10 days')::date |
| 37 | +FROM generate_series(1, 6) AS g, |
| 38 | + LATERAL (SELECT timestamptz '2024-03-08 09:15:00+00' + (g * interval '1 day')) AS s(ts); |
| 39 | + |
| 40 | +SELECT order_id, ordered_at, shipped_at, due_date FROM orders ORDER BY order_id; |
| 41 | +``` |
| 42 | + |
| 43 | +The session timezone matters for everything below; the examples assume `SET timezone = 'UTC';` unless stated otherwise. |
| 44 | + |
| 45 | +## date minus date returns integer days |
| 46 | + |
| 47 | +The simplest case is two `date` columns. The `-` operator returns an `integer`: |
| 48 | + |
| 49 | +```sql |
| 50 | +SELECT date '2024-03-20' - date '2024-03-08' AS days; -- 12 |
| 51 | +SELECT due_date - ordered_at::date AS days_to_due FROM orders; -- 10 for every row |
| 52 | +``` |
| 53 | + |
| 54 | +This is exactly what SQL Server's `DATEDIFF(day, a, b)` returns, including the sign: `b - a` is positive when `b` is later. Because the result is a plain integer, you can `SUM`, `AVG` or compare it directly without any casting. |
| 55 | + |
| 56 | +## timestamp minus timestamp returns an interval |
| 57 | + |
| 58 | +As soon as either operand is a `timestamp` or `timestamptz`, the result is an `interval`: |
| 59 | + |
| 60 | +```sql |
| 61 | +SELECT shipped_at - ordered_at AS lead_time FROM orders ORDER BY order_id; |
| 62 | +``` |
| 63 | + |
| 64 | +``` |
| 65 | + lead_time |
| 66 | +----------------- |
| 67 | + 07:13:00 |
| 68 | + 14:26:00 |
| 69 | + 21:39:00 |
| 70 | + 1 day 04:52:00 |
| 71 | + 1 day 12:05:00 |
| 72 | + 1 day 19:18:00 |
| 73 | +``` |
| 74 | + |
| 75 | +An interval is a structured value (months, days, microseconds), not a number, so you cannot average it into hours directly or compare it with `> 24`. You have to extract the unit you want, which is the job of the next sections. |
| 76 | + |
| 77 | +## AGE(): calendar-aware difference |
| 78 | + |
| 79 | +`AGE(later, earlier)` also returns an interval, but it normalizes the result into years, months and days the way a human would: |
| 80 | + |
| 81 | +```sql |
| 82 | +SELECT AGE(timestamp '2024-03-15', timestamp '2021-11-30'); |
| 83 | +-- 2 years 3 mons 15 days |
| 84 | + |
| 85 | +SELECT AGE(date '2021-11-30'); -- one-argument form: difference from CURRENT_DATE |
| 86 | +``` |
| 87 | + |
| 88 | +Compare this with the raw subtraction, which never produces months: |
| 89 | + |
| 90 | +```sql |
| 91 | +SELECT timestamp '2024-03-15' - timestamp '2021-11-30'; |
| 92 | +-- 836 days |
| 93 | +``` |
| 94 | + |
| 95 | +Use `AGE()` when you need "2 years 3 months" style output, such as a customer tenure or a person's age. Use subtraction when you need an exact number of days or seconds. |
| 96 | + |
| 97 | +## Days, hours, minutes, seconds with EXTRACT(EPOCH) |
| 98 | + |
| 99 | +The universal way to turn any interval into a number is `EXTRACT(EPOCH FROM interval)`, which returns the total number of seconds (as `numeric` in PostgreSQL 14+, `double precision` before). Divide to get the unit you want: |
| 100 | + |
| 101 | +```sql |
| 102 | +SELECT order_id, |
| 103 | + EXTRACT(EPOCH FROM (shipped_at - ordered_at)) AS seconds, |
| 104 | + EXTRACT(EPOCH FROM (shipped_at - ordered_at)) / 60 AS minutes, |
| 105 | + EXTRACT(EPOCH FROM (shipped_at - ordered_at)) / 3600 AS hours, |
| 106 | + EXTRACT(EPOCH FROM (shipped_at - ordered_at)) / 86400 AS days |
| 107 | +FROM orders |
| 108 | +ORDER BY order_id; |
| 109 | +``` |
| 110 | + |
| 111 | +``` |
| 112 | + order_id | seconds | minutes | hours | days |
| 113 | +----------+---------+---------+--------+--------- |
| 114 | + 1 | 25980 | 433 | 7.216 | 0.30069 |
| 115 | + 2 | 51960 | 866 | 14.433 | 0.60138 |
| 116 | + 3 | 77940 | 1299 | 21.650 | 0.90208 |
| 117 | + ... |
| 118 | +``` |
| 119 | + |
| 120 | +Wrap in `FLOOR()`, `ROUND()` or cast to `integer` depending on whether you want truncation or rounding. This is also the correct approach for an average lead time in hours: |
| 121 | + |
| 122 | +```sql |
| 123 | +SELECT ROUND(AVG(EXTRACT(EPOCH FROM (shipped_at - ordered_at)) / 3600), 2) AS avg_hours |
| 124 | +FROM orders; |
| 125 | +``` |
| 126 | + |
| 127 | +`EXTRACT(EPOCH FROM timestamptz)` also works on a single value, giving seconds since 1970-01-01 UTC, so `EXTRACT(EPOCH FROM b) - EXTRACT(EPOCH FROM a)` is an alternative that avoids the intermediate interval. |
| 128 | + |
| 129 | +## DATE_PART and the "field of an interval" trap |
| 130 | + |
| 131 | +`DATE_PART('field', source)` is an older alias for `EXTRACT` and returns `double precision`. It is tempting to write: |
| 132 | + |
| 133 | +```sql |
| 134 | +SELECT DATE_PART('day', shipped_at - ordered_at) FROM orders; -- careful |
| 135 | +``` |
| 136 | + |
| 137 | +This returns only the *days component* of the interval, not the total days. For order 1 (`07:13:00`) it returns 0, and for an interval of `1 mon 5 days` it returns 5, ignoring the month entirely. The same is true of `EXTRACT(DAY FROM interval)` and `EXTRACT(HOUR FROM interval)`. Only `EPOCH` gives you a total across all fields. A related pitfall: `DATE_PART('day', ts)` on a timestamp returns the day of the month, not a difference. |
| 138 | + |
| 139 | +## Difference in months and years |
| 140 | + |
| 141 | +Months and years are not fixed numbers of seconds, so `EPOCH` cannot give an exact answer. Combine `AGE()` with `EXTRACT`: |
| 142 | + |
| 143 | +```sql |
| 144 | +WITH d AS (SELECT AGE(date '2024-03-15', date '2021-11-30') AS a) |
| 145 | +SELECT EXTRACT(YEAR FROM a) AS years, -- 2 |
| 146 | + EXTRACT(YEAR FROM a) * 12 + EXTRACT(MONTH FROM a) AS total_months -- 27 |
| 147 | +FROM d; |
| 148 | +``` |
| 149 | + |
| 150 | +`AGE()` only counts a full month once the day of month has been reached, so 2024-03-15 minus 2021-11-30 is 27 months, not 28. If you instead want SQL Server's `DATEDIFF(month, ...)`, which counts month *boundaries crossed* regardless of the day, use the year and month fields of each date: |
| 151 | + |
| 152 | +```sql |
| 153 | +SELECT (EXTRACT(YEAR FROM date '2024-03-15') - EXTRACT(YEAR FROM date '2021-11-30')) * 12 |
| 154 | + + (EXTRACT(MONTH FROM date '2024-03-15') - EXTRACT(MONTH FROM date '2021-11-30')) AS month_boundaries; -- 28 |
| 155 | +``` |
| 156 | + |
| 157 | +Both are legitimate; pick the one whose definition matches the report you are producing. |
| 158 | + |
| 159 | +## justify_interval for readable output |
| 160 | + |
| 161 | +The raw difference between two timestamps is expressed in days and time only, which is why `836 days` appeared earlier. `justify_interval()` rolls 24-hour blocks into days and 30-day blocks into months for display: |
| 162 | + |
| 163 | +```sql |
| 164 | +SELECT justify_interval(timestamp '2024-03-15' - timestamp '2021-11-30'); |
| 165 | +-- 2 years 3 mons 26 days |
| 166 | +``` |
| 167 | + |
| 168 | +Note the result differs from `AGE()` (26 days instead of 15) because `justify_interval` assumes every month has exactly 30 days. It is a presentation helper, not a calendar calculation. `justify_hours()` and `justify_days()` do only one of the two conversions. |
| 169 | + |
| 170 | +## Business days between two dates |
| 171 | + |
| 172 | +Counting weekdays is a common DATEDIFF follow-up. `generate_series` with a `date` step plus a weekday filter is the clearest approach: |
| 173 | + |
| 174 | +```sql |
| 175 | +SELECT count(*) AS business_days |
| 176 | +FROM generate_series(date '2024-03-08', date '2024-03-20' - 1, interval '1 day') AS d |
| 177 | +WHERE EXTRACT(ISODOW FROM d) < 6; -- 1 = Monday ... 7 = Sunday |
| 178 | +-- 8 |
| 179 | +``` |
| 180 | + |
| 181 | +The `- 1` excludes the end date (start inclusive, end exclusive), which matches how most lead-time calculations work; drop it if both ends should count. To exclude public holidays, `LEFT JOIN` a `holidays(holiday_date date)` table and add `AND h.holiday_date IS NULL`. For large tables, wrap this in a SQL function marked `IMMUTABLE` and call it per row, or precompute a calendar table with a running weekday counter and subtract two lookups, which is far cheaper than generating a series per row. |
| 182 | + |
| 183 | +## A drop-in datediff(unit, start, end) function |
| 184 | + |
| 185 | +If you are porting code, a compatibility function saves a lot of rewriting. This version mirrors SQL Server semantics: it counts *boundaries crossed*, so `datediff('year', '2023-12-31', '2024-01-01')` is 1. |
| 186 | + |
| 187 | +```sql |
| 188 | +CREATE OR REPLACE FUNCTION datediff(unit text, start_ts timestamptz, end_ts timestamptz) |
| 189 | +RETURNS bigint |
| 190 | +LANGUAGE sql |
| 191 | +IMMUTABLE |
| 192 | +AS $$ |
| 193 | + SELECT CASE lower(unit) |
| 194 | + WHEN 'year' THEN (EXTRACT(YEAR FROM end_ts) - EXTRACT(YEAR FROM start_ts))::bigint |
| 195 | + WHEN 'month' THEN ((EXTRACT(YEAR FROM end_ts) - EXTRACT(YEAR FROM start_ts)) * 12 |
| 196 | + + EXTRACT(MONTH FROM end_ts) - EXTRACT(MONTH FROM start_ts))::bigint |
| 197 | + WHEN 'day' THEN (end_ts::date - start_ts::date)::bigint |
| 198 | + WHEN 'hour' THEN (EXTRACT(EPOCH FROM date_trunc('hour', end_ts)) |
| 199 | + - EXTRACT(EPOCH FROM date_trunc('hour', start_ts)))::bigint / 3600 |
| 200 | + WHEN 'minute' THEN (EXTRACT(EPOCH FROM date_trunc('minute', end_ts)) |
| 201 | + - EXTRACT(EPOCH FROM date_trunc('minute', start_ts)))::bigint / 60 |
| 202 | + WHEN 'second' THEN (EXTRACT(EPOCH FROM date_trunc('second', end_ts)) |
| 203 | + - EXTRACT(EPOCH FROM date_trunc('second', start_ts)))::bigint |
| 204 | + ELSE NULL |
| 205 | + END |
| 206 | +$$; |
| 207 | + |
| 208 | +SELECT datediff('day', ordered_at, shipped_at) AS d, |
| 209 | + datediff('hour', ordered_at, shipped_at) AS h, |
| 210 | + datediff('month', ordered_at, due_date) AS m |
| 211 | +FROM orders ORDER BY order_id; |
| 212 | +``` |
| 213 | + |
| 214 | +Two honest caveats. First, `EXTRACT(YEAR ...)` and `::date` on a `timestamptz` depend on the session timezone, so the function is not truly immutable unless you pin the timezone (declare it with `SET timezone = 'UTC'` on the function or pass `AT TIME ZONE 'UTC'` inside). Declare it `STABLE` if you cannot guarantee that. Second, if you prefer MySQL semantics, where `DATEDIFF(a, b)` is just `a - b` in days, you do not need a function at all: `a::date - b::date` is already identical. |
| 215 | + |
| 216 | +## Timezone and DST pitfalls |
| 217 | + |
| 218 | +`timestamptz` is stored as an instant in UTC and displayed in the session timezone. Subtracting two `timestamptz` values gives the real elapsed time, which is usually what you want for lead times, but it can surprise you around DST transitions: |
| 219 | + |
| 220 | +```sql |
| 221 | +SET timezone = 'America/New_York'; |
| 222 | +SELECT timestamptz '2024-03-11 00:00' - timestamptz '2024-03-10 00:00'; -- 23:00:00 |
| 223 | +SELECT (timestamptz '2024-03-11 00:00')::date - (timestamptz '2024-03-10 00:00')::date; -- 1 |
| 224 | +``` |
| 225 | + |
| 226 | +Clocks sprang forward on 2024-03-10, so only 23 hours elapsed between the two midnights. `EXTRACT(EPOCH ...) / 86400` gives 0.958 days, while the `date` subtraction gives 1. Decide explicitly which you mean: elapsed physical time (subtract `timestamptz`) or calendar days (cast to `date` in a specific timezone, for example `(ordered_at AT TIME ZONE 'America/New_York')::date`). Also note that `timestamp` without time zone knows nothing about DST, so subtracting two of those always yields a nominal 24 hours per day, which is wrong if the values actually represent local wall-clock times in a DST zone. |
| 227 | + |
| 228 | +## Performance: filter by range, not by DATEDIFF |
| 229 | + |
| 230 | +The most expensive way to use a date difference is inside `WHERE`: |
| 231 | + |
| 232 | +```sql |
| 233 | +-- Slow: the function hides the column from the index |
| 234 | +SELECT * FROM orders WHERE EXTRACT(EPOCH FROM (now() - ordered_at)) / 86400 > 30; |
| 235 | +SELECT * FROM orders WHERE now()::date - ordered_at::date > 30; |
| 236 | +``` |
| 237 | + |
| 238 | +Both force a sequential scan and evaluate the expression for every row. Rewrite the condition as a range on the bare column so a B-tree index on `ordered_at` can be used: |
| 239 | + |
| 240 | +```sql |
| 241 | +CREATE INDEX orders_ordered_at_idx ON orders (ordered_at); |
| 242 | + |
| 243 | +-- Fast: sargable |
| 244 | +SELECT * FROM orders WHERE ordered_at < now() - interval '30 days'; |
| 245 | +``` |
| 246 | + |
| 247 | +The same applies to `BETWEEN` windows ("orders due within 7 days") and to joins on date differences. If you truly need to filter or sort by a computed difference between two columns of the same row, such as `shipped_at - ordered_at`, you can create an expression index on that exact expression, or store a generated column: `ALTER TABLE orders ADD COLUMN lead_time interval GENERATED ALWAYS AS (shipped_at - ordered_at) STORED;` and index that. Check with `EXPLAIN (ANALYZE, BUFFERS)` that the plan switched from `Seq Scan` to `Index Scan` or `Bitmap Heap Scan`. |
| 248 | + |
| 249 | +## Quick reference |
| 250 | + |
| 251 | +| Need | Expression | |
| 252 | +| --- | --- | |
| 253 | +| Days between two dates | `end_date - start_date` (integer) | |
| 254 | +| Days between two timestamps | `EXTRACT(EPOCH FROM (b - a)) / 86400` or `b::date - a::date` | |
| 255 | +| Hours / minutes / seconds | `EXTRACT(EPOCH FROM (b - a)) / 3600`, `/ 60`, as-is | |
| 256 | +| Human-readable years/months/days | `AGE(b, a)` | |
| 257 | +| Total months | `EXTRACT(YEAR FROM AGE(b,a)) * 12 + EXTRACT(MONTH FROM AGE(b,a))` | |
| 258 | +| Business days | `generate_series` + `EXTRACT(ISODOW ...) < 6` | |
| 259 | +| SQL Server-style units | custom `datediff(unit, a, b)` function | |
| 260 | + |
| 261 | +## FAQ |
| 262 | + |
| 263 | +### Does PostgreSQL have a DATEDIFF function? |
| 264 | + |
| 265 | +No. PostgreSQL has no built-in `DATEDIFF`. Use `end - start` (integer days for `date`, an `interval` for timestamps), `AGE()` for calendar-style differences, and `EXTRACT(EPOCH FROM ...)` divided by 60, 3600 or 86400 for minutes, hours or days. You can create your own `datediff(unit, start, end)` function as shown above if you are migrating code from SQL Server or MySQL. |
| 266 | + |
| 267 | +### What is the difference between AGE() and timestamp subtraction? |
| 268 | + |
| 269 | +Subtraction returns the exact elapsed interval expressed only in days and time (for example `836 days 03:00:00`). `AGE()` returns a calendar-normalized interval with years and months (for example `2 years 3 mons 15 days`), computed the way people count birthdays. Use subtraction for precise durations and `AGE()` for human-facing tenure or age values. |
| 270 | + |
| 271 | +### How do I get the difference in days as an integer from two timestamptz columns? |
| 272 | + |
| 273 | +Either cast both to `date` and subtract (`b::date - a::date`), which counts calendar-day boundaries in the session timezone, or compute `FLOOR(EXTRACT(EPOCH FROM (b - a)) / 86400)::int`, which counts full 24-hour periods of elapsed time. They differ by one on DST transition days and whenever the times of day differ, so choose based on whether you need calendar days or elapsed days. |
| 274 | + |
| 275 | +## Conclusion |
| 276 | + |
| 277 | +PostgreSQL replaces `DATEDIFF` with a small set of composable tools: operator subtraction for days and intervals, `AGE()` for calendar differences, `EXTRACT(EPOCH FROM ...)` for numeric seconds, minutes, hours and days, and `justify_interval()` for display. Months and years need `AGE()` plus `EXTRACT`, business days need `generate_series` with an `ISODOW` filter, and a ten-line SQL function gives you familiar `datediff(unit, start, end)` semantics for ported code. Keep `timestamptz` and DST in mind when an off-by-one-hour result appears, and keep date arithmetic out of `WHERE` clauses so your indexes stay usable. |
0 commit comments