Every dataset has dates in it somewhere. Date of birth. Exam date. Order date. Registration date. And almost every useful question you can ask about that data involves doing something with those dates - comparing them, formatting them, extracting parts of them, calculating distances between them.
Today we worked through 12 questions on Greenwood Academy's student and exam records. Each one built on the last. By Question 12 we were building a birthday reminder query that could run in a real school system - using the same functions we introduced in Question 1.
Here's every question, every function, and every moment that landed.
Q1: What Time Is It Right Now?
Before touching any table data, we started with the simplest date question possible.
SELECT
CURRENT_DATE AS today,
NOW() AS current_datetime,
CURRENT_TIMESTAMP AS timestamp;
today | current_datetime | timestamp
------------|-------------------------------|-------------------------------
2026-07-05 | 2026-07-05 10:23:41.847+03 | 2026-07-05 10:23:41.847+03
Three ways to ask PostgreSQL what time it is. The distinction matters:
-
CURRENT_DATE- date only, no time component -
NOW()andCURRENT_TIMESTAMP- both return date + time + timezone; they're effectively the same
"Use CURRENT_DATE when you only care about the date - filtering by today, calculating days between two dates. Use NOW() when you need the exact moment something happened - audit logs, timestamps on records."
Q2: Pull the Year, Month, and Day Out Separately
A date of birth stored as 2008-03-15 is a single value. What if you want just the year? Just the month? EXTRACT does it.
SELECT
first_name,
date_of_birth,
EXTRACT(YEAR FROM date_of_birth) AS birth_year,
EXTRACT(MONTH FROM date_of_birth) AS birth_month,
EXTRACT(DAY FROM date_of_birth) AS birth_day
FROM greenwood_academy.students;
first_name | date_of_birth | birth_year | birth_month | birth_day
-----------|---------------|------------|-------------|----------
Amina | 2008-03-15 | 2008 | 3 | 15
Brian | 2010-11-22 | 2010 | 11 | 22
Njeri | 2007-07-04 | 2007 | 7 | 4
Syntax: EXTRACT(field FROM column). The field can be YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, DOW (day of week), and more. We'll use several of these before the end of the session.
"EXTRACT doesn't change the column - it reads it and returns a number. You're not modifying anything, you're asking a specific question about part of the date."
Q3: How Old Is Each Student?
AGE() calculates the interval between two dates. Pass it CURRENT_DATE and a date of birth and it returns the full age - years, months, and days.
SELECT
first_name,
last_name,
date_of_birth,
AGE(CURRENT_DATE, date_of_birth) AS full_age
FROM greenwood_academy.students;
first_name | date_of_birth | full_age
-----------|---------------|---------------------------
Amina | 2008-03-15 | 18 years 3 mons 21 days
Brian | 2010-11-22 | 15 years 7 mons 14 days
Njeri | 2007-07-04 | 19 years 0 mons 1 day
The output is a PostgreSQL interval - a readable duration. Perfect for displaying a person's age in a report. Not useful yet if you need to sort or compare ages numerically, which is where Q4 comes in.
Q4: Age in Whole Years - The Nesting Trick
For sorting, filtering, or displaying a clean number, you want age in years only. The solution: nest AGE() inside EXTRACT().
SELECT
CONCAT(first_name, ' ', last_name) AS full_name,
EXTRACT(YEAR FROM AGE(date_of_birth)) AS age_years
FROM greenwood_academy.students
ORDER BY age_years DESC;
full_name | age_years
----------------|----------
Njeri Kamau | 19
Amina Hassan | 18
Brian Otieno | 15
I wrote the order of operations on the board: "The inner AGE() runs first - it calculates the interval between today and the date of birth. Then the outer EXTRACT(YEAR FROM ...) pulls just the year number out of that interval. Two functions, one clean integer."
This is the first nesting pattern of the session, and it's important - it shows that SQL functions can be composed, each one feeding its output into the next.
Q5: How Many Days Ago Was Each Exam?
Date arithmetic in PostgreSQL is simple: subtract two dates and you get the number of days between them as an integer.
SELECT
result_id,
exam_date,
CURRENT_DATE - exam_date AS days_ago
FROM greenwood_academy.exam_results;
result_id | exam_date | days_ago
----------|-------------|----------
1 | 2024-03-15 | 843
2 | 2024-04-02 | 825
3 | 2024-05-10 | 787
date1 - date2 returns an integer. No function required. The simplicity is the point - I told the class: "When you need days between two dates, just subtract them. PostgreSQL handles the calendar for you - leap years, month lengths, all of it. You get the right number."
Q6: When Are Results Released? Adding Time with INTERVAL
You can add time to a date using INTERVAL. The value and the unit go together inside quotes.
SELECT
result_id,
exam_date,
exam_date + INTERVAL '2 weeks' AS result_release_date
FROM greenwood_academy.exam_results;
result_id | exam_date | result_release_date
----------|-------------|--------------------
1 | 2024-03-15 | 2024-03-29
2 | 2024-04-02 | 2024-04-16
INTERVAL '2 weeks' and INTERVAL '14 days' give the same result - I ran both to prove it. You can use days, weeks, months, years, hours, minutes - whatever unit fits the problem.
Real-world use: payment due dates, subscription renewal dates, probation end dates, warranty expiry. Any situation where you need to add a fixed duration to a stored date.
Q7: Displaying Dates in a Human-Friendly Format
Dates stored as 2024-03-15 are machine-readable. Reports need Friday, 15th March 2024. TO_CHAR() handles the formatting.
SELECT
result_id,
exam_date,
TO_CHAR(exam_date, 'Day, DDth Month YYYY') AS friendly_date
FROM greenwood_academy.exam_results;
result_id | exam_date | friendly_date
----------|-------------|---------------------------
1 | 2024-03-15 | Friday , 15th March 2024
2 | 2024-04-02 | Tuesday , 02nd April 2024
One thing worth noting: PostgreSQL pads Day and Month to a fixed width with trailing spaces - you can see "Friday " with extra spaces. Add FM (Fill Mode) at the start of the format to suppress padding: TO_CHAR(exam_date, 'FMDay, DDth FMMonth YYYY').
Q8: Two Formats, One Query
TO_CHAR() is flexible enough to produce any format you need. Here we run two different formats side by side:
SELECT
result_id,
exam_date,
TO_CHAR(exam_date, 'DD/MM/YYYY') AS first_format,
TO_CHAR(exam_date, 'Month YYYY') AS month_year
FROM greenwood_academy.exam_results;
result_id | exam_date | first_format | month_year
----------|-------------|--------------|------------
1 | 2024-03-15 | 15/03/2024 | March 2024
2 | 2024-04-02 | 02/04/2024 | April 2024
Common format codes worth memorising:
| Code | Meaning | Example |
|---|---|---|
DD |
Day number (with leading zero) | 05 |
MM |
Month number | 03 |
YYYY |
4-digit year | 2024 |
Month |
Full month name | March |
Mon |
Abbreviated month | Mar |
Day |
Full day name | Friday |
FMDay |
Day name, no padding | Friday |
DDth |
Day with suffix | 15th |
Q9: Filtering by Part of a Date
EXTRACT isn't only useful in SELECT - it works inside WHERE too. This is one of the most commonly used date patterns in real queries.
SELECT
first_name, last_name, date_of_birth
FROM greenwood_academy.students
WHERE EXTRACT(YEAR FROM date_of_birth) = 2008;
first_name | last_name | date_of_birth
-----------|-----------|---------------
Amina | Hassan | 2008-03-15
Samuel | Kiprop | 2008-09-11
"You don't need to know the full date. You just need the year. EXTRACT pulls it out, WHERE compares it. This pattern works for any part - filter by month to find March birthdays, filter by day to find everyone born on the 1st of any month."
Q10: Grouping Exams by Month with DATE_TRUNC
How many exams were held in each month? The challenge: exam dates are specific days (2024-03-15, 2024-03-21). You can't GROUP BY the exact date - you'd get one row per exam. You need to snap all March dates to a single value.
DATE_TRUNC() does this - it rounds a date down to the start of a specified unit.
SELECT
DATE_TRUNC('month', exam_date) AS exam_month,
COUNT(*) AS total_exams
FROM greenwood_academy.exam_results
GROUP BY DATE_TRUNC('month', exam_date)
ORDER BY exam_month;
exam_month | total_exams
---------------------|------------
2024-03-01 00:00:00 | 4
2024-04-01 00:00:00 | 3
2024-05-01 00:00:00 | 5
DATE_TRUNC('month', '2024-03-15') returns 2024-03-01. So does DATE_TRUNC('month', '2024-03-21'). Both March dates become the same value - GROUP BY can now treat them as a single group.
"DATE_TRUNC is how you go from daily data to weekly or monthly summaries without changing your raw data. You're not deleting the day - you're temporarily ignoring it for the purpose of grouping."
Q11: Was the Exam on a Weekday or Weekend?
EXTRACT(DOW FROM date) returns the day of the week as a number: 0 = Sunday, 1 = Monday, through 6 = Saturday.
SELECT
result_id,
exam_date,
TO_CHAR(exam_date, 'FMDay') AS day_name,
EXTRACT(DOW FROM exam_date) AS day_number,
CASE
WHEN EXTRACT(DOW FROM exam_date) IN (0, 6) THEN 'Weekend'
ELSE 'Weekday'
END AS day_type
FROM greenwood_academy.exam_results;
result_id | exam_date | day_name | day_number | day_type
----------|-------------|-----------|------------|----------
1 | 2024-03-15 | Friday | 5 | Weekday
2 | 2024-04-02 | Tuesday | 2 | Weekday
3 | 2024-03-17 | Sunday | 0 | Weekend
Two things working together here: TO_CHAR(exam_date, 'FMDay') gives the readable name. EXTRACT(DOW FROM exam_date) gives the number we can test in CASE WHEN. Sunday is 0 and Saturday is 6 - those two values are the Weekend, everything else is a Weekday.
This is also the first query where CASE WHEN connects directly to a date function - a pattern that shows up constantly in real reporting: label the data based on when something happened.
Q12: The Session Closer - Birthday Reminders
The most complex query of the session. "Find every student whose birthday falls in the next 30 days."
The challenge: dates of birth are from years ago. 2008-03-15 can't be compared directly to today's date range because the year is wrong. You need to rebuild "this year's version" of each birthday.
SELECT
first_name,
last_name,
date_of_birth,
TO_CHAR(date_of_birth, 'DDth FMMonth') AS birthday
FROM greenwood_academy.students
WHERE
TO_DATE(
TO_CHAR(CURRENT_DATE, 'YYYY') || '-' ||
TO_CHAR(date_of_birth, 'MM-DD'),
'YYYY-MM-DD'
) BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days';
I broke this down step by step before running it:
Step 1 - Extract the current year as text: TO_CHAR(CURRENT_DATE, 'YYYY') → '2026'
Step 2 - Extract the birth month and day: TO_CHAR(date_of_birth, 'MM-DD') → '03-15'
Step 3 - Join them with || (PostgreSQL's string concatenation): '2026' || '-' || '03-15' → '2026-03-15'
Step 4 - Convert that text back to a real date: TO_DATE('2026-03-15', 'YYYY-MM-DD') → 2026-03-15
Step 5 - Check if that date falls in the next 30 days: BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days'
The result: every student with a birthday coming up in the next month, with their birthday formatted as 15th March.
"This is a real-world pattern. Schools use it for birthday lists. HR systems use it for contract renewals. Subscription services use it for renewal reminders. The logic is always the same - rebuild this year's version of a stored date, then test whether it falls in your target window."
The Full Function Reference
| Function | What it does | Example |
|---|---|---|
CURRENT_DATE |
Today's date | 2026-07-05 |
NOW() |
Current date + time + timezone | 2026-07-05 10:23:41+03 |
EXTRACT(field FROM date) |
Pull one part out of a date |
EXTRACT(YEAR FROM dob) → 2008
|
AGE(date1, date2) |
Interval between two dates | 18 years 3 mons 21 days |
date1 - date2 |
Days between two dates | 843 |
date + INTERVAL '14 days' |
Add time to a date | 2024-03-29 |
TO_CHAR(date, format) |
Format a date as text | '15th March 2024' |
DATE_TRUNC('month', date) |
Round date down to month start | 2024-03-01 |
EXTRACT(DOW FROM date) |
Day of week (0=Sun, 6=Sat) |
5 (Friday) |
TO_DATE(text, format) |
Parse text into a date | TO_DATE('2026-03-15', 'YYYY-MM-DD') |
Practice Problems
Easy:
-- 1. Show each student's first name and the year they were born
-- 2. How many days old is each student? (CURRENT_DATE - date_of_birth)
-- 3. Format each exam date as 'April 2024' style
Medium:
-- Show each student's full name and age in whole years
-- Order from oldest to youngest
SELECT
CONCAT(first_name, ' ', last_name) AS full_name,
EXTRACT(YEAR FROM AGE(date_of_birth)) AS age_years
FROM greenwood_academy.students
ORDER BY age_years DESC;
Challenge:
-- Monthly exam summary:
-- Show which month each exam fell in, how many exams that month,
-- and whether the month was in Term 1 (Jan-Apr), Term 2 (May-Aug),
-- or Term 3 (Sep-Dec)
-- Use DATE_TRUNC for grouping and CASE WHEN + EXTRACT for the term label
-- Your query here...
I'm a data trainer in Nairobi running a full data programme
Top comments (0)