The dates and times article works through the two hardest things about temporal data: the arithmetic, which is fiddly, and the timezones, which are genuinely treacherous. This workbook runs both. It starts with plain calendar dates and their arithmetic, moves through datetime objects, formatting, and parsing, then into timedelta durations, and finally the two topics that trip up everyone: timezones with the modern zoneinfo module, and daylight saving, where an hour can vanish or happen twice. It ends in pandas, where the .dt accessor and resampling turn all of this into vectorised operations on whole columns. The rule that runs underneath the timezone half: do your arithmetic in UTC, always.
1. Date objects: calendar days
A date represents a calendar day with no time attached. You build one from year, month, and day, read its parts as attributes, and ask which weekday it fell on.
from datetime import dated = date(1992, 8, 24)print(d.year, d.month, d.day) # 1992 8 24print(d) # 1992-08-24 (ISO format by default)# weekday(): Monday is 0, Sunday is 6print("weekday number:", d.weekday())days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]print("that was a", days[d.weekday()])
A date is the right type whenever the time of day does not matter: a birthday, a deadline, a report period. Its attributes are read-only integers, so d.year is 1992, and printing a date gives the ISO YYYY-MM-DD form automatically. weekday() returns 0 for Monday through 6 for Sunday, which is the number you index into a list of names or use to detect weekends, and it is calculated correctly for any date in the calendar, so you never compute day-of-week by hand.
2. Date arithmetic and timedelta
Subtracting one date from another gives a timedelta, a span of time, whose .days attribute is the gap. This is how you count the days between two events.
from datetime import datestart = date(2007, 5, 9)end = date(2007, 12, 13)gap = end - start # a timedelta, not a numberprint(type(gap).__name__) # timedeltaprint("days between:", gap.days) # 218# adding a timedelta moves a datefrom datetime import timedeltadeadline = start + timedelta(days=30)print("30 days after start:", deadline)
Subtraction between dates does not return an integer; it returns a timedelta object, and reading .days off it gives the 218-day gap. This matters because the object also handles leap years and month lengths correctly, so you never have to remember that February is short or that 2007 was not a leap year. Arithmetic works both ways: adding a timedelta to a date moves it forward, which is how you compute a deadline thirty days out without touching a calendar.
3. Datetime: a day plus a time
A datetime extends a date with hours, minutes, and seconds, for when the moment matters. It has all the date attributes plus time ones.
from datetime import datetimedt = datetime(2017, 10, 1, 15, 26, 26)print(dt.year, dt.month, dt.day) # 2017 10 1print(dt.hour, dt.minute, dt.second) # 15 26 26print(dt) # 2017-10-01 15:26:26# the current momentnow = datetime.now()print("now:", now)# build one from a Unix timestamp (seconds since 1970)stamp = datetime.fromtimestamp(1506871586)print("from timestamp:", stamp)
datetime is a date with a clock: it carries .hour, .minute, and .second alongside the calendar attributes, so datetime(2017, 10, 1, 15, 26, 26) pins an exact moment. datetime.now() grabs the current one, and fromtimestamp converts a Unix timestamp, the seconds-since-1970 count that logs and APIs love, into a readable datetime. Everything the date examples did, arithmetic, weekday, works on datetimes too; the difference is only that the time of day now travels along.
4. Formatting with strftime
strftime turns a datetime into a string in any format you specify, using percent-codes as placeholders. This is how you produce human-readable or system-specific date strings.
from datetime import datetimedt = datetime(2017, 10, 1, 15, 26, 26)print(dt.strftime("%Y-%m-%d")) # 2017-10-01print(dt.strftime("%d/%m/%Y")) # 01/10/2017print(dt.strftime("%H:%M")) # 15:26print(dt.strftime("%A, %d %B %Y")) # Sunday, 01 October 2017print(dt.strftime("%I:%M %p")) # 03:26 PM
Each percent-code is a placeholder that strftime fills from the datetime: %Y is the four-digit year, %m the zero-padded month number, %d the day, %H the 24-hour hour, and %M the minute. The named forms are handy too, %A for the full weekday name, %B for the month name, %p for AM or PM with %I for the 12-hour clock. You compose these into whatever layout you need, which is why the same moment can print as 2017-10-01 for a database or Sunday, 01 October 2017for a reader. The codes are worth keeping on a cheatsheet, since nobody memorises all of them.
5. Parsing with strptime, and ISO format
strptime is the reverse of strftime: it reads a string into a datetime, given the format the string is in. And isoformat produces the one universal string format worth standardising on.
from datetime import datetime# parse a string INTO a datetime: you must state its formattext = "2017-10-01 15:26:26"dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")print(dt, "->", type(dt).__name__)# a different layout needs a matching format stringuk = datetime.strptime("01/10/2017", "%d/%m/%Y")print(uk)# isoformat: the unambiguous international standardprint(dt.isoformat()) # 2017-10-01T15:26:26# and datetime can read its own ISO output backprint(datetime.fromisoformat("2017-10-01T15:26:26"))
strptime needs you to describe the incoming string with the same percent-codes, so "%Y-%m-%d %H:%M:%S" tells it to expect year-month-day then time, and it raises an error if the string does not match, which is a feature since it catches malformed dates loudly. The format string is required because a date like 01/02/2017 is genuinely ambiguous between January and February without knowing the convention. isoformat sidesteps all of that: the YYYY-MM-DDTHH:MM:SS standard sorts correctly as text, parses everywhere, and is the format to store and exchange dates in, with fromisoformat reading it straight back.
6. Timedelta: building and measuring durations
A timedelta is a span you can construct directly with keyword arguments, add to datetimes, and collapse to a single number of seconds. It is the arithmetic engine for time.
from datetime import datetime, timedelta# construct a span from any mix of unitsspan = timedelta(days=1, hours=6, minutes=30)print("total seconds:", span.total_seconds()) # 109800.0start = datetime(2026, 1, 1, 9, 0, 0)# add and subtract spans from a momentprint("plus the span: ", start + span)print("a week earlier:", start - timedelta(weeks=1))# the difference between two datetimes is itself a timedeltaend = datetime(2026, 1, 1, 17, 30, 0)worked = end - startprint("hours worked: ", worked.total_seconds() / 3600)
timedelta accepts keyword arguments in any units, days, hours, minutes, weeks, and normalises them, so a day and six and a half hours becomes one object. total_seconds() is the method to remember: it flattens a whole duration to a single float, which is what you need for computing rates, averages, or, as at the end here, hours worked from two timestamps. Adding and subtracting timedeltas from datetimes moves them around cleanly, handling every rollover across days and months so you never carry hours by hand.
7. Timezones: attaching and converting
A naive datetime has no timezone; an aware one does. The modern zoneinfo module attaches real timezones by IANA name, and astimezone converts a moment from one zone to another, showing the same instant on two clocks.
from datetime import datetimefrom zoneinfo import ZoneInfo# an AWARE datetime: the same instant, labelled with its zonedt = datetime(2026, 6, 1, 12, 0, 0, tzinfo=ZoneInfo("America/New_York"))print("New York: ", dt)# convert to another zone: the clock changes, the instant does notlondon = dt.astimezone(ZoneInfo("Europe/London"))tokyo = dt.astimezone(ZoneInfo("Asia/Tokyo"))print("London: ", london)print("Tokyo: ", tokyo)# UTC is the anchor everything converts throughutc = dt.astimezone(ZoneInfo("UTC"))print("UTC: ", utc)
Attaching tzinfo=ZoneInfo("America/New_York") makes the datetime aware, meaning it knows which zone its clock reading belongs to, and zoneinfo uses the IANA database so the name carries the region’s full history of rules. astimezone then converts: noon in New York is 5pm in London and 1am the next day in Tokyo, the same instant expressed on three clocks. zoneinfo is the standard-library replacement for the old pytz library, so no extra install is needed on most systems, and it is the current recommended approach. Everything routes through UTC, which is why the next example insists on it.
8. Daylight saving: the hours that break arithmetic
Daylight saving is where naive date arithmetic goes wrong. In spring an hour never happens; in autumn an hour happens twice. The rule that saves you is to convert to UTC before doing any arithmetic across these boundaries.
from datetime import datetime, timedeltafrom zoneinfo import ZoneInfony = ZoneInfo("America/New_York")# the night the clocks spring forward in 2026 (2am -> 3am)before = datetime(2026, 3, 8, 1, 30, tzinfo=ny)after = before + timedelta(hours=1)# NAIVE-looking local arithmetic can mislead across the gapprint("local before:", before)print("local +1h: ", after)# the SAFE way: convert to UTC, do the maths there, convert backutc = ZoneInfo("UTC")before_utc = before.astimezone(utc)after_utc = before_utc + timedelta(hours=1)print("\nvia UTC: ", after_utc.astimezone(ny))# the wall clock jumps 1:30 -> 3:30, because 2:00-3:00 does not exist
On the spring-forward night the local clock skips from 2:00 straight to 3:00, so the 2-to-3 hour simply does not exist, and adding an hour to 1:30 lands you at a wall-clock time that has to jump the gap. The reliable discipline is the one the article states flatly: convert aware datetimes to UTC, do the arithmetic there where every hour is real and equal, then convert back to local for display. UTC has no daylight saving and no gaps, so durations computed in it are always correct, and the only place local time should appear is at the very edges, input and output. The autumn fall-back, where an hour repeats, is disambiguated with the fold attribute, but the UTC rule spares you needing it for arithmetic.
9. Dates in pandas: parse_dates and the .dt accessor
Pandas handles dates at the column level. parse_dates converts date columns to datetimes on load, and the .dt accessor applies datetime operations to a whole column at once.
import pandas as pdfrom io import StringIOcsv = StringIO( "user,start,end\n" "A,2026-01-01 09:00,2026-01-01 17:30\n" "B,2026-01-02 10:15,2026-01-02 12:45\n" "C,2026-01-02 14:00,2026-01-02 18:20\n")# parse the two columns as datetimes on loadsessions = pd.read_csv(csv, parse_dates=["start", "end"])print(sessions.dtypes)# .dt exposes datetime parts on the whole column, vectorisedsessions["weekday"] = sessions["start"].dt.day_name()sessions["hour"] = sessions["start"].dt.hour# subtracting two datetime columns gives a timedelta columnsessions["duration_min"] = (sessions["end"] - sessions["start"]).dt.total_seconds() / 60print(sessions[["user", "weekday", "hour", "duration_min"]])
parse_dates=["start", "end"] tells read_csv to convert those columns from text to real datetimes as it loads, so you never parse row by row. The .dt accessor is the pandas equivalent of the single-object attributes from earlier, applied across the whole column at once: .dt.day_name() and .dt.hour compute for every row in one vectorised operation, far faster than a loop. And subtracting two datetime columns yields a timedelta column, on which .dt.total_seconds() flattens each span to a number, exactly as the single-object version did, but for the entire table.
10. Resampling: grouping by time
Resampling groups rows into time buckets, daily, monthly, hourly, so you can count or average over periods. Combined with groupby, it produces per-category trends, the natural end point of temporal analysis.
import pandas as pdimport numpy as nprng = np.random.default_rng(0)n = 200sessions = pd.DataFrame({ "start": pd.date_range("2026-01-01", periods=n, freq="6h"), "plan": rng.choice(["free", "pro"], size=n), "duration": rng.integers(5, 120, size=n),})# count sessions per DAYdaily = sessions.resample("D", on="start").size()print("sessions per day (first 3):")print(daily.head(3))# median duration per MONTH, split by planmonthly = (sessions .groupby("plan") .resample("ME", on="start")["duration"] .median())print("\nmedian duration per month by plan:")print(monthly)
resample("D", on="start") buckets the rows by calendar day using the start column, and .size() counts how many fell in each, which is how you turn a log of events into a daily time series. The frequency string is the dial: "D" for day, "ME" for month-end, "h" for hour, "15min" for quarter-hours, and many more. The final query is the pattern most real analysis reaches for, chaining groupby("plan") with resample so each subscription plan gets its own monthly trend, which is how you compare how free and pro users behave over time. Resampling is groupby for the clock, and it is where the whole chapter pays off.
Work through these and you have the whole article in practice: date objects and weekday, date arithmetic through timedelta, datetime with time components, strftime formatting and strptime parsing, the ISO standard, timedelta construction and total_seconds, timezone attachment and conversion with zoneinfo, the daylight-saving traps and the UTC rule that avoids them, pandas parse_dates and the .dt accessor, and resampling for time-based grouping. The two habits to carry away are the ones that prevent the most bugs: store and exchange dates in ISO format, and do every calculation in UTC, converting to local time only when a human needs to read it.
See you soon.
[…] Dates and Times in Python: 10 Code-Along Examples […]
[…] Code along: https://datalad.co.uk/dates-and-times-in-python-10-code-along-examples/ […]