Даты и время в SQL: шпаргалка для собеседования

Зачем аналитику даты в SQL

80% аналитических запросов привязаны ко времени: за день, за неделю, за месяц, YoY, MoM, Retention D7. Если даты путаются — вся аналитика рассыпается.

Даты в SQL — одна из самых частых тем на собеседовании для продуктовых аналитиков. Спрашивают редко глубоко, но ошибок делают много — и интервьюеры это знают.

Типы данных

Тип Что хранит Пример
DATE Только дата '2026-04-15'
TIME Только время '14:30:00'
TIMESTAMP Дата + время без timezone '2026-04-15 14:30:00'
TIMESTAMPTZ Дата + время с timezone '2026-04-15 14:30:00+03'

На собесе спрашивают: «Когда использовать TIMESTAMP vs TIMESTAMPTZ?» Ответ: всегда TIMESTAMPTZ в продакшн — он не теряет timezone информацию. TIMESTAMP без TZ интерпретируется относительно настройки сервера.

DATE_TRUNC — округление

Самая часто используемая функция. Срезает точность до нужного уровня:

SELECT
    DATE_TRUNC('hour', created_at) AS hour_bucket,
    DATE_TRUNC('day', created_at) AS day_bucket,
    DATE_TRUNC('week', created_at) AS week_bucket,
    DATE_TRUNC('month', created_at) AS month_bucket,
    DATE_TRUNC('quarter', created_at) AS quarter_bucket,
    DATE_TRUNC('year', created_at) AS year_bucket
FROM orders;

Группировка по неделям:

SELECT DATE_TRUNC('week', created_at) AS week, SUM(amount)
FROM orders
GROUP BY 1
ORDER BY 1;

Нюанс: в PostgreSQL неделя начинается с понедельника, в BigQuery — с воскресенья. Проверяйте.

EXTRACT — достать часть даты

SELECT
    EXTRACT(YEAR FROM created_at) AS yr,
    EXTRACT(MONTH FROM created_at) AS mo,
    EXTRACT(DAY FROM created_at) AS dy,
    EXTRACT(DOW FROM created_at) AS day_of_week,  -- 0=воскресенье в PostgreSQL
    EXTRACT(ISODOW FROM created_at) AS iso_dow,    -- 1=понедельник
    EXTRACT(HOUR FROM created_at) AS hr
FROM orders;

В ClickHouse — функции toYear, toMonth, toDayOfWeek.

INTERVAL — арифметика дат

-- Дата 7 дней назад
SELECT CURRENT_DATE - INTERVAL '7 day';

-- Заказы за последние 30 дней
SELECT * FROM orders WHERE created_at >= CURRENT_DATE - INTERVAL '30 day';

-- Разница между датами
SELECT (end_date - start_date) AS days_between FROM events;

-- В часах/секундах
SELECT EXTRACT(EPOCH FROM (end_ts - start_ts)) / 3600 AS hours_between FROM sessions;

Ловушка: в MySQL используется INTERVAL 7 DAY (без кавычек), в PostgreSQL INTERVAL '7 day' (с кавычками). Это разные диалекты, но путают их часто.

Если хочется сразу закрепить тему на практике — открой тренажёр в Telegram. 10 минут в день — и синтаксис в пальцах.

Форматирование даты в строку

-- PostgreSQL
SELECT TO_CHAR(created_at, 'YYYY-MM-DD') AS d,
       TO_CHAR(created_at, 'Day') AS day_name;

-- MySQL
SELECT DATE_FORMAT(created_at, '%Y-%m-%d');

Парсинг строки в дату

-- PostgreSQL
SELECT TO_DATE('15.04.2026', 'DD.MM.YYYY');
SELECT TO_TIMESTAMP('2026-04-15 14:30', 'YYYY-MM-DD HH24:MI');

-- Cast
SELECT '2026-04-15'::DATE;
SELECT CAST('2026-04-15' AS DATE);

Timezone: главная ловушка

-- Конвертация timezone
SELECT
    created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Moscow' AS moscow_time,
    created_at::timestamptz AT TIME ZONE 'Asia/Tokyo' AS tokyo_time
FROM events;

Почему это частая ошибка:

  • Аналитик считает DAU группировкой по day.
  • У кого-то пользователь заходит в 23:30 UTC = 02:30 MSK следующего дня.
  • Если не конвертировать в правильную TZ, пользователь «попадает» не в тот день.

На собесе: всегда спрашивайте, в какой timezone считать метрики. Базовое правило: хранить в UTC, при агрегации приводить к пользовательской TZ.

Первый и последний день периода

-- Первый день месяца
SELECT DATE_TRUNC('month', CURRENT_DATE) AS month_start;

-- Последний день месяца
SELECT (DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day')::DATE;

-- Первый понедельник месяца
SELECT DATE_TRUNC('week', DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '6 day')::DATE;

Разница в «рабочих днях»

Нестандартная задача, но спрашивают:

WITH cal AS (
    SELECT generate_series(start_date, end_date, '1 day'::INTERVAL)::DATE AS day
)
SELECT COUNT(*) AS workdays
FROM cal
WHERE EXTRACT(ISODOW FROM day) NOT IN (6, 7);  -- без субботы и воскресенья

Чтобы не только читать теорию, но и решать реальные задачи — загляните в бот Карьерника. Там по каждой теме подборка вопросов с разборами.

Генерация календаря

Нужно, когда надо LEFT JOIN-ить данные и включить дни без записей:

SELECT generate_series('2026-04-01'::DATE, '2026-04-30'::DATE, '1 day'::INTERVAL)::DATE AS day;

Или рекурсивный CTE (если generate_series недоступен — ClickHouse, SQL Server):

WITH RECURSIVE cal AS (
    SELECT DATE '2026-04-01' AS day
    UNION ALL
    SELECT day + INTERVAL '1 day' FROM cal WHERE day < DATE '2026-04-30'
)
SELECT * FROM cal;

10 задач на даты

1. Выручка по дням за апрель 2026

SELECT created_at::DATE AS day, SUM(amount)
FROM orders
WHERE created_at >= '2026-04-01' AND created_at < '2026-05-01'
GROUP BY 1 ORDER BY 1;

2. MoM growth

WITH monthly AS (
    SELECT DATE_TRUNC('month', created_at) AS month, SUM(amount) AS rev
    FROM orders GROUP BY 1
)
SELECT month, rev,
    LAG(rev) OVER (ORDER BY month) AS prev,
    ROUND(100.0*(rev-LAG(rev) OVER (ORDER BY month))/LAG(rev) OVER (ORDER BY month), 1) AS growth
FROM monthly;

3. Retention D7

SELECT
    COUNT(DISTINCT u.user_id) AS cohort,
    COUNT(DISTINCT e.user_id) AS returned_d7
FROM users u
LEFT JOIN events e ON e.user_id = u.user_id
    AND e.event_time::DATE = u.registered_at::DATE + 7;

4. Возраст пользователей в днях

SELECT user_id, CURRENT_DATE - registered_at::DATE AS days_since_signup
FROM users;

5. Время между первым и последним заказом

SELECT user_id, MAX(created_at) - MIN(created_at) AS lifespan
FROM orders GROUP BY user_id;

6. Пользователи, зашедшие сегодня

SELECT DISTINCT user_id
FROM events
WHERE event_time >= CURRENT_DATE AND event_time < CURRENT_DATE + INTERVAL '1 day';

7. WAU (Weekly Active Users) по неделям

SELECT DATE_TRUNC('week', event_time) AS week, COUNT(DISTINCT user_id)
FROM events
GROUP BY 1 ORDER BY 1;

8. Доля заказов в выходные

SELECT
    COUNT(*) FILTER (WHERE EXTRACT(ISODOW FROM created_at) IN (6,7)) * 100.0 / COUNT(*) AS weekend_pct
FROM orders;

9. Средний чек по часам

SELECT EXTRACT(HOUR FROM created_at) AS hr, AVG(amount)
FROM orders GROUP BY 1 ORDER BY 1;

10. Пользователи с перерывом больше 30 дней

WITH sessions AS (
    SELECT user_id, event_time,
        LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev
    FROM events
)
SELECT DISTINCT user_id
FROM sessions
WHERE event_time - prev > INTERVAL '30 day';

Как тренироваться

Работа с датами в SQL учится на реальных аналитических задачах: MoM, retention, когорты, расчёты «за последние N дней». Теория без практики забывается за неделю.

Тренажёр Карьерник содержит блок задач на даты и временные функции — от базового DATE_TRUNC до таймзон и рабочих дней. 10 минут в день, и блок проходится без запинок.

Совет: на собесе всегда уточняйте timezone. Это не ваша занудство — это признак зрелости. Интервьюеры знают, что 80% багов в аналитике идёт из-за несогласованных TZ.

Читайте также

FAQ

TIMESTAMP vs TIMESTAMPTZ — что выбрать?

TIMESTAMPTZ (с timezone). Он не теряет timezone информацию. TIMESTAMP без TZ интерпретируется относительно серверной настройки — это источник багов. Единственное исключение: локальные бизнес-даты (день рождения, дата события в локальной TZ), где timezone не нужна.

Как посчитать разницу в месяцах между датами?

В PostgreSQL: EXTRACT(YEAR FROM AGE(d1, d2)) * 12 + EXTRACT(MONTH FROM AGE(d1, d2)). В MySQL: TIMESTAMPDIFF(MONTH, d2, d1). Помните, что «месяцев» не всегда 30 дней — арифметика зависит от конкретных дат.

Как сгруппировать по неделям, начиная с понедельника?

DATE_TRUNC('week', date) в PostgreSQL возвращает понедельник (ISO-неделя). В BigQuery: DATE_TRUNC(date, WEEK(MONDAY)). В ClickHouse: toMonday(date).

Что делать, если нужно работать с часовыми поясами?

Всегда храните в UTC. При агрегации конвертируйте в пользовательскую TZ через AT TIME ZONE. Если у вас многорегиональный продукт — фиксируйте основную TZ для репортинга (обычно локальная для бизнеса) и документируйте это.