PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 10: Dates and text

Dates, time zones and text

Level 2: Querying25 min

timestamptz stores an absolute instant and displays it in the session time zone. Most date bugs come from mixing it with timestamp, or from grouping by days in the wrong zone. Text functions and regular expressions handle the rest of everyday cleanup.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Choose between timestamp and timestamptz and convert with AT TIME ZONE
  • Bucket and filter time with date_trunc, intervals and half-open ranges
  • Clean and search text with string functions and regular expressions

conceptThe concept

timestamptz is stored as UTC and converted to the session's TimeZone on output. timestamp (without time zone) is a wall-clock reading with no zone at all. ts AT TIME ZONE 'Asia/Kolkata' converts a timestamptz into the local wall-clock time there (a timestamp); applied to a timestamp it does the reverse.

date_trunc('day', ts) buckets times; date_trunc on a timestamptz truncates in the session zone, and the three-argument form date_trunc('day', ts, 'Europe/Berlin') picks the zone explicitly. Intervals do calendar arithmetic: + interval '1 month' from January 31 lands on the last day of February.

Filter time with half-open ranges: created_at >= '2025-03-01' AND created_at < '2025-04-01'. It is exact, index-friendly and never misses the last second of a day. For text, lower, trim, split_part, format, regexp_replace and ~ (regex match) cover most cleanup.

whyWhy it matters

A daily revenue report grouped by UTC day puts late-evening US orders on the wrong date. A BETWEEN on dates silently drops everything after midnight on the last day. These are real bugs that pass tests.

Use caseReal-world scenario

The Bengaluru team wants orders per local day, and the Berlin team wants the same report in their time zone. One query with the three-argument date_trunc serves both.

Try itExamples

Run and edit these in the interactive lesson.

One instant, three time zones
SELECT created_at,
       created_at AT TIME ZONE 'Asia/Kolkata' AS kolkata_wall_clock,
       created_at AT TIME ZONE 'America/New_York' AS new_york_wall_clock
FROM orders
WHERE id = 1;

BEGIN;
SET LOCAL TimeZone = 'Asia/Kolkata';
SELECT created_at AS displayed_in_session_zone FROM orders WHERE id = 1;
ROLLBACK;
SET LOCAL lasts until the end of the transaction, so the session zone is back to UTC afterwards.
Daily buckets in a chosen zone
SELECT date_trunc('day', created_at, 'Asia/Kolkata')::date AS kolkata_day,
       count(*) AS orders,
       sum(total) AS revenue
FROM orders
WHERE created_at >= '2025-03-01' AND created_at < '2025-03-08'
GROUP BY 1
ORDER BY 1;
Interval arithmetic
SELECT date '2025-01-31' + interval '1 month' AS plus_one_month,
       age(timestamp '2025-10-08', timestamp '1990-05-17') AS age,
       extract(epoch FROM interval '1 day 2 hours') AS seconds,
       now() - interval '90 days' > timestamptz '2020-01-01' AS sanity,
       make_interval(days => 10, hours => 3) AS built;
Text cleanup
SELECT email,
       split_part(email, '@', 2) AS domain,
       initcap(replace(split_part(email, '@', 1), '.', ' ')) AS from_email,
       format('%s (%s, %s)', name, city, country) AS label,
       regexp_replace(email, '\d+', '', 'g') AS digits_removed
FROM customers
ORDER BY id
LIMIT 5;
Regular expressions
SELECT name,
       name ~ '^(Smart|Pro) ' AS is_premium_line,
       substring(sku FROM '\d+')::int AS sku_number,
       regexp_matches(name, '(\w+)$') AS last_word
FROM products
WHERE name ~* 'headphones|speaker'
ORDER BY sku
LIMIT 6;

internalsUnder the hood

Both timestamp types take 8 bytes with microsecond precision. The session TimeZone only affects input parsing and display of timestamptz; the stored value never changes. Use IANA names such as 'America/New_York' rather than abbreviations like EST, which ignore daylight saving.

Avoid / preferDo this, not that

Avoid
SELECT count(*) FROM orders WHERE created_at::date = '2025-03-01';
Prefer
SELECT count(*) FROM orders
WHERE created_at >= '2025-03-01' AND created_at < '2025-03-02';

Casting the column prevents a plain index on created_at from being used, and the cast depends on the session time zone. A half-open range is index-friendly and explicit.

Avoid
SELECT date_trunc('day', created_at), count(*) FROM orders GROUP BY 1;
-- "days" depend on whoever's session TimeZone runs it
Prefer
SELECT date_trunc('day', created_at, 'Europe/London'), count(*) FROM orders GROUP BY 1;

Day boundaries depend on a time zone. The three-argument date_trunc makes the business time zone explicit.

PitfallsCommon mistakes

  • Storing event times as timestamp without time zone. When servers or users span zones, you can no longer tell when something happened.
  • WHERE created_at BETWEEN '2025-03-01' AND '2025-03-31' on a timestamp column: the upper bound is midnight at the start of the 31st. Use a half-open range.
  • Wrapping the column in a function in WHERE (WHERE created_at::date = '2025-03-01'). It cannot use a plain index on created_at; rewrite it as a range.

LimitsWhen not to use it

Do not store dates as text or as integer epoch seconds. You lose validation, time zone handling, interval arithmetic and readable output, and every query needs conversions.

SecuritySecurity notes

Do not pass user input as a regular expression: a crafted pattern can be very slow, and wildcards change meaning. Escape user text for LIKE (with an ESCAPE clause) or use plain equality or full-text search.

ChallengeTry it yourself

Count 2025 orders per weekday in India time (Asia/Kolkata), Monday first, with the weekday name.

Show one solution
One solution
SELECT extract(isodow FROM created_at AT TIME ZONE 'Asia/Kolkata') AS dow,
       to_char(created_at AT TIME ZONE 'Asia/Kolkata', 'Dy') AS day,
       count(*) AS orders
FROM orders
WHERE created_at >= '2025-01-01 00:00 Asia/Kolkata'
  AND created_at <  '2026-01-01 00:00 Asia/Kolkata'
GROUP BY 1, 2
ORDER BY 1;

InterviewQuestions you should be able to answer

What is the difference between timestamp and timestamptz?
timestamptz represents an absolute point in time: input is converted to UTC using the given or session zone, and output is converted to the session TimeZone. timestamp is a zone-less wall-clock value that is never converted. Use timestamptz for anything that happened at a moment in time; timestamp only for things like "store opens at 09:00 local".
How do you group events by day in a specific time zone?
Use date_trunc('day', ts, 'Europe/Berlin') (PostgreSQL 12+), or date_trunc('day', ts AT TIME ZONE 'Europe/Berlin'). Grouping by ts::date uses the session zone, which is usually UTC on servers and produces the wrong local days.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation