Validating and troubleshooting feeds
A feed that a browser renders can still be rejected by readers. Validation in the build is the only way to know before subscribers do.
The checks worth automating
# 1. Well-formedness: catches unescaped ampersands and stray markup
xmllint --noout feed.xml
# 2. Structural validation against a schema
xmllint --noout --schema rss-2.0.xsd feed.xml
# 3. Feed-specific rules the W3C validator enforces (dates, guids, absolute URLs)
# Note that the hosted validator expects a publicly reachable feed URL.
# 4. Practical assertions the validator cannot know about
python - <<'PY'
import sys, feedparser
from datetime import datetime, timezone
f = feedparser.parse("feed.xml")
assert f.version, "not a recognised feed format"
assert f.feed.get("title"), "channel is missing a title"
assert f.feed.get("link"), "channel is missing a link"
newest = None
for e in f.entries:
assert e.get("link", "").startswith(("http://", "https://")), "relative link"
assert e.get("id") or e.get("guid"), "item without an identifier"
if not e.get("published_parsed"):
print("warning: item without a date:", e.get("title"))
else:
d = datetime(*e.published_parsed[:6], tzinfo=timezone.utc)
assert d <= datetime.now(timezone.utc), "date in the future: " + str(d)
newest = max(newest or d, d)
assert newest, "feed has no dated items"
print("feed looks publishable; newest item", newest.isoformat())
PY| Symptom | Cause | Fix |
|---|---|---|
| Reader shows only the first item | An unescaped & in later content | Escape every ampersand in text nodes |
Titles show as é | UTF-8 bytes served as Latin-1 | Declare the charset in the XML declaration and the HTTP header |
| Every item is unread | guid changed | Restore the old guid values; never derive them from mutable data |
| Dates display as today | Invalid RFC 822 date | Use +0100 style offsets, not +01:00 |
| Feed works locally, not on the server | Compression or a proxy rewriting the body | Compare the raw bytes from both environments |
| Images do not load | Relative URLs | Make every URL absolute, including inside content |
| Reader says the feed is invalid | A prefix used but not declared | Declare every namespace on the rss element |
# Validate in the build, so a broken feed never reaches subscribers
import subprocess, sys
def build_and_check(path: str) -> None:
xml = render_feed()
open(path, "w", encoding="utf-8").write(xml)
r = subprocess.run(["xmllint", "--noout", path], capture_output=True, text=True)
if r.returncode != 0:
print(r.stderr, file=sys.stderr)
raise SystemExit("feed is not well-formed XML")Dates and identifiers, the two chronic problems
from datetime import datetime, timezone
# RSS 2.0 wants RFC 822 with a numeric offset and an English day and month name
def rfc822(dt: datetime) -> str:
if dt.tzinfo is None:
raise ValueError("a datetime without a timezone is a bug, not a default")
return dt.astimezone(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000")
# Atom wants RFC 3339 and requires a timezone
def rfc3339(dt: datetime) -> str:
if dt.tzinfo is None:
raise ValueError("a datetime without a timezone is a bug, not a default")
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
# A stable guid: derived from data that never changes, never from the title
def guid(release_id: str) -> str:
return "release-" + release_id- Always use a timezone-aware datetime. A naive datetime serialised as a local time shifts by an hour twice a year and by a day across regions.
- In RSS 2.0, use a numeric offset such as
+0100. The colon form is ISO 8601 and is rejected by strict parsers. - Do not use the connection between the guid and the URL unless the URL is genuinely permanent, which requires
isPermaLink="true"to be honest. - If you must change a guid, accept that every existing subscriber will see the affected items as new. Warn them in a post rather than doing it silently.
⚠️
The
lastBuildDate element is not what tells a reader there is new content — readers compare item identifiers and dates. Setting it to the current time on every request signals an update that does not exist, and some readers respond by polling more often.FAQ
Why does my feed validate but display badly?
Validation checks structure, not rendering. Absolute URLs, a usable summary and a working image are quality issues a validator will not catch, which is why the practical assertions matter.
How often should I test the live feed?
Check well-formedness on every deploy, and run a scheduled daily check against the published URL so an infrastructure change that breaks compression or content type is caught without a subscriber complaint.
Related
Generating a valid feed Podcast feeds and the Apple and Podcasting 2.0 tags
Last refreshed 2026-09-18.