Computing the month end — Round down to the 1st, add a month, subtract a day
The last day of a month varies from 28 to 31, and it also changes in a leap year. Rather than assembling the date yourself, round down to the first of the month, advance by one month, then step back one day — one expression that works for every month.
SELECT (DATE_TRUNC('month', date_col) + INTERVAL '1 month' - INTERVAL '1 day')::date AS month_end FROM table_name; -- 2026-02-03 → 2026-02-01 → 2026-03-01 → 2026-02-28
INTERVAL '1 month' moves to "the same day next month". Starting from the 1st, it always lands on the 1st of the next month, so stepping back one day gives the last day of the current month. You never have to enumerate the length of each month yourself.For each row of the billing_cycles table, compute the last day of the month the start date falls in and the number of days in that month. Return cycle_id, started_on, month_end, days_in_month, sorted by started_on ascending.
| cycle_id | plan | started_on |
|---|---|---|
| 1 | standard | 2026-01-15 |
| 2 | standard | 2026-02-03 |
| 3 | premium | 2026-04-20 |
| 4 | premium | 2024-02-10 |
| cycle_id | started_on | month_end | days_in_month |
|---|---|---|---|
| 4 | 2024-02-10 | 2024-02-29 | 29 |
| 1 | 2026-01-15 | 2026-01-31 | 31 |
| 2 | 2026-02-03 | 2026-02-28 | 28 |
| 3 | 2026-04-20 | 2026-04-30 | 30 |
SELECT cycle_id, started_on, (DATE_TRUNC('month', started_on) + INTERVAL '1 month' - INTERVAL '1 day')::date AS month_end, -- 1st → next 1st → back one day EXTRACT(DAY FROM DATE_TRUNC('month', started_on) + INTERVAL '1 month' - INTERVAL '1 day') AS days_in_month -- the day part of the last day is the month length FROM billing_cycles ORDER BY started_on; /* Execution order (logical evaluation order): 1. FROM billing_cycles → Read 4 rows 2. SELECT month end and length → Output 4 columns 3. ORDER BY started_on → Ascending by start date */
LEGEND
① FROM billing_cycles
FROM billing_cyclesRead all 4 rows of billing_cycles. Besides January, February and April, the data includes a row in February 2024, a leap year.| cycle_id | plan | started_on |
|---|---|---|
| 1 | standard | 2026-01-15 |
| 2 | standard | 2026-02-03 |
| 3 | premium | 2026-04-20 |
| 4 | premium | 2024-02-10 |
INTERVAL, so you never write your own.EXTRACT(DAY FROM month end) returns 28, 29, 30 or 31. Use it as the denominator for pro-rating or as the basis of a monthly average.>= month start AND < next month start without computing the last day at all (see Q1). You need the last day when the cutoff or billing date itself is displayed or stored.CASE WHEN month IN (1,3,5,...) THEN 31 ... always ends up missing the leap-year branch. Leave calendar rules to the date functions.month start + 30 drifts from month to month. + INTERVAL '1 month' understands the calendar and lands on the next month's first day whether the month has 31 days or 28.AT TIME ZONE — Aggregate UTC-stored logs by the Japanese calendar day
A TIMESTAMPTZ column holds the point in time itself and is converted to a time zone on display. AT TIME ZONE converts it to the "wall clock" of the given region and returns a TIMESTAMP that carries no zone information.
SELECT tstz_col AT TIME ZONE 'Asia/Tokyo' FROM table_name; -- 2025-11-10 16:20:00+00 (UTC) → 2025-11-11 01:20:00 (JST wall clock)
Aggregate the events_utc table by Japanese calendar day (Asia/Tokyo) and return the number of events per day. occurred_at is a TIMESTAMPTZ column displayed in UTC. Return jst_day, events, sorted by jst_day ascending.
| event_id | user_id | occurred_at |
|---|---|---|
| 1 | 301 | 2026-07-01 14:30:00+00 |
| 2 | 302 | 2026-07-01 15:30:00+00 |
| 3 | 303 | 2026-07-01 23:10:00+00 |
| 4 | 304 | 2026-07-02 16:00:00+00 |
| 5 | 305 | 2026-07-02 03:45:00+00 |
| jst_day | events |
|---|---|
| 2026-07-01 | 1 |
| 2026-07-02 | 3 |
| 2026-07-03 | 1 |
SELECT DATE_TRUNC('day', occurred_at AT TIME ZONE 'Asia/Tokyo')::date AS jst_day, -- convert to JST first, then truncate to the day COUNT(*) AS events FROM events_utc GROUP BY DATE_TRUNC('day', occurred_at AT TIME ZONE 'Asia/Tokyo') ORDER BY jst_day; /* Execution order (logical evaluation order): 1. FROM events_utc → Read 5 rows 2. GROUP BY DATE_TRUNC('day', JST value) → Split into 3 groups 3. SELECT COUNT(*) → Count per group 4. ORDER BY jst_day → Ascending by date */
LEGEND
① FROM events_utc
FROM events_utcRead all 5 rows of events_utc. occurred_at is shown in UTC, where the counts are 3 events on 07-01 and 2 on 07-02.| event_id | user_id | occurred_at (UTC) |
|---|---|---|
| 1 | 301 | 2026-07-01 14:30:00+00 |
| 2 | 302 | 2026-07-01 15:30:00+00 |
| 3 | 303 | 2026-07-01 23:10:00+00 |
| 4 | 304 | 2026-07-02 16:00:00+00 |
| 5 | 305 | 2026-07-02 03:45:00+00 |
TIMESTAMPTZ in UTC and convert with AT TIME ZONE just before reporting. Storing local time instead makes cross-region comparison and daylight-saving handling impossible.DATE_TRUNC('day', ...) while still in UTC and converting afterwards leaves the boundary at UTC midnight. Always convert, then truncate.'Asia/Tokyo' rather than a fixed '+09' applies the rule that was actually in force at that moment, including daylight saving where it exists.occurred_at + INTERVAL '9 hours' happens to work for Japan, but it is wrong twice a year anywhere with daylight saving. Leave offset rules to the time zone database.Detecting overlapping periods — Build the condition for two intervals that intersect
Two periods A and B overlap when A starts before B ends, and B starts before A ends. There is no need to enumerate the ways in which they can be arranged.
SELECT * FROM table_name x JOIN table_name y ON x.begin_col < y.end_col AND y.begin_col < x.end_col; -- end = next start (adjacent) does not count as an overlap
<, "10:00–11:00" and "11:00–12:00" do not overlap. With <=, even the instant of contact counts as an overlap. For booking slots that exclude the end time, use <.From the bookings table, find pairs of bookings whose time ranges overlap in the same meeting room. To avoid emitting the same pair twice, put the smaller booking_id on the left. A booking that ends exactly when the next one starts does not count as an overlap. Return room_id, booking_a, booking_b, sorted by booking_a then booking_b ascending.
| booking_id | room_id | starts_at | ends_at |
|---|---|---|---|
| 1 | A | 2026-08-01 10:00:00 | 2026-08-01 11:00:00 |
| 2 | A | 2026-08-01 10:30:00 | 2026-08-01 11:30:00 |
| 3 | A | 2026-08-01 11:30:00 | 2026-08-01 12:30:00 |
| 4 | B | 2026-08-01 10:30:00 | 2026-08-01 11:30:00 |
| 5 | A | 2026-08-02 10:00:00 | 2026-08-02 11:00:00 |
| room_id | booking_a | booking_b |
|---|---|---|
| A | 1 | 2 |
SELECT a.room_id, a.booking_id AS booking_a, b.booking_id AS booking_b FROM bookings a JOIN bookings b ON a.room_id = b.room_id -- compare within the same room only AND a.booking_id < b.booking_id -- drop self-pairs and mirrored duplicates AND a.starts_at < b.ends_at -- overlap condition (first half) AND b.starts_at < a.ends_at -- overlap condition (second half) ORDER BY booking_a, booking_b; /* Execution order (logical evaluation order): 1. FROM bookings a → Read 5 rows 2. JOIN bookings b (room+overlap) → Narrow to 1 pair 3. SELECT room_id, booking_a, … → Select 3 columns 4. ORDER BY booking_a, booking_b → Ascending by id */
LEGEND
① FROM bookings
FROM bookings aRead all 5 rows of bookings: four in room A and one in room B, split across August 1 and August 2.| booking_id | room_id | starts_at | ends_at |
|---|---|---|---|
| 1 | A | 2026-08-01 10:00:00 | 2026-08-01 11:00:00 |
| 2 | A | 2026-08-01 10:30:00 | 2026-08-01 11:30:00 |
| 3 | A | 2026-08-01 11:30:00 | 2026-08-01 12:30:00 |
| 4 | B | 2026-08-01 10:30:00 | 2026-08-01 11:30:00 |
| 5 | A | 2026-08-02 10:00:00 | 2026-08-02 11:00:00 |
A.start < B.end AND B.start < A.end captures every overlap, and nothing else.a.booking_id < b.booking_id removes both the self-comparison and the duplicated (1,2)/(2,1) output at once. Using <> instead doubles the result.(a.starts_at, a.ends_at) OVERLAPS (b.starts_at, b.ends_at), with the same half-open meaning. It reads well, but the explicit inequalities state the boundary treatment more clearly where that matters.a.starts_at < b.ends_at alone is also true for pairs where B lies entirely in the past. The two inequalities must always be written together.b.starts_at BETWEEN a.starts_at AND a.ends_at catches only the case where B starts inside A, and misses the overlap where B fully contains A.tstzrange) with an exclusion constraint (EXCLUDE USING gist) lets the database itself reject an insert that overlaps in the same room. Writing "search for an overlap, and insert if none is found" in application code leaves a race in which two concurrent searches both come back empty and both rows are inserted. For bookings, inventory and price periods, where consistency really matters, declaring the rule as a constraint is the reliable path.Aggregating elapsed time — Convert a timestamp difference to hours and average it
Subtracting one TIMESTAMP from another returns an INTERVAL. To treat it as a number for averages or totals, convert it to seconds with EXTRACT(EPOCH FROM ...) and divide by the unit you want.
SELECT EXTRACT(EPOCH FROM (end_ts - start_ts)) / 3600 AS hours FROM table_name; -- EPOCH returns the interval in seconds. Divide by 3600 for hours, by 60 for minutes.
AVG ignores NULL, but COUNT(*) still counts the row, so the count and the average end up over different populations. It is safer to restrict the rows explicitly in WHERE.From the tickets table, compute the number of closed tickets per category and their average handling time in hours, rounded to two decimal places. Return category, closed_tickets, avg_hours, sorted by category ascending.
| ticket_id | category | created_at | closed_at |
|---|---|---|---|
| 1 | billing | 2026-04-01 09:00:00 | 2026-04-01 12:30:00 |
| 2 | billing | 2026-04-02 10:00:00 | 2026-04-03 10:00:00 |
| 3 | tech | 2026-04-01 08:00:00 | 2026-04-01 09:30:00 |
| 4 | tech | 2026-04-05 13:00:00 | NULL |
| 5 | tech | 2026-04-06 09:15:00 | 2026-04-06 11:45:00 |
| category | closed_tickets | avg_hours |
|---|---|---|
| billing | 2 | 13.75 |
| tech | 2 | 2.00 |
SELECT category, COUNT(*) AS closed_tickets, ROUND(AVG(EXTRACT(EPOCH FROM (closed_at - created_at)) / 3600)::numeric, 2) AS avg_hours -- seconds → hours → average → rounded FROM tickets WHERE closed_at IS NOT NULL -- keep unfinished tickets out of the population GROUP BY category ORDER BY category; /* Execution order (logical evaluation order): 1. FROM tickets → Read 5 rows 2. WHERE closed_at IS NOT NULL → Narrow to 4 rows 3. GROUP BY category → Split into 2 groups 4. SELECT COUNT / AVG(EPOCH…) → Count and average hours 5. ORDER BY category → Ascending by category */
LEGEND
① FROM tickets
FROM ticketsRead all 5 rows of tickets. Ticket 4 is still open, so its closed_at is NULL.| ticket_id | category | created_at | closed_at |
|---|---|---|---|
| 1 | billing | 2026-04-01 09:00:00 | 2026-04-01 12:30:00 |
| 2 | billing | 2026-04-02 10:00:00 | 2026-04-03 10:00:00 |
| 3 | tech | 2026-04-01 08:00:00 | 2026-04-01 09:30:00 |
| 4 | tech | 2026-04-05 13:00:00 | NULL |
| 5 | tech | 2026-04-06 09:15:00 | 2026-04-06 11:45:00 |
AVG does work on an INTERVAL, but the result reads as something like 1 day 04:30:00, which is awkward to compare against a threshold or to chart. Convert to seconds with EPOCH, divide by the unit you want, then aggregate.avg_hours) saves the reader from guessing.COUNT(*) and the average over the same rows. Relying on AVG to ignore NULL leaves the count including unfinished work.HOUR returns only the hour component of the interval, so a 24-hour difference comes back as 0 (it has carried into the day component). Total elapsed time must come from EPOCH.AGE and age in years — Let the calendar decide whether the birthday has passed
AGE(reference date, past date) returns the difference between two dates as an INTERVAL of the form "N years N months N days". Take the year part and you have the age in completed years.
SELECT EXTRACT(YEAR FROM AGE(DATE '2025-01-01', birth_col)) AS age FROM table_name; -- AGE borrows down into years, months and days, so before the birthday the year part is one less
2025 - 1990 never checks whether the birthday has arrived, so anyone whose birthday is still ahead in the year comes out one year too old. AGE compares the month and day as well.For the members table, compute the age in completed years as of the reference date 2026-09-01. Return member_id, birth_on, age, sorted by age descending.
| member_id | name | birth_on |
|---|---|---|
| 1 | Sato | 1990-09-01 |
| 2 | Suzuki | 1990-09-02 |
| 3 | Takahashi | 2000-12-31 |
| 4 | Tanaka | 2008-08-31 |
| member_id | birth_on | age |
|---|---|---|
| 1 | 1990-09-01 | 36 |
| 2 | 1990-09-02 | 35 |
| 3 | 2000-12-31 | 25 |
| 4 | 2008-08-31 | 18 |
SELECT member_id, birth_on, EXTRACT(YEAR FROM AGE(DATE '2026-09-01', birth_on)) AS age -- take only the year part of the difference FROM members ORDER BY age DESC; /* Execution order (logical evaluation order): 1. FROM members → Read 4 rows 2. SELECT year part of AGE → Output 3 columns 3. ORDER BY age DESC → Descending by age */
LEGEND
① FROM members
FROM membersRead all 4 rows of members. The birthdays line up on the boundaries around the reference date 2026-09-01: the day itself, the day after, the end of the year and the day before.| member_id | name | birth_on |
|---|---|---|
| 1 | Sato | 1990-09-01 |
| 2 | Suzuki | 1990-09-02 |
| 3 | Takahashi | 2000-12-31 |
| 4 | Tanaka | 2008-08-31 |
INTERVAL borrowed down into years, months and days. If the birthday has not arrived, the year part is one lower, which matches the definition of age in completed years exactly.AGE(reference date, birth date) can reproduce the age at any past point in time. The one-argument AGE(birth date) is relative to the day it runs, which makes the query hard to verify.EXTRACT(YEAR FROM reference date) - EXTRACT(YEAR FROM birth_on) counts anyone before their birthday as one year too old. It is always wrong for rows like members 2 and 3, whose birthday has not come round yet this year.(reference date - birth_on) / 365 runs fast by the leap days, and the error accumulates with age. Leave the judgement to the calendar functions.AGE returns the ordinary completed-years age that increments on the birthday itself, so it cannot be used as-is where a scheme defines things differently, as with school years or insurance brackets. In any query that uses age as a condition, confirm which definition of age is meant first, and if necessary write the correction — such as shifting the reference date by one day — explicitly.