SQL Date and Time — Basics of Month Ends, Zones, Elapsed Time

BASICDate and TimeMonth end / cutoffAT TIME ZONEOverlapping periodsAGE / EPOCHPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 6

Computing the month end — Round down to the 1st, add a month, subtract a day

DATE_TRUNCINTERVALMonth end / cutoffLeap year
Background

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
The calendar knowledge lives in the function: 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.
Problem

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.

Tables used
▸ billing_cycles
cycle_idplanstarted_on
1standard2026-01-15
2standard2026-02-03
3premium2026-04-20
4premium2024-02-10
Expected Output
cycle_idstarted_onmonth_enddays_in_month
42024-02-102024-02-2929
12026-01-152026-01-3131
22026-02-032026-02-2828
32026-04-202026-04-3030
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT cycle_id, started_on, (DATE_TRUNC('month', started_on) + INTERVAL '1 month' - INTERVAL '1 day')::date AS month_end, EXTRACT(DAY FROM DATE_TRUNC('month', started_on) + INTERVAL '1 month' - INTERVAL '1 day') AS days_in_month FROM billing_cycles ORDER BY started_on;
LEGEND
Rows read / loaded
① 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.
1 / 4
cycle_idplanstarted_on
1standard2026-01-15
2standard2026-02-03
3premium2026-04-20
4premium2024-02-10
All 4 rows read
LEARNING POINTS
The month end is "one day before the next month starts": you do not need to store the length of each month. Rounding down, advancing a month and stepping back a day is enough, and the leap-year rule lives inside INTERVAL, so you never write your own.
The day part of the last day is the month length: 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.
For filtering, prefer the half-open interval: to select the data of that month, it is safer to write >= 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.
ANTI-PATTERNS
Enumerating month ends with CASE: CASE WHEN month IN (1,3,5,...) THEN 31 ... always ends up missing the leap-year branch. Leave calendar rules to the date functions.
Adding a fixed number of days to the 1st: something like 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.
Field Notes
A "month-end cutoff" is not always the last day of the month. Billing cutoffs are business rules — the 20th of each month, or month end with payment at the end of the following month — and they need not coincide with the calendar month end. On top of that, a cutoff falling on a weekend or holiday is often pulled back to the previous business day. SQL can give you the calendar month end; beyond that you need a business-day calendar. Keep the month-end expression out of scattered queries and concentrate it in a master table or a single view, so a rule change is a one-place edit.
QUESTION 7

AT TIME ZONE — Aggregate UTC-stored logs by the Japanese calendar day

TIMESTAMPTZAT TIME ZONEZone conversionDay boundary
Background

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)
The day boundary moves with the time zone: anything from 15:00 UTC onward is already the next day in Japan. Aggregating in UTC pushes rows that happened during the Japanese night into the previous day, and the daily counts stop matching what people experienced.
Problem

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.

Tables used
▸ events_utc
event_iduser_idoccurred_at
13012026-07-01 14:30:00+00
23022026-07-01 15:30:00+00
33032026-07-01 23:10:00+00
43042026-07-02 16:00:00+00
53052026-07-02 03:45:00+00
Expected Output
jst_dayevents
2026-07-011
2026-07-023
2026-07-031
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT DATE_TRUNC('day', occurred_at AT TIME ZONE 'Asia/Tokyo')::date AS jst_day, COUNT(*) AS events FROM events_utc GROUP BY DATE_TRUNC('day', occurred_at AT TIME ZONE 'Asia/Tokyo') ORDER BY jst_day;
LEGEND
Rows read / loaded
① 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.
1 / 4
event_iduser_idoccurred_at (UTC)
13012026-07-01 14:30:00+00
23022026-07-01 15:30:00+00
33032026-07-01 23:10:00+00
43042026-07-02 16:00:00+00
53052026-07-02 03:45:00+00
All 5 rows read
LEARNING POINTS
Store in UTC, aggregate in local time: keep the point in time as 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.
Convert first, then truncate: the order matters. Applying DATE_TRUNC('day', ...) while still in UTC and converting afterwards leaves the boundary at UTC midnight. Always convert, then truncate.
Name the region, not the offset: using a region name such as 'Asia/Tokyo' rather than a fixed '+09' applies the rule that was actually in force at that moment, including daylight saving where it exists.
ANTI-PATTERNS
Adding nine hours by hand: 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.
Relying on the session time zone: when the default zone differs between connections or environments, the same query returns different daily aggregates. State the region name explicitly in reporting queries.
Field Notes
Daily batches and mismatched time zones: when a "yesterday's numbers" batch disagrees with the dashboard, the cause is usually that the extraction filter and the grouping key assume different zones. Extract from UTC midnight but display by the Japanese day, and nine hours of rows spill into the neighbouring day every single time. Decide on one time zone for the three places that matter — the extraction boundary, the grouping key and the display — and leave the boundary values in a comment so the next person can verify them.
QUESTION 8

Detecting overlapping periods — Build the condition for two intervals that intersect

Overlapping periodsSelf joinDouble bookingTouching bounds
Background

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
Where adjacency ends and overlap begins: with <, "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 <.
Problem

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.

Tables used
▸ bookings
booking_idroom_idstarts_atends_at
1A2026-08-01 10:00:002026-08-01 11:00:00
2A2026-08-01 10:30:002026-08-01 11:30:00
3A2026-08-01 11:30:002026-08-01 12:30:00
4B2026-08-01 10:30:002026-08-01 11:30:00
5A2026-08-02 10:00:002026-08-02 11:00:00
Expected Output
room_idbooking_abooking_b
A12
Model Answer
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
  */
Explanation (table transitions & key points)
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 AND a.booking_id < b.booking_id AND a.starts_at < b.ends_at AND b.starts_at < a.ends_at ORDER BY booking_a, booking_b;
LEGEND
Rows read / loaded
① 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.
1 / 4
booking_idroom_idstarts_atends_at
1A2026-08-01 10:00:002026-08-01 11:00:00
2A2026-08-01 10:30:002026-08-01 11:30:00
3A2026-08-01 11:30:002026-08-01 12:30:00
4B2026-08-01 10:30:002026-08-01 11:30:00
5A2026-08-02 10:00:002026-08-02 11:00:00
All 5 rows read
LEARNING POINTS
Two inequalities say all of it: you do not need cases for "A first", "B first" or "one contains the other". A.start < B.end AND B.start < A.end captures every overlap, and nothing else.
The id inequality is what makes pairs unique: in a self join, 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.
OVERLAPS is another way to write it: PostgreSQL offers (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.
ANTI-PATTERNS
Testing only one inequality: 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.
Using BETWEEN to test containment only: 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.
Field Notes
From detecting duplicates to preventing them: an overlap query is useful for investigation, but it cannot stop a double booking. In PostgreSQL, a range type (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.
QUESTION 9

Aggregating elapsed time — Convert a timestamp difference to hours and average it

EXTRACT (EPOCH)Timestamp differenceHandling timeNULL exclusion
Background

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.
Unfinished rows are NULL: a row with no end time produces a NULL difference. 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.
Problem

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.

Tables used
▸ tickets
ticket_idcategorycreated_atclosed_at
1billing2026-04-01 09:00:002026-04-01 12:30:00
2billing2026-04-02 10:00:002026-04-03 10:00:00
3tech2026-04-01 08:00:002026-04-01 09:30:00
4tech2026-04-05 13:00:00NULL
5tech2026-04-06 09:15:002026-04-06 11:45:00
Expected Output
categoryclosed_ticketsavg_hours
billing213.75
tech22.00
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT category, COUNT(*) AS closed_tickets, ROUND(AVG(EXTRACT(EPOCH FROM (closed_at - created_at)) / 3600)::numeric, 2) AS avg_hours FROM tickets WHERE closed_at IS NOT NULL GROUP BY category ORDER BY category;
LEGEND
Rows read / loaded
① FROM tickets
FROM ticketsRead all 5 rows of tickets. Ticket 4 is still open, so its closed_at is NULL.
1 / 5
ticket_idcategorycreated_atclosed_at
1billing2026-04-01 09:00:002026-04-01 12:30:00
2billing2026-04-02 10:00:002026-04-03 10:00:00
3tech2026-04-01 08:00:002026-04-01 09:30:00
4tech2026-04-05 13:00:00NULL
5tech2026-04-06 09:15:002026-04-06 11:45:00
All 5 rows read
LEARNING POINTS
Turn the interval into a number once: 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.
The divisor picks the unit: 60 for minutes, 3600 for hours, 86400 for days. Putting the unit in the column name (avg_hours) saves the reader from guessing.
Let WHERE define the population: excluding open tickets before aggregating keeps COUNT(*) and the average over the same rows. Relying on AVG to ignore NULL leaves the count including unfinished work.
ANTI-PATTERNS
Counting hours with EXTRACT(HOUR FROM difference): 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.
Describing responsiveness with the average alone: a single very long ticket drags the average up — here one 24-hour ticket is what makes billing average 13.75 hours. Reporting a median or a percentile alongside it stays closer to reality.
Field Notes
Elapsed time versus business hours: "handling time" in support work is usually measured as time spent inside business hours, not raw elapsed time. A ticket that arrives on Friday evening and closes on Monday morning is over 60 hours of wall-clock time but only a few business hours. If you handle an SLA, you need to subtract non-working periods using a calendar of business days and hours. The practical order is to read the overall trend with raw elapsed time first, then switch to business hours at the point where the SLA is judged.
QUESTION 10

AGE and age in years — Let the calendar decide whether the birthday has passed

AGEEXTRACT (YEAR)Age calculationBirthday boundary
Background

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
Subtracting years is not enough: 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.
Problem

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.

Tables used
▸ members
member_idnamebirth_on
1Sato1990-09-01
2Suzuki1990-09-02
3Takahashi2000-12-31
4Tanaka2008-08-31
Expected Output
member_idbirth_onage
11990-09-0136
21990-09-0235
32000-12-3125
42008-08-3118
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT member_id, birth_on, EXTRACT(YEAR FROM AGE(DATE '2026-09-01', birth_on)) AS age FROM members ORDER BY age DESC;
LEGEND
Rows read / loaded
① 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.
1 / 4
member_idnamebirth_on
1Sato1990-09-01
2Suzuki1990-09-02
3Takahashi2000-12-31
4Tanaka2008-08-31
All 4 rows read
LEARNING POINTS
AGE compares the month and day too: the return value is an 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.
Pass the reference date as an argument: the two-argument 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.
Do not store the age; derive it: an age stored in a table is stale the next day. Store the birth date only and compute the age when you need it — the same reasoning as the expiry date in Q4.
ANTI-PATTERNS
Treating the year difference as the age: 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.
Dividing a day count by 365: (reference date - birth_on) / 365 runs fast by the leap days, and the error accumulates with age. Leave the judgement to the calendar functions.
Field Notes
The definition of age differs by country and by scheme. Under Japanese law, age increases at the end of the day before the birthday, so a child born on April 1 turns a year older on March 31 and lands in the higher school year. 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.