Date Arithmetic: Intermediate
Round two at a subscription company. The interviewer drops a schema into the shared doc: orders(order_id, region, order_ts, amount) and regions(region, tz_name).
"Finance closes the books by fiscal quarter, and our year starts July 1. I need revenue per region per quarter for FY2025. Go."
She stops typing. Nobody has said whether order_ts holds UTC or each store's wall-clock time. That alone decides whether a Los Angeles order rung up at 7 p.m. on June 30 counts in FY2025 or FY2026. Within a minute most candidates have written DATE_TRUNC('quarter', order_ts), which buckets January-start quarters on the UTC clock and is wrong twice.
What she is scoring is whether you settle which calendar and which clock before any date function goes on the page.
What you will be able to do
This part of the question rarely announces itself. It arrives as "Q3 bookings", "last week's signups" or "revenue this year", and each phrase names a period whose edges the company chose. Microsoft's year begins in July, the US federal government's in October, and Apple's ends on the last Saturday of September. Before any DATE_TRUNC, the interviewer is listening for the question that fixes which calendar the business counts by.
4 calendars behind one word
| Calendar | Year begins | How a date finds its period | On the record |
|---|---|---|---|
| Calendar year | January 1 | Extract the year and quarter directly | Most companies |
| Month-offset fiscal year | First day of a chosen month | Shift by whole months, then extract | Microsoft (July 1), US federal government (October 1) |
| 52/53-week fiscal year | A weekday near a month end | Look up a published boundary in a calendar table | Apple (last Saturday of September), Target (Saturday nearest January 31) |
| ISO 8601 week-year | Monday of the week holding the first Thursday | ISO week paired with ISO year | Week numbers in Postgres and Spark |
Mapping a date to a month-offset year
The quarter_start column answers the follow-up that usually comes next, "now give me fiscal quarter to date". Truncating the shifted date and shifting it back yields the real first day of the fiscal quarter, 2025-01-01 for anything in fiscal Q3, and that value works in a range filter like any other date. The animation below runs the period mapping on the 4 dates that decide most fiscal reports.
SELECTorder_id,order_date,EXTRACT(YEAR FROM order_date + INTERVAL '6' MONTH) AS fiscal_year,EXTRACT(QUARTER FROM order_date + INTERVAL '6' MONTH) AS fiscal_quarterFROM ordersORDER BY order_date
Order 1 on 2024-06-30 shifts to 2024-12-30 and closes FY2024 Q4; order 2, a day later, shifts to 2025-01-01 and opens FY2025 Q1. Order 3 on 2025-01-02 sits in calendar Q1 and fiscal Q3, which is the row a DATE_TRUNC('quarter', ...) answer files under the wrong quarter. Order 4 on 2025-06-30 is the last day of FY2025.
Week-based years need a lookup table
A 52/53-week year has boundaries that move. Apple's year ends on the last Saturday of September, so most years run 52 weeks and every fifth or sixth year runs 53, as fiscal 2023 did. No month shift reproduces that, and a 4-5-4 retail calendar adds week-based months on top. The answer is a calendar table keyed by date that carries fiscal_year plus fiscal_quarter and fiscal_week. Build it from the dates finance publishes and join it on the order's date. Say the split out loud: arithmetic for a month-aligned year, a lookup for anything week-based, because there the boundary is a published date rather than a rule.
ISO weeks and the year they belong to
| Date | Weekday | Calendar year | ISO year | ISO week |
|---|---|---|---|---|
| 2024-12-29 | Sunday | 2024 | 2024 | 52 |
| 2024-12-30 | Monday | 2024 | 2025 | 1 |
| 2024-12-31 | Tuesday | 2024 | 2025 | 1 |
| 2026-12-31 | Thursday | 2026 | 2026 | 53 |
| 2027-01-03 | Sunday | 2027 | 2026 | 53 |
| 2027-01-04 | Monday | 2027 | 2027 | 1 |
The usual bug comes from pairing the calendar year with the ISO week. EXTRACT(YEAR ...) next to an ISO week number turns December 30 and 31, 2024 into the key (2024, 1), the same key as January 1 to 7, 2024, so a weekly report adds 2 days of late-December revenue into a week from 12 months earlier. Pair the ISO week with the ISO year, or group by the Monday that starts each week and skip both numbers: a week-start date is unique per week, so it never collides as a join key or a chart label. Which weekday a given engine treats as the start is the part to confirm against the engine in front of you.
- ▸Does the fiscal year begin on the 1st of a month, or follow a 52/53-week pattern?
- ▸Is FY2025 the year that ends in 2025 or the one that begins in it?
- ▸Do weeks start on Monday (ISO) or Sunday, and who reads the week numbers?
- ▸Does a week straddling New Year count in the old year or the new one, or is it split at midnight?
| Order | Stored instant (UTC) | Los Angeles wall clock | Tokyo wall clock |
|---|---|---|---|
| A | 2024-03-01 03:30 | 2024-02-29 19:30 (UTC-8) | 2024-03-01 12:30 (UTC+9) |
| B | 2024-02-29 22:00 | 2024-02-29 14:00 (UTC-8) | 2024-03-01 07:00 (UTC+9) |
Which way the conversion goes
In Postgres and Redshift, AT TIME ZONE runs in opposite directions depending on its input. Applied to a TIMESTAMP WITH TIME ZONE, an instant, it returns a TIMESTAMP with no zone that holds the wall clock in the named zone. Applied to a plain TIMESTAMP, it reads the value as wall-clock time in the named zone and returns the instant. The keyword stays the same and the input type picks the direction.
The bug shows up in the second case written with a single AT TIME ZONE. The plain value 2024-03-01 03:30 is read as 03:30 in Los Angeles, which is the instant 11:30 UTC, so the conversion moves order A 8 hours later instead of 8 hours earlier. The result sits 16 hours from the right answer, and an error of exactly twice a zone's offset is the signature of a conversion run backwards. Ask the question that prevents it before writing anything: is order_ts an instant or a wall-clock reading, and in which zone?
Convert first, then bucket
The padded range on the stored column keeps the scan bounded, since no zone sits more than 14 hours from UTC, and the exact filter runs on the converted date. Writing the conversion once, in the CTE, means the GROUP BY and the filter agree about which day a row is on, and so does any window function downstream.
Zone names, not offsets
- Right for Los Angeles only until 2024-03-10, when its clock moves to UTC-7
- Off by 1 hour for around 8 months of every year, with no error raised
- In Postgres AT TIME ZONE, 'UTC+8' follows POSIX signs and means 8 hours behind UTC
- Carries every DST transition and historical rule change
- Converts each row with the offset in force at that instant
- Lives in a tz_name column on the regions table, one value per region
One more question decides the join: whose clock? A store's sales belong to the store's zone and a customer's activity streak to the customer's zone. A company-wide daily total needs one zone for everyone, usually headquarters or UTC. Each is a different source for tz_name, and stating which you picked is half the answer.
The hour that happens twice
| Instant (UTC) | Los Angeles wall clock | Offset |
|---|---|---|
| 2024-11-03 08:00 | 01:00 | UTC-7 (PDT) |
| 2024-11-03 08:30 | 01:30 | UTC-7 (PDT) |
| 2024-11-03 09:00 | 01:00 | UTC-8 (PST) |
| 2024-11-03 09:30 | 01:30 | UTC-8 (PST) |
| 2024-11-03 10:00 | 02:00 | UTC-8 (PST) |
Calendar days versus elapsed hours
Interval math splits into 2 kinds that agree on every other day of the year. In Postgres, adding INTERVAL '1 day' to a TIMESTAMP WITH TIME ZONE keeps the wall-clock time in the session's zone, while INTERVAL '24 hours' adds 24 elapsed hours. Across the spring change they land an hour apart.
Durations go wrong the same way when computed from naive local values. A support ticket opened at 00:30 and closed at 05:30 local time on 2024-03-10 shows 5 hours, but 4 hours passed; the same ticket on 2024-11-03 shows 5 hours when 6 passed. Any SLA threshold near those values gets the wrong verdict twice a year. Subtract instants instead, whether TIMESTAMP WITH TIME ZONE values or UTC, and the elapsed time comes out right every day.
Joining hours_in_day onto a daily revenue table turns the November question into a sentence: divide by it, compare revenue per hour, and the jump disappears. The interviewer is listening for that move, because it keeps the local day finance asked for and removes the clock from the comparison.
Answers that hold up
This one arrives as "when is the next billing date?", "compare to the same day last year" or "retention 1 month after signup". A month has no fixed length, so + 1 month is a rule the engine applies to the calendar fields rather than a quantity of time. Nearly every engine applies the same rule for a single step: add to the month and keep the day, clipping it to the month's last day when it overflows.
SELECTsub_id,start_date,CAST(start_date + INTERVAL '1' MONTH AS DATE) AS plus_1,CAST(start_date + INTERVAL '1' MONTH + INTERVAL '1' MONTHAS DATE) AS chained_2,CAST(start_date + INTERVAL '2' MONTH AS DATE) AS plus_2FROM subscriptions
The clip also makes month math irreversible. Taking 2024-01-31 forward 1 month and back 1 month returns 2024-01-29, so a filter written as renewal_date - INTERVAL '1 month' = start_date misses every start that was clipped. And "1 month" and "30 days" part ways at once: 2024-01-31 plus 30 days is 2024-03-01, plus 1 month is 2024-02-29, and a trial sold as a month but coded as 30 days ends on a different date for most starts in a month that is not 30 days long. The exceptions are starts where the clip happens to land on the same day, such as 2024-01-30, which reaches 2024-02-29 either way.
Anchor every schedule to its start
February 29 and year math
| This-year row | day - INTERVAL '1 year' | Last-year row it joins |
|---|---|---|
| 2024-02-28 | 2023-02-28 | 2023-02-28 |
| 2024-02-29 | 2023-02-28 | 2023-02-28 (again) |
| 2024-03-01 | 2023-03-01 | 2023-03-01 |
Joined on ly.day = ty.day - INTERVAL '1 year', the 2023-02-28 row matches 2 rows this year, so last year's February counts that day twice and the growth figure comes out low. Written the other way, ly.day + INTERVAL '1 year' = ty.day, February 29 matches nothing. The business has to say whether February 29 compares to February 28 or to nothing. Retail usually sidesteps it by comparing to 364 days back, which keeps a Saturday against a Saturday at the price of the calendar dates drifting apart year after year.
- ▸Is each due date computed from the start date, or from the previous due date?
- ▸Does a month-end anchor stay on the last day, or keep its day number?
- ▸Does February 29 compare to last year's February 28 or to the weekday 364 days back, or does it get no comparison at all?
- ▸Does "1 month" mean a calendar month or a fixed 30 days?
The same operation in 4 dialects
| Operation | Postgres | BigQuery | Snowflake | Spark SQL |
|---|---|---|---|---|
| Add 1 month | `d + INTERVAL '1 month'` | `DATE_ADD(d, INTERVAL 1 MONTH)` | `DATEADD(month, 1, d)` | `add_months(d, 1)` |
| Days between | `end_date - start_date` | `DATE_DIFF(end_date, start_date, DAY)` | `DATEDIFF(day, start_date, end_date)` | `datediff(end_date, start_date)` |
| UTC instant to Los Angeles wall clock | `ts AT TIME ZONE 'America/Los_Angeles'` | `DATETIME(ts, 'America/Los_Angeles')` | `CONVERT_TIMEZONE('UTC', 'America/Los_Angeles', ts)` | `from_utc_timestamp(ts, 'America/Los_Angeles')` |
| Last day of month | `CAST(DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day' AS DATE)` | `LAST_DAY(d)` | `LAST_DAY(d)` | `last_day(d)` |
| ISO week and ISO year | `EXTRACT(WEEK FROM d)`, `EXTRACT(ISOYEAR FROM d)` | `EXTRACT(ISOWEEK FROM d)`, `EXTRACT(ISOYEAR FROM d)` | `WEEKISO(d)`, `YEAROFWEEKISO(d)` | `weekofyear(d)`, `extract(YEAROFWEEK FROM d)` |
Where the Meaning Changes
| Expression | Engine | Result |
|---|---|---|
| `DATEDIFF(year, '2023-12-31', '2024-01-01')` | Snowflake, SQL Server | 1 |
| `TIMESTAMPDIFF(YEAR, '2023-12-31', '2024-01-01')` | MySQL | 0 |
| `DATEADD(month, 1, '2024-02-29')` | Snowflake | 2024-03-29 |
| `ADD_MONTHS('2024-02-29', 1)` | Snowflake | 2024-03-31 |
| `add_months(DATE '2019-02-28', 1)` | Spark 3.x | 2019-03-28 (Spark 2.4 returned 2019-03-31) |
| `DATE_TRUNC(DATE '2024-03-13', WEEK)` | BigQuery | 2024-03-10, a Sunday |
| `DATE_TRUNC('week', DATE '2024-03-13')` | Postgres | 2024-03-11, a Monday |
DATEDIFF in Snowflake and SQL Server counts unit boundaries crossed, so a single day across New Year is 1 year, and BigQuery's DATE_DIFF counts boundaries the same way at the month and year level. MySQL's TIMESTAMPDIFF and Postgres's AGE count completed units. A tenure report moved from MySQL to Snowflake adds a year to every employee whose anniversary is still ahead this year, and nothing errors.
Snowflake ships 2 month functions with different month-end rules: ADD_MONTHS keeps a last-of-month date on the last day, DATEADD keeps the day number. Spark changed add_months in 3.0 to stop preserving month ends, so the same query returns different dates on 2.4 and 3.x clusters. Week truncation splits on the first weekday: Postgres starts weeks on Monday, BigQuery's WEEK starts on Sunday unless written WEEK(MONDAY) or ISOWEEK, and Snowflake follows the session's WEEK_START parameter.
The type name itself moves. A BigQuery TIMESTAMP is an instant; a Postgres TIMESTAMP is a wall-clock value, as is Snowflake's default TIMESTAMP_NTZ; Spark's default TIMESTAMP is an instant shown in the session zone. Functions that read the clock depend on the session too. Snowflake's TIMEZONE parameter defaults to America/Los_Angeles, so CURRENT_DATE there rolls over at Los Angeles midnight unless someone changed it, while BigQuery's CURRENT_DATE() uses UTC unless given a zone.
Return types shift under smaller calls as well. In Postgres, a DATE plus an integer is a DATE, while a DATE plus an INTERVAL is a TIMESTAMP, which is why the month-add queries in this lesson cast back to DATE before grouping or joining on the result.
Handling dialect in the room
Name a dialect in the first minute and write in it. Then name the places where your answer depends on it, usually 2 of the rows above: "I'm writing Postgres; in Snowflake I'd use ADD_MONTHS here because the anchor is a month end, and I'd check WEEK_START before trusting the week buckets." Interviewers rarely grade exact spelling. They are listening for whether you know which calls change meaning when the engine changes, because those are the calls that break silently in a migration.
- Ask whether the fiscal year is month-aligned or 52/53-week, and how its years are named
- Convert instants to the region's IANA zone once, and group on the local day only after that
- Measure durations on instants and bucket hours on UTC across DST days
- Compute every recurring date from its anchor with an explicit month-end rule
- Name the dialect, and check DATEDIFF semantics and week start before porting
- Pair EXTRACT(YEAR ...) with an ISO week number
- Truncate in UTC and relabel the bucket as a local day
- Store a fixed offset like '-08:00' for a zone that observes DST
- Chain +1 month steps to build a billing schedule
- Assume DATEDIFF(year, ...) counts completed years
> A streaming service headquartered in Los Angeles runs its warehouse on Snowflake and closes its books on a fiscal year beginning July 1. Finance asks for FY2025 Q1 subscription revenue by country, booked on each country's local date, together with the renewals falling due in FY2025 Q2 (October to December 2024) and an hourly view of charge volume across that quarter.
Fiscal years, ISO weeks, time zones, DST and month-end math: where a correct-looking date query lands rows in the wrong period.
- Category
- SQL
- Difficulty
- intermediate
- Duration
- 25 minutes
- Challenges
- 2 hands-on challenges
Topics covered: Fiscal Calendars and ISO Weeks, Converting With AT TIME ZONE, DST and the Missing Hour, Adding a Month to January 31, Dialect Differences in Date Math
Lesson Sections
- Fiscal Calendars and ISO Weeks (concepts: Date Parts, Date Truncation, Date Math, Grouping)
4 calendars behind one word Mapping a date to a month-offset year For a year that begins on the first of a month, shift each date forward by the number of months from the fiscal start to the next January, then read the year and quarter off the shifted date. A July year needs 6 months: July lands on January, so fiscal Q1 lines up with calendar Q1 of the shifted value. Whole-month shifts are safe here because they always land in the right month even when the day gets clipped (August 31 becomes Feb
- Converting With AT TIME ZONE (concepts: Timezones, Timestamp Types, Date vs Timestamp, Common Table Expressions)
The time zone part arrives as "daily revenue for the Tokyo office", "orders by hour of the day" or "why doesn't our month-end match finance's?" Each needs the same fact: a single instant has one UTC value and a different local date and hour in every zone, so the day a row belongs to depends on whose clock reads it. Order A is March revenue in UTC and February revenue in Los Angeles. Order B is February revenue in UTC and March revenue in Tokyo. A month-end report that buckets on the stored value
- DST and the Missing Hour (concepts: Timezones, Date Differences, Date Math, Timestamp Types, Grouping)
Daylight saving arrives disguised as an anomaly: "Sunday revenue jumped in November and nothing launched", or "the hourly chart has a hole at 2 a.m. every March". Both come from the same fact. In a zone that observes daylight saving, 2 local days a year are not 24 hours long, and any math that assumes a fixed-length day or a unique hour label breaks on those days. The hour that happens twice Group that day by local hour and the 01:00 bucket holds 2 real hours of orders, so it reads as a spike. G
- Adding a Month to January 31 (concepts: Date Math, Date Truncation, Join Cardinality)
Subscription 1 starts on 2024-01-31 and its next month is 2024-02-29, the clip. Adding a second month to that result gives 2024-03-29, while adding 2 months to the start gives 2024-03-31: the days lost in February never come back when steps are chained. Subscription 3 shows a leap day moving to 2024-03-29 and staying on the 29th, and subscription 4 shows a 31st clipping to 2024-04-30 and then drifting to 2024-05-30. Anchor every schedule to its start A schedule computed as anchor plus n months n
- Dialect Differences in Date Math (concepts: Date Math, Date Differences, Timezones, Date Parts)
Dialect comes up in 2 ways: "we're on Snowflake, write it there", or "this report was ported from SQL Server and the numbers moved". Spelling differences cost a minute of syntax. The expensive ones are calls that read the same and compute something else, and those are what the second phrasing is fishing for. The same operation in 4 dialects The day-difference row and the conversion row deserve a second look. The day difference takes its arguments in different orders: BigQuery and Spark put the e