
Short answer: To serve an RSS feed from a Starlette application, add a GET route for /feed.xml, load the newest published rows in a worker thread so the event loop never waits for the database, build the document with xml.etree.ElementTree so every value is escaped, format dates in UTC with email.utils, and return the bytes as application/rss+xml. Hash the finished body into an ETag and answer a matching If-None-Match header with a 304, take the last build date from the newest item so the body stays identical between changes, test the route with curl and a validator, then give the public address to an auto-poster.
Starlette is the small, fast toolkit that sits underneath FastAPI, and many teams use it directly when they want routing, middleware and responses without anything else. It is a good fit for content services, internal tools and small APIs that grow a public face over time. Sooner or later such a service publishes something that people want to follow: articles, release notes, offers, events or listings. A feed is the standard way to let readers, aggregators and auto-posters watch that content without an account or an API key. Starlette has no feed support of its own, and it does not need any, because the Python standard library can write a valid RSS document in a few dozen lines. This guide builds a complete Starlette RSS feed with an asynchronous-friendly query, stable identifiers, correct dates and conditional requests, explains each decision and ends with the checks that keep the feed dependable once other systems rely on it.
A feed is worth adding whenever new items appear on a schedule you do not control: a changelog that grows with every release, a directory of listings, a news section, an events calendar or a library of guides. Readers who follow it in a feed reader get every item in order. Search engines and aggregators discover new pages faster. And an auto-poster such as PostRSS can share each new item on social networks the moment it is published, which turns a quiet publishing routine into a steady stream of visitors.
If your content already lives in a blog or a CMS, use the feed that system provides instead; see how to find your WordPress feed link. If you prefer a larger framework, the same ideas are covered in the FastAPI RSS feed guide. Build the route yourself when the content is in your own database and no ready-made feed exists.
Create a virtual environment and install Starlette with an ASGI server. The example uses SQLite from the standard library, so nothing else is needed.
python -m venv .venv
source .venv/bin/activate
pip install starlette uvicorn httpx pytestAssume a table named articles with the columns slug, title, summary, image_url, image_bytes, image_type and published_at. The last column holds a UTC unix timestamp, which keeps comparisons simple and avoids time zone surprises. Items scheduled for the future simply have a timestamp that has not arrived yet.
The program below defines the query, builds the XML once per request and sends it with a strong validator. The condition on the publication time matters: without it, an item scheduled for tomorrow would appear in the feed today, and an auto-poster would share it before it exists on the website.
import hashlib
import sqlite3
from datetime import datetime, timezone
from email.utils import format_datetime
from xml.etree import ElementTree as ET
from starlette.applications import Starlette
from starlette.concurrency import run_in_threadpool
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
SITE = "https://example.com"
DB_PATH = "site.db"
ATOM = "http://www.w3.org/2005/Atom"
ET.register_namespace("atom", ATOM)
def rfc822(timestamp: int) -> str:
moment = datetime.fromtimestamp(timestamp, tz=timezone.utc)
return format_datetime(moment, usegmt=True)
def load_items(limit: int = 20):
now = int(datetime.now(timezone.utc).timestamp())
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
return conn.execute(
"SELECT slug, title, summary, image_url, image_bytes, image_type, published_at "
"FROM articles WHERE published_at <= ? "
"ORDER BY published_at DESC LIMIT ?",
(now, limit),
).fetchall()
finally:
conn.close()
def build_feed(rows) -> bytes:
rss = ET.Element("rss", {"version": "2.0"})
channel = ET.SubElement(rss, "channel")
ET.SubElement(channel, "title").text = "Example Journal"
ET.SubElement(channel, "link").text = SITE + "/"
ET.SubElement(channel, "description").text = "News and guides from Example Journal."
ET.SubElement(channel, "language").text = "en"
newest = rows[0]["published_at"] if rows else 0
ET.SubElement(channel, "lastBuildDate").text = rfc822(newest)
ET.SubElement(channel, f"{{{ATOM}}}link", {
"href": SITE + "/feed.xml",
"rel": "self",
"type": "application/rss+xml",
})
for row in rows:
url = f"{SITE}/articles/{row['slug']}/"
item = ET.SubElement(channel, "item")
ET.SubElement(item, "title").text = row["title"]
ET.SubElement(item, "link").text = url
ET.SubElement(item, "guid", {"isPermaLink": "true"}).text = url
ET.SubElement(item, "pubDate").text = rfc822(row["published_at"])
ET.SubElement(item, "description").text = row["summary"]
if row["image_url"]:
ET.SubElement(item, "enclosure", {
"url": row["image_url"],
"length": str(row["image_bytes"] or 0),
"type": row["image_type"] or "image/jpeg",
})
return ET.tostring(rss, encoding="utf-8", xml_declaration=True)
def etag_matches(header: str | None, etag: str) -> bool:
if not header:
return False
for token in header.split(","):
token = token.strip().removeprefix("W/")
if token == "*" or token == etag:
return True
return False
async def feed(request: Request) -> Response:
rows = await run_in_threadpool(load_items)
body = build_feed(rows)
etag = '"' + hashlib.sha256(body).hexdigest()[:32] + '"'
headers = {"ETag": etag, "Cache-Control": "public, max-age=300"}
if etag_matches(request.headers.get("if-none-match"), etag):
return Response(status_code=304, headers=headers)
return Response(
body,
media_type="application/rss+xml; charset=utf-8",
headers=headers,
)
app = Starlette(routes=[Route("/feed.xml", feed, methods=["GET"])])Save it as app.py and start it with uvicorn app:app --port 8000. The route accepts GET, and Starlette adds HEAD automatically for routes that allow GET, which is what some validators and monitoring tools use first.
Starlette handlers are coroutines that share one event loop. A synchronous database call inside such a handler blocks the loop for as long as the query takes, and every other request waits behind it. The standard sqlite3 driver is synchronous, so the example hands the work to run_in_threadpool, which runs it on a worker thread and returns control to the loop in the meantime. If you use an asynchronous driver, such as one for PostgreSQL, await it directly and drop the helper. Either way, keep the query small: twenty rows, one table, an index on the publication column.
Auto-posters, readers and search engines request a feed often, and almost always nothing has changed. A validator lets them ask a cheap question: has the document changed since the version I hold? The handler hashes the finished bytes, quotes the hash as an entity tag and compares it with the If-None-Match header. A match produces a 304 response with no body, which saves bandwidth for both sides and keeps your server calm.
The hash is computed from the body, which brings one rule: the body must not change unless the content changes. That is why the example takes the last build date from the newest item and not from the clock. If you wrote the current time into that element, the hash would change on every request, no client would ever receive a 304 and the validator would be useless. The same rule applies to any value generated at request time, such as a random identifier or a timestamp in a comment. Note that a compressing proxy may turn a strong tag into a weak one, which is why the comparison above strips the W/ prefix before it compares.
| Feed element | Source | Rule |
|---|---|---|
| title | Article title | Plain text, escaped by the library |
| link | Public address of the page | Absolute, with https, returning 200 directly |
| guid | Same address, or a stored identifier | Never changes after publication |
| pubDate | Publication timestamp | RFC 822 in UTC, never the current time |
| description | Short summary | Plain text or escaped markup, one or two sentences |
| enclosure | Featured image | Address, size in bytes and media type |
The identifier is the most important decision. An auto-poster remembers which items it has already shared by their GUID. If the value changes when someone edits a slug or republishes an article, the same story is shared again. If you may rename slugs, store a separate permanent identifier in the database and write it in the guid element with isPermaLink set to false.
RSS dates follow RFC 822, with English abbreviations for days and months. The format_datetime function from the email package produces exactly that, and the usegmt argument writes the zone as GMT, which every parser understands. The timestamp comes from the database as an integer, is converted to an aware UTC datetime and is never formatted with the local zone of the server. Mixing in local time is the most common cause of feeds whose items appear hours late or early in readers.
The Cache-Control header in the example lets shared caches keep the response for five minutes, and the ETag lets everyone revalidate cheaply after that. If you place a content delivery network in front of the application, keep the lifetime short so a new item does not wait long for its turn. A feed that lags behind the website by an hour defeats the purpose of automation. For the background, read how auto-posting tools use ETags and conditional requests और what 301, 304, 404 and 500 mean for auto-posting.
A few minutes of testing prevents days of confusion later. Start with an automated test that proves the conditional logic, using the test client that ships with Starlette.
from starlette.testclient import TestClient
from app import app
def test_feed_supports_conditional_requests():
client = TestClient(app)
first = client.get("/feed.xml")
assert first.status_code == 200
assert first.headers["content-type"].startswith("application/rss+xml")
etag = first.headers["etag"]
second = client.get("/feed.xml", headers={"If-None-Match": etag})
assert second.status_code == 304
assert second.content == b""Then check the running service from the outside:
curl -i https://example.com/feed.xml and confirm a 200 status, the right content type and an ETag header.-H 'If-None-Match: ...' and the tag you received, and confirm that the answer is 304.There is a longer checklist in the feed validation guide and a short curl routine in the guide to debugging a feed with curl.
Run the application with several worker processes behind a reverse proxy that terminates HTTPS, and make sure the proxy passes the real host and scheme so that absolute links are correct. Keep the site address in configuration instead of a constant, so a staging copy never publishes production links. Add a basic health route that does not touch the database, and monitor the feed route separately, because a feed can fail while the home page still works. If the route sits behind a login or a firewall rule for unknown clients, an auto-poster will not reach it; the feed must be public.
PostRSS has been publishing RSS and Atom feeds to social networks since 2014. Paste the address of your feed route, connect your accounts and choose how each post is built from the title, the description or the page title. PostRSS checks the feed as often as every minute and shares each new item, with its image and link, to the channels you choose among 66 supported networks, messengers, team chats and blogs, including LinkedIn, X, Bluesky, Mastodon and Telegram. Keyword filters, UTM parameters and posting windows let you tune each channel, and webhooks connect PostRSS to your own systems. See the PostRSS features page and the plan limits on the pricing page. More developer guides are under the Technical Guide tag.
Starlette needs only a route, a query that does not block the loop and the standard XML library to serve a dependable RSS feed: select published items dated in the past, let the library escape every value, use a stable GUID, format dates in UTC, derive the last build date from the newest item and send the result with the right content type. Add a hash-based ETag so repeat requests end in a 304. Test the route with the test client, curl and a validator, keep the address unchanged and connect it to an auto-poster. After that, every item you publish can reach your social channels automatically.
No. Starlette has no feed support, but the xml.etree.ElementTree module in the Python standard library produces a valid RSS document from a few elements, and the route returns it with the right content type.
The handler is a coroutine, but a synchronous database call inside it must run in a worker thread, for example with run_in_threadpool, or the event loop blocks. With an asynchronous driver you can await the query directly.
Most often the body changes between requests, for example because the last build date is the current time. Take that date from the newest item and the hash stays the same until the content changes.
Between ten and twenty of the newest published items is enough. An auto-poster only needs recent items, and a smaller file is faster to download and parse.
Yes. PostRSS reads any valid RSS or Atom address that is reachable without a login. Paste the address, connect your channels and new items are shared as soon as they appear.
What changed in the networks, what broke, and how to fix it before it costs you reach.
PostRSS बनाने वाली टीम Internet Solutions की ओर से। हर प्रोडक्ट आपका समय अपने तरीके से बचाता है।