
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_zoneisNone; the "UTC" lives only in the documentation. - Its last field,
tm_isdst, is 0. entry.publishedstill 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.

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 oftime.gmtime(): it reads a tuple as UTC.datetime(*t[:6], tzinfo=timezone.utc)gave the same instant in every row. Our Actor usestimegm.time.mktime(t)was 6 hours late,.timestamp()5. The difference is thattm_isdst=0.mktimeappears 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",mktimewas 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. So09:30:00 CETcame back as 09:30 UTC, an hour late, andbozostayed 0: no warning. - An RSS date with no zone gives no
published_parsedat all.Tue, 27 Oct 2026 09:30:00came back asNone, whilepublishedheld the string. - An Atom date with no zone is taken as UTC. Atom requires a
Zor an offset, so2026-10-27T09:30:00is 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:58as 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.
Comments
Join the discussionNo comments yet. Be the first to comment.



