Build an RSS Feed Reader in Python
What an RSS feed reader does
An RSS feed reader pulls a list of recently published articles from a website so you can read them in one place, without checking each site by hand. The list itself is just an XML (or JSON) document the site publishes at a stable URL. Your reader fetches that document, parses out the entries, and shows them.
Three formats show up in the wild. RSS 2.0 is the oldest and still the most common. Atom 1.0 is the IETF standard that fixed a lot of RSS’s rough edges. JSON Feed is a newer format that drops XML entirely and uses JSON, which is friendlier to web developers. The good news: the feedparser library handles all three with the same API, so your code does not have to care which one a site happens to publish.
Choose a library: feedparser vs the standard library
You can parse feeds with the standard library. xml.etree.ElementTree ships with Python and will happily read an RSS document:
import urllib.request
import xml.etree.ElementTree as ET
with urllib.request.urlopen("https://realpython.com/atom.xml", timeout=10) as r:
root = ET.fromstring(r.read())
ns = {"a": "http://www.w3.org/2005/Atom"}
print(root.findtext("a:title", namespaces=ns))
# output: Real Python
for entry in root.findall("a:entry", ns)[:3]:
print("-", entry.findtext("a:title", namespaces=ns))
# output: - First Article Title
# output: - Second Article Title
# output: - Third Article Title
It works, but it falls over fast. Atom puts every element in the http://www.w3.org/2005/Atom namespace, so you have to thread that namespace dict through every query. RSS 2.0 has no namespace but uses different element names (channel and item), and older RSS 0.91 feeds use still other names. feedparser handles all of that for you, normalizes field names across formats, and turns dates into time.struct_time values you can sort on. That is why almost every Python feed reader you find uses it.
Install feedparser and httpx
Create a virtual environment and install both libraries:
python -m venv .venv
source .venv/bin/activate
pip install feedparser httpx
feedparser 6.0.11 requires Python 3.8 or newer. If you only want one dependency, feedparser can fetch feeds on its own, but pulling in httpx gives you control over the User-Agent header, timeouts, and conditional GETs, which matters once you start running a reader on a schedule.
Fetch a feed with httpx
Passing a URL straight to feedparser.parse(url) works, but it uses the standard library’s urllib under the hood, so you cannot set a User-Agent, you cannot pass an If-None-Match header, and you do not get an HTTP status code you can branch on. Fetch the bytes yourself with httpx and hand them to feedparser:
import feedparser
import httpx
URL = "https://realpython.com/atom.xml"
resp = httpx.get(
URL,
headers={"User-Agent": "pyguides-rss-reader/1.0"},
timeout=10.0,
)
resp.raise_for_status()
feed = feedparser.parse(resp.content)
print(feed.feed.title)
# output: Real Python
Two things to notice. First, resp.content is bytes. If you pass resp.text (a str), feedparser will try to open that string as a filename and fail with a FileNotFoundError. Second, resp.raise_for_status() turns HTTP 4xx and 5xx responses into exceptions so you do not silently parse an error page as a feed.
Parse with feedparser
The result of feedparser.parse(...) is a dict-like object. The two attributes you actually use are feed (channel-level metadata) and entries (the list of items):
for entry in feed.entries[:5]:
print(f"{entry.title}\n {entry.link}")
# output: First article title
# output: https://realpython.com/first-article/
# output: Second article title
# output: https://realpython.com/second-article/
Useful entry attributes:
| Attribute | Meaning |
|---|---|
title | Entry title |
link | URL of the article |
published | Publication date as an RFC 822 string |
published_parsed | Same date as a time.struct_time (sortable) |
summary or description | Short text or HTML body |
id | Unique ID (RSS <guid> or Atom <id>) |
author | Author name, when present |
Every one of these is optional in the wild. Older feeds often skip published, and some omit author entirely. Defensive code uses getattr(entry, "title", "(untitled)") or a small helper that wraps .get() and returns a placeholder.
There is one more attribute worth knowing about: feed.bozo. If bozo == 1, the feed is not well-formed XML. Most of the time you still get a usable entries list, so do not throw the result away, but you probably want to log feed.bozo_exception so you can investigate later.
Normalize dates and entries across RSS and Atom
published_parsed is a time.struct_time, not a datetime. If you want to compare or sort dates across feeds, convert it:
from datetime import datetime, timezone
dt = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
print(dt.isoformat())
# output: 2026-06-15T09:00:00+00:00
If published_parsed is None (the feed skipped the date), your code needs a fallback. A common pattern is to drop the entry, or to give it a sentinel date so it sorts last.
A small normalizer that flattens RSS and Atom entries into one shape keeps the rest of your code simple:
from datetime import datetime, timezone
def normalize(entry):
parsed = entry.get("published_parsed")
published = (
datetime(*parsed[:6], tzinfo=timezone.utc) if parsed else None
)
return {
"title": (entry.get("title") or "").strip(),
"link": entry.get("link"),
"published": published,
"id": entry.get("id"),
}
Strip whitespace from title because some publishers pad titles with spaces or newlines.
Build a small CLI feed reader
Pull the pieces together into a script that fetches several feeds, prints the latest entries, and fails gracefully when one feed is down:
import sys
import feedparser
import httpx
FEEDS = [
"https://realpython.com/atom.xml",
"https://feeds.feedburner.com/PythonInsider",
]
HEADERS = {"User-Agent": "pyguides-rss-reader/1.0"}
TIMEOUT = 10.0
def fetch(url):
resp = httpx.get(url, headers=HEADERS, timeout=TIMEOUT)
resp.raise_for_status()
return feedparser.parse(resp.content)
def latest_entries(feed, n=5):
items = []
for entry in feed.entries[:n]:
items.append({
"title": (entry.get("title") or "").strip(),
"link": entry.get("link"),
"published": entry.get("published_parsed"),
})
return items
def show_feed(url):
try:
feed = fetch(url)
except httpx.HTTPError as exc:
print(f"[skip] {url}: {exc}", file=sys.stderr)
return
print(f"\n== {feed.feed.title} ==")
if feed.bozo:
print(f"[warn] malformed feed: {feed.bozo_exception}", file=sys.stderr)
for item in latest_entries(feed):
print(f"- {item['title']}\n {item['link']}")
def main():
for url in FEEDS:
show_feed(url)
if __name__ == "__main__":
main()
Run it and you get a quick scan of both feeds in your terminal. If a feed is down, the script prints a warning and keeps going instead of crashing the whole run.
Common pitfalls
A handful of things will bite you if you have not seen them before:
feed.bozo == 1does not mean the feed is empty.feedparserkeeps parsing past the first error and usually returns usable entries. Logfeed.bozo_exceptionand move on.feed.statusisNonewhen you feed bytes tofeedparser.parse(...). Only the internal fetcher populates it. Read the status fromhttpx.Response.status_codeinstead.- The
descriptionfield on RSS feeds is HTML by convention, but some publishers put plain text or unescaped junk in there. Do not render it raw. feedparser.parse(text)tries to open the string as a filename. Always pass bytes (feedparser.parse(resp.content)).- Send a real
User-Agentand respectETagandLast-Modifiedso you do not hammer publishers. The next section shows the polite version.
Going further
A few directions once the basic reader works:
- Conditional GET. Save
resp.headers["ETag"]andresp.headers["Last-Modified"]to disk. On the next run, send them back asIf-None-MatchandIf-Modified-Since. If you get a 304, skip the parse. - Async fetching. If you poll many feeds, swap
httpxforhttpx.AsyncClientand gather the requests withasyncio.gather. The parsing stays synchronous; only the network calls parallelize. - JSON Feed. Some sites publish
feed.jsoninstead of XML.feedparserhandles it through the samefeed/entriesAPI, so no code change is needed once you add the URL to your list. - Storage. Persist entries to SQLite keyed on the entry
id, so you only show new ones on the next run.
For a deeper look at httpx itself, including async usage and connection pooling, see the httpx guide.
Conclusion
A working RSS reader in Python is small: one library to fetch the bytes, one library to parse them, and a loop to print the results. The feedparser library hides the messy differences between RSS, Atom, and JSON Feed, and httpx gives you the HTTP control you need to be polite to publishers. From here the most useful additions are conditional GETs for politeness, SQLite for persistence, and asyncio if you start polling a long feed list.