Elfeed: Emacs RSS Reader

Table of Contents

1. Overview

Elfeed is a feed reader for Emacs. It fetches RSS/Atom feeds, stores entries in an in-memory database backed by disk, and provides a search-first interface inspired by notmuch.

Key properties:

  • Pure Emacs Lisp implementation
  • SQLite-like on-disk database format (custom, not SQLite)
  • Incremental search with tag filters
  • Extensible via hooks and advice

The entry point is M-x elfeed. The database lives in ~/.elfeed/.

2. Database Architecture

Elfeed stores feed state in three files:

File Purpose
~/.elfeed/index Entry metadata (GUID, timestamp, tags)
~/.elfeed/data/XX/YY/GUID Entry content (title, author, body HTML)
~/.elfeed/db.el Feed URLs and metadata (read-only after init)

The index is a hash table serialized with print=/=read. Entry content is stored in a two-level directory tree keyed by GUID hash prefix.

2.1. Inspecting the Database

;; Load elfeed database without starting the UI
(require 'elfeed-db)
(elfeed-db-load)

;; Count total entries
(hash-table-count elfeed-db-entries)

The database is lazy-loaded. Reading an entry from disk occurs on first access:

;; Fetch an entry by GUID (triggers disk read if not in memory)
(let ((entry (elfeed-db-get-entry "https://example.com/post-123")))
  (when entry
    (list :title (elfeed-entry-title entry)
          :date (elfeed-entry-date entry)
          :tags (elfeed-entry-tags entry))))

3. Feed Configuration

Feeds are registered in elfeed-feeds, a list of URLs or (url . tags) pairs.

3.1. Basic Configuration

(setq elfeed-feeds
      '("https://nullprogram.com/feed/"
        "http://planet.emacsen.org/atom.xml"
        ("https://openai.com/blog/rss.xml" ai research)))

The third entry assigns tags ai and research to all entries from that feed.

3.2. Hierarchical Organization with elfeed-org

The elfeed-org package allows feed lists in org-mode syntax with nested tags:

* Blogs                                                              :elfeed:
** entry-title: \(linux\|linus\|ubuntu\|kde\|gnome\)                  :linux:
** http://git-annex.branchable.com/design/assistant/blog/index.rss :mustread:
** Software Development                                                 :dev:
*** Emacs                                                    :emacs:mustread:
**** http://www.terminally-incoherent.com/blog/feed
**** http://nullprogram.com/feed
**** http://planet.emacsen.org/atom.xml
*** AI & Machine Learning                                               :ai:
**** https://openai.com/blog/rss.xml
**** https://distill.pub/rss.xml                                   :mustread:

Tags propagate down the tree. The nullprogram.com feed inherits elfeed, dev, emacs, mustread.

Configuration:

(require 'elfeed-org)
(elfeed-org)
(setq rmh-elfeed-org-files '("~/.emacs.d/elfeed.org"))

3.3. Title Filtering

The entry-title: pattern applies tags based on regex match against entry titles:

** entry-title: \(emacs\|org-mode\)                              :emacs:

Any entry with "emacs" or "org-mode" in the title receives the emacs tag, regardless of source feed.

4. Search Interface

The elfeed buffer is a filtered view of the database. The search bar accepts space-separated tokens.

4.1. Search Syntax

Query Matches
@6-months-ago Entries from last 6 months
+unread Entries tagged unread
-junk Entries NOT tagged junk
emacs lisp Title/content contains both words (case-insensitive)
@1-week-ago +unread emacs Unread Emacs entries from last week

Default filter is @6-months-ago +unread.

4.2. Programmatic Search

;; Find all entries tagged 'ai' from the last month
(elfeed-search-set-filter "@1-month-ago +ai")

The filter is a string parsed by elfeed-search-parse-filter.

5. Entry Actions

Key Action
RET Open entry (eww or external browser)
b Open in browser
y Copy URL to clipboard
r Mark as read (remove unread tag)
u Mark as unread (add unread tag)
+ Add tag
- Remove tag
g Refresh all feeds
G Fetch feed updates (background)

5.1. Custom Actions

Add custom keybindings via elfeed-show-mode-map:

(defun my/elfeed-pocket-save ()
  "Save current entry to Pocket."
  (interactive)
  (let ((url (elfeed-entry-link elfeed-show-entry)))
    (shell-command (format "pocket-cli add %s" (shell-quote-argument url)))
    (message "Saved to Pocket: %s" url)))

(define-key elfeed-show-mode-map (kbd "P") 'my/elfeed-pocket-save)

6. Fetch Strategy

Elfeed fetches feeds in parallel. Default concurrency is 8.

(setq elfeed-curl-max-connections 8)

Fetch interval is manual by default. To auto-update every 10 minutes:

(run-at-time nil (* 10 60) 'elfeed-update)

6.1. Fetch Hooks

Hook elfeed-new-entry-hook runs for each new entry:

(add-hook 'elfeed-new-entry-hook
          (lambda (entry)
            (when (string-match-p "breaking" (elfeed-entry-title entry))
              (message "ALERT: %s" (elfeed-entry-title entry)))))

7. Performance Characteristics

Database size grows linearly with entry count. On a corpus of 4,283 entries:

Metric Value
Disk usage 47 MB
Load time (cold) 1.2 seconds
Load time (warm) 0.3 seconds
Search latency < 50ms

Entry content is HTML. Viewing an entry in elfeed-show mode renders it via shr (Simple HTML Renderer).

7.1. Pruning Old Entries

Limit database growth by removing entries older than N days:

(defun my/elfeed-prune-old-entries ()
  "Remove entries older than 90 days."
  (interactive)
  (let* ((cutoff-time (time-subtract (current-time) (days-to-time 90)))
         (entries (elfeed-db-get-all-entries))
         (count 0))
    (dolist (entry entries)
      (when (time-less-p (elfeed-entry-date entry) cutoff-time)
        (elfeed-db-remove-entry entry)
        (setq count (1+ count))))
    (elfeed-db-save)
    (message "Removed %d old entries" count)))

Run monthly via cron or run-at-time.

8. Integration Patterns

8.1. Org-mode Capture

Capture interesting articles to org-mode:

(defun my/elfeed-org-capture ()
  "Capture current entry to org-mode."
  (interactive)
  (let* ((entry elfeed-show-entry)
         (title (elfeed-entry-title entry))
         (url (elfeed-entry-link entry))
         (date (format-time-string "%Y-%m-%d" (elfeed-entry-date entry))))
    (org-capture nil "a") ; assuming template key 'a'
    (insert (format "* [[%s][%s]]\n:PROPERTIES:\n:CREATED: %s\n:END:\n\n"
                    url title date))))

(define-key elfeed-show-mode-map (kbd "c") 'my/elfeed-org-capture)

8.2. Scoring and Ranking

Tag entries by predicted interest:

(defun my/elfeed-score-entry (entry)
  "Assign a score tag based on content."
  (let ((title (elfeed-entry-title entry))
        (tags (elfeed-entry-tags entry)))
    (cond
     ((and (member 'ai tags) (string-match-p "llm\\|transformer" title))
      (elfeed-tag entry 'high-priority))
     ((string-match-p "javascript" title)
      (elfeed-tag entry 'low-priority))
     (t nil))))

(add-hook 'elfeed-new-entry-hook 'my/elfeed-score-entry)

Search for high-priority items: +high-priority +unread.

8.3. Export to JSON

Extract feed data for external tools:

(defun my/elfeed-export-json (file)
  "Export all unread entries to JSON."
  (interactive "FExport to: ")
  (require 'json)
  (let* ((entries (elfeed-search-parse-filter "+unread"))
         (data (mapcar (lambda (e)
                         `((title . ,(elfeed-entry-title e))
                           (url . ,(elfeed-entry-link e))
                           (date . ,(format-time-string "%Y-%m-%dT%H:%M:%S"
                                                        (elfeed-entry-date e)))
                           (tags . ,(mapcar 'symbol-name (elfeed-entry-tags e)))))
                       entries)))
    (with-temp-file file
      (insert (json-encode data)))
    (message "Exported %d entries to %s" (length entries) file)))

9. Comparison with Alternatives

Feature Elfeed Newsboat Feedly
Platform Emacs Terminal Web
Database Custom disk format SQLite Cloud
Search Tag-based, regex SQL-like AI-powered
Offline Yes Yes No
Extensibility Elisp hooks Shell scripts Browser extensions

Elfeed fits the Emacs philosophy: live in one environment, extend via Lisp.

10. Troubleshooting

10.1. Feed Not Updating

Check elfeed-log buffer for fetch errors:

(switch-to-buffer "*elfeed-log*")

Common causes:

  • 403/404 errors (feed moved or removed)
  • SSL certificate issues (elfeed-curl-extra-arguments can disable verification)
  • Timeout (increase url-queue-timeout)

10.2. Database Corruption

If the index file is corrupted, rebuild from content:

# Backup current database
cp -r ~/.elfeed ~/.elfeed.backup

# Remove index
rm ~/.elfeed/index

# Restart Emacs and run elfeed
# Index will rebuild from data/ directory

Rebuilding is slow (minutes for thousands of entries) but preserves all content.

11. Source Code Architecture

Elfeed is distributed as three packages:

Package Lines of Code Purpose
elfeed 2,100 Core database and UI
elfeed-search 500 Search buffer and filters
elfeed-show 400 Entry display

The database module (elfeed-db.el) is independent of UI. Command-line tools can load it directly.

11.1. Custom Feed Parser

Override default parsers for non-standard feeds:

(defun my/custom-feed-parser (xml)
  "Parse a custom XML format."
  ;; Return list of entries in elfeed format
  (list (elfeed-entry--create
         :title "Example Title"
         :link "https://example.com"
         :date (current-time)
         :content "<p>Content</p>")))

(add-to-list 'elfeed-feed-functions
             '("https://custom-feed.example.com/rss" . my/custom-feed-parser))

12. Configuration Reference

Complete minimal configuration:

(use-package elfeed
  :ensure t
  :config
  (setq elfeed-db-directory "~/.elfeed"
        elfeed-curl-max-connections 8
        elfeed-search-filter "@6-months-ago +unread"
        elfeed-feeds
        '(("https://nullprogram.com/feed/" emacs)
          ("https://openai.com/blog/rss.xml" ai)
          ("http://planet.emacsen.org/atom.xml" emacs community))))

(use-package elfeed-org
  :ensure t
  :after elfeed
  :config
  (elfeed-org)
  (setq rmh-elfeed-org-files '("~/.emacs.d/elfeed.org")))

;; Keybinding
(global-set-key (kbd "C-x w") 'elfeed)

;; Auto-update every 30 minutes
(run-at-time nil (* 30 60) 'elfeed-update)

13. Further Reading