Skip to content
gohacks.dev
Web

Generate an RSS Feed From a Go Application Correctly

Generate a valid RSS 2.0 feed in Go with encoding/xml: required fields, stable GUIDs, RFC 1123 dates, absolute URLs, escaping and conditional GET.

10 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

An RSS feed is the oldest piece of the open web that still works as designed. A reader subscribes once and your new posts arrive without anyone visiting your site, without an algorithm in between and without you owning the audience's inbox. Generating one in Go takes an afternoon, and most of the afternoon goes on details that make the difference between a feed that every reader accepts and one that works in the reader you tried and fails in three others: date formats, identifiers, escaping and relative URLs.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds an RSS 2.0 generator with encoding/xml, feeds it deliberately awkward content, and serves the result with conditional requests. It continues the Markdown blog project, whose posts supply the entries, and sits beside the sitemap generator. It belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with the standard library only.

What a feed must contain

RSS 2.0 is a small specification. A document has an <rss version="2.0"> root and one <channel>. The channel must have a title, a link to the site and a description. Everything else is optional, and the extras that matter in practice are language, lastBuildDate, and an atom:link pointing at the feed's own URL, which several readers and validators expect. Each <item> needs at least a title or a description; in practice give every item a title, a link, a description, a publication date and a guid. The RSS 2.0 specification is short enough to read in one sitting, and the RSS feed validator tool on this site checks a pasted feed against the usual mistakes.

Three of those fields carry the most risk. The guid is how a reader recognises an item it has already shown. If it changes, every subscriber sees every post again as new. Use the permanent URL of the post, mark it isPermaLink="true", and never derive it from the title or the date, both of which editors change. The pubDate must be an RFC 822 date, which in Go means time.RFC1123Z (Mon, 02 Jan 2006 15:04:05 -0700) with a numeric zone: the abbreviations that time.RFC1123 produces for zones such as CET are not understood by every parser. The link must be absolute. A reader displays the item outside your site, and /posts/first resolves to nothing there.

The generator

feed.goGo
package main

import (
	"bytes"
	"encoding/xml"
	"sort"
	"time"
)

type RSS struct {
	XMLName xml.Name `xml:"rss"`
	Version string   `xml:"version,attr"`
	Atom    string   `xml:"xmlns:atom,attr"`
	Content string   `xml:"xmlns:content,attr"`
	Channel Channel  `xml:"channel"`
}

type Channel struct {
	Title         string   `xml:"title"`
	Link          string   `xml:"link"`
	Description   string   `xml:"description"`
	Language      string   `xml:"language,omitempty"`
	LastBuildDate string   `xml:"lastBuildDate"`
	AtomLink      AtomLink `xml:"atom:link"`
	Items         []Item   `xml:"item"`
}

type AtomLink struct {
	Href string `xml:"href,attr"`
	Rel  string `xml:"rel,attr"`
	Type string `xml:"type,attr"`
}

type GUID struct {
	IsPermaLink bool   `xml:"isPermaLink,attr"`
	Value       string `xml:",chardata"`
}

type Item struct {
	Title       string   `xml:"title"`
	Link        string   `xml:"link"`
	GUID        GUID     `xml:"guid"`
	PubDate     string   `xml:"pubDate"`
	Description string   `xml:"description"`
	Encoded     string   `xml:"content:encoded,omitempty"`
	Categories  []string `xml:"category"`
}

type Entry struct {
	Slug      string
	Title     string
	Summary   string
	HTML      string
	Published time.Time
	Tags      []string
}

type Site struct {
	Title, Description, BaseURL, Language string
}

// BuildFeed turns entries into RSS 2.0 XML. Entries are sorted newest first and capped at limit.
func BuildFeed(site Site, entries []Entry, limit int) ([]byte, time.Time, error) {
	sorted := append([]Entry(nil), entries...)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].Published.After(sorted[j].Published) })
	if len(sorted) > limit {
		sorted = sorted[:limit]
	}
	var last time.Time
	if len(sorted) > 0 {
		last = sorted[0].Published
	}
	feed := RSS{
		Version: "2.0",
		Atom:    "http://www.w3.org/2005/Atom",
		Content: "http://purl.org/rss/1.0/modules/content/",
		Channel: Channel{
			Title: site.Title, Link: site.BaseURL + "/", Description: site.Description, Language: site.Language,
			LastBuildDate: last.UTC().Format(time.RFC1123Z),
			AtomLink:      AtomLink{Href: site.BaseURL + "/feed.xml", Rel: "self", Type: "application/rss+xml"},
		},
	}
	for _, e := range sorted {
		url := site.BaseURL + "/posts/" + e.Slug
		feed.Channel.Items = append(feed.Channel.Items, Item{
			Title: e.Title, Link: url, GUID: GUID{IsPermaLink: true, Value: url},
			PubDate: e.Published.UTC().Format(time.RFC1123Z), Description: e.Summary, Encoded: e.HTML, Categories: e.Tags,
		})
	}
	var buf bytes.Buffer
	buf.WriteString(xml.Header)
	enc := xml.NewEncoder(&buf)
	enc.Indent("", "  ")
	if err := enc.Encode(feed); err != nil {
		return nil, time.Time{}, err
	}
	return buf.Bytes(), last, nil
}

The structs mirror the XML, and the tags do the work. xml:"version,attr" becomes an attribute, xml:",chardata" is the element's text, and omitempty leaves out language and content:encoded when unset. The atom:link and content:encoded names are written with their prefixes literally and the namespaces declared as attributes on the root, which is the pragmatic way to get RSS extensions out of encoding/xml: it does not manage prefixes for you, so declare each one yourself and spell the names exactly. The conversion to UTC before formatting is deliberate. An author in a different zone would otherwise push the date into the next day for readers in another zone, and a feed that sorts by pubDate reads timestamps across zones consistently only if you normalise them.

BuildFeed sorts entries newest first and caps the list. Twenty items is a reasonable limit: readers fetch the whole document on every poll, and a feed with every post since 2012 is a large file nobody needs. The lastBuildDate is the newest item's date, not the time of generation. If it were time.Now(), the feed would look changed on every request and defeat conditional caching.

Feeding it awkward content

The program builds a feed from three entries, one of which has a title full of XML syntax and a control character, prints it, parses it back and then serves it.

main.goGo
package main

import (
	"bytes"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

func main() {
	site := Site{Title: "Notes & Queries", Description: "Go, written down", BaseURL: "https://notes.example", Language: "en"}
	entries := []Entry{
		{Slug: "first", Title: "First post", Summary: "Hello.", HTML: "<p>Hello, <b>world</b>.</p>", Published: time.Date(2026, 1, 10, 9, 0, 0, 0, time.UTC), Tags: []string{"go"}},
		{Slug: "tricky", Title: "Tags <b>& \"quotes\" and a control\x08character", Summary: "R&D < 5 > 3", HTML: "<pre>a ]]> b</pre>", Published: time.Date(2026, 2, 14, 18, 30, 0, 0, time.UTC), Tags: []string{"xml", "edge cases"}},
		{Slug: "latest", Title: "Latest, with a non-UTC author", Summary: "Fresh.", HTML: "<p>New.</p>", Published: time.Date(2026, 3, 1, 8, 0, 0, 0, time.FixedZone("CET", 3600)), Tags: nil},
	}
	body, last, err := BuildFeed(site, entries, 20)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(body))

	// The output must parse, and the fields a reader depends on must survive the round trip.
	var back RSS
	if err := xml.NewDecoder(bytes.NewReader(body)).Decode(&back); err != nil {
		panic(err)
	}
	fmt.Println("parsed back:", len(back.Channel.Items), "items, newest first:", back.Channel.Items[0].Title)
	guids := map[string]bool{}
	for _, it := range back.Channel.Items {
		if _, err := time.Parse(time.RFC1123Z, it.PubDate); err != nil {
			fmt.Println("bad date:", it.PubDate)
		}
		if !strings.HasPrefix(it.Link, "https://") || guids[it.GUID.Value] {
			fmt.Println("relative link or duplicate guid:", it.Link)
		}
		guids[it.GUID.Value] = true
	}
	fmt.Println("title with markup survives the round trip:", back.Channel.Items[1].Title)

	// Serving it: conditional requests answer 304.
	h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
		w.Header().Set("Cache-Control", "public, max-age=600")
		http.ServeContent(w, r, "feed.xml", last, bytes.NewReader(body))
	})
	srv := httptest.NewServer(h)
	defer srv.Close()
	res, _ := http.Get(srv.URL)
	io.Copy(io.Discard, res.Body)
	res.Body.Close()
	fmt.Println("GET:", res.StatusCode, res.Header.Get("Content-Type"), "| Last-Modified:", res.Header.Get("Last-Modified"))
	req, _ := http.NewRequest("GET", srv.URL, nil)
	req.Header.Set("If-Modified-Since", res.Header.Get("Last-Modified"))
	res2, _ := http.DefaultClient.Do(req)
	res2.Body.Close()
	fmt.Println("revalidate with If-Modified-Since:", res2.StatusCode)
}
Text
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Notes &amp; Queries</title>
    <link>https://notes.example/</link>
    <description>Go, written down</description>
    <language>en</language>
    <lastBuildDate>Sun, 01 Mar 2026 07:00:00 +0000</lastBuildDate>
    <atom:link href="https://notes.example/feed.xml" rel="self" type="application/rss+xml"></atom:link>
    <item>
      <title>Latest, with a non-UTC author</title>
      <link>https://notes.example/posts/latest</link>
      <guid isPermaLink="true">https://notes.example/posts/latest</guid>
      <pubDate>Sun, 01 Mar 2026 07:00:00 +0000</pubDate>
      <description>Fresh.</description>
      <content:encoded>&lt;p&gt;New.&lt;/p&gt;</content:encoded>
    </item>
    <item>
      <title>Tags &lt;b&gt;&amp; &#34;quotes&#34; and a control�character</title>
      <link>https://notes.example/posts/tricky</link>
      <guid isPermaLink="true">https://notes.example/posts/tricky</guid>
      <pubDate>Sat, 14 Feb 2026 18:30:00 +0000</pubDate>
      <description>R&amp;D &lt; 5 &gt; 3</description>
      <content:encoded>&lt;pre&gt;a ]]&gt; b&lt;/pre&gt;</content:encoded>
      <category>xml</category>
      <category>edge cases</category>
    </item>
    <item>
      <title>First post</title>
      <link>https://notes.example/posts/first</link>
      <guid isPermaLink="true">https://notes.example/posts/first</guid>
      <pubDate>Sat, 10 Jan 2026 09:00:00 +0000</pubDate>
      <description>Hello.</description>
      <content:encoded>&lt;p&gt;Hello, &lt;b&gt;world&lt;/b&gt;.&lt;/p&gt;</content:encoded>
      <category>go</category>
    </item>
  </channel>
</rss>
parsed back: 3 items, newest first: Latest, with a non-UTC author
title with markup survives the round trip: Tags <b>& "quotes" and a control�character
GET: 200 application/rss+xml; charset=utf-8 | Last-Modified: Sun, 01 Mar 2026 07:00:00 GMT
revalidate with If-Modified-Since: 304

Read the XML. The site title Notes & Queries came out as Notes &amp; Queries, and the <b>, quotes and ampersand in the second post's title were escaped, which is encoding/xml doing its job: you never concatenate XML strings by hand. The round trip proves it, since the decoded title is the original text with its markup intact.

The control character is the surprise. The backspace control character (code point 8) in the title is not allowed in XML 1.0 at all, and the encoder replaced it with the Unicode replacement character, which shows up as a visible question mark in a reader. The feed is still valid, but the title is damaged. The fix belongs where the text enters your system (strip control characters from titles when a post is saved or loaded), not in the feed generator, since the same title is also going into your HTML and your search index. Likewise a ]]> b in the content is harmless here because the HTML is entity-escaped. If you prefer CDATA sections for content:encoded, add the ,cdata option to the field tag; the encoder splits an embedded ]]> correctly, but readers handle escaped and CDATA content equally well, and the escaped form has no sequences to worry about.

The third post was dated 08:00 in a UTC+1 zone and appears as 07:00 +0000, and it sorts first. The last two lines show the serving side: http.ServeContent set Last-Modified from the newest post, and a request carrying that value back got a 304.

Serving the feed well

Feed readers poll, some every few minutes, and a popular feed gets thousands of requests a day from the same clients asking the same question. Answer them cheaply. Generate the XML once per change (at startup, or when content reloads) and keep the bytes; serve them with http.ServeContent and the newest post's date as the modification time, as above, so If-Modified-Since costs a header exchange and no body. An ETag works too, as described in ETags and conditional requests. Set Content-Type: application/rss+xml; charset=utf-8 (some people serve application/xml or text/xml, which also work, but the specific type lets browsers and extensions recognise a feed), and a modest Cache-Control: public, max-age=600. Compress it with gzip like any text response.

To make the feed discoverable, put an autodiscovery tag in the head of every page:

Text
<link rel="alternate" type="application/rss+xml" title="Notes" href="/feed.xml">

Without it, a reader app cannot find the feed from your homepage URL, and people who do not know the feed exists will never subscribe. Keep the URL stable: it is the one address that every subscriber has saved.

Content: summary or full text

Whether to put the whole post in the feed is an editorial decision with technical consequences. A summary in <description> and the full HTML in content:encoded serves both kinds of reader. Full text keeps subscribers inside their reader, which they like and which costs you page views; a summary sends them to the site, which suits ad-funded or analytics-driven sites. Whichever you choose, make the HTML self-contained: absolute URLs for links and images, no scripts, and no reliance on your stylesheet. Check the output with the sanitizing rules of rendering Markdown safely, since the feed is another place your HTML ends up on someone else's screen.

Atom, JSON Feed and validating

Atom (RFC 4287) is the stricter sibling: every entry has a required id and updated, dates use RFC 3339, and the format has a proper standard behind it. Readers support both. If you generate only one, RSS 2.0 is the safe choice for reach; if you want the cleaner format and your audience is technical, Atom is equally supported. JSON Feed is a third option with growing but narrower support. Whichever you ship, validate it: paste it into the validator linked above, open it in two or three readers, and add the parse-back check from the program as a unit test so a refactor of the structs cannot silently break the document. The encoding rules the program relies on are in the encoding/xml documentation.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.