RSS Feed Validator

Every RSS 2.0 rule, with a citation for each one.

Fetched straight from the origin. Many servers block that; if yours does, paste instead.
Input
WaitingPaste a document to check it. Validation runs as you type.

Everything runs in this tab. Nothing you paste is uploaded, logged or sent anywhere. Open your network panel and check.

Paste your feed above, or enter its address and press Fetch, and it is checked against the RSS 2.0 specification. Every finding names the element, quotes the value and cites the rule. That last part matters: about a third of the things a feed gets told off for are conventions readers rely on rather than spec violations, and they are labelled as such.

You are probably here because something downstream rejected the feed. A reader shows the channel but no episodes. An RSS-to-email service refuses it. A subscriber says every old post reappeared as new. Each has a short list of likely causes, and all of them are checked.

This page exists because the canonical tool is decaying in public. Checked in September 2026, feedvalidator.org served an expired TLS certificate, so browsers refuse to open it, and its SourceForge mirror is copyright 2002 to 2004 and still headlines Atom 0.3. The W3C validator works, but it is server-side: your feed goes to their machine.

What RSS 2.0 actually requires

Less than most people expect. The root is <rss> with version="2.0", holding exactly one <channel>. The channel needs a title, a link and a description, and the link must be an absolute URL. Items are looser: at least one of title or description, and nothing else is required at all.

What the specification does not permit is repetition. Two <title> elements in a channel, or two <link> elements in an item, are errors, and the second one is reported with its position. One structural check catches an easy mistake: <item> elements sitting directly under <rss> rather than inside <channel>. They parse, and no reader will ever show them.

  • Channel: title, link and description required.
  • Item: at least one of title or description.
  • Singleton elements must not repeat in a channel or an item.
  • RSS core elements are in no namespace, so <atom:link> is not an RSS <link>.

Dates are RFC 822, not ISO 8601

The most common valid-looking mistake in RSS. pubDate and lastBuildDate must be RFC 822: Wed, 02 Oct 2024 13:00:00 GMT. What most frameworks emit by default is ISO 8601, 2024-10-02T13:00:00Z, which is right for Atom and wrong here. A reader that cannot parse a pubDate hides the item or sorts it into the wrong place.

Four things are checked. Whether it parses at all. Whether the named weekday agrees with the date, since a mismatch is the fingerprint of a formatter written by hand. Whether the year is two digits, which RSS permits and aggregators misread. And whether a timezone is present, since RFC 822 lets you omit it and readers then guess differently.

Every language has a one-call answer, used in each sample below: toUTCString, format_datetime, RFC_1123_DATE_TIME, the "r" specifier, DATE_RSS.

guid, and why subscribers see your old posts twice

A guid is the identity of an item. Readers use it to decide whether something is new or already shown, so it must be stable and unique. Duplicate guids are an error: a reader treats the two items as one and displays only one of them.

The trap is isPermaLink, which defaults to true, so a bare <guid>abc-123</guid> asserts that abc-123 is a resolvable URL. An opaque identifier needs isPermaLink="false" written explicitly, and a non-URL guid without it is an error. An item with no guid is a warning: readers fall back to matching on link or title.

The catastrophic version is a plugin that regenerates guids on every build, usually from something volatile such as a permalink. Every subscriber receives the entire back catalogue at once.

Ampersands, enclosures, and the checks that cannot run

The commonest way a feed breaks is a raw & inside a URL, as in p?id=12&sort=asc. In XML an ampersand begins an entity reference, so the parse fails and readers reject the whole document rather than the one item. You get the syntax error with its position, and a note that no RSS rules could run.

An <enclosure> needs url, length and type. length is the size in bytes as a whole number, and 0 is a warning because it means the generator could not stat the file. Two enclosures on one item is a warning from the Best Practices Profile rather than the specification.

The channel is checked for an <atom:link rel="self">, which is not an RSS element at all but which aggregators lean on. Three checks are listed as not run, since a browser cannot make them: whether rel="self" matches where the feed is served, and whether enclosure URLs and lengths are right.

Checking a feed in code

Worth running in your publish pipeline, because the failure modes above are silent: the feed still builds and serves, and you find out from a subscriber. Each sample checks the item rule, the date format and the guid.

// Node 18 or later.  npm i @xmldom/xmldom
import { DOMParser } from '@xmldom/xmldom';

const xml = await (await fetch('https://example.com/feed.xml')).text();
const doc = new DOMParser().parseFromString(xml, 'text/xml');

const channel = doc.getElementsByTagName('channel').item(0);
for (const required of ['title', 'link', 'description']) {
  if (channel.getElementsByTagName(required).length === 0) {
    console.error('channel has no <' + required + '>');
  }
}

// RFC 822, not ISO 8601. Anything with a "T" separator is the wrong format.
const RFC822 = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}(:\d{2})? (GMT|UT|[A-Z]{3}|[+-]\d{4})$/;

const items = channel.getElementsByTagName('item');
for (let i = 0; i < items.length; i++) {
  const item = items.item(i);
  const text = (name) => {
    const el = item.getElementsByTagName(name).item(0);
    return el ? el.textContent.trim() : null;
  };

  if (!text('title') && !text('description')) {
    console.error('item ' + (i + 1) + ' has neither a title nor a description');
  }

  const pubDate = text('pubDate');
  if (pubDate && !RFC822.test(pubDate)) {
    console.error('item ' + (i + 1) + ' pubDate is not RFC 822: ' + pubDate);
  }

  const guid = item.getElementsByTagName('guid').item(0);
  if (guid && guid.getAttribute('isPermaLink') !== 'false'
      && !/^https?:\/\//.test(guid.textContent.trim())) {
    console.error('item ' + (i + 1) + ' guid defaults to isPermaLink="true" but is not a URL');
  }
}

// Producing a correct date is one call:
console.log(new Date().toUTCString());   // Wed, 02 Oct 2024 13:00:00 GMT
# pip install lxml requests
import sys
import requests
from email.utils import format_datetime, parsedate_to_datetime
from datetime import datetime, timezone
from lxml import etree

raw = requests.get('https://example.com/feed.xml', timeout=30).content

# resolve_entities=False and no_network=True are the safe form.
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
root = etree.fromstring(raw, parser)

channel = root.find('channel')
if channel is None:
    sys.exit('No <channel> element')

for required in ('title', 'link', 'description'):
    if channel.find(required) is None:
        print('channel has no <%s>' % required)

for i, item in enumerate(channel.findall('item'), start=1):
    if item.find('title') is None and item.find('description') is None:
        print('item %d has neither a title nor a description' % i)

    pub = item.findtext('pubDate')
    if pub:
        try:
            # parsedate_to_datetime is the RFC 2822 parser, and it rejects
            # ISO 8601 outright, which is exactly what you want here.
            parsedate_to_datetime(pub)
        except (TypeError, ValueError):
            print('item %d pubDate is not RFC 822: %s' % (i, pub))

    guid = item.find('guid')
    if guid is not None and guid.get('isPermaLink', 'true') == 'true':
        if not (guid.text or '').startswith(('http://', 'https://')):
            print('item %d guid is treated as a permalink but is not a URL' % i)

# Producing a correct date:
print(format_datetime(datetime.now(timezone.utc)))
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.ZonedDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

byte[] bytes = HttpClient.newHttpClient()
    .send(HttpRequest.newBuilder(URI.create("https://example.com/feed.xml")).build(),
          HttpResponse.BodyHandlers.ofByteArray())
    .body();

DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true);
f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setXIncludeAware(false);

Document doc = f.newDocumentBuilder().parse(new ByteArrayInputStream(bytes));
Element channel = (Element) doc.getElementsByTagName("channel").item(0);

for (String required : new String[] { "title", "link", "description" }) {
    if (channel.getElementsByTagName(required).getLength() == 0) {
        System.err.println("channel has no <" + required + ">");
    }
}

NodeList items = channel.getElementsByTagName("item");
for (int i = 0; i < items.getLength(); i++) {
    Element item = (Element) items.item(i);
    boolean hasTitle = item.getElementsByTagName("title").getLength() > 0;
    boolean hasDesc = item.getElementsByTagName("description").getLength() > 0;
    if (!hasTitle && !hasDesc) {
        System.err.println("item " + (i + 1) + " has neither a title nor a description");
    }

    NodeList pub = item.getElementsByTagName("pubDate");
    if (pub.getLength() > 0) {
        String v = pub.item(0).getTextContent().trim();
        try {
            // RFC_1123_DATE_TIME is the RFC 822 profile RSS wants, and it
            // refuses ISO 8601, so a wrong-format date fails loudly here.
            DateTimeFormatter.RFC_1123_DATE_TIME.parse(v);
        } catch (DateTimeParseException e) {
            System.err.println("item " + (i + 1) + " pubDate is not RFC 822: " + v);
        }
    }
}

// Producing a correct date:
System.out.println(DateTimeFormatter.RFC_1123_DATE_TIME
    .format(ZonedDateTime.now(ZoneOffset.UTC)));
using System.Globalization;
using System.Xml;
using System.Xml.Linq;

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,  // external entities are never fetched
    XmlResolver = null,
};

var bytes = await new HttpClient().GetByteArrayAsync("https://example.com/feed.xml");
using var stream = new MemoryStream(bytes);
using var reader = XmlReader.Create(stream, settings);
var doc = XDocument.Load(reader);

// RSS core elements are in no namespace, so plain element names are correct here.
var channel = doc.Root?.Element("channel");
if (channel is null) throw new InvalidOperationException("No <channel> element");

foreach (var required in new[] { "title", "link", "description" })
{
    if (channel.Element(required) is null)
        Console.Error.WriteLine("channel has no <" + required + ">");
}

int n = 0;
foreach (var item in channel.Elements("item"))
{
    n++;
    if (item.Element("title") is null && item.Element("description") is null)
        Console.Error.WriteLine("item " + n + " has neither a title nor a description");

    var pub = (string?)item.Element("pubDate");
    if (pub is not null)
    {
        // .NET has no dedicated RFC 822 parser. TryParse handles the usual
        // spellings but also accepts ISO 8601, which RSS forbids, so reject
        // anything carrying a "T" separator before parsing.
        if (pub.Contains('T') ||
            !DateTimeOffset.TryParse(pub, CultureInfo.InvariantCulture,
                                     DateTimeStyles.None, out _))
        {
            Console.Error.WriteLine("item " + n + " pubDate is not RFC 822: " + pub);
        }
    }

    var guid = item.Element("guid");
    if (guid is not null && (string?)guid.Attribute("isPermaLink") != "false"
        && !guid.Value.StartsWith("http", StringComparison.Ordinal))
    {
        Console.Error.WriteLine("item " + n + " guid is a permalink by default but is not a URL");
    }
}

// Producing a correct date: "r" is the RFC 1123 pattern RSS accepts.
Console.WriteLine(DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture));
<?php
// LIBXML_NONET stops libxml fetching anything the document references.
libxml_use_internal_errors(true);

$raw = file_get_contents('https://example.com/feed.xml');
$feed = simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NONET);

if ($feed === false) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "XML error at line %d: %s", $e->line, trim($e->message) . "\n");
    }
    exit(1);
}

foreach (['title', 'link', 'description'] as $required) {
    if (!isset($feed->channel->{$required})) {
        fprintf(STDERR, "channel has no <%s>\n", $required);
    }
}

$n = 0;
foreach ($feed->channel->item as $item) {
    $n++;

    if (!isset($item->title) && !isset($item->description)) {
        fprintf(STDERR, "item %d has neither a title nor a description\n", $n);
    }

    if (isset($item->pubDate)) {
        $raw_date = (string) $item->pubDate;
        // DATE_RSS expects a numeric offset; the second pattern covers "GMT".
        $parsed = DateTimeImmutable::createFromFormat(DATE_RSS, $raw_date)
            ?: DateTimeImmutable::createFromFormat('D, d M Y H:i:s T', $raw_date);
        if ($parsed === false) {
            fprintf(STDERR, "item %d pubDate is not RFC 822: %s\n", $n, $raw_date);
        }
    }

    $guid = $item->guid;
    if ($guid !== null && (string) ($guid['isPermaLink'] ?? 'true') !== 'false'
        && !preg_match('#^https?://#', (string) $guid)) {
        fprintf(STDERR, "item %d guid defaults to isPermaLink=true but is not a URL\n", $n);
    }
}

// Producing a correct date: DATE_RSS is RFC 822.
echo (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_RSS), "\n";

Every sample turns off external entity resolution, because a feed is a document you fetch from elsewhere and did not write. In Java and PHP the unsafe behaviour is the default.

Common questions

My feed works in one reader and not another. What is wrong with it?

Usually a date, a guid or an encoding. Readers differ enormously in what they forgive, so a feed Feedly renders happily can be rejected by a stricter client.

Work through it in order. Check the document is well-formed XML at all, since a raw ampersand breaks everything downstream. Then check pubDate on every item, because an unparseable date makes items vanish or sort strangely. Then check guids for uniqueness. If the feed is clean here and one reader still misbehaves, the cause is transport: a wrong Content-Type, a redirect chain, or caching.

Is feedvalidator.org still the tool I should use?

It was the canonical answer for twenty years, but as of September 2026 it serves an expired TLS certificate, so browsers block it. Its SourceForge mirror is copyright 2002 to 2004 and still advertises Atom 0.3, which RFC 4287 replaced in 2005.

The W3C Feed Validation Service is maintained and its error catalogue is thorough. Its limits are age and architecture: terse messages, and your feed sent to their server. This page reproduces the checks from that catalogue that correspond to real breakage and runs them locally.

What date format does RSS need?

RFC 822: Wed, 02 Oct 2024 13:00:00 GMT. A numeric offset such as +0000 is equally valid in place of the zone name. The weekday is optional, but if you include it, it has to be the right one.

ISO 8601, meaning 2024-10-02T13:00:00Z, is not valid in RSS. It is valid in Atom, which is why so many generators emit it: someone copied a date helper from an Atom template. Avoid two-digit years, and always include a timezone.

Why did all my old posts reappear as new to subscribers?

Something changed the guid on every item. A reader keeps a list of guids it has shown, and anything not on it is new. Change them all at once and every subscriber gets the archive again, which for a podcast means every episode downloading to every device.

The cause is a guid derived from something impermanent: one built from the permalink breaks when you move to https, one built from the title breaks when you fix a typo. Use a database key, a UUID or a tag URI, with isPermaLink="false".

Does my feed get sent to a server?

No. The parser and every rule run in this tab as JavaScript. There is no back end to send anything to and no analytics with access to the editor. Open your network panel while you validate and watch it stay empty.

That looks unimportant for a format as public as RSS, until you consider which feeds people paste into a validator: shows that have not launched, private podcast feeds whose enclosure URLs carry a per-subscriber token, staging feeds full of drafts.

Does this validate podcast feeds and iTunes tags?

It validates the RSS 2.0 layer, which is where a large share of podcast rejections start: a malformed pubDate, a duplicate guid, an enclosure missing its length or type. Those rules matter particularly here, since the enclosure is the episode.

What it does not check is the itunes namespace: itunes:image and its dimension rules, itunes:category, itunes:explicit, itunes:duration. Those are left alone unless their prefix is undeclared. One Apple rule is worth restating: each episode needs a globally unique identifier that never changes, which is the guid check above.

Related tools

Background reading

Errors this fixes