Python з нуля · Блок 5 · Тема 22

Час, який не знає, де він

У памʼяті лежить «28 березня, 14:05». Це коли? Питання не риторичне: без назви поясу відповіді немає, і саме на цьому ламається більшість програм, що працюють із часом.

У темі про файли ми навчилися читати журнал програми рядок за рядком. Майже кожен такий рядок починається з мітки часу — і поки вона лишається текстом, з нею нічого не зробиш: не віднімеш, не порівняєш, не відсортуєш надійно. Щоб мітка стала величиною, її треба перетворити на обʼєкт. Цим і займається модуль datetime.

Наскрізний приклад цієї лекції — одне замовлення в невеликій крамниці. Замовлення №4417, оформлене 28 березня 2026 року о 14:05:09 за київським часом. Ми будемо його зберігати, показувати клієнту, додавати до нього строк доставки й порівнювати з іншими замовленнями. І на кожному з цих кроків знайдеться пастка.

01 / ТипиТри типи й що в них лежить

Модуль datetime дає три типи для позначення часу. Різниця між ними проста: скільки саме з календаря вони знають.

типщо зберігаєприкладдля чого
dateрік, місяць, деньdate(2026, 3, 28)дата народження, дедлайн
timeгодина, хвилина, секунда, мікросекундаtime(14, 5, 9)час відкриття, розклад
datetimeусе разомdatetime(2026, 3, 28, 14, 5, 9)момент події

Плюс четвертий тип — timedelta. Він стоїть окремо, бо позначає не момент, а проміжок: «три доби», «сорок хвилин». До нього ми повернемось у розділі 06.

Розтин: що насправді всередині

Тут варто зупинитися, бо інтуїція новачка й реальність розходяться. Здається, що datetime усередині — це велике число секунд, як показує годинник комп'ютера. Насправді ні. CPython зберігає окремі календарні поля, спаковані в десять байтів:

рік (2 байти) · місяць (1) · день (1) · година (1) · хвилина (1) · секунда (1) · мікросекунди (3)

Плюс окреме посилання на tzinfo — обʼєкт поясу, який може бути й порожнім. Разом sys.getsizeof(datetime(2026, 3, 28, 14, 5)) дає 48 байтів; у date — 32, бо полів менше.

Із цього устрою випливають три речі, які інакше довелося б запамʼятовувати окремо. Перша: діапазон типу — від 0001-01-01 до 9999-12-31, бо під рік відведено рівно два байти. Друга: роздільність — рівно одна мікросекунда, і наносекунди в datetime записати неможливо. Третя, найважливіша: поле tzinfoнеобовʼязкове. Обʼєкт цілком коректний і без нього. Саме ця «дірка» й породжує головну тему лекції.

02 / СтворенняСтворення, поля, now()

Створюють обʼєкти напряму — переліком чисел у порядку від більшого до меншого:

from datetime import date, time, datetime
замовлення = datetime(2026, 3, 28, 14, 5, 9)

Порядок аргументів той самий, що в ISO-запису: рік, місяць, день, година, хвилина, секунда. Обовʼязкові лише перші три — решта за замовчуванням нулі. Читаються поля через крапку: замовлення.year дає 2026, замовлення.month3, замовлення.hour14.

Обʼєкти незмінні (immutable) — так само, як кортежі. Присвоїти замовлення.hour = 15 не вийде, буде AttributeError. Щоб отримати змінений момент, роблять копію методом replace: замовлення.replace(hour=15) повертає новий обʼєкт, а старий лишається цілим. Це зручно: момент, який ти передав у функцію, ніхто не зіпсує потай.

Поточний час беруть двома способами: date.today() дає сьогоднішню дату, datetime.now() — момент прямо зараз. Обидва питають системний годинник і локальні налаштування машини.

Перше правило тестованого коду. Не викликай now() всередині логіки. Функція, яка сама питає годинник, дає різний результат щохвилини — таку не перевіриш тестом і не відтвориш чужу помилку. Передавай момент аргументом: def прострочено(замовлення, зараз):. Тоді в бою ти передаси datetime.now(...), а в тесті — фіксовану дату, і поведінка стане передбачуваною. Практика до цієї теми написана саме так: жодного now(), усі дати зашиті, тому зошит дає однаковий вивід при кожному запуску.

03 / НаївніНаївні проти обізнаних

Ось головна ідея всієї теми, і вона коштує того, щоб прочитати цей розділ двічі.

Обʼєкт datetime буває у двох станах:

Побутова аналогія: наївний момент — це фотографія настінного годинника без підпису, де зроблена. «14:05» — і все. Обізнаний — та сама фотографія з підписом «Київ». Тільки з підписом можна відповісти, що було в цю мить у Токіо.

Python не дозволяє змішувати ці два світи. Спроба порівняти наївний з обізнаним дає TypeError — і це не примха, а захист. Порівняти їх чесно неможливо: щоб сказати, який момент раніший, треба знати обидва пояси, а один із них не названо. Мова радше зупиниться, ніж вгадає.

Інтерактив 1 · Наївний проти обізнаного

Два замовлення на 14:05:09. Дивись, чи вдається покласти їх на світову вісь часу.

a < b
a == b
b - a
Що читати: нижня вісь — світовий час UTC. Обізнаний момент має на ній рівно одну точку. Наївний — заштриховану смугу «будь-де»: із самого обʼєкта неможливо дізнатись, куди його поставити. Порівняти точку зі смугою не можна, тому Python і кидає TypeError замість того, щоб вигадати відповідь.
Витончена деталь для тих, хто вже пише код. Оператор < кидає TypeError, а от == — ні: він тихо повертає False. Логіка тут своя: рівність зобовʼязана відповідати на будь-яку пару обʼєктів (інакше зламався б пошук у множинах і словниках), і «різні за природою — отже не рівні» є законною відповіддю. Але для коду це найгірший із можливих варіантів: перевірка if момент == дедлайн: мовчки не спрацює жодного разу, і жодної помилки ти не побачиш.

04 / utcnowЧому прибрали utcnow()

Десятиліттями стандартним способом узяти світовий час був datetime.utcnow(). У Python 3.12 він оголошений застарілим (deprecated) і чекає на видалення. Причина рівно та, про яку ми щойно говорили.

utcnow() повертає наївний обʼєкт, у полях якого лежить час за UTC. Тобто обʼєкт, який містить світовий час, але не каже про це. Це обман за замовчуванням, і він породжує дві помилки, що трапляються найчастіше.

помилка 1 · порівняння з місцевим

Поруч у коді хтось написав datetime.now() — теж наївний обʼєкт, але в його полях місцевий київський час. Обидва наївні, тому Python порівняє їх без жодної помилки — просто зіставить цифри. А цифри розходяться на дві-три години. Логіка «чи прострочено замовлення» тихо бреше півроку, потім переходить на літній час і починає брехати на іншу величину.

помилка 2 · перетворення в unix-час

Метод .timestamp() на наївному обʼєкті вважає, що в полях місцевий час, і віднімає місцеве зміщення. Подай йому результат utcnow() — і отримаєш число, зсунуте рівно на пояс. Мітка часу в базі даних поїде на дві години, і ніхто не помітить, поки клієнт не поскаржиться.

як правильно
from datetime import datetime, timezone
зараз = datetime.now(timezone.utc)

Той самий момент, але обʼєкт обізнаний: у ньому написано +00:00. Тепер помилка 1 неможлива — при спробі порівняти його з наївним Python зупинить програму, а не мовчки видасть неправильну відповідь. І помилка 2 неможлива теж: .timestamp() бачить пояс і рахує точно.

Урок ширший за один метод. utcnow() прибирають не тому, що він рахував неправильно — рахував він бездоганно. Його прибирають тому, що він викидав інформацію: знав пояс і не поклав його в результат. Це загальне правило проєктування — якщо функція знає щось важливе про свій результат, вона мусить це віддати, а не лишити користувачеві здогадуватись.

05 / ПоясиПояси через zoneinfo

Звідки взяти пояс? З Python 3.9 у стандартній бібліотеці є модуль zoneinfo, який читає системну базу часових поясів IANA — ту саму, якою користуються Linux, macOS і мільйони серверів.

from zoneinfo import ZoneInfo
київ = ZoneInfo("Europe/Kyiv")
замовлення = datetime(2026, 3, 28, 14, 5, 9, tzinfo=київ)

Ім'я поясу — це завжди Регіон/Місто, і воно позначає не зміщення, а набір правил: яке зміщення діяло в цій місцевості в кожен момент історії. У «Europe/Kyiv» записано і +02:00 узимку, і +03:00 улітку, і дати всіх переходів за багато десятиліть.

Тому ZoneInfo("Europe/Kyiv") і timezone(timedelta(hours=2)) — це різні речі, і плутати їх дорого. Друге — фіксовані «плюс дві години назавжди»: у липні воно дасть неправильний київський час. Фіксоване зміщення годиться рівно для двох випадків: UTC (він і є фіксований) і розбір мітки, у якій зміщення вже написане числом.

Переведення між поясами робить метод astimezone. Він не змінює момент — він переписує той самий момент іншим циферблатом:

замовлення.astimezone(ZoneInfo("Asia/Tokyo"))
→ 2026-03-28 21:05:09+09:00

Інтерактив 2 · Одна мить у чотирьох містах

Повзунки задають момент за UTC. Кожен рядок — місцевий циферблат того самого моменту.

Київ
Лондон
Токіо
Нью-Йорк
Що читати: кожна клітинка — одна година місцевого циферблата, число в ній — місцева година. Бірюзові клітинки — робочий час 09–18, рожеві — ніч 00–06. Рожева лінія стоїть на обраному моменті й перетинає всі чотири рядки одразу: це одна мить, просто чотири різні написи. Поводи повзунок дня через кінець березня — рядки Києва й Лондона зсунуться на клітинку, а Токіо не ворухнеться жодного разу за рік.

06 / timedeltatimedelta — це різниця

Відніми один момент від іншого — отримаєш не число, а обʼєкт timedelta:

datetime(2026, 3, 31) − datetime(2026, 3, 28, 14, 5, 9)
→ timedelta(days=2, seconds=35691)

Це та сама ідея, що з числами: різниця двох величин має інший тип, ніж самі величини. Момент часу й проміжок — різні сутності. Момент плюс момент безглуздий (де середина між двома датами? питання не про час), а момент плюс проміжок — цілком осмислений і дає новий момент.

Усередині timedelta зберігає лише три числа: дні, секунди й мікросекунди. Усе інше, що ти передаси — години, хвилини, тижні — одразу переводиться в них і зникає. Тому timedelta(hours=24) і timedelta(days=1) — це буквально той самий обʼєкт, і перевірка на рівність дасть True.

Чому немає місяців і років

Спроба написати timedelta(months=1) обривається помилкою: TypeError: 'months' is an invalid keyword argument. Це найчастіше запитання новачка — і відповідь тут не «забули додати».

Місяць не має довжини. Точніше, має аж чотири різні: 28, 29, 30 або 31 день. Тому «плюс один місяць» не є проміжком часу — це календарна операція над датою, і вона не має єдиної правильної відповіді. Скільки буде 31 січня плюс місяць? 28 лютого? 3 березня? 29 лютого у високосний рік? Кожна відповідь має прихильників, і жодна не є арифметичною. Рік не кращий: 365 або 366 днів.

Стандартна бібліотека відмовляється вгадувати. Якщо тобі справді потрібні календарні місяці — або рахуй поля вручну через replace(month=…), або бери зовнішню бібліотеку dateutil з її relativedelta, де ця неоднозначність задокументована й керована.

Інтерактив 3 · Арифметика з timedelta

Скільки додати до замовлення 4417. Дивись, як Python нормалізує проміжок.

як зберігся проміжок
total_seconds()
результат
Що читати: верхня вісь — календарні дні від моменту замовлення, вертикальні засічки — межі місяців. Бірюзова мітка це старт, рожева — результат. Нижні смуги показують, чому місяця немає серед аргументів: чотири місяці мають чотири різні довжини, тож «плюс місяць» — це не відстань, а окреме правило.

07 / Літній часДоба не завжди 24 години

Тепер найкоштовніша пастка теми — та, що двічі на рік породжує помилки «рівно на годину». Двічі на рік місцева доба перестає бути 24-годинною.

У 2026 році Київ переводить годинники 29 березня: о третій ночі стрілки стрибають одразу на четверту. Година «03:xx» того дня не існує — доба має 23 години. І 25 жовтня: о четвертій ранку стрілки повертаються на третю. Година «03:xx» того дня трапляється двічі — доба має 25 годин.

Тут і починається найцікавіше, бо Python поводиться не так, як очікує більшість. Арифметика над обізнаним моментом — «настінна» (wall-clock): додаючи timedelta(days=1), Python додає одиницю до поля «день» і лишає циферблат на місці, а зміщення перераховує вже потім. Тому:

спосіб 1 · додати добу в місцевому поясі
старт = datetime(2026, 3, 28, 14, 5, tzinfo=київ)  → 14:05+02:00
старт + timedelta(days=1)  → 2026-03-29 14:05+03:00

На циферблаті знову 14:05 — «завтра о тій самій порі». Але зміщення змінилось з +02 на +03, тобто реально минуло лише 23 години.

спосіб 2 · додати 24 години в UTC
(старт.astimezone(timezone.utc) + timedelta(hours=24)).astimezone(київ)
→ 2026-03-29 15:05+03:00

Минуло рівно 24 години фізичного часу — але циферблат показує вже 15:05.

Обидві відповіді правильні. Просто це відповіді на різні запитання: «завтра о тій самій порі» й «через добу від цієї миті» — не одне й те саме, і різниця стає видимою двічі на рік. Вибирати треба свідомо: нагадування будильника хоче спосіб 1, вимірювання тривалості — спосіб 2.

Інтерактив 4 · Доба, у якій 23 або 25 годин

Кожна клітинка — година місцевого циферблата. Полічи їх у другому рядку.

результат
реально минуло
годин у добі
Що читати: верхній рядок — доба перед переходом, рівно 24 клітинки. Нижній — доба переходу: навесні в ній бракує години (рожевий розрив), восени одна година трапляється двічі (бурштинова пара). Довжина рядка на екрані — це і є довжина доби. Перемикай спосіб додавання й дивись, як рожева мітка результату переїжджає на сусідню клітинку, хоча вихідний момент той самий.
Найтонше місце в усій темі. Різниця двох обізнаних моментів в одному поясі теж рахується по-настінному: datetime(2026,3,29,14,5,tzinfo=київ) − datetime(2026,3,28,14,5,tzinfo=київ) дасть рівно 1 day, хоча фізично минуло 23 години. Щоб отримати справжню тривалість, обидва моменти треба спершу перевести в UTC — тоді різниця буде 23:00:00. Правило просте: вимірюєш тривалість — рахуй у UTC.
І ще одна пара граблів, для повноти. Осіння «подвійна» година неоднозначна: 25 жовтня о 03:30 у Києві буває двічі. Python розрізняє їх полем fold: fold=0 — перше проходження, fold=1 — друге. Але == між ними всередині одного поясу дає True, хоча .timestamp() у них відрізняється на 3600. А весняна «неіснуюча» 03:30 створюється без помилки й тихо перетворюється на 01:30 UTC. Це не баги, а свідомі компроміси стандарту — і ще один доказ, що зберігати треба UTC.

08 / КалендарВисокосний рік

Друга календарна нерівномірність — лютий. Земля обертається навколо Сонця приблизно за 365.2422 доби, тобто майже на чверть доби довше за календарний рік. Юлій Цезар компенсував це додатковим днем раз на чотири роки — але чверть не точна, і календар почав відставати приблизно на добу за 128 років. Григоріанська реформа додала два винятки: рік, кратний 100, високосним не є, а кратний 400 — таки є.

високосний = (рік % 4 == 0) і (рік % 100 ≠ 0 або рік % 400 == 0)

Тому 2000-й був високосним, а 1900-й і 2100-й — ні. Реальна ціна цієї дрібниці: у 2000 році чимало систем помилилися саме на правилі «÷400», бо програмісти памʼятали лише перші два поділи.

Інтерактив 5 · Три поділи, один зайвий день

Поводи роком через 1900 і 2000 — правила спрацьовують по-різному.

днів у лютому
днів у році
date(рік, 2, 29)
Що читати: рік проходить крізь три ворота зліва направо. Бірюзові ворота пропускають, рожеві зупиняють. Праворуч — календар лютого цього року з правильними днями тижня. Зверни увагу на 1900 і 2100: обидва діляться на 4, обидва не високосні — їх зупиняють другі ворота, і треті ворота вже не рятують.

09 / Форматstrftime і strptime

Обʼєкт треба показати людині — і прочитати назад із тексту. За це відповідають дві дзеркальні операції з майже однаковими іменами:

Літера f — від format, p — від parse. Обидві беруть той самий шаблон: звичайний текст, у якому % із літерою позначає поле.

кодщо означаєна 2026-03-28 14:05:09
%Yрік, чотири цифри2026
%yрік, дві цифри26
%mмісяць числом, з нулем03
%dдень місяця, з нулем28
%Hгодина, 00–2314
%Mхвилина05
%Sсекунда09
%Bназва місяця повністюMarch
%Aназва дня тижняSaturday
%jдень від початку року087
%%сам знак відсотка%

Про %B і %A варто знати одразу: вони залежать від локалі процесу. За замовчуванням буде англійська — March, а не березня. Українські назви доводиться робити самому, звичайним списком назв і звертанням за момент.month - 1. Це нудно, зате передбачувано й не залежить від налаштувань чужого сервера.

Інтерактив 6 · Конструктор шаблону

Склади шаблон із кодів — і подивись, що дасть форматування і що вціліє при розборі назад.

клацай коди — вони додаються в кінець

strftime дає
strptime повертає
Що читати: верхній рядок — шаблон, розібраний на шматки: рожеві — коди, сірі — звичайний текст. Під кожним шматком те, у що він перетворився. Нижня стрічка — які поля вдасться відновити при зворотному розборі: сірі клітинки означають, що інформацію втрачено назавжди, і strptime підставить туди нулі.
Пастка форматів дати. 03/04/2026 — це 3 квітня чи 4 березня? У Британії перше, у США друге. Тому %d/%m/%Y і %m/%d/%Y обидва «спрацюють» на такому рядку й дадуть різні дати — без жодної помилки. Це класична причина мовчазного псування даних при імпорті чужих таблиць. Захист один: не приймай неоднозначні формати, а якщо мусиш — перевір на даті з числом більшим за 12.

10 / ISOISO 8601

Із нескінченної кількості способів записати дату міжнародний стандарт ISO 8601 виділяє один. У Python він доступний без жодних шаблонів:

замовлення.isoformat() → '2026-03-28T14:05:09+02:00'
datetime.fromisoformat('2026-03-28T14:05:09+02:00') → назад той самий обʼєкт

Формат виглядає незвично («що це за T посередині?»), але кожна його риса розвʼязує конкретну проблему:

Практичний висновок простий: для зберігання й обміну — тільки ISO. У базі даних, у JSON, у файлі журналу, у назві файлу з резервною копією. Формат %d.%m.%Y — це формат показу людині, і він має жити лише в тому рядку коду, де ти малюєш екран.

Дрібниця, що економить години. До Python 3.11 fromisoformat умів читати рівно те, що видавав isoformat, і спотикався на '2026-03-28T12:05:09Z' — літера Z його ламала, хоча це найпоширеніший запис UTC у вебі. З 3.11 підтримка стала повною: і Z, і скорочені форми читаються. Якщо код має працювати на старіших версіях — Z заздалегідь замінюють на +00:00.

11 / СортуванняЧому дати не сортують як рядки

Тепер закриємо старий борг. Ти вже вмієш сортувати списки, і спокуса очевидна: дати в журналі й так лежать текстом, навіщо перетворювати?

Бо сортування рядків посимвольне. Python порівнює перший символ, при рівності — другий, і так далі; про календар він при цьому не знає нічого. Для запису 28.03.2026 першим символом виявляється день, тому весь список шикується за днем місяця: 02 листопада стане раніше за 31 грудня, бо «0» менше за «3».

З ISO-записом виходить навпаки — і це не щаслива випадковість, а те, заради чого формат так і збудували. Старший розряд стоїть першим, ширина полів стала, отже посимвольне порівняння збігається з хронологічним. Це єдиний формат дати, рядки якого можна сортувати безкарно.

Інтерактив 7 · Сортування рядків проти сортування дат

Шість дат із журналу. Перемикай формат запису й дивись на результат sorted().

на місці
інверсій
Що читати: ліворуч — рядки в порядку, у якому їх записали в журнал, праворуч — після sorted(). Цифра в кружечку це справжній хронологічний номер запису. Якщо праворуч кружечки читаються 1, 2, 3, 4, 5, 6 згори вниз — сортування правильне. Варіант «ISO без нулів» показує, що самого лише порядку «рік-місяць-день» замало: без ведучих нулів 2026-11-2 стає меншим за 2026-3-5.

12 / Unix-часUnix-час і як зберігати

Є ще один спосіб позначити мить — одним числом. Unix-час (POSIX time) — це кількість секунд, що минули від 1970-01-01 00:00:00 UTC. Момент нашого замовлення в цьому вигляді — 1774699509.

замовлення.timestamp() → 1774699509.0
datetime.fromtimestamp(1774699509, timezone.utc) → 2026-03-28 12:05:09+00:00

Головна перевага числа — воно не може бути наївним. Точка відліку зафіксована в самому визначенні, тому двозначності не існує в принципі. Плюс числа легко порівнювати, віднімати й зберігати в будь-якій базі.

Недоліки теж є, і серйозні. Число нечитабельне для людини. Воно не знає ані поясу, ані календаря, тому «додати місяць» до нього неможливо. І в ньому немає високосних секунд: за визначенням у добі рівно 86400 секунд, тож у рідкісні дні з високосною секундою unix-час просто повторює одне значення двічі.

Правило, на якому тримається все

Із усього, що ми розібрали, випливає одна архітектурна порада, і вона варта окремого рядка:

зберігай у UTC · показуй у місцевому

Розгорнуто це означає ось що. У базу, у журнал, у файл іде обізнаний момент за UTC (або unix-час — це те саме іншими символами). Уся арифметика — тривалості, порівняння, сортування — теж рахується в UTC, де доба завжди 24 години й переходів не буває. І лише в останню мить, коли треба намалювати екран чи надіслати лист, момент переводиться в пояс конкретного користувача через astimezone і форматується його звичним записом.

Порушення цього правила й дає ті самі помилки «на годину», що зʼявляються двічі на рік. Механіка їх завжди однакова: десь час зберегли в місцевому поясі, а перерахували вже після переходу — і зміщення виявилось іншим, ніж було в момент запису. Якщо в базі лежить UTC, такої розбіжності не буває фізично: там немає що зсувати.

13 / ПідсумокЩо з цього забрати

П'ять пунктів, які варто памʼятати навіть через рік:

І загальна думка в дусі цього курсу. Час здається найпростішою величиною у світі — і виявляється найзаплутанішою, бо він не фізичний, а домовлений. Кордони поясів, переходи на літній час, високосні роки й секунди — це рішення людей, які регулярно змінюються урядами. Тому zoneinfo й читає базу з диска, а не рахує за формулою: формули для цього не існує. Уміння бачити за міткою часу дві різні речі — фізичну мить і людський напис про неї — і є та відмінність, з якої починається надійна робота з датами.

Що далі. У наступній темі — регулярні вирази, і почнеться вона рівно з того місця, де ми зупинились: як витягти мітку часу з рядка журналу, коли її формат заздалегідь невідомий, а strptime вимагає точного шаблону. А в practice.ipynb ти порахуєш вік у днях, розберешся з поясами вручну, зламаєш порівняння наївного з обізнаним і полагодиш його.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.