Embercode
✕
Data Formats and TextLesson 4 of 7

40 min read

Dates, times and time zones

Why this matters

Almost every app stores a time. A payment has a time it happened, a meeting has a time it starts, and a log line says when an error occurred. Time looks simple because people read it every day, but computers have to deal with three separate problems: which moment is meant, which clock reading a person sees, and how the clock changes over the year.

Engineers meet these problems as wrong reports, duplicate bookings and sums that are off by one hour twice a year. A payroll tool that adds 8 hours to a shift can show 8 hours on one day and 7 hours on another, depending on the calendar. A scheduled job set to run at "2 AM" can run twice or not at all. None of these bugs produces an error message, which is why they last so long.

This lesson covers three ideas. UTC is a fixed reference clock with no daylight saving changes. A time zone is a set of rules that turns UTC into the local clock reading for a place, including the changes. ISO 8601 is a standard text format for dates and times, which makes times easy to store, sort and compare. The Unicode lesson in this module covers how text is stored as bytes. This lesson covers what a time string means.

Three things a time can mean

The phrase "09:30 on 5 November" can refer to three different things.

A moment is one exact point on the timeline. Every moment has a single value in UTC, and every computer that agrees on the rules sees the same value. Most stored timestamps are moments.

A clock reading is what a wall clock shows at a certain place. "09:30" in Pune is a clock reading. It becomes a moment only when you also know the place and its rules.

A calendar date has no time at all. "5 November 2026" is the day a holiday falls on. It becomes a moment only when you choose a time and a place for it.

The mistake that causes most bugs is storing a clock reading and treating it as a moment. A database column called starts_at that holds 09:30 gives no hint about the place. The next person who reads it has to guess.

UTC and local time

UTC, short for Coordinated Universal Time, is the reference clock used for computing. It does not move for daylight saving time. Its offset is written +00:00, and it is often shown with the letter Z at the end of a timestamp, which means "zero offset".

An offset is the difference between a local clock and UTC. India uses the offset +05:30. A time in India is 5 hours and 30 minutes ahead of UTC. So 09:30 in India is 04:00 in UTC, because 04:00 plus 5 hours 30 minutes is 09:30.

Here is a worked example. The script creates the same moment in three ways, first as a local reading in India and then converted to UTC and to New York:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
 
ist = datetime(2026, 11, 5, 9, 30, tzinfo=ZoneInfo("Asia/Kolkata"))
print(ist.isoformat())
print(ist.astimezone(timezone.utc).isoformat())
print(ist.astimezone(ZoneInfo("America/New_York")).isoformat())
print(ist.astimezone(ZoneInfo("Europe/London")).isoformat())

The output is:

2026-11-05T09:30:00+05:30
2026-11-05T04:00:00+00:00
2026-11-04T23:00:00-05:00
2026-11-05T04:00:00+00:00

The first line is the clock reading in India. The second is the same moment in UTC. The third shows that New York was at 23:00 on the previous day, because New York is 5 hours behind UTC on that date. The fourth shows London at 04:00, because the United Kingdom was on UTC that day. The moment is the same in all four lines. Only the clock reading changes.

The ZoneInfo class from Python's standard library holds the rules for a named zone such as Asia/Kolkata. Names of this form come from the IANA time zone database, which is the shared list that most operating systems and programming languages use. The names look like Continent/City because they point to a city that represents the local rules.

Avoid abbreviations such as IST, EST or PST in stored data. They are not unique. CST can mean Central Standard Time in the United States, China Standard Time or Cuba Standard Time. A name like Asia/Kolkata or an explicit offset like +05:30 is unambiguous.

The same conversions work from the command line with the date command. This is the GNU version that comes with most Linux systems. macOS uses different flags for date, so check the manual page on that system:

$ date -u -d '2026-11-05T04:00:00Z' '+%Y-%m-%d %H:%M %Z'
2026-11-05 04:00 UTC
$ TZ=Asia/Kolkata date -d '2026-11-05T04:00:00Z' '+%Y-%m-%d %H:%M %Z'
2026-11-05 09:30 IST

The TZ variable tells date which zone to show. The -u flag means "use UTC" for the input and output. The + part is a format string: %Y is the four-digit year, %m the month, %d the day, %H and %M the hour and minute, and %Z the zone abbreviation.

ISO 8601 strings

ISO 8601 is an international standard for writing dates and times. Its shape is YYYY-MM-DDTHH:MM:SS followed by an offset. The letter T separates the date from the time. The offset is either Z for UTC or +HH:MM or -HH:MM for other zones.

Here are three valid strings for the same moment:

2026-11-05T04:00:00Z
2026-11-05T04:00:00+00:00
2026-11-05T09:30:00+05:30

The first two are the same moment in UTC. The third is the same moment in India. A parser that reads any of them returns the same instant.

ISO 8601 has three properties that make it useful for storage. First, the year comes first, so sorting the strings alphabetically also sorts them by time, as long as the zone is the same. Second, the fields have fixed widths, so 2026-01-09 cannot be confused with a different date. Third, the format has no language-specific month names, so the same text reads the same in any country.

A string without an offset is a common source of bugs. 2026-11-05T09:30:00 has no zone. It is a clock reading, and the computer has to guess which zone it belongs to. Store the offset or use UTC for anything that matters.

Python can read ISO strings with fromisoformat. The example below reads three strings, one with an offset, one with Z, and one without:

from datetime import datetime, timezone
 
a = datetime.fromisoformat("2026-11-05T09:30:00+05:30")
b = datetime.fromisoformat("2026-11-05T04:00:00Z")
c = datetime.fromisoformat("2026-11-05T09:30:00")
print(repr(a))
print(repr(b))
print(repr(c))
print(a == b, a.astimezone(timezone.utc) == b)
print(b.isoformat(timespec="seconds"))
print(c.tzinfo)

The output is:

datetime.datetime(2026, 11, 5, 9, 30, tzinfo=datetime.timezone(datetime.timedelta(seconds=19800)))
datetime.datetime(2026, 11, 5, 4, 0, tzinfo=datetime.timezone.utc)
datetime.datetime(2026, 11, 5, 9, 30)
True True
2026-11-05T04:00:00+00:00
None

The first two values have a zone attached. The third has no zone, and tzinfo is None. Python calls the first two kinds of value aware, because they know their offset, and the third kind naive, because it does not. The fourth line shows that a and b are equal, since they are the same moment. The fifth line shows the timespec argument, which controls how many digits of seconds are written. The isoformat method is the reverse of fromisoformat.

ISO 8601 also allows fractions of a second, such as 2026-11-05T09:30:00.123+05:30. The fractional part is optional. If your data has it, check whether your parser keeps it.

A worked example: a meeting in three cities

Suppose a team in Pune schedules a call at 09:30 local time on 5 November 2026. A colleague in London and a colleague in New York must join at the same moment. Here is a way to find their clock readings without guessing.

The first step is to store the moment in UTC, since it is the one value every place agrees on. The second step is to convert it to each local zone when showing it to a person.

from datetime import datetime
from zoneinfo import ZoneInfo
 
meeting = datetime(2026, 11, 5, 9, 30, tzinfo=ZoneInfo("Asia/Kolkata"))
stored = meeting.isoformat()
print("stored:", stored)
 
for city, zone in [("Pune", "Asia/Kolkata"), ("London", "Europe/London"), ("New York", "America/New_York")]:
    local = datetime.fromisoformat(stored).astimezone(ZoneInfo(zone))
    print(f"{city:10} {local.strftime('%a %d %b %H:%M %Z')}")

The output is:

stored: 2026-11-05T09:30:00+05:30
Pune       Thu 05 Nov 09:30 IST
London     Thu 05 Nov 04:00 GMT
New York   Wed 04 Nov 23:00 EST

The stored string keeps the offset, so reading it back gives the same moment. The strftime method formats a time for display. The %a code is the short weekday name, which shows that New York is still on 4 November. The display code is the only place that needs the local names, so the database stays simple.

Daylight saving time

Daylight saving time, often shortened to DST, is a rule that moves a local clock forward by one hour in spring and back in autumn. Many countries use it. India does not. The United States, the United Kingdom and most of Europe do. The exact dates are set by law and can change, so a program should never hard-code them. It should ask the zone database.

DST creates three situations that simple code does not handle.

The first is the gap. In spring, the clock jumps from 02:00 to 03:00, so the reading 02:30 never happens on that day in that zone. A reminder set for 02:30 has no clock reading to match.

The second is the repeat. In autumn, the clock goes from 02:00 back to 01:00, so the readings from 01:00 to 02:00 happen twice. A reading of 01:30 can be either of two moments, one hour apart.

The third is the short or long day. A day in spring has 23 hours and a day in autumn has 25 hours. A calculation that assumes 24 hours in every day gives wrong totals for any period that crosses one of these changes.

Adding a day is not adding 24 hours

Here is the first example. It adds one day to noon in New York on 7 March 2026, the day before the spring change in the United States. Python keeps the wall-clock reading when you add days to an aware datetime, so the result is noon on the next day. The number of real hours between the two moments is different:

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
 
ny = ZoneInfo("America/New_York")
start = datetime(2026, 3, 7, 12, 0, tzinfo=ny)
plus_day = start + timedelta(days=1)
print("start      ", start.isoformat())
print("plus 1 day ", plus_day.isoformat())
print("hours apart", (plus_day.astimezone(timezone.utc) - start.astimezone(timezone.utc)).total_seconds() / 3600)
exact = start.astimezone(timezone.utc) + timedelta(hours=24)
print("plus 24 h  ", exact.astimezone(ny).isoformat())

The output is:

start       2026-03-07T12:00:00-05:00
plus 1 day  2026-03-08T12:00:00-04:00
hours apart 23.0
plus 24 h   2026-03-08T13:00:00-04:00

The clock reads noon on both days, but the offset changed from -05:00 to -04:00, so only 23 real hours passed. Adding 24 hours to the UTC moment gives 13:00 on the next day. Both results are correct, but they answer different questions. "Same time tomorrow" and "24 hours later" are not the same thing when a change falls between them.

A shift that is not 56 hours

The second example is a shift that starts at 09:00 on Saturday 31 October 2026 and ends at 17:00 on Monday 2 November in New York. Between those days, the United States changes its clocks on 1 November. The first calculation subtracts the clock readings. The second subtracts the real moments:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
 
ny = ZoneInfo("America/New_York")
shift_start = datetime(2026, 10, 31, 9, 0, tzinfo=ny)
shift_end = datetime(2026, 11, 2, 17, 0, tzinfo=ny)
wall = (shift_end.replace(tzinfo=None) - shift_start.replace(tzinfo=None)).total_seconds() / 3600
real = (shift_end.astimezone(timezone.utc) - shift_start.astimezone(timezone.utc)).total_seconds() / 3600
print("wall-clock hours", wall)
print("real hours      ", real)

The output is:

wall-clock hours 56.0
real hours       57.0

A payroll sheet that subtracts clock readings pays one hour less than the time that was worked. The replace(tzinfo=None) call in the first line removes the zone, so Python subtracts the readings as if both were in one fixed zone. The second line uses the UTC moments, which include the change.

A time that happens twice

The third example shows the repeated hour. On 1 November 2026, the reading 01:30 in New York happens twice. Python uses the fold attribute to pick the first occurrence (fold=0) or the second (fold=1). The example also shows a time that never happens, the 02:30 reading on 8 March:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
 
ny = ZoneInfo("America/New_York")
gap = datetime(2026, 3, 8, 2, 30, tzinfo=ny)
print("2:30 on spring day:", gap.isoformat(), "=", gap.astimezone(timezone.utc).isoformat())
repeat = datetime(2026, 11, 1, 1, 30, tzinfo=ny)
print("1:30 first time:", repeat.isoformat(), "=", repeat.astimezone(timezone.utc).isoformat())
later = repeat.replace(fold=1)
print("1:30 second time:", later.isoformat(), "=", later.astimezone(timezone.utc).isoformat())

The output is:

2:30 on spring day: 2026-03-08T02:30:00-05:00 = 2026-03-08T07:30:00+00:00
1:30 first time: 2026-11-01T01:30:00-04:00 = 2026-11-01T05:30:00+00:00
1:30 second time: 2026-11-01T01:30:00-05:00 = 2026-11-01T06:30:00+00:00

The gap case is worth reading closely. Python keeps the offset that was in force before the jump, -05:00, so 02:30 becomes 07:30 UTC. That is 03:30 in the daylight saving offset. With TZ=America/New_York, date -d '2026-03-08 02:30' prints 03:30 EDT, which is the same moment. Different tools choose a value for a gap in different ways, so do not rely on the choice. Avoid storing or scheduling readings that fall inside a gap.

Storing times

A sound design follows three rules. Store moments in UTC, using a column type that keeps the zone or an ISO string with an explicit offset. Store a user's zone name separately, such as Asia/Kolkata, if you need it for future display. Convert to local time only when you show a value to a person.

The separate zone name matters for future events. A meeting set for 09:30 in Pune should move to 09:30 in Pune if the government changes the rules for that zone. A meeting stored only as a UTC moment would stay at the old moment. The rule is to store the local reading and zone for things a person planned in local time, and the UTC moment for things that happened.

Common mistakes

Using a naive datetime for a moment. A value without an offset can be read as the wrong zone. Make parsing strict, and reject strings that have no offset when the data needs one.

Subtracting clock readings across a change. As the shift example showed, the result is one hour off in both directions. Convert to UTC before subtracting.

Assuming every day has 24 hours. A billing period that adds timedelta(days=30) to a start date is usually fine, but a count of hours is not. Use UTC moments for elapsed time.

Passing a date string to a time parser. Python gives a clear message:

ValueError: Invalid isoformat string: '05/11/2026'

The string 05/11/2026 is also ambiguous to people, because it can mean 5 November or 11 May. Use 2026-11-05 for dates, and name the format in any API that accepts other forms.

Mixing aware and naive values in a comparison. Python refuses to compare them:

TypeError: can't compare offset-naive and offset-aware datetimes

The fix is to make both sides aware, or to make both naive if the data really has no zone. Mixing the two is a sign that some value has lost its zone somewhere.

Using a made-up zone name. A name that is not in the database fails when the program starts:

ZoneInfoNotFoundError: 'No time zone found with key Mars/Olympus'

This error is useful. It appears at once, while a typo in an abbreviation such as IST might give a wrong answer for months.

Hard-coding DST dates. A rule like "the clocks change on the second Sunday of March" is true today for the United States, but the law has changed in the past. Read the rules from the zone database.

Practice

Exercise 1: Convert to UTC

A meeting is at 09:30 in India on 5 November 2026. What is the UTC time?

Show the answer

It is 04:00 UTC on 5 November 2026. India is 5 hours 30 minutes ahead of UTC, so subtract 5 hours 30 minutes from 09:30.

Exercise 2: Count the real hours

What does this code print, and why?

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
 
ny = ZoneInfo("America/New_York")
start = datetime(2026, 3, 7, 12, 0, tzinfo=ny)
end = start + timedelta(days=1)
print((end.astimezone(timezone.utc) - start.astimezone(timezone.utc)).total_seconds() / 3600)

Show the answer

It prints 23.0. The clocks move forward on 8 March 2026 in New York, so the day that starts at noon on 7 March has only 23 real hours before noon on 8 March.

Exercise 3: Explain the TypeError

Why does this code fail?

from datetime import datetime, timezone
 
a = datetime(2026, 11, 5, 9, 30)
b = datetime(2026, 11, 5, 9, 30, tzinfo=timezone.utc)
print(a < b)

Show the answer

a has no zone, so it is naive. b has the UTC zone, so it is aware. Python will not compare the two, and it raises TypeError: can't compare offset-naive and offset-aware datetimes. To fix it, decide which zone a belongs to and attach it, for example with a.replace(tzinfo=timezone.utc) if the reading is already in UTC.

Exercise 4: Find the two moments

A meeting is listed for 01:30 in New York on 1 November 2026. Which UTC moments could it be?

Show the answer

It could be 05:30 UTC or 06:30 UTC. The clocks go back from 02:00 to 01:00 that day, so the reading 01:30 happens twice. The first occurrence is in daylight saving time, at -04:00, and the second is in standard time, at -05:00. A calendar invitation needs an explicit offset or a fold choice to avoid guessing.

Exercise 5: Choose a storage format

A user in Pune books a 09:30 appointment for 5 November 2026. Which two values should the database store, and why?

Show the answer

Store the UTC moment 2026-11-05T04:00:00Z for sorting and comparison, and also store the zone name Asia/Kolkata. The zone name lets the app show 09:30 in Pune later, even if the rules for that zone change. The UTC moment records the exact time for comparisons and reminders.

Summary

  • A time can be a moment, a clock reading or a calendar date, and bugs appear when one is treated as another.
  • UTC is the fixed reference clock, and an ISO 8601 string with an explicit offset is the safest way to store a moment as text.
  • Convert to local time only when showing a value to a person, using a named zone such as Asia/Kolkata.
  • Daylight saving time makes some days 23 or 25 hours long, so subtract UTC moments, not clock readings, when you count real time.
  • Some readings are skipped or repeated at a change, so avoid scheduling inside a gap and check the fold value for a repeated hour.

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Unicode and UTF-8