Mert Özen Further With Every Line
Database PostgreSQL — #19

Dates, Times and Intervals in PostgreSQL: now() vs clock_timestamp(), date_trunc, extract, AT TIME ZONE, age, date_bin and generate_series

Mert Özen Nov 15, 2026 16 min 11 views
Dates, Times and Intervals in PostgreSQL: now() vs clock_timestamp(), date_trunc, extract, AT TIME ZONE, age, date_bin and generate_series

In PostgreSQL, now() returns the moment the transaction started and does not change during the transaction; clock_timestamp() gives the real clock. Both directions of AT TIME ZONE, reports by Istanbul day, month and daylight saving rules, age and justify_interval, extract, time buckets with date_trunc and date_bin, filling empty days with generate_series, and why a WHERE with date_trunc cannot use an index, with SQL Server equivalents.

Version note: The examples in this post were run on PostgreSQL 18.7 (the official postgres:18 Docker image), in the Bookstore lab we set up in post 02, right after reloading the schema; the session time zone is the lab default, Etc/UTC.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, has a nightly import job that writes "the moment it was processed" into every row, using now(). The job runs in a single transaction. In one transaction we read three clocks, waited a second and a half, and read them again:

BEGIN;
SELECT now(), statement_timestamp(), clock_timestamp();
SELECT pg_sleep(1.5);
SELECT now(), statement_timestamp(), clock_timestamp();
COMMIT;

Which values change on the second read? If the job took an hour, what would "the moment it was processed" be in all the rows?

              now              |     statement_timestamp      |        clock_timestamp
-------------------------------+------------------------------+-------------------------------
 2026-11-15 00:13:15.353291+00 | 2026-11-15 00:13:15.35344+00 | 2026-11-15 00:13:15.353864+00

              now              |      statement_timestamp      |        clock_timestamp
-------------------------------+-------------------------------+-------------------------------
 2026-11-15 00:13:15.353291+00 | 2026-11-15 00:13:16.863345+00 | 2026-11-15 00:13:16.863506+00

now() returns the moment the transaction started and does not change until the transaction ends; every row of an hour-long job would get the same time. statement_timestamp() is the moment the statement started, and clock_timestamp() is the real clock at the moment of the call; it can change from row to row even within a single statement. The behavior is deliberate: most of the time you want everything a transaction writes to carry the same "moment". If you need the real time per row, use clock_timestamp(). In this post we look at PostgreSQL's date, time and interval functions, day boundaries with time zones, and filling empty days in reports. We covered the types themselves (the difference between timestamp and timestamptz) in post 03, and the T-SQL equivalents in the "T-SQL Tarih ve Saat Function'ları" post of the SQL Temelleri series.

The Current Time: now(), current_date and localtimestamp

SELECT now() = transaction_timestamp() AS same, current_timestamp, localtimestamp, current_date,
       pg_typeof(localtimestamp) AS localtimestamp_type;
 same |       current_timestamp       |       localtimestamp       | current_date |     localtimestamp_type
------+-------------------------------+----------------------------+--------------+-----------------------------
 t    | 2026-11-15 00:13:16.864018+00 | 2026-11-15 00:13:16.864018 | 2026-11-15   | timestamp without time zone

now(), transaction_timestamp() and the standard spelling current_timestamp are the same thing; all three are timestamptz. localtimestamp is the wall clock of the same moment in the session time zone, typed as a timestamp without time zone. current_date is today's date in the session time zone too: if the session is in UTC while it is 02:30 in Istanbul, current_date is still "yesterday". In SQL Server, GETDATE() and SYSDATETIME() return the server's local time and SYSUTCDATETIME() the UTC time; in PostgreSQL a timestamptz already is a moment, so there is no separate "UTC version", only the display changes.

Time Zones: the TimeZone Setting and Both Directions of AT TIME ZONE

Bookstore's reports count days in Turkish time. An order placed at 22:30 UTC on 30 September falls on 1 October in Istanbul:

SHOW TimeZone;
SELECT ts AS utc_instant,
       ts AT TIME ZONE 'Europe/Istanbul' AS istanbul_wall_clock,
       timestamp '2026-10-01 00:00' AT TIME ZONE 'Europe/Istanbul' AS istanbul_midnight,
       date_trunc('day', ts) AS trunc_session_tz,
       date_trunc('day', ts, 'Europe/Istanbul') AS trunc_istanbul
FROM (VALUES (timestamptz '2026-09-30 22:30:00+00')) AS v(ts);
 TimeZone
----------
 Etc/UTC

      utc_instant       | istanbul_wall_clock |   istanbul_midnight    |    trunc_session_tz    |     trunc_istanbul
------------------------+---------------------+------------------------+------------------------+------------------------
 2026-09-30 22:30:00+00 | 2026-10-01 01:30:00 | 2026-09-30 21:00:00+00 | 2026-09-30 00:00:00+00 | 2026-09-30 21:00:00+00

AT TIME ZONE works in two directions, and the result type flips with the direction. timestamptz AT TIME ZONE 'Europe/Istanbul' takes a moment and answers "what did the wall clock show in Istanbul at that moment" with a timestamp without time zone (01:30). The other way round, timestamp AT TIME ZONE 'Europe/Istanbul' takes a wall clock and answers "which moment is midnight in Istanbul" with a timestamptz (21:00 UTC). date_trunc('day', ...) truncates in the session time zone by default; the third argument lets you give the time zone explicitly, and the result is the moment of midnight in Istanbul. Always write that argument or AT TIME ZONE explicitly, so that the result of a report does not depend on the TimeZone setting of the client that runs it.

The effect on a report, for the orders of the 48 hours around 30 September and 1 October:

SELECT (created_at AT TIME ZONE 'Europe/Istanbul')::date = date '2026-10-01' AS istanbul_oct1,
       created_at::date = date '2026-10-01' AS utc_oct1,
       count(*), min(order_number), max(order_number)
FROM orders
WHERE created_at >= '2026-09-30 00:00+00' AND created_at < '2026-10-02 00:00+00'
GROUP BY 1, 2
ORDER BY 1, 2;
 istanbul_oct1 | utc_oct1 | count |     min     |     max
---------------+----------+-------+-------------+-------------
 f             | f        |   630 | BK-00984961 | BK-00985590
 f             | t        |    90 | BK-00986311 | BK-00986400
 t             | f        |    90 | BK-00985591 | BK-00985680
 t             | t        |   630 | BK-00985681 | BK-00986310

The lab has one order every two minutes, so 720 a day. If "1 October" is counted in UTC, 90 orders placed on the night of 30 September in Istanbul are left out, and 90 orders that already belong to 2 October in Istanbul are counted in. Because the total is the same, the mistake does not stand out; but daily revenue, sales on a campaign day or a "today's orders" screen are wrong. Istanbul has not used daylight saving time since 2016, so the difference is a constant three hours; for customers in Germany and the Netherlands it changes twice a year. That is why using the time zone name ('Europe/Istanbul') instead of a fixed offset such as '+03' matters.

Interval Arithmetic: Adding Months, age and justify

SELECT date '2026-01-31' + interval '1 month' AS jan31_plus_1m,
       date '2026-01-31' + interval '1 month' + interval '1 month' AS plus_twice,
       date '2026-01-31' + interval '2 months' AS plus_2m,
       date '2026-03-01' - date '2026-02-01' AS days_between,
       pg_typeof(date '2026-03-01' - date '2026-02-01') AS diff_type;
SELECT timestamptz '2026-10-20 21:18:00+00' - timestamptz '2023-01-01 00:00:00+00' AS diff,
       age(timestamptz '2026-10-20 21:18:00+00', timestamptz '2023-01-01 00:00:00+00') AS age_result,
       justify_interval(interval '50 days 30 hours') AS justified,
       extract(epoch FROM timestamptz '2026-10-20 21:18:00+00' - timestamptz '2023-01-01 00:00:00+00') / 86400 AS days;
    jan31_plus_1m    |     plus_twice      |       plus_2m       | days_between | diff_type
---------------------+---------------------+---------------------+--------------+-----------
 2026-02-28 00:00:00 | 2026-03-28 00:00:00 | 2026-03-31 00:00:00 |           28 | integer

        diff        |           age_result            |       justified        |         days
--------------------+---------------------------------+------------------------+-----------------------
 1388 days 21:18:00 | 3 years 9 mons 19 days 21:18:00 | 1 mon 21 days 06:00:00 | 1388.8875000000000000

Adding one month to 31 January lands on the last day of the month, 28 February, because February has no 31st. Adding another month gives 28 March, while adding two months at once gives 31 March: when calculating monthly subscription or instalment dates, always add "n months" to the start date, never to the previous result. Another thing to watch is the type: date + interval returns a timestamp, while date - date returns the number of days as an integer. The difference between two timestamptz values is an interval made of days and hours; age expresses the same difference in years and months. Between the first and the last order in the lab there are 1388 days, or "3 years 9 months 19 days". justify_interval turns 30 hours into a day and 6 hours, and 30 days into a month. If you need total seconds, use extract(epoch FROM ...).

SQL Server's DATEDIFF answers a different question: how many boundaries are crossed. DATEDIFF(month, '2026-01-31', '2026-02-01') returns 1 because a month boundary is crossed; in PostgreSQL, age says 1 day for the same two dates. When porting code, check what "difference in months" is supposed to mean.

Daylight Saving Time: interval '1 day' Is Not interval '24 hours'

In Germany the clocks go forward one hour on the night of 29 March 2026. In a session set to Berlin time we added one day and 24 hours to the same moment, then did the same with the time zone argument of date_add, added in PostgreSQL 16, while the session was in UTC:

SET TimeZone = 'Europe/Berlin';
SELECT ts + interval '1 day' AS plus_1_day, ts + interval '24 hours' AS plus_24_hours
FROM (VALUES (timestamptz '2026-03-28 12:00 Europe/Berlin')) AS v(ts);
RESET TimeZone;
SELECT date_add(timestamptz '2026-03-28 11:00+00', interval '1 day', 'Europe/Berlin') AS date_add_berlin,
       timestamptz '2026-03-28 11:00+00' + interval '1 day' AS plus_in_utc;
       plus_1_day       |     plus_24_hours
------------------------+------------------------
 2026-03-29 12:00:00+02 | 2026-03-29 13:00:00+02

    date_add_berlin     |      plus_in_utc
------------------------+------------------------
 2026-03-29 10:00:00+00 | 2026-03-29 11:00:00+00

'1 day' means "the same wall-clock time tomorrow", and since that day lasts 23 hours it lands at 12:00 noon; '24 hours' adds exactly 24 hours and gives 13:00. timestamptz + interval computes the day part in the session time zone, so the same query gives a different result in a UTC session: date_add with Berlin rules found 10:00 UTC, plain addition 11:00 UTC. If you show the customer something like "delivery tomorrow at the same time", use date_add/date_subtract with an explicit time zone.

Parts and Constructors: extract, make_date, to_char

SELECT extract(year FROM ts) AS y, extract(isodow FROM ts) AS isodow, extract(dow FROM ts) AS dow,
       extract(week FROM ts) AS iso_week, extract(epoch FROM ts) AS epoch, date_part('hour', ts) AS h,
       pg_typeof(extract(epoch FROM ts)) AS extract_type, pg_typeof(date_part('hour', ts)) AS date_part_type
FROM (VALUES (timestamptz '2026-11-15 09:45:30+00')) AS v(ts);
SELECT make_date(2026, 2, 29);
SELECT make_date(2026, 12, 31) AS d, make_timestamptz(2026, 11, 15, 9, 30, 0, 'Europe/Istanbul') AS tstz,
       make_interval(days => 10, hours => 3) AS iv,
       date_trunc('month', date '2026-02-14') + interval '1 month - 1 day' AS end_of_month,
       (date_trunc('month', date '2026-02-14') + interval '1 month - 1 day')::date AS end_of_month_date;
  y   | isodow | dow | iso_week |       epoch       | h | extract_type |  date_part_type
------+--------+-----+----------+-------------------+---+--------------+------------------
 2026 |      7 |   0 |       46 | 1794735930.000000 | 9 | numeric      | double precision

ERROR:  date field value out of range: 2026-02-29

     d      |          tstz          |        iv        |      end_of_month      | end_of_month_date
------------+------------------------+------------------+------------------------+-------------------
 2026-12-31 | 2026-11-15 06:30:00+00 | 10 days 03:00:00 | 2026-02-28 00:00:00+00 | 2026-02-28

15 November 2026 is a Sunday: dow counts Sunday as 0, while isodow, which follows the ISO 8601 week, gives 7. For Turkish reports, where the week starts on Monday, isodow and week (the ISO week number) are the right choice. extract returns numeric, the older date_part returns double precision; the documentation recommends extract because of the precision loss. make_date does not silently shift an invalid date, it raises an error. SQL Server's EOMONTH has no single-function equivalent: truncate to the start of the month, add one month, subtract one day. Watch the type of the result: given a date, date_trunc converts it to timestamptz and truncates that, so the result is a timestamptz; if you need a date, add ::date at the end.

To turn a date into text, use to_char:

SELECT to_char(ts AT TIME ZONE 'Europe/Istanbul', 'DD.MM.YYYY HH24:MI') AS tr_format,
       to_char(ts, 'YYYY-MM-DD"T"HH24:MI:SSOF') AS iso_like,
       to_char(ts, 'TMDay, DD TMMonth YYYY') AS long_en,
       to_char(ts, 'IYYY-"W"IW') AS iso_week
FROM (VALUES (timestamptz '2026-11-15 09:45:30+00')) AS v(ts);
    tr_format     |        iso_like        |         long_en          | iso_week
------------------+------------------------+--------------------------+----------
 15.11.2026 12:45 | 2026-11-15T09:45:30+00 | Sunday, 15 November 2026 | 2026-W46

to_char formats a timestamptz in the session time zone; to show Istanbul time, apply AT TIME ZONE first. The TM prefix writes day and month names in the language of the lc_time setting; as we saw in post 18, the official image only has the en_US locale, so Turkish month names do not come out. That is another reason to produce Turkish date text in the application. Do not convert dates to text for comparison or sorting; do those on the timestamptz.

Time Buckets: date_trunc, date_bin and generate_series

Grouping time in reports means splitting it into intervals, called buckets in time series analysis. For calendar units such as day, week and month use date_trunc; for fixed lengths such as 15 minutes or 6 hours, use date_bin, added in PostgreSQL 14 (the counterpart of DATE_BUCKET in SQL Server 2022):

SELECT date_bin(interval '15 minutes', created_at, timestamptz '2026-01-01') AS bucket, count(*)
FROM orders
WHERE created_at >= '2026-10-20 20:00+00'
GROUP BY 1
ORDER BY 1;
SELECT d AS berlin_midnight
FROM generate_series(timestamptz '2026-03-28 00:00 Europe/Berlin', timestamptz '2026-03-31 00:00 Europe/Berlin',
                     interval '1 day', 'Europe/Berlin') AS d;
         bucket         | count
------------------------+-------
 2026-10-20 20:00:00+00 |     8
 2026-10-20 20:15:00+00 |     7
 2026-10-20 20:30:00+00 |     8
 2026-10-20 20:45:00+00 |     7
 2026-10-20 21:00:00+00 |     8
 2026-10-20 21:15:00+00 |     2

    berlin_midnight
------------------------
 2026-03-27 23:00:00+00
 2026-03-28 23:00:00+00
 2026-03-29 22:00:00+00
 2026-03-30 22:00:00+00

The third argument of date_bin is the origin the buckets are aligned to; units of a month or larger are not accepted. The last order in the lab is at 21:18, which is why the last bucket ends with two orders. generate_series works with timestamps too and has taken a time zone argument since PostgreSQL 16: after the switch to daylight saving time, Berlin midnights moved from 23:00 to 22:00 UTC; the steps are "one calendar day", not 24 hours.

Bookstore: Filling Empty Days with Zero

In a daily sales chart, a day without sales has no row in the query result, so the chart skips it and the line carries on misleadingly. The fix is to generate all the days with generate_series first and LEFT JOIN the sales to them. The day boundary follows Istanbul time:

WITH days AS (
    SELECT d::date AS day
    FROM generate_series(date '2026-10-17', date '2026-10-23', interval '1 day') AS d
),
sales AS (
    SELECT (created_at AT TIME ZONE 'Europe/Istanbul')::date AS day, count(*) AS orders, sum(total_amount) AS revenue
    FROM orders
    WHERE created_at >= timestamptz '2026-10-17 00:00 Europe/Istanbul'
      AND created_at <  timestamptz '2026-10-24 00:00 Europe/Istanbul'
      AND currency = 'TRY' AND status <> 'Cancelled'
    GROUP BY 1
)
SELECT days.day, coalesce(sales.orders, 0) AS orders, coalesce(sales.revenue, 0) AS revenue
FROM days
LEFT JOIN sales USING (day)
ORDER BY days.day;
    day     | orders | revenue
------------+--------+----------
 2026-10-17 |    469 | 36128.92
 2026-10-18 |    472 | 37556.13
 2026-10-19 |    463 | 37639.40
 2026-10-20 |    474 | 38026.82
 2026-10-21 |      6 |   606.41
 2026-10-22 |      0 |        0
 2026-10-23 |      0 |        0

The lab data ends at 21:18 UTC on 20 October; in Istanbul that is 00:18 on 21 October, which is why 21 October has 6 orders, and 22 and 23 October come back as zero rows. Two details matter: the WHERE condition is written with the moments of Istanbul midnight, without wrapping the column in a function (we will see below why the index cares), and the numbers are turned into zero with coalesce. The same structure works for an hourly chart with interval '1 hour' and date_trunc('hour', ...).

A Short Map for SQL Server Developers

T-SQLPostgreSQLNote
GETDATE(), SYSDATETIME()now(), clock_timestamp()now() is fixed for the whole transaction
SYSUTCDATETIME()now()timestamptz already is a moment
DATEADD+ interval, date_addsame end-of-month rule
DATEDIFFdate - date, age, extract(epoch ...)DATEDIFF counts boundaries
EOMONTHdate_trunc('month', d) + interval '1 month - 1 day'result is timestamptz, add ::date
DATETRUNC, DATE_BUCKETdate_trunc, date_bintake a time zone argument
DATEFROMPARTSmake_dateerror on an invalid date
AT TIME ZONEAT TIME ZONEIANA names, not Windows names

Indexes and Date Conditions

The most common wrong way to write a report is a condition that wraps the column in a function. We recreated the created_at index from post 07 and compared the two spellings:

CREATE INDEX ix_orders_created_at ON orders (created_at);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders WHERE date_trunc('day', created_at) = timestamptz '2026-10-01';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM orders WHERE created_at >= timestamptz '2026-10-01' AND created_at < timestamptz '2026-10-02';
                                                       QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate (actual rows=1.00 loops=1)
   Buffers: shared hit=2539 read=7771
   ->  Gather (actual rows=3.00 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=2539 read=7771
         ->  Partial Aggregate (actual rows=1.00 loops=3)
               Buffers: shared hit=2539 read=7771
               ->  Parallel Seq Scan on orders (actual rows=240.00 loops=3)
                     Filter: (date_trunc('day'::text, created_at) = '2026-10-01 00:00:00+00'::timestamp with time zone)
                     Rows Removed by Filter: 333093
                     Buffers: shared hit=2539 read=7771
 Planning:
   Buffers: shared hit=25 read=1

                                                                           QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregate (actual rows=1.00 loops=1)
   Buffers: shared hit=3 read=3
   ->  Index Only Scan using ix_orders_created_at on orders (actual rows=720.00 loops=1)
         Index Cond: ((created_at >= '2026-10-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2026-10-02 00:00:00+00'::timestamp with time zone))
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=3 read=3
 Planning:
   Buffers: shared hit=5 read=3

6 buffers against 10,310 for the same 720 rows. The index is ordered by created_at, not by date_trunc(created_at); the planner cannot look up the result of the function in the index. The range form (>= the start, < the start of the next day) uses the index and does not miss the microseconds at the end of the day; BETWEEN '...00:00' AND '...23:59:59' leaves out rows in the last second.

So if the condition really has to be written as "an Istanbul day", can the expression not be indexed? It can, with an expression index from post 06, but the expression must be IMMUTABLE:

CREATE INDEX ix_orders_created_day ON orders (date_trunc('day', created_at));
CREATE INDEX ix_orders_created_day_ist ON orders (date_trunc('day', created_at, 'Europe/Istanbul'));
SELECT proname, provolatile, pg_get_function_identity_arguments(oid) AS args
FROM pg_proc
WHERE proname = 'date_trunc'
ORDER BY 3;
ERROR:  functions in index expression must be marked IMMUTABLE
CREATE INDEX

  proname   | provolatile |                 args
------------+-------------+--------------------------------------
 date_trunc | i           | text, interval
 date_trunc | i           | text, timestamp without time zone
 date_trunc | s           | text, timestamp with time zone
 date_trunc | i           | text, timestamp with time zone, text

The two-argument date_trunc(text, timestamptz) is STABLE (s): its result depends on the session's TimeZone setting, it does not give the same answer for the same input in every session, so it cannot go into an index. The three-argument form that takes the time zone as an argument has been IMMUTABLE (i) since PostgreSQL 16, and the index was created. The same can be done with the expression ((created_at AT TIME ZONE 'Europe/Istanbul')::date). Post 22 covers the volatility categories and how to mark your own functions correctly. Still, in most cases the simplest solution is a range condition on the column itself, as above; one index serves reports in every time zone.

In Production

Keep the server and database TimeZone at UTC; do conversions explicitly in the query with AT TIME ZONE or a time zone argument. Sessions of the reporting tool, psql and the application can open in different time zones: a connection setting, ALTER ROLE ... SET timezone or ALTER DATABASE ... SET timezone changes the session value. Check which value the application really runs with by running SHOW TimeZone on its own connection.

In long-running jobs, decide which clock you want for "the moment it was processed": the transaction's moment (now()) gives consistency, the row's real moment (clock_timestamp()) gives duration measurements. Logs that mix the two produce misleading pictures such as "50,000 rows processed in the same second".

Time zone rules can change (Turkey moved to permanent daylight saving time in 2016). PostgreSQL takes these rules from the IANA time zone database; PostgreSQL built from source updates its own copy with minor releases, while the official Debian-based image uses the operating system's tzdata package (--with-system-tzdata). Keeping the image up to date is therefore needed not only for security but also for the correct time.

Common Pitfalls

Writing a value with an offset into a timestamp column without time zone, and using DEFAULT 'now'. We tried both in the same table:

CREATE TEMP TABLE import_log
(
    id          int,
    imported_at timestamp,
    created_at  timestamptz DEFAULT 'now',
    logged_at   timestamptz DEFAULT now()
);
INSERT INTO import_log (id, imported_at) VALUES (1, '2026-11-15 10:00:00+03');
SELECT pg_sleep(1);
INSERT INTO import_log (id, imported_at) VALUES (2, '2026-11-15 10:00:00+03');
SELECT id, imported_at, created_at, logged_at FROM import_log;
SELECT pg_get_expr(adbin, adrelid) AS default_expr FROM pg_attrdef WHERE adrelid = 'import_log'::regclass;
 id |     imported_at     |          created_at          |           logged_at
----+---------------------+------------------------------+-------------------------------
  1 | 2026-11-15 10:00:00 | 2026-11-15 00:13:17.26475+00 | 2026-11-15 00:13:17.265653+00
  2 | 2026-11-15 10:00:00 | 2026-11-15 00:13:17.26475+00 | 2026-11-15 00:13:18.267119+00

                       default_expr
----------------------------------------------------------
 '2026-11-15 00:13:17.26475+00'::timestamp with time zone
 now()

The timestamp column dropped +03 without an error or a warning: the value stayed "10:00", and the information about its time zone was lost. DEFAULT 'now' was turned into a constant the moment the table was created; both rows got the same old time. The right way is DEFAULT now(): a function call is evaluated on every insert.

Thinking that now() moves forward inside a transaction: in a batch, a "rows added in the last 5 minutes" query gives unexpected results if the transaction has been open for 10 minutes.

Leaving the day boundary to the session time zone: created_at::date and the two-argument date_trunc follow the client's TimeZone; the same report gives two different numbers in two teams.

Writing WHERE date_trunc(...) = ... or WHERE created_at::date = ... and expecting an index: any condition that wraps the column in a function scans the whole table unless there is an index on that exact expression.

A Small Experiment

★☆☆ Run SET TimeZone = 'America/New_York'; in a session, then SELECT now(), current_date, localtimestamp, now()::date;. Which values differ from the UTC session, and which one is the same moment?

★★☆ For Bookstore's customers in Germany, produce order counts per "week in Berlin time", with empty weeks filled with zero. Weeks should start on Monday; use date_trunc('week', created_at, 'Europe/Berlin') and generate_series.

★★★ Add an expression index on ((created_at AT TIME ZONE 'Europe/Istanbul')::date) to orders. Show with EXPLAIN that WHERE (created_at AT TIME ZONE 'Europe/Istanbul')::date = '2026-10-01' uses this index while the range form uses ix_orders_created_at, and compare the sizes of the two indexes with pg_relation_size.

In the next post we move on to aggregate, array and conditional functions: conditional aggregation with FILTER, ordered string_agg and array_agg, unnest, array parameters with = ANY, and how GREATEST treats NULL.