Generating a valid feed

Building feed XML safely from application data, validating it, and advertising it so readers can find it.

Never build XML by concatenation

Hand-written string templates are how feeds break. One headline containing an ampersand, an emoji, or a stray tag produces invalid XML. Use a serialiser that escapes text nodes for you.

from xml.etree import ElementTree as ET
from email.utils import format_datetime
from datetime import datetime, timezone

rss = ET.Element("rss", {"version": "2.0"})
ch = ET.SubElement(rss, "channel")
ET.SubElement(ch, "title").text = "Example Dev Blog"
ET.SubElement(ch, "link").text = "https://example.com/"
ET.SubElement(ch, "description").text = "Notes on building web systems."

for post in posts:                       # each post: dict with title/url/id/published/body
    it = ET.SubElement(ch, "item")
    ET.SubElement(it, "title").text = post["title"]
    ET.SubElement(it, "link").text = post["url"]
    ET.SubElement(it, "guid", {"isPermaLink": "false"}).text = post["id"]
    ET.SubElement(it, "pubDate").text = format_datetime(
        post["published"].astimezone(timezone.utc))
    ET.SubElement(it, "description").text = post["body"]   # escaping handled here

ET.ElementTree(rss).write("feed.xml", encoding="utf-8", xml_declaration=True)
const esc = (s) => String(s)
  .replace(/&/g, "&amp;").replace(/</g, "&lt;")
  .replace(/>/g, "&gt;").replace(/"/g, "&quot;");

const item = (p) => [
  "<item>",
  "  <title>" + esc(p.title) + "</title>",
  "  <link>" + esc(p.url) + "</link>",
  '  <guid isPermaLink="false">' + esc(p.id) + "</guid>",
  "  <pubDate>" + p.published.toUTCString() + "</pubDate>",
  "  <description>" + esc(p.summary) + "</description>",
  "</item>"
].join("\n");
💡
Escape the text and let a serialiser decide the structure. If you must concatenate, escape in this order — ampersand first — otherwise the escapes you just inserted get escaped again.

Validate and serve correctly

# well-formedness, then structure against the RSS 2.0 schema
xmllint --noout feed.xml
curl -sSL https://www.rssboard.org/files/schema.rng -o rss.rng
xmllint --noout --relaxng rss.rng feed.xml

# check what the reader will actually see
curl -sSI https://example.com/feed.xml | grep -i content-type
curl -s https://example.com/feed.xml | xmllint --format - | head -30
MistakeSymptom in readers
Unescaped ampersand or angle bracketThe whole feed fails to parse
Relative URLs in link or guidLinks open on the reader's domain or fail
guid changes on every publishOld items reappear as new
pubDate in ISO 8601Dates show as missing or fall back to fetch time
Wrong Content-TypeBrowser renders raw XML instead of offering subscription
Feed not updated when content isReaders poll forever and never see the change
Missing atom:link rel="self"Aggregators cannot detect the canonical feed URL
HTTP/1.1 200 OK
Content-Type: application/rss+xml; charset=utf-8
Cache-Control: max-age=300
ETag: "feed-2026-09-18-01"
Last-Modified: Fri, 18 Sep 2026 09:00:00 GMT

Advertise the feed in the document head so browsers and readers discover it, and keep the URL stable forever — subscribers are lost when a feed moves without a redirect.

<link rel="alternate" type="application/rss+xml"
      title="Example Dev Blog" href="https://example.com/feed.xml"/>

Publishing reliably

  • Generate the feed in the same build as the pages, so it can never be newer or older than the content.
  • Sort items newest first, and cap the list to a fixed number so the file stays small.
  • Emit lastBuildDate from the newest item, not from the current time — otherwise the feed looks changed on every regeneration.
  • Keep a stable guid derived from the item's canonical URL or a content id, never from a timestamp.
  • Serve the feed with a short Cache-Control and an ETag; readers poll often and a 304 costs almost nothing.
  • Set a realistic ttl or document your update cadence rather than relying on readers guessing.

FAQ

My feed validates but no reader shows it. Why?
Check the Content-Type header, the link element in the page head, and whether the URL redirects. A 302 through an HTML error page is the most common cause of a silently rejected feed.
Can I serve different content by User-Agent?
Avoid it. Cloaking feeds breaks caches and confuses validators. Serve one honest document and let the client choose what to display.

The RSS 2.0 feed format Atom and how readers consume feeds

Last refreshed 2026-09-18.