Skip to main content

Why feedparser's published_parsed has no time zone, and how to read it

It is always UTC, in a time.struct_time. Ten feed dates, five ways to convert them, measured with feedparser 6.0.14: two were 5 and 6 hours off

Updated 9 min readMarkdown
Why feedparser's published_parsed has no time zone, and how to read it

Disclosure: we built and sell the Apify Actor this post mentions at the end, RSS, Atom & JSON Feed Reader (publisher WrenTools). The post was written with AI assistance and reviewed by a person before publishing. Every output below was produced by one script, run on our own machine against two feeds we wrote ourselves; every title and date in them is made up.

In short: feedparser's published_parsed carries no time zone because it is always UTC, given as a time.struct_time, a design its maintainer says probably dates from before Python had a datetime module. Read it with calendar.timegm(t) or datetime(*t[:6], tzinfo=timezone.utc); both gave feedparser's UTC time exactly for all nine of our ten test dates that it parsed. time.mktime(t) reads it as local time instead: on our machine in US Central time it was 6 hours off, and on a server set to UTC it was 0 hours off, which is how the mistake hides.

The question

In 2020 someone asked on feedparser's tracker, "Why is published_parsed naive?": "why return a naive time struct normalized to UTC when it's possible to keep the timezone information?" The maintainer answered that the design probably goes back to 2002, when "Python 2.2 had just been released and it didn't include a datetime module." In the same thread the asker predicted the trouble this post measures: "So long as it does time structs, people will be confused about what to do with them."

The issue is still open, and 6.0.14, the newest release on PyPI as we write, still returns a time.struct_time. So it's worth knowing exactly what that tuple means.

The obvious way, and what it returns

One RSS item, written at 09:30 at UTC+2, which is 07:30 UTC:

import time
from datetime import datetime, timezone
import feedparser

feed = feedparser.parse(b"""<rss version="2.0"><channel><item><title>Harbour</title>
<pubDate>Tue, 27 Oct 2026 09:30:00 +0200</pubDate></item></channel></rss>""")
e = feed.entries[0]
print(e.published)
print(e.published_parsed)
print(datetime.fromtimestamp(time.mktime(e.published_parsed), timezone.utc))
Tue, 27 Oct 2026 09:30:00 +0200
time.struct_time(tm_year=2026, tm_mon=10, tm_mday=27, tm_hour=7, tm_min=30, tm_sec=0, tm_wday=1, tm_yday=300, tm_isdst=0)
2026-10-27 13:30:00+00:00

feedparser got it right: hour 7, minute 30. The conversion turned it into 13:30 UTC, six hours late, with no error and no warning.

What published_parsed is

feedparser's date documentation says it parses every date "into a standard Python 9-tuple in UTC". The zone isn't lost by accident. feedparser reads it, shifts the time to UTC, and keeps only the result.

Three things about that result matter:

  • It has no zone of its own. Its tm_zone is None; the "UTC" lives only in the documentation.
  • Its last field, tm_isdst, is 0.
  • entry.published still holds the date exactly as the feed wrote it, offset and all. It did in all ten of our rows.

So the tuple is a UTC clock reading with no label. Anything that turns it into an instant has to be told it's UTC, and the two most familiar tools assume local time instead. Python's docs say so: time.mktime takes a tuple "which expresses the time in local time, not UTC", and for datetime.timestamp(), "Naive datetime instances are assumed to represent local time".

Ten dates, five conversions

We wrote ten dates into two feeds: RSS dates with +0200, -0500, EST, GMT, CET and no zone at all, and Atom dates with Z, +05:30, -07:00 and no zone. Each item says, in the script, which instant its writer meant. Then we converted each published_parsed five ways and measured how far each answer landed from that instant.

Predict: our machine is set to US Central time, which was UTC-5 on 27 October 2026. How many hours off is time.mktime(t)? Is datetime(*t[:6]).timestamp() off by the same amount? And which of the ten dates does feedparser get wrong itself, before any conversion? Decide, then look.

A table of ten feed dates, all 09:30 on 27 October 2026 in different zones, with the UTC time feedparser put in published_parsed and the error in hours of five conversions. calendar.timegm and our Actor are 0 hours off in every row except CET, where feedparser itself read the zone as UTC and every column is 1 hour more off. time.mktime is 6 hours off and datetime(...).timestamp() 5 hours off on a machine in US Central time; time.mktime on a UTC server is 0 hours off. The RSS date with no zone has no published_parsed; the Atom date with no zone was taken as UTC.

time.mktime(t) was 6 hours off and datetime(*t[:6]).timestamp() was 5, and feedparser itself got the CET date wrong. Taking the columns one at a time:

  • calendar.timegm(t) was right in every row feedparser read right. Python's docs call it the inverse of time.gmtime(): it reads a tuple as UTC. datetime(*t[:6], tzinfo=timezone.utc) gave the same instant in every row. Our Actor uses timegm.
  • time.mktime(t) was 6 hours late, .timestamp() 5. The difference is that tm_isdst=0. mktime appears to take the 0 at its word and read 07:30 as Central standard time (UTC-6), though daylight time was in effect that day. With the flag set to -1, which the docs say to use "if it is unknown", mktime was 5 hours off too. On a January date both were 6 hours off. So the size of the error depends on the date as well as the machine.
  • On a UTC server, time.mktime(t) was 0 hours off in every row. That's how this bug ships: the tests pass on a server set to UTC, and the same code is off by the local offset on a machine set to any other zone. We measured Central and UTC; other zones will be off by their own offset.
  • The CET row is wrong in every column, including the right ones. That one is feedparser's doing, before any conversion (below).

The fix, step by step

Tell the conversion the tuple is UTC. Either of these does it:

import calendar

t = e.published_parsed
print(datetime.fromtimestamp(calendar.timegm(t), timezone.utc))
print(datetime(*t[:6], tzinfo=timezone.utc))
2026-10-27 07:30:00+00:00
2026-10-27 07:30:00+00:00

Use calendar.timegm(t) when you want a Unix timestamp. Use the aware datetime when you'll compare, sort or format. t[:6] is year, month, day, hour, minute and second; the last three fields (weekday, day of the year, the dst flag) aren't needed.

The easy slip is leaving out tzinfo. datetime(*t[:6]) prints the right digits, so it looks fine, until you compare it with a time that has a zone:

cutoff = datetime(2026, 10, 1, tzinfo=timezone.utc)
print(datetime(*t[:6]) > cutoff)
TypeError: can't compare offset-naive and offset-aware datetimes

That error is the lucky case. The quiet one is calling .timestamp() on the naive value, which reads it as local time: the 5 hours in the figure.

Keeping the writer's offset

published_parsed keeps the instant and drops the writer's offset. If you need the offset (to show the time as the writer wrote it, say), parse entry.published yourself:

from email.utils import parsedate_to_datetime

print(parsedate_to_datetime(e.published))
print(datetime.fromisoformat("2026-10-27T09:30:00+05:30"))
print(parsedate_to_datetime("Tue, 27 Oct 2026 09:30:00 CET").tzinfo)
2026-10-27 09:30:00+02:00
2026-10-27 09:30:00+05:30
None

email.utils.parsedate_to_datetime reads RSS's RFC 822 dates; datetime.fromisoformat reads Atom's ISO 8601 dates. Both return an aware datetime when the string carries a zone they know, and a naive one when it doesn't: that's the None on the last line. A naive result tells you the zone is unknown, which is more than feedparser tells you for the same string. Someone in the thread suggests dateutil.parser.parse on the raw string instead; we didn't measure it.

What feedparser gets wrong before you convert anything

  • A zone name it doesn't know is read as UTC. RFC 822, which RSS 2.0 dates follow, names only UT, GMT, the four US zones in standard and daylight time, military letters and numeric offsets like +0200. feedparser's table adds a few (MET and MEST are in it), but not CET, and a name missing from the table counts as +0000. So 09:30:00 CET came back as 09:30 UTC, an hour late, and bozo stayed 0: no warning.
  • An RSS date with no zone gives no published_parsed at all. Tue, 27 Oct 2026 09:30:00 came back as None, while published held the string.
  • An Atom date with no zone is taken as UTC. Atom requires a Z or an offset, so 2026-10-27T09:30:00 is invalid, and feedparser's guess, 09:30 UTC, may or may not be what the writer meant. Nobody can know.
  • The docs' table is out of date here. It lists 2004-07-08 23:56:58 as parsing to 14:56:58; 6.0.14 returned 23:56:58, read as UTC like the rest. Measure the version you run rather than trusting the table.

What our reader does, and what it gets wrong

Our Actor turns published_parsed into ISO 8601 with calendar.timegm, so a row reads 2026-10-27T07:30:00Z. That was 0 hours off in every row feedparser read right. It inherits feedparser's mistakes, though: the CET item came out an hour late, and the RSS item with no zone came out with published empty, because a row carries the parsed date and not the string as written. It also drops the writer's offset: you get the instant in UTC, not "09:30 at +02:00". Its date filter keeps undated items rather than dropping them, so that item isn't lost, but it can't be sorted by date.

Questions people ask

Will feedparser return time-zone-aware datetimes? In September 2020 the maintainer wrote in the thread, "I'll target smarter time handling for a major version after 6.0.0 is released." As of 6.0.14 published_parsed is still a time.struct_time. We don't know when that will change.

Is updated_parsed the same? It's parsed the same way, into a UTC tuple. It has a trap of its own: when an item has no updated date, entry.get("updated_parsed") quietly returns the published one. Post 6 measures that, along with three other feedparser traps.

Try it

RSS, Atom & JSON Feed Reader is an Apify Actor that reads RSS, Atom and JSON Feed into rows with the same fields for every format, with dates already converted to UTC by the calendar.timegm route above. One honest limit: it keeps the instant, not the writer's offset, and a date feedparser can't read comes out empty.

Which one bit you: time.mktime, a naive datetime, or a zone name like CET? Tell us in the comments.

No comments yet. Be the first to comment.