Lesson 15 of 20

Date Functions

The Date Types, and Choosing Between Them

The first rule of dates in SQL: store them in a date type, never in a VARCHAR. A date kept as text cannot be sorted chronologically unless it happens to be written year-first, cannot be compared with a range, cannot have days added to it, and will cheerfully accept '31/02/2026'. Every one of those problems disappears the moment the column has a real type.

MySQL offers five. DATE holds a calendar date with no time. DATETIME holds a date and a time. TIME holds a clock time or a duration. YEAR holds a year. And TIMESTAMP holds a date and time too, but with an important difference.

That difference — DATETIME versus TIMESTAMP — is not a matter of taste. MySQL stores a TIMESTAMP converted to UTC and converts it back into the session's time zone on the way out, whereas a DATETIME is stored and returned exactly as written with no time-zone handling at all. So a TIMESTAMP represents a real moment that means the same thing everywhere on earth, while a DATETIME represents a reading on a wall clock. TIMESTAMP also has a limited range, roughly 1970 to 2038, which makes it unusable for dates of birth or for anything far in the future; DATETIME spans the years 1000 to 9999.

PostgreSQL draws the same distinction with clearer names: timestamptz is time-zone aware and timestamp is not. The guidance is identical on both. Use the time-zone-aware type for things that happened — an order placed, a row created — and the plain type for things that are inherently local, such as a class scheduled for nine in the morning.

Example
CREATE TABLE class_sessions (
    id           INT AUTO_INCREMENT PRIMARY KEY,
    subject      VARCHAR(60),
    session_date DATE,      -- 2026-08-14, no time component at all
    starts_at    TIME,      -- 09:00:00, a wall-clock time
    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,  -- a real moment
    updated_at   DATETIME                              -- stored as written
);

-- Date literals are written year-month-day: unambiguous, and sortable
INSERT INTO class_sessions (subject, session_date, starts_at)
VALUES ('Databases', '2026-08-14', '09:00:00');

-- Never do this
-- CREATE TABLE bad_idea (session_date VARCHAR(20));
-- '14/08/2026' cannot be sorted, ranged over, or added to,
-- and '31/02/2026' would be accepted without a murmur.

-- Converting text into a real date
SELECT DATE('2026-08-14 09:30:00') AS just_the_date;
SELECT CAST('2026-08-14' AS DATE)  AS parsed;
Notes
  • Always write date literals in 'YYYY-MM-DD' form. It is the ISO standard, every database accepts it, and it removes the ambiguity that makes '05/08/2026' mean 5 August to one reader and 8 May to another.

Now, Today, and the Time Zone Question

NOW() returns the current date and time, CURDATE() just the date, and CURTIME() just the time. CURRENT_DATE and CURRENT_TIMESTAMP are the standard spellings and work on both MySQL and PostgreSQL, which makes them the more portable habit.

MySQL has one subtlety here worth knowing. NOW() returns the time at which the statement started and holds that value for the whole statement, so every row inserted by a single statement receives exactly the same timestamp. SYSDATE() returns the time at the instant it is evaluated and can therefore differ from row to row within one statement. Use NOW() unless you specifically want the other behaviour.

Which time zone are these functions reporting? MySQL uses the session's time_zone setting, which defaults to the server's system zone. That means the same query returns different values on your laptop and on a server hosted abroad — and shared hosting very often runs on UTC while you and your users are on IST, five and a half hours ahead. A row inserted at 1 a.m. IST is stored as the previous day in UTC, so a report grouped by date can put it in the wrong day entirely.

The advice that scales is to store timestamps in UTC and convert to local time only at the moment of display. When you do need the conversion inside SQL, MySQL provides CONVERT_TZ, though named zones such as 'Asia/Kolkata' only work if the server's time zone tables have been loaded, while the numeric offset '+05:30' always works. On any unfamiliar server, run SELECT NOW(), UTC_TIMESTAMP(); first — if the two agree, you are on UTC.

Example
-- The basics
SELECT NOW()             AS date_and_time;
SELECT CURDATE()         AS today;
SELECT CURTIME()         AS time_now;
SELECT CURRENT_TIMESTAMP AS portable_now;   -- MySQL and PostgreSQL

-- What time zone is this server actually running in?
SELECT NOW() AS server_time, UTC_TIMESTAMP() AS utc_time;
SELECT @@global.time_zone, @@session.time_zone;

-- Set the current connection to IST (MySQL)
SET time_zone = '+05:30';

-- Convert a stored UTC value for display (MySQL)
SELECT id, CONVERT_TZ(ordered_at, '+00:00', '+05:30') AS ordered_at_ist
FROM orders;

-- Let the database fill the columns in for you
CREATE TABLE enquiries (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    name       VARCHAR(100),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                         ON UPDATE CURRENT_TIMESTAMP
);
Notes
  • ON UPDATE CURRENT_TIMESTAMP is a MySQL feature: the column refreshes itself whenever the row changes, with no application code involved. PostgreSQL and SQL Server have no equivalent in the column definition and need a trigger instead. It is genuinely convenient, and it is worth remembering that it does not travel.

Pulling Parts Out of a Date

Extracting components is what turns a date into something you can group by. MySQL provides one function per part: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, plus QUARTER and WEEK.

The portable spelling is EXTRACT(part FROM column), which is the SQL standard and works in MySQL, PostgreSQL and Oracle. PostgreSQL has no YEAR() function at all, so EXTRACT(YEAR FROM joined_on) is what you must write there. SQL Server uses DATEPART(year, joined_on) and also has its own YEAR().

Day-of-week functions carry a numbering trap. MySQL's DAYOFWEEK() returns 1 for Sunday through to 7 for Saturday, following an old ODBC convention. MySQL's own WEEKDAY() returns 0 for Monday through to 6 for Sunday. Two functions, one database, different bases. When you need to test for a weekend, DAYNAME() is far harder to get wrong, and you should never assume the numbering matches whatever your programming language uses.

Week numbers are worse, and it is worth knowing before you build a weekly report. Several competing definitions of "week 1" exist — whether it is the week containing 1 January or the first week with four days in the new year, and whether weeks begin on Sunday or Monday. MySQL's WEEK() takes a mode argument precisely because of this. When two systems disagree about which week a date belongs to, this is nearly always the reason.

Example
-- MySQL: one function per part
SELECT
    joined_on,
    YEAR(joined_on)    AS y,
    MONTH(joined_on)   AS m,
    DAY(joined_on)     AS d,
    QUARTER(joined_on) AS q,
    DAYNAME(joined_on) AS day_name
FROM students;

-- The portable form: MySQL, PostgreSQL and Oracle
SELECT
    EXTRACT(YEAR  FROM joined_on) AS y,
    EXTRACT(MONTH FROM joined_on) AS m
FROM students;

-- Two day-of-week functions in one database, numbered differently.
-- 2026-08-14 is a Friday.
SELECT
    DAYOFWEEK('2026-08-14') AS dayofweek,   -- 6, where 1 = Sunday
    WEEKDAY('2026-08-14')   AS weekday,     -- 4, where 0 = Monday
    DAYNAME('2026-08-14')   AS day_name;    -- 'Friday'

-- Easier to read, and much harder to get wrong
SELECT id, ordered_at
FROM orders
WHERE DAYNAME(ordered_at) IN ('Saturday', 'Sunday');

-- Enrolments per month
SELECT
    EXTRACT(YEAR  FROM joined_on) AS y,
    EXTRACT(MONTH FROM joined_on) AS m,
    COUNT(*)                      AS students
FROM students
GROUP BY EXTRACT(YEAR FROM joined_on), EXTRACT(MONTH FROM joined_on)
ORDER BY y, m;
Notes
  • Grouping by year and month separately gives you two columns that you then have to sort together. Grouping by DATE_FORMAT(joined_on, '%Y-%m') in MySQL, or DATE_TRUNC('month', joined_on) in PostgreSQL, gives one column that already sorts correctly and is easier to feed into a chart.

Date Arithmetic: Adding, Subtracting and Measuring

Adding time to a date is where MySQL's INTERVAL keyword appears: DATE_ADD(joined_on, INTERVAL 30 DAY), with DATE_SUB for the other direction. MySQL also accepts the shorter joined_on + INTERVAL 30 DAY, which means the same thing. PostgreSQL writes it as joined_on + INTERVAL '30 days' with the quantity quoted, and SQL Server uses DATEADD(day, 30, joined_on).

Intervals understand calendars, which is exactly why you use them instead of adding plain numbers. + INTERVAL 1 MONTH applied to 31 January gives 28 February, rather than the 31st of a month that does not have one. Adding 30 days instead would have given 2 March. Both behaviours are useful and they are not the same; make sure you picked the one you meant.

Measuring the gap between two dates is where the function signatures genuinely conflict between products. MySQL's DATEDIFF(a, b) takes two dates and always returns the difference in days, computed as a minus b. SQL Server's DATEDIFF(unit, a, b) takes three arguments with the unit first and returns b minus a. Same function name, different argument count, and the opposite sign. Copying a SQL Server example into MySQL is a reliable way to produce a negative number you were not expecting.

For units other than days, MySQL has TIMESTAMPDIFF(unit, a, b) — and note that this one returns b minus a, the reverse of DATEDIFF in the very same database. It is also the correct way to calculate an age. Dividing a day count by 365 is wrong: it ignores leap years and drifts by a day every four years, which is precisely the kind of error that surfaces on somebody's birthday. TIMESTAMPDIFF(YEAR, dob, CURDATE()) returns completed years, which is what "age" actually means.

Example
-- Adding and subtracting (MySQL)
SELECT
    joined_on,
    DATE_ADD(joined_on, INTERVAL 30 DAY)  AS plus_30_days,
    DATE_SUB(joined_on, INTERVAL 1 MONTH) AS minus_1_month,
    joined_on + INTERVAL 1 YEAR           AS next_year
FROM students;

-- Calendars, not counting: 31 Jan plus one month is 28 Feb in 2026
SELECT
    DATE_ADD('2026-01-31', INTERVAL 1 MONTH) AS add_a_month,  -- 2026-02-28
    DATE_ADD('2026-01-31', INTERVAL 30 DAY)  AS add_30_days;  -- 2026-03-02

-- MySQL: two dates in, days out, computed as the first minus the second
SELECT name, DATEDIFF(CURDATE(), joined_on) AS days_since_joining
FROM students;

-- MySQL: any unit you like, but the arguments are the other way round
SELECT
    TIMESTAMPDIFF(DAY,   joined_on, CURDATE()) AS days,
    TIMESTAMPDIFF(MONTH, joined_on, CURDATE()) AS months,
    TIMESTAMPDIFF(YEAR,  joined_on, CURDATE()) AS years
FROM students;

-- Age, done properly: completed years, leap years handled
SELECT name, dob, TIMESTAMPDIFF(YEAR, dob, CURDATE()) AS age
FROM students;

-- Age, done wrongly: drifts by a day every four years
-- SELECT name, DATEDIFF(CURDATE(), dob) / 365 AS age FROM students;

-- Other dialects
-- PostgreSQL: joined_on + INTERVAL '30 days'
--             AGE(CURRENT_DATE, dob)
-- SQL Server: DATEADD(day, 30, joined_on)
--             DATEDIFF(day, joined_on, GETDATE())
Notes
  • MySQL's DATEDIFF(a, b) is a minus b, while TIMESTAMPDIFF(unit, a, b) is b minus a. That inconsistency lives inside one database and catches people constantly. When a duration comes out negative, check the argument order before you check your data.

Filtering by Date Without Destroying Your Index

This is the practical heart of the lesson. Date filters appear in almost every real query, and there is one very natural way of writing them that quietly costs you all of your performance.

WHERE YEAR(ordered_at) = 2026 is correct and it is slow. Wrapping the column in a function means the index on ordered_at cannot be used: the index stores dates, and the engine has no way to look up "rows whose year is 2026" without computing YEAR() for every row in the table. Written as a range instead — ordered_at >= '2026-01-01' AND ordered_at < '2027-01-01' — the same question asks for a contiguous slice of the index and can be answered by reading only that slice.

Notice the shape of that range: greater-than-or-equal on the lower bound, strictly-less-than on the upper, and the upper bound is the start of the next period rather than the end of this one. This is called a half-open range and it is the pattern to use for every date filter you ever write. It needs no adjustment for month lengths, none for leap years, and no thought at all about the last second of the day.

That last point hides a bug worth naming. BETWEEN '2026-06-01' AND '2026-06-30' on a DATETIME column misses nearly all of 30 June, because the literal '2026-06-30' is read as midnight at the start of that day. An order placed at 09:15 on the 30th falls after the upper bound and is excluded. On a DATE column the same query is perfectly fine, which is what makes the bug so hard to spot — it passes testing against one table and silently drops a day's data against another. The half-open range is immune to it.

Example
-- Correct but slow: the function hides the column from its own index
SELECT id, ordered_at FROM orders WHERE YEAR(ordered_at) = 2026;

-- Correct and fast: one contiguous slice of the index
SELECT id, ordered_at
FROM orders
WHERE ordered_at >= '2026-01-01'
  AND ordered_at <  '2027-01-01';

-- A single month, written exactly the same way
SELECT id, ordered_at
FROM orders
WHERE ordered_at >= '2026-06-01'
  AND ordered_at <  '2026-07-01';

-- The BETWEEN bug on a DATETIME column: everything after midnight
-- on 30 June is silently excluded
-- SELECT id FROM orders
-- WHERE ordered_at BETWEEN '2026-06-01' AND '2026-06-30';

-- Relative windows still use the index, because the function is
-- applied to a constant rather than to the column
SELECT id, ordered_at
FROM orders
WHERE ordered_at >= DATE_SUB(CURDATE(), INTERVAL 90 DAY);

-- Today's orders, with no function anywhere near the column
SELECT id, ordered_at
FROM orders
WHERE ordered_at >= CURDATE()
  AND ordered_at <  CURDATE() + INTERVAL 1 DAY;
Notes
  • The rule generalises well beyond dates: put the functions on the constants, never on the column. WHERE ordered_at >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) computes one value and compares the raw column against it, so the index still works. WHERE DATEDIFF(CURDATE(), ordered_at) <= 90 asks exactly the same question and has to compute a value for every single row.

Formatting Dates for Display

Internally the database stores a date as a number; how it looks is purely a display decision, and every product spells the formatting differently. MySQL has DATE_FORMAT(value, pattern). PostgreSQL and Oracle have TO_CHAR(value, pattern). SQL Server has FORMAT(value, pattern). The patterns are not compatible either: MySQL uses %Y and %m, while PostgreSQL and SQL Server use YYYY and MM.

MySQL's common specifiers are worth keeping to hand: %Y four-digit year, %y two-digit year, %m month as 01-12, %c month as 1-12, %d day as 01-31, %e day as 1-31, %M full month name, %b short month name, %W weekday name, %H hour on a 24-hour clock, %h hour on a 12-hour clock, %i minutes, %s seconds, and %p for AM or PM. The one people get wrong is minutes: %m is the month and %i is the minute, so '%H:%m' prints the hour followed by the month number and looks almost right.

Two rules about where formatting belongs. Never store a formatted date — store the real type and format it when you display it, or you trade away sorting, comparison and arithmetic to save one function call. And never sort on a formatted date unless the format is year-first, because as text '01/12/2026' sorts before '02/01/2026' while the actual dates run the other way.

For anything a program will read rather than a person, use the ISO format YYYY-MM-DD. It is unambiguous, it sorts correctly as plain text, and it sidesteps the confusion that makes 05/08/2026 mean 5 August in India and 8 May in the United States. Save the friendly formats for the screen, where there is a human present to interpret them.

Example
-- MySQL
SELECT
    joined_on,
    DATE_FORMAT(joined_on, '%d %b %Y')          AS friendly,
    DATE_FORMAT(joined_on, '%W, %d %M %Y')      AS long_form,
    DATE_FORMAT(joined_on, '%Y-%m')             AS month_key,
    DATE_FORMAT(NOW(),     '%d/%m/%Y %h:%i %p') AS with_time
FROM students;

-- %m is the month and %i is the minute. This is the usual slip:
--   DATE_FORMAT(NOW(), '%H:%m')   -- hour and MONTH, not hour and minute
--   DATE_FORMAT(NOW(), '%H:%i')   -- what was actually wanted

-- Sorting a formatted date only works when it is year-first
SELECT DATE_FORMAT(joined_on, '%Y-%m') AS month_key, COUNT(*) AS students
FROM students
GROUP BY DATE_FORMAT(joined_on, '%Y-%m')
ORDER BY month_key;

-- An ISO string, for when a program will read the value
SELECT DATE_FORMAT(joined_on, '%Y-%m-%d') AS iso_date FROM students;

-- Other dialects
-- PostgreSQL and Oracle: TO_CHAR(joined_on, 'DD Mon YYYY')
-- SQL Server:            FORMAT(joined_on, 'dd MMM yyyy')
Notes
  • If your application already has a date library, do the formatting there rather than in SQL. The database should hand over real date values; the application decides whether the user sees '14 Aug 2026' or '14/08/2026', and that decision often depends on the user's language settings, which the database knows nothing about.
Ask AI