pyguides

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:

AttributeMeaning
titleEntry title
linkURL of the article
publishedPublication date as an RFC 822 string
published_parsedSame date as a time.struct_time (sortable)
summary or descriptionShort text or HTML body
idUnique ID (RSS <guid> or Atom <id>)
authorAuthor 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 == 1 does not mean the feed is empty. feedparser keeps parsing past the first error and usually returns usable entries. Log feed.bozo_exception and move on.
  • feed.status is None when you feed bytes to feedparser.parse(...). Only the internal fetcher populates it. Read the status from httpx.Response.status_code instead.
  • The description field 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-Agent and respect ETag and Last-Modified so 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"] and resp.headers["Last-Modified"] to disk. On the next run, send them back as If-None-Match and If-Modified-Since. If you get a 304, skip the parse.
  • Async fetching. If you poll many feeds, swap httpx for httpx.AsyncClient and gather the requests with asyncio.gather. The parsing stays synchronous; only the network calls parallelize.
  • JSON Feed. Some sites publish feed.json instead of XML. feedparser handles it through the same feed/entries API, 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.

See also