В прошлом уроке мы складывали в таблицы текст. Но у кинотеатра половина данных про время: когда начинается сеанс, когда продан билет, когда зритель зарегистрировался. Дату можно хранить и строкой '24.10.2026', но тогда база не скажет, сколько дней прошло с регистрации, и не найдёт сеансы на завтра. Сегодня разбираем три типа для даты и времени, считаем с ними и ловим две ловушки: часовой пояс и конец месяца.
Настенный календарь и часы на вокзале
Представь два способа записать время встречи. Первый: на бумажке «24 октября, 12:00». Кто бы ни прочитал бумажку, в Москве или в Новосибирске, он увидит те же цифры. Второй: «встречаемся в момент, когда в Гринвиче полдень». Это одна точка на оси времени, а местное время у каждого читателя своё: москвич скажет «в три часа дня».
В MySQL оба способа есть. DATETIME хранит бумажку: дату и время ровно как написали. TIMESTAMP хранит точку на оси: момент в мировом времени, который каждому клиенту показывают по его часам. А DATE это лист настенного календаря, день без времени.
| Тип | Что хранит | Пример | Диапазон |
|---|---|---|---|
DATE | день | 2026-10-24 | от 1000 до 9999 года |
DATETIME | день и время, как написали | 2026-10-24 21:30:00 | от 1000 до 9999 года |
TIMESTAMP | момент в UTC | 2026-10-24 12:00:00 | от 1970 до 19 января 2038 года |
В базе kino сеансы и продажи билетов лежат в DATETIME, день регистрации зрителя в DATE.
Который час в песочнице
Текущий момент дают три функции:
SELECT NOW(), CURDATE(), CURTIME();
+---------------------+------------+-----------+
| NOW() | CURDATE() | CURTIME() |
+---------------------+------------+-----------+
| 2026-10-24 12:00:00 | 2026-10-24 | 12:00:00 |
+---------------------+------------+-----------+
1 row in set (0.00 sec)
Часы песочницы остановлены на 24 октября 2026 года, полдень по UTC. Так примеры урока выходят одинаковыми, когда бы ты их ни запустил. На настоящем сервере NOW() идёт вперёд.
MySQL пишет даты в одном формате: год, месяц, день через дефис. Из DATETIME можно вынуть части:
SELECT id, starts_at, DATE(starts_at), TIME(starts_at) FROM screenings;
+----+---------------------+-----------------+-----------------+
| id | starts_at | DATE(starts_at) | TIME(starts_at) |
+----+---------------------+-----------------+-----------------+
| 1 | 2026-10-23 19:00:00 | 2026-10-23 | 19:00:00 |
| 2 | 2026-10-24 21:30:00 | 2026-10-24 | 21:30:00 |
| 3 | 2026-10-24 18:00:00 | 2026-10-24 | 18:00:00 |
| 4 | 2026-10-25 20:00:00 | 2026-10-25 | 20:00:00 |
| 5 | 2026-10-25 17:00:00 | 2026-10-25 | 17:00:00 |
| 6 | 2026-10-26 11:00:00 | 2026-10-26 | 11:00:00 |
| 7 | 2026-10-26 22:00:00 | 2026-10-26 | 22:00:00 |
+----+---------------------+-----------------+-----------------+
7 rows in set (0.00 sec)
Для части поменьше есть YEAR, MONTH и DAYNAME:
SELECT YEAR(registered_at), MONTH(registered_at), DAYNAME(registered_at), name FROM viewers WHERE id = 1;
+---------------------+----------------------+------------------------+---------------------------+
| YEAR(registered_at) | MONTH(registered_at) | DAYNAME(registered_at) | name |
+---------------------+----------------------+------------------------+---------------------------+
| 2025 | 12 | Monday | Анна Ковалёва |
+---------------------+----------------------+------------------------+---------------------------+
1 row in set (0.00 sec)
День недели пришёл по-английски: так настроен сервер по умолчанию.
Как привычно человеку
Для афиши 2026-10-24 21:30:00 выглядит казённо. Функция DATE_FORMAT собирает строку по шаблону: %d день, %m месяц, %Y год из четырёх цифр, %H часы, %i минуты.
SELECT id, DATE_FORMAT(starts_at, '%d.%m.%Y %H:%i') AS shown FROM screenings;
+----+------------------+
| id | shown |
+----+------------------+
| 1 | 23.10.2026 19:00 |
| 2 | 24.10.2026 21:30 |
| 3 | 24.10.2026 18:00 |
| 4 | 25.10.2026 20:00 |
| 5 | 25.10.2026 17:00 |
| 6 | 26.10.2026 11:00 |
| 7 | 26.10.2026 22:00 |
+----+------------------+
7 rows in set (0.00 sec)
Обратно так не работает. Записать в колонку DATE строку в русском формате нельзя:
INSERT INTO viewers (name, email, registered_at) VALUES ('Жанна', 'zhanna@example.com', '24.10.2026');
ERROR 1292 (22007): Incorrect date value: '24.10.2026' for column 'registered_at' at row 1
Строгий режим, знакомый по числовым типам, не пустил кривое значение. Храни даты в формате ГГГГ-ММ-ДД, а DATE_FORMAT оставь для вывода.
Считаем дни и часы
DATEDIFF возвращает разницу в днях. Сколько дней каждый зритель с нами:
SELECT name, registered_at, DATEDIFF(CURDATE(), registered_at) AS days FROM viewers;
+---------------------------+---------------+------+
| name | registered_at | days |
+---------------------------+---------------+------+
| Анна Ковалёва | 2025-12-01 | 327 |
| Борис Орлов | 2026-02-14 | 252 |
| Вера Ильина | 2026-03-30 | 208 |
| Глеб Соколов | 2026-06-11 | 135 |
| Дарья Белова | 2026-08-02 | 83 |
| Егор Ёлкин | 2026-10-01 | 23 |
+---------------------------+---------------+------+
6 rows in set (0.00 sec)
Для других единиц есть TIMESTAMPDIFF(единица, от, до). За сколько часов до первого сеанса купили билеты на него:
SELECT TIMESTAMPDIFF(HOUR, sold_at, '2026-10-23 19:00:00') AS hours_before FROM tickets WHERE screening_id = 1;
+--------------+
| hours_before |
+--------------+
| 78 |
| 78 |
| 0 |
+--------------+
3 rows in set (0.00 sec)
Третий билет купили в кассе в 18:40, за 20 минут до начала. Полных часов ноль: TIMESTAMPDIFF отбрасывает остаток, а не округляет.
Сдвигать время помогает INTERVAL. Его прибавляют знаком + или функциями DATE_ADD и DATE_SUB:
SELECT starts_at, starts_at + INTERVAL 2 HOUR, DATE_ADD(starts_at, INTERVAL 1 DAY), DATE_SUB(starts_at, INTERVAL 30 MINUTE) FROM screenings WHERE id = 1;
+---------------------+-----------------------------+-------------------------------------+-----------------------------------------+
| starts_at | starts_at + INTERVAL 2 HOUR | DATE_ADD(starts_at, INTERVAL 1 DAY) | DATE_SUB(starts_at, INTERVAL 30 MINUTE) |
+---------------------+-----------------------------+-------------------------------------+-----------------------------------------+
| 2026-10-23 19:00:00 | 2026-10-23 21:00:00 | 2026-10-24 19:00:00 | 2026-10-23 18:30:00 |
+---------------------+-----------------------------+-------------------------------------+-----------------------------------------+
1 row in set (0.00 sec)
Конец месяца и несуществующие даты
Прибавить месяц к 24 октября легко: выйдет 24 ноября. А что будет с 31 января? 31 февраля нет, и MySQL берёт последний день месяца:
SELECT DATE('2026-02-30'), '2026-10-24' + INTERVAL 1 MONTH, LAST_DAY('2026-02-10'), DATE_ADD('2026-01-31', INTERVAL 1 MONTH);
+--------------------+---------------------------------+------------------------+------------------------------------------+
| DATE('2026-02-30') | '2026-10-24' + INTERVAL 1 MONTH | LAST_DAY('2026-02-10') | DATE_ADD('2026-01-31', INTERVAL 1 MONTH) |
+--------------------+---------------------------------+------------------------+------------------------------------------+
| NULL | 2026-11-24 | 2026-02-28 | 2026-02-28 |
+--------------------+---------------------------------+------------------------+------------------------------------------+
1 row in set, 1 warning (0.00 sec)
Отсюда ловушка для подписок: 31 января плюс месяц даёт 28 февраля, а 28 февраля плюс месяц уже 28 марта. День «съехал» навсегда. Если считаешь даты оплаты, отсчитывай каждый раз от исходной даты, а не от прошлого результата. LAST_DAY возвращает последний день месяца, по ней находят границу отчёта «за месяц».
Первая колонка дала NULL и предупреждение. Посмотрим его:
SHOW WARNINGS;
+---------+------+----------------------------------------+
| Level | Code | Message |
+---------+------+----------------------------------------+
| Warning | 1292 | Incorrect datetime value: '2026-02-30' |
+---------+------+----------------------------------------+
1 row in set (0.00 sec)
В выражении SELECT несуществующая дата превращается в NULL с предупреждением. При записи в таблицу строгий режим отвечает ошибкой, это увидим ниже.
Все сеансы за один день
Нужна афиша на 24 октября. Первое, что приходит в голову, обрезать время функцией DATE:
SELECT id, starts_at FROM screenings WHERE DATE(starts_at) = '2026-10-24';
+----+---------------------+
| id | starts_at |
+----+---------------------+
| 2 | 2026-10-24 21:30:00 |
| 3 | 2026-10-24 18:00:00 |
+----+---------------------+
2 rows in set (0.00 sec)
Ответ верный, но есть беда. Функция над колонкой мешает базе пользоваться индексом по starts_at: ей приходится вычислить DATE для каждой строки таблицы. Про индексы подробно поговорим в уроке про индексы и EXPLAIN, а пока запомни приём: полуоткрытый интервал. Колонку не трогаем, сравниваем её с двумя границами:
SELECT id, film_id, starts_at FROM screenings WHERE starts_at >= '2026-10-25' AND starts_at < '2026-10-26';
+----+---------+---------------------+
| id | film_id | starts_at |
+----+---------+---------------------+
| 4 | 3 | 2026-10-25 20:00:00 |
| 5 | 4 | 2026-10-25 17:00:00 |
+----+---------+---------------------+
2 rows in set (0.00 sec)
Почему не BETWEEN '2026-10-25' AND '2026-10-25 23:59:59'? Такая граница сломается, когда колонка начнёт хранить доли секунды: момент 23:59:59.5 в неё не попадёт. А < '2026-10-26' ловит всё до самой полуночи при любой точности. Тот же запрос на «сегодня» пишется через CURDATE():
SELECT id, film_id, starts_at FROM screenings WHERE starts_at >= CURDATE() AND starts_at < CURDATE() + INTERVAL 1 DAY;
+----+---------+---------------------+
| id | film_id | starts_at |
+----+---------+---------------------+
| 2 | 1 | 2026-10-24 21:30:00 |
| 3 | 2 | 2026-10-24 18:00:00 |
+----+---------+---------------------+
2 rows in set (0.00 sec)
TIMESTAMP и часовой пояс
Вернёмся к бумажке и вокзальным часам. Заведём таблицу с колонкой каждого типа и запишем в обе одно и то же:
CREATE TABLE clock_test (d DATETIME, ts TIMESTAMP);
INSERT INTO clock_test VALUES (NOW(), NOW());
SELECT * FROM clock_test;
+---------------------+---------------------+
| d | ts |
+---------------------+---------------------+
| 2026-10-24 12:00:00 | 2026-10-24 12:00:00 |
+---------------------+---------------------+
1 row in set (0.00 sec)
Пока одинаково. Теперь скажем серверу, что клиент живёт в Москве. У каждого подключения свой часовой пояс сессии, его меняет SET time_zone:
SET time_zone = '+03:00';
SELECT * FROM clock_test;
+---------------------+---------------------+
| d | ts |
+---------------------+---------------------+
| 2026-10-24 12:00:00 | 2026-10-24 15:00:00 |
+---------------------+---------------------+
1 row in set (0.00 sec)
DATETIME остался бумажкой с надписью «12:00». TIMESTAMP хранит момент в UTC и показал его по московским часам, 15:00. NOW() тоже перешёл на новый пояс:
SELECT NOW();
+---------------------+
| NOW() |
+---------------------+
| 2026-10-24 15:00:00 |
+---------------------+
1 row in set (0.00 sec)
Какой тип выбрать? Если важно «во сколько по местным часам» (сеанс в 19:00 в московском зале), бери DATETIME. Если важно «в какой момент это случилось» (время входа в аккаунт из разных стран), подходит TIMESTAMP. Но у него есть срок годности.
Проблема 2038 года
TIMESTAMP хранит число секунд от 1 января 1970 года в четырёх байтах. Это число кончается 19 января 2038 года в 03:14:07 по UTC. Дальше не влезает:
SET time_zone = '+00:00';
INSERT INTO clock_test VALUES ('2026-02-30 10:00:00', NOW());
INSERT INTO clock_test VALUES ('2040-01-01 00:00:00', '2040-01-01 00:00:00');
INSERT INTO clock_test VALUES ('2040-01-01 00:00:00', '2038-01-19 03:14:07');
Query OK, 0 rows affected (0.00 sec)
ERROR 1292 (22007): Incorrect datetime value: '2026-02-30 10:00:00' for column 'd' at row 1
ERROR 1292 (22007): Incorrect datetime value: '2040-01-01 00:00:00' for column 'ts' at row 1
Query OK, 1 row affected (0.00 sec)
Первая вставка споткнулась о 30 февраля: при записи в таблицу несуществующая дата даёт ошибку, а не NULL. Вторая не влезла в TIMESTAMP, хотя DATETIME 2040 год принял. Третья прошла: последняя секунда, которую помнит TIMESTAMP.
До 2038 года осталось немного, а кредиты, подписки и сроки действия документов уже заходят за эту границу. Для дат в будущем бери DATETIME.
Отметки «создано» и «изменено»
Почти в каждой таблице нужны две колонки: когда строка появилась и когда её меняли последний раз. MySQL заполняет их сам:
CREATE TABLE schedule (id INT AUTO_INCREMENT PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, note VARCHAR(20));
INSERT INTO schedule (note) VALUES ('первый');
DEFAULT CURRENT_TIMESTAMP подставляет текущий момент при вставке. ON UPDATE CURRENT_TIMESTAMP обновляет колонку при каждом изменении строки. Чтобы увидеть разницу, переведём часы сессии на полтора часа вперёд: переменная TIMESTAMP подменяет «сейчас» для текущего подключения.
SET TIMESTAMP = UNIX_TIMESTAMP('2026-10-24 13:30:00');
UPDATE schedule SET note = 'второй' WHERE id = 1;
SELECT * FROM schedule;
+----+---------------------+---------------------+--------------+
| id | created_at | updated_at | note |
+----+---------------------+---------------------+--------------+
| 1 | 2026-10-24 12:00:00 | 2026-10-24 13:30:00 | второй |
+----+---------------------+---------------------+--------------+
1 row in set (0.00 sec)
created_at остался прежним, updated_at сдвинулся. Код приложения про эти колонки может не думать. Те же слова работают и с DATETIME, тогда граница 2038 года не грозит.
Что запомнить
DATEхранит день,DATETIMEдату со временем как на стене,TIMESTAMPмомент времени с привязкой к часовому поясу.SET time_zoneсдвигает показTIMESTAMP, аDATETIMEостаётся прежним.TIMESTAMPзаканчивается в январе 2038 года, для дат дальше бериDATETIME.- Строгий режим не пропускает 30 февраля, а
INTERVAL 1 MONTHот 31 января даёт 28 февраля. - Сеансы за день ищи полуоткрытым диапазоном
>= день AND < следующий день, тогда сервер сможет взять индекс.
Попробуй сам
В песочнице запишем текущий момент в DATETIME и TIMESTAMP, сменим часовой пояс и выведем сегодняшнюю афишу:
Поставь SET time_zone = '+07:00' и снова выведи clock_test: какая колонка изменилась? Посчитай DATE_ADD('2026-03-31', INTERVAL 1 MONTH) и объясни ответ. Попробуй вставить в clock_test дату 2039 года в обе колонки.
Проверь себя
Задание
Дальше
Дата, число и строка это значения из одного кусочка. А в колонке films.info лежит целая структура: список жанров, страна, награды. В следующем уроке разбираем тип JSON и подводим итог: как выбрать тип для колонки.
домашка