Atom Feed Validator
RFC 4287 checked properly, including author inheritance.
Everything runs in this tab. Nothing you paste is uploaded, logged or sent anywhere. Open your network panel and check.
Paste an Atom feed above, or fetch it by URL, and it is checked against RFC 4287. Every finding cites the section it comes from. Standalone Atom Entry Documents, where the root is <entry> rather than <feed>, are recognised and checked under the entry rules.
Atom is stricter than RSS, and that is the point of it: almost everything RSS left to interpretation, Atom decided. Exactly one id, title and updated on a feed and on every entry. One date format with no variants. Every text construct declares whether it is text, HTML or XHTML.
One rule here is implemented properly that most tools skip: author inheritance. An entry needs no author of its own if it has an <atom:source> carrying one, or if the feed has one. Checking only for entry/author produces a page of false errors on a valid feed; checking nothing misses a real failure.
Why Atom exists, and how it differs from RSS
RSS 2.0 was frozen in 2002 and its ambiguities were frozen with it. The largest was <description>: the specification never said whether it holds plain text or HTML, so every reader guessed. Item identity was optional. Dates followed RFC 822, which permits two-digit years and no timezone at all.
Atom was written at the IETF in response and published as RFC 4287 in December 2005. Text carries an explicit type. Every feed and entry has a required, permanent id. Dates are RFC 3339 with a mandatory zone. Everything sits in one namespace, so an extension cannot collide with the core vocabulary.
- RSS: description may be text or HTML, nobody knows which. Atom: type stated.
- RSS: guid optional and often unstable. Atom: id required, absolute IRI, permanent.
- RSS: RFC 822 dates, timezone optional. Atom: RFC 3339, timezone required.
What a feed and its entries must contain
Exactly one <id>, one <title> and one <updated> on the feed, and the same three on every entry. Not one or more: exactly one, so a duplicate is an error too. The commonest failure is a feed with a title and entries but no id.
An entry with no <content> must carry at least one <link rel="alternate">: it has to offer the reader either the thing itself or a way to reach it. If <content> has a src attribute the element must be empty and the entry needs a <summary>, which also applies to base64 content.
Every <link> needs an href, and nothing may carry two rel="alternate" links sharing a type and hreflang, since a reader could not choose between them. A missing rel defaults to alternate. A feed should also carry <link rel="self">, which RFC 4287 states as a SHOULD, so it is a labelled warning.
The author-inheritance rule
RFC 4287 requires an author to be resolvable for every entry, but not that the element sits on the entry. The rule is a fallback chain: the entry own <author>, or an <author> inside its <atom:source>, or an <author> on the <feed>. The source case exists for aggregators republishing entries collected elsewhere.
Almost no free validator implements all three steps. The ones checking only entry/author report an error on every entry of a valid single-author blog feed, the commonest shape of Atom feed there is. This tool walks the chain and reports an error only when all three fail.
A standalone Atom Entry Document has no feed to inherit from, so it must carry its own author. Every person construct also needs a <name>: an <author> with only an email address is an error under section 3.2.1.
Dates and ids, exactly as the RFC writes them
Atom timestamps are RFC 3339, and section 3.3 adds two requirements. The separator must be an uppercase T, and with no numeric offset the zone must be an uppercase Z. So 2026-03-14T09:30:00Z is valid and 2026-03-14t09:30:00z is not, though every date library will parse it. Lowercase gets its own message. A bare date is invalid too.
An id must be an absolute IRI, so it needs a scheme: https:, tag: and urn: all qualify, a relative path or a bare string does not. Duplicate ids across entries are an error, since readers deduplicate on id.
Ids differing only by letter case, a trailing slash or a default port produce a warning, because ids are compared character by character: /p/1 and /p/1/ are two entries. The tag scheme, RFC 4151, avoids that, and tag:example.com,2026:post-4192 survives a move to a new domain.
Producing and checking Atom in code
The four rules worth automating are the ones a schema cannot express: RFC 3339 casing, id uniqueness, author inheritance, and content or alternate link. Each sample checks those, in the safe parsing form.
// Node 18 or later. npm i @xmldom/xmldom
import { DOMParser } from '@xmldom/xmldom';
const ATOM = 'http://www.w3.org/2005/Atom';
// RFC 3339 as RFC 4287 section 3.3 requires it: uppercase T, uppercase Z.
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
const xml = await (await fetch('https://example.com/atom.xml')).text();
const doc = new DOMParser().parseFromString(xml, 'text/xml');
const feed = doc.documentElement;
const kids = (el, name) => Array.from(el.getElementsByTagNameNS(ATOM, name))
.filter((n) => n.parentNode === el);
for (const required of ['id', 'title', 'updated']) {
if (kids(feed, required).length !== 1) {
console.error('feed must have exactly one <' + required + '>');
}
}
const feedHasAuthor = kids(feed, 'author').length > 0;
const seen = new Map();
for (const [i, entry] of kids(feed, 'entry').entries()) {
const n = i + 1;
const updated = kids(entry, 'updated')[0];
if (updated && !RFC3339.test(updated.textContent.trim())) {
console.error('entry ' + n + ' updated is not RFC 3339: ' + updated.textContent.trim());
}
const id = kids(entry, 'id')[0];
const value = id ? id.textContent.trim() : '';
if (!/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
console.error('entry ' + n + ' id is not an absolute IRI: ' + value);
} else if (seen.has(value)) {
console.error('entry ' + n + ' repeats the id of entry ' + seen.get(value));
} else {
seen.set(value, n);
}
// The inheritance chain: entry/author, else entry/source/author, else feed/author.
const source = kids(entry, 'source')[0];
const hasAuthor = kids(entry, 'author').length > 0
|| (source ? kids(source, 'author').length > 0 : false)
|| feedHasAuthor;
if (!hasAuthor) console.error('entry ' + n + ' has no resolvable author');
const hasAlternate = kids(entry, 'link')
.some((l) => (l.getAttribute('rel') || 'alternate') === 'alternate');
if (kids(entry, 'content').length === 0 && !hasAlternate) {
console.error('entry ' + n + ' has neither <content> nor a <link rel="alternate">');
}
}
// Producing a correct timestamp:
console.log(new Date().toISOString()); // 2026-03-14T09:30:00.000Z# pip install lxml requests
import re
import sys
import requests
from datetime import datetime, timezone
from lxml import etree
ATOM = 'http://www.w3.org/2005/Atom'
NS = {'a': ATOM}
# datetime.fromisoformat is too permissive for this: it accepts a lowercase
# separator, which RFC 4287 section 3.3 forbids. Check the shape directly.
RFC3339 = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$')
IRI = re.compile(r'^[A-Za-z][A-Za-z0-9+.\-]*:')
raw = requests.get('https://example.com/atom.xml', timeout=30).content
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
feed = etree.fromstring(raw, parser)
for required in ('id', 'title', 'updated'):
if len(feed.findall('a:%s' % required, NS)) != 1:
print('feed must have exactly one <%s>' % required)
feed_has_author = len(feed.findall('a:author', NS)) > 0
seen = {}
for n, entry in enumerate(feed.findall('a:entry', NS), start=1):
updated = entry.findtext('a:updated', namespaces=NS) or ''
if not RFC3339.match(updated.strip()):
print('entry %d updated is not RFC 3339: %s' % (n, updated))
ident = (entry.findtext('a:id', namespaces=NS) or '').strip()
if not IRI.match(ident):
print('entry %d id is not an absolute IRI: %s' % (n, ident))
elif ident in seen:
print('entry %d repeats the id of entry %d' % (n, seen[ident]))
else:
seen[ident] = n
has_author = (len(entry.findall('a:author', NS)) > 0
or len(entry.findall('a:source/a:author', NS)) > 0
or feed_has_author)
if not has_author:
print('entry %d has no resolvable author' % n)
alternates = [l for l in entry.findall('a:link', NS)
if l.get('rel', 'alternate') == 'alternate']
if entry.find('a:content', NS) is None and not alternates:
print('entry %d has neither <content> nor a <link rel="alternate">' % n)
# Producing a correct timestamp. Both spellings below are valid RFC 3339.
print(datetime.now(timezone.utc).isoformat(timespec='seconds')) # ...+00:00
print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) # ...Zimport 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.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.HashMap;
import java.util.Map;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
final String ATOM = "http://www.w3.org/2005/Atom";
byte[] bytes = HttpClient.newHttpClient()
.send(HttpRequest.newBuilder(URI.create("https://example.com/atom.xml")).build(),
HttpResponse.BodyHandlers.ofByteArray())
.body();
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true); // without this, every Atom lookup below finds nothing
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 feed = doc.getDocumentElement();
boolean feedHasAuthor = childCount(feed, ATOM, "author") > 0;
Map<String, Integer> seen = new HashMap<>();
NodeList entries = feed.getElementsByTagNameNS(ATOM, "entry");
for (int i = 0; i < entries.getLength(); i++) {
Element entry = (Element) entries.item(i);
int n = i + 1;
String updated = firstText(entry, ATOM, "updated");
// java.time matches literals case-sensitively, but check explicitly so the
// lowercase case gets its own message: RFC 4287 requires "T" and "Z".
if (updated.indexOf('t') >= 0 || updated.indexOf('z') >= 0) {
System.err.println("entry " + n + " updated uses a lowercase T or Z: " + updated);
} else {
try {
OffsetDateTime.parse(updated);
} catch (DateTimeParseException e) {
System.err.println("entry " + n + " updated is not RFC 3339: " + updated);
}
}
String id = firstText(entry, ATOM, "id");
if (!id.matches("[A-Za-z][A-Za-z0-9+.\\-]*:.+")) {
System.err.println("entry " + n + " id is not an absolute IRI: " + id);
} else if (seen.containsKey(id)) {
System.err.println("entry " + n + " repeats the id of entry " + seen.get(id));
} else {
seen.put(id, n);
}
// entry/author, else entry/source/author, else feed/author.
NodeList sources = entry.getElementsByTagNameNS(ATOM, "source");
boolean sourceAuthor = sources.getLength() > 0
&& childCount((Element) sources.item(0), ATOM, "author") > 0;
if (childCount(entry, ATOM, "author") == 0 && !sourceAuthor && !feedHasAuthor) {
System.err.println("entry " + n + " has no resolvable author");
}
}
// Producing a correct timestamp:
System.out.println(DateTimeFormatter.ISO_INSTANT.format(Instant.now()));using System.Globalization;
using System.Xml;
using System.Xml.Linq;
XNamespace atom = "http://www.w3.org/2005/Atom";
// RFC 4287 section 3.3: uppercase T, and uppercase Z when there is no offset.
// The literals are quoted so they are matched exactly rather than as specifiers.
string[] rfc3339 =
{
"yyyy-MM-dd'T'HH:mm:ssK",
"yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK",
};
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, // external entities are never fetched
XmlResolver = null,
};
var bytes = await new HttpClient().GetByteArrayAsync("https://example.com/atom.xml");
using var stream = new MemoryStream(bytes);
using var reader = XmlReader.Create(stream, settings);
var feed = XDocument.Load(reader).Root!;
foreach (var required in new[] { "id", "title", "updated" })
{
if (feed.Elements(atom + required).Count() != 1)
Console.Error.WriteLine("feed must have exactly one <" + required + ">");
}
bool feedHasAuthor = feed.Elements(atom + "author").Any();
var seen = new Dictionary<string, int>(StringComparer.Ordinal);
int n = 0;
foreach (var entry in feed.Elements(atom + "entry"))
{
n++;
var updated = ((string?)entry.Element(atom + "updated") ?? string.Empty).Trim();
if (!DateTimeOffset.TryParseExact(updated, rfc3339, CultureInfo.InvariantCulture,
DateTimeStyles.None, out _))
{
Console.Error.WriteLine("entry " + n + " updated is not RFC 3339: " + updated);
}
var id = ((string?)entry.Element(atom + "id") ?? string.Empty).Trim();
if (!Uri.IsWellFormedUriString(id, UriKind.Absolute))
Console.Error.WriteLine("entry " + n + " id is not an absolute IRI: " + id);
else if (seen.TryGetValue(id, out int first))
Console.Error.WriteLine("entry " + n + " repeats the id of entry " + first);
else
seen[id] = n;
// entry/author, else entry/source/author, else feed/author.
bool hasAuthor = entry.Elements(atom + "author").Any()
|| entry.Elements(atom + "source").Elements(atom + "author").Any()
|| feedHasAuthor;
if (!hasAuthor) Console.Error.WriteLine("entry " + n + " has no resolvable author");
bool hasAlternate = entry.Elements(atom + "link")
.Any(l => ((string?)l.Attribute("rel") ?? "alternate") == "alternate");
if (entry.Element(atom + "content") is null && !hasAlternate)
Console.Error.WriteLine("entry " + n + " has no content and no alternate link");
}
// Producing a correct timestamp: "o" on a UTC DateTime ends in Z.
Console.WriteLine(DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture));# RFC 4287 Appendix B carries a RELAX NG Compact schema for Atom. trang
# converts it to the XML syntax xmllint understands.
trang atom.rnc atom.rng
xmllint --noout --nonet --relaxng atom.rng feed.xml
# That schema is informative and cannot express every rule in the RFC. Author
# inheritance and id uniqueness are two of the rules it cannot state, so check
# them separately. Entries carrying no author of their own:
xmlstarlet sel -N a=http://www.w3.org/2005/Atom \
-t -v 'count(//a:entry[not(a:author) and not(a:source/a:author)])' -n feed.xml
# If that is not zero, the <feed> element itself needs an <author>.
# Duplicate entry ids. Any output at all is a bug:
xmlstarlet sel -N a=http://www.w3.org/2005/Atom \
-t -m '//a:entry/a:id' -v . -n feed.xml | sort | uniq -d
# Timestamps with a lowercase t or z, which RFC 4287 section 3.3 forbids:
grep -n -E '<(updated|published)>[^<]*[tz]' feed.xmlThe RELAX NG schema in RFC 4287 is worth running, but know its limits. It is informative rather than normative, and a grammar cannot express constraints that span elements: author inheritance, id uniqueness and the summary rule all have to be checked in code.
Common questions
What is the difference between RSS and Atom?
Atom is the later format and the stricter one. It is an IETF standard, RFC 4287, published in December 2005; RSS 2.0 was frozen in 2002 and is maintained as a specification document rather than a standard.
The differences that matter are about ambiguity. In RSS nobody can tell whether a <description> holds text or HTML; in Atom every text construct declares its type. RSS makes item identity optional and permits several date spellings; Atom requires a permanent id and one date format with a zone.
Why does the validator say my entry has no author when the feed has one?
It should not, and if it does the author element is probably not where you think. The rule implemented here is the RFC 4287 chain: the entry own <author>, or one inside its <atom:source>, or one on the <feed> element.
The usual cause of a surprise is namespace. An <author> that is not in http://www.w3.org/2005/Atom, a Dublin Core dc:creator for instance, is not an Atom author and does not satisfy the rule. The other is placement: an <author> inside the first <entry> covers that entry only.
Why is 2026-03-14 not a valid Atom date?
Because RFC 4287 section 3.3 requires a full RFC 3339 date-time, not a date. Atom timestamps need the time and a zone: 2026-03-14T09:30:00Z, or 2026-03-14T09:30:00+05:30 for an offset.
Two further details are explicit MUSTs. The separator must be an uppercase T, and with no numeric offset the zone must be an uppercase Z. A lowercase t or z parses fine in every date library and is still invalid, which is why it gets its own error message.
What should I use for the id?
Something globally unique that you will never need to change. The specification is emphatic: an id must not change when the entry is relocated or republished, because readers compare ids character by character to decide what is new.
The tag URI scheme, RFC 4151, was designed for this. tag:example.com,2026:post-4192 is unique, is not expected to resolve, and does not tie the entry to where it lives now. A page URL works but commits you: adding a trailing slash or moving to https changes the identity of every entry, and subscribers receive the archive again.
Is my feed sent anywhere when I validate it here?
No. The parser and every RFC 4287 rule are JavaScript running in this tab. There is no server component and no analytics with access to the editor. Open the network panel and validate something: the page assets load once and then nothing.
The feeds people paste into a validator are the ones not working yet: an unlaunched site, a feed whose links carry access tokens, a client feed under NDA. The W3C service is server-side, so anything checked there is transmitted. Fetch here goes from your browser straight to the host you named.
Can it check an Atom 0.3 feed?
No, and it says so clearly. Atom 0.3 used the namespace http://purl.org/atom/ns#, and a feed declaring it is reported as an error naming the version, with the 1.0 namespace you need instead.
Atom 0.3 was a pre-standard draft RFC 4287 replaced in 2005, and element names changed in between: 0.3 has <modified> and <issued> where 1.0 has <updated> and <published>, and <tagline> became <subtitle>. Changing only the namespace produces a document that is invalid in a new way, so the message says to review the element names too.