How to Scrape Reddit Posts, Subreddits and Profiles

by Mazen Ramadan Aug 21, 2026 26 min read
Summarize this article with
How to Scrape Reddit Posts, Subreddits and Profiles How to Scrape Reddit Posts, Subreddits and Profiles

A plain HTTP request to www.reddit.com returns a JavaScript challenge interstitial that never resolves into real content without executing that script. The same URL routed through our scrapfly-scrapers Reddit implementation with render_js=True returned the actual rendered page with parsable HTML.

That gap is why this guide is built around one maintained scraper instead of a hand rolled httpx client.

Key Takeaways

  • Direct HTTP requests to www.reddit.com returned a 200 JavaScript challenge page, not real content, while the same URL routed through Scrapfly with render_js=True returned parsable HTML.
  • The canonical scraper targets www.reddit.com for subreddit and post metadata and old.reddit.com for comments and profile pages, both through the same four BASE_CONFIG settings: asp=True, render_js=True, country="US", proxy_pool="public_residential_pool".
  • All four output families, subreddit, post, profile posts, and profile comments, passed the repository's live test suite on a re-run on August 18, 2026.
  • HTTP 200 alone doesn't confirm a working scraper. The canonical tests also assert on shreddit-post selectors, pagination cursors, and Cerberus schema validation before a page counts as successfully scraped.
Get web scraping tips in your inboxTrusted by 100K+ developers and 30K+ enterprises. Unsubscribe anytime.

Does Reddit Scraping Still Work in 2026?

Yes, but which route you use decides whether it works. A direct HTTP request returned a 200 status but with a JavaScript challenge page instead of real content, while the same URL routed through the Scrapfly configuration with render_js=True returned the actual rendered page.

If Reddit's official Data API or PRAW as a Python wrapper around it already fits your use case and rate limits both remain valid and this guide doesn't replace them. For everything else: subreddit listings, post, comment threads and profile activity across many pages this guide follows the maintained scrapfly-scrapers implementation instead.

Which Reddit Scraping Method Does This Guide Use?

Every example below is pulled directly from scrapfly-scrapers repository not rewritten or paraphrased, so there's no second parallel tutorial to keep in sync as the source changes. A plain How to Web Scrape with HTTPX and Python request appears later only as a diagnostic to show what a direct request actually returns not as an alternative scraper.

One more constraint worth flagging before writing any code. Reddit announced that unauthenticated access to the .json suffix on Reddit URLs will be shut down (source). The scraper this guide follows doesn't depend on that suffix, so the announcement doesn't affect any of the code below.

Which Reddit Data Can You Collect From Public Pages?

The canonical scraper covers four output families, subreddit info and posts, post metadata and comments, profile posts and profile comments, all scoped to what a logged out browser can already see.

In scope Out of scope
Subreddit info and post listings Private messages
Post metadata and comment threads Private or restricted communities
Profile submitted posts Login-only data
Profile comments Deleted content reconstruction
Exhaustive historical archives

The text heavy families, posts and comments are also common inputs further down the pipeline for work like sentiment analysis.

With the scope defined, the next step is setting up the environment the canonical scraper actually runs on.

How Do You Set Up the Canonical Python Reddit Scraper?

The implementation source of truth for this guide is the Reddit scraper repository that lives in scrapfly-scrapers/reddit-scraper

  1. Set your Scrapfly API key:
    shell
    $ export SCRAPFLY_KEY="your key from https://scrapfly.io/dashboard"
  2. Clone the repository, install, and run the example scraper:
    shell
    $ git clone https://github.com/scrapfly/scrapfly-scrapers.git
    $ cd scrapfly-scrapers/reddit-scraper
    $ poetry install
    $ poetry run python run.py
  3. Install the dev dependencies and run a targeted test:
    shell
    $ poetry install --with dev
    $ poetry run pytest test.py -k test_subreddit_scraping

Install Python and scrapfly-sdk for the Reddit Scraper

Every request the canonical scraper makes shares the same four BASE_CONFIG settings:

python
BASE_CONFIG = {
    # enable the anti scraping protection
    "asp": True,
    # set the proxy country to US
    "country": "US",
    # bypassing reddit requires enabling JavaScript and using the residential proxy pool
    "render_js": True,
    "proxy_pool": "public_residential_pool"
}

The client setup the rest of this article reuses:

python
import os
from typing import Dict, List, Union
from datetime import datetime
from loguru import logger as log
from scrapfly import ScrapeConfig, ScrapflyClient, ScrapeApiResponse

SCRAPFLY = ScrapflyClient(key=os.environ["SCRAPFLY_KEY"])

Run the scrapfly-scrapers Reddit Tests Before Using the Output

The repository ships four tests in test.py, one per output family:

  • test_subreddit_scraping
  • test_post_scraping
  • test_user_post_scraping
  • test_user_comment_scraping

Each test scrapes live Reddit pages, validates the schema, and asserts a minimum item count. That combination not a bare 200 is the publication gate. A pass today isn't proof against drift since Reddit's markup can change without notice.

Run every test, each with 3 reruns and a 30 second delay on failure

shell
$ poetry run pytest test.py

Or target one output family at a time

shell
$ poetry run pytest test.py -k test_subreddit_scraping
$ poetry run pytest test.py -k test_post_scraping
$ poetry run pytest test.py -k test_user_post_scraping
$ poetry run pytest test.py -k test_user_comment_scraping

With the environment installed and the current test baseline in hand, the next section scrapes the first output family, subreddits.

How Do You Scrape Reddit Subreddits and Paginate Posts?

Scraping a subreddit returns two things, a subreddit info object and a list of its posts. The canonical parse_subreddit() and scrape_subreddit() functions handle both, and test_subreddit_scraping passed against them most recently on August 18, 2026.

The info object carries description, members, rank, bookmarks, and url. Each post carries author, title, link, publishingDate, postId, postLabel, postUpvotes, commentCount, attachmentType, and attachmentLink. Several of these are nullable in the schema the tests enforce, authorId on deleted profiles, postLabel, and attachmentLink, so treat a missing value as an expected outcome rather than a parsing bug, and don't assume every subreddit exposes every bookmark link.

python
def parse_subreddit(response: ScrapeApiResponse) -> Dict:
    """parse article data from HTML"""
    selector = response.selector
    url = response.context["url"]
    info = {}
    info["id"] = url.split("/r")[-1].replace("/", "")
    info["description"] = selector.xpath("//shreddit-subreddit-header/@description").get()
    members_text = selector.xpath("//faceplate-number[following-sibling::text()[contains(., 'members')]]/@number").get()
    weekly_active = selector.xpath("//shreddit-subreddit-header/@weekly-active-users").get()
    rank = selector.xpath("//strong[@id='position']/text()").get()
    info["rank"] = rank.strip() if rank else None
    info["members"] = int(members_text) if members_text else (int(weekly_active) if weekly_active else None)
    info["bookmarks"] = {}
    for item in selector.xpath("//div[faceplate-tracker[@source='community_menu']]/faceplate-tracker"):
        name = item.xpath(".//a/span/span/span/text()").get()
        link = item.xpath(".//a/@href").get()
        if name and link:
            info["bookmarks"][name] = link
    info["url"] = url

    post_data = []
    for box in selector.xpath("//article[@data-post-id]"):
        link = box.xpath(".//a/@href").get()
        author = box.xpath(".//shreddit-post/@author").get()
        post_label = box.xpath(".//span[contains(@class, 'bg-tone-4')]/div/text()").get()
        upvotes = box.xpath(".//shreddit-post/@score").get()
        comment_count = box.xpath(".//shreddit-post/@comment-count").get()
        attachment_type = box.xpath(".//shreddit-post/@post-type").get()
        # simplified here, the repository file also covers gallery posts and a content-href fallback
        attachment_link = None
        if attachment_type == "image":
            attachment_link = box.xpath(".//img[contains(@class, 'media-lightbox-img')]/@src").get()
        elif attachment_type == "video":
            attachment_link = box.xpath(".//shreddit-player/@preview").get()
        post_data.append({
            "authorProfile": "https://www.reddit.com/user/" + author if author else None,
            "authorId": box.xpath(".//shreddit-post/@author-id").get(),
            "title": box.xpath(".//shreddit-post/@post-title").get(),
            "link": "https://www.reddit.com" + link if link and link.startswith("/") else link,
            "publishingDate": box.xpath(".//shreddit-post/@created-timestamp").get(),
            "postId": box.xpath(".//shreddit-post/@id").get(),
            "postLabel": post_label.strip() if post_label else None,
            "postUpvotes": int(upvotes) if upvotes else None,
            "commentCount": int(comment_count) if comment_count else None,
            "attachmentType": attachment_type,
            "attachmentLink": attachment_link,
        })
    cursor_id = selector.xpath("//shreddit-post/@more-posts-cursor").get()
    return {"post_data": post_data, "info": info, "cursor": cursor_id}


async def scrape_subreddit(subreddit_id: str, max_pages: int = None) -> Dict:
    """scrape a subreddit's first page (pagination covered next)"""
    base_url = f"https://www.reddit.com/r/{subreddit_id}/"
    response = await SCRAPFLY.async_scrape(ScrapeConfig(base_url, **BASE_CONFIG))
    data = parse_subreddit(response)
    subreddit_data = {"info": data["info"], "posts": data["post_data"]}
    log.success(f"scraped {len(subreddit_data['posts'])} posts from r/{subreddit_id}")
    return subreddit_data
Example Output
json
{
  "info": {
    "id": "wallstreetbets",
    "description": "Like 4chan found a Bloomberg Terminal.",
    "rank": null,
    "members": 3098660,
    "bookmarks": {
      "Wiki": "/r/wallstreetbets/wiki/index/",
      "YouTube": "https://www.youtube.com/@WSBverse?sub_confirmation=1",
      "Discord": "https://discord.gg/wsbverse",
      "Twitch": "https://twitch.tv/wsbverse"
    },
    "url": "https://www.reddit.com/r/wallstreetbets/"
  },
  "posts": [
    {
      "authorProfile": "https://www.reddit.com/user/WonderfulWorld3326",
      "authorId": "t2_2cwghnxcnl",
      "title": "Everything is priced in.",
      "link": "https://www.reddit.com/r/wallstreetbets/comments/1vrrswq/everything_is_priced_in/",
      "publishingDate": "2026-08-18T15:06:09.475000+0000",
      "postId": "t3_1vrrswq",
      "postLabel": null,
      "postUpvotes": 465,
      "commentCount": 109,
      "attachmentType": "text",
      "attachmentLink": "https://www.reddit.com/r/wallstreetbets/comments/1vrrswq/everything_is_priced_in/"
    },
    ....
  ]
}

Parse shreddit-post Fields From a Reddit Subreddit

Two selectors carry most of the subreddit page's structured data. Each post lives inside an article[@data-post-id] container, and the actual field values, author, score, comment-count, post-type, created-timestamp sit as attributes on a nested shreddit-post custom element rather than in visible text nodes.

Member counts come from a faceplate-number element with the subreddit header's weekly-active-users attribute used as a fallback when Reddit doesn't render the member count directly.

This selector set was validated against live Reddit most recently on August 18, 2026, as part of test_subreddit_scraping. If Reddit changes its component markup that test is what catches it before this article's examples go stale.

Beyond the first page the subreddit scraper follows a more-posts-cursor attribute exposed on the last shreddit-post element and requests a hidden Reddit endpoint directly instead of scrolling a browser. This endpoint is one example of the broader pattern covered in

scrape_subreddit() builds that URL with the subreddit_id argument it was called with, so pagination stays on the same subreddit as the first page:

python
def make_pagination_url(cursor_id: str, subreddit_id: str):
    return f"https://www.reddit.com/svc/shreddit/community-more-posts/hot/?after={cursor_id}%3D%3D&t=DAY&name={subreddit_id}&feedLength=3"

With subreddit listings covered, the next step is scraping a single post and its full comment tree.

How Do You Scrape Reddit Posts and Nested Comments?

A Reddit post spans two page shapes. www.reddit.com exposes metadata through the same shreddit-post element used on subreddits but its comments render dynamically and don't scale to scroll through a headless browser. The canonical scraper instead requests old.reddit.com which renders comments as plain server side HTML and supports a limit=500 parameter to load hundreds in one request.

test_post_scraping passed against both shapes recently and asserts at least 50 comments for the test post. Treat that as a floor not a promised count since Reddit threads keep growing after any number gets captured.

Parse Reddit Post Metadata From shreddit-post

parse_post_info() pulls the author, label, publish date, title, link, comment count, upvote count, and attachment details straight from the shreddit-post element's attributes:

python
def parse_post_info(response: ScrapeApiResponse) -> Dict:
    """parse post data from a subreddit post"""
    selector = response.selector
    info = {}
    label = selector.xpath("//faceplate-tracker[@source='post']/a/span/div/text()").get()
    comments = selector.xpath("//shreddit-post/@comment-count").get()
    upvotes = selector.xpath("//shreddit-post/@score").get()
    info["authorId"] = selector.xpath("//shreddit-post/@author-id").get()
    info["author"] = selector.xpath("//shreddit-post/@author").get()
    info["authorProfile"] = "https://www.reddit.com/user/" + info["author"] if info["author"] else None
    subreddit = selector.xpath("//shreddit-post/@subreddit-prefixed-name").get()
    info["subreddit"] = subreddit.replace("r/", "") if subreddit else None
    info["postId"] = selector.xpath("//shreddit-post/@id").get()
    info["postLabel"] = label.strip() if label else None
    info["publishingDate"] = selector.xpath("//shreddit-post/@created-timestamp").get()
    info["postTitle"] = selector.xpath("//shreddit-post/@post-title").get()
    info["postLink"] = selector.xpath("//shreddit-canonical-url-updater/@value").get()
    info["commentCount"] = int(comments) if comments else None
    info["upvoteCount"] = int(upvotes) if upvotes else None
    info["attachmentType"] = selector.xpath("//shreddit-post/@post-type").get()
    info["attachmentLink"] = selector.xpath("//shreddit-post/@content-href").get()
    return info

The subreddit line above guards against a missing subreddit-prefixed-name attribute. The repository's current version at d156309a doesn't, it calls .replace("r/", "") unguarded, which raises AttributeError if that attribute is absent. Use the null checked version shown here.

Parse old Reddit Comments and Replies

Comments recurse in the same shape old.reddit's HTML uses. Each nested reply lives inside a div[@data-type='comment'] under its parent, so parse_replies() calls itself on every comment box it finds:

python
def parse_comment(parent_selector) -> Dict:
    """parse a comment object"""
    author = parent_selector.xpath("./@data-author").get()
    link = parent_selector.xpath("./@data-permalink").get()
    dislikes = parent_selector.xpath(".//span[contains(@class, 'dislikes')]/@title").get()
    upvotes = parent_selector.xpath(".//span[contains(@class, 'likes')]/@title").get()
    downvotes = parent_selector.xpath(".//span[contains(@class, 'unvoted')]/@title").get()
    return {
        "authorId": parent_selector.xpath("./@data-author-fullname").get(),
        "author": author,
        "authorProfile": "https://www.reddit.com/user/" + author if author else None,
        "commentId": parent_selector.xpath("./@data-fullname").get(),
        "link": "https://www.reddit.com" + link if link else None,
        "publishingDate": parent_selector.xpath(".//time/@datetime").get(),
        "commentBody": parent_selector.xpath(".//div[@class='md']/p/text()").get(),
        "upvotes": int(upvotes) if upvotes else None,
        "dislikes": int(dislikes) if dislikes else None,
        "downvotes": int(downvotes) if downvotes else None,
    }


def parse_replies(what) -> List[Dict]:
    """recursively parse replies"""
    replies = []
    for reply_box in what.xpath(".//div[@data-type='comment']"):
        reply_comment = parse_comment(reply_box)
        child_replies = parse_replies(reply_box)
        if child_replies:
            reply_comment["replies"] = child_replies
        replies.append(reply_comment)
    return replies


def parse_post_comments(response: ScrapeApiResponse) -> List[Dict]:
    """parse post comments"""
    selector = response.selector
    data = []
    for item in selector.xpath("//div[@class='sitetable nestedlisting']/div[@data-type='comment']"):
        comment_data = parse_comment(item)
        replies = parse_replies(item)
        if replies:
            comment_data["replies"] = replies
        data.append(comment_data)
    return data


async def scrape_post(url: str, sort: Union["old", "new", "top"]) -> Dict:
    """scrape a post's info and comments"""
    response = await SCRAPFLY.async_scrape(ScrapeConfig(url, **BASE_CONFIG))
    post_data = {"info": parse_post_info(response)}
    post_link = post_data["info"]["postLink"] or post_data["info"]["attachmentLink"]
    comments_url = post_link.replace("www", "old") + f"?sort={sort}&limit=500"
    response = await SCRAPFLY.async_scrape(ScrapeConfig(comments_url, **BASE_CONFIG))
    post_data["comments"] = parse_post_comments(response)
    log.success(f"scraped {len(post_data['comments'])} comments from {url}")
    return post_data

The Cerberus schema test_post_scraping validates against marks authorId, author, authorProfile, and commentBody nullable. That's not a parsing gap. It reflects deleted or removed comments, where old.reddit itself no longer exposes an author or body for that entry.

Build every consumer of this data to expect None in those fields rather than assuming they're always present.

Example Output
json
{
  "info": {
    "authorId": "t2_qbvp0eq8b",
    "author": "wsbapp",
    "authorProfile": "https://www.reddit.com/user/wsbapp",
    "subreddit": "wallstreetbets",
    "postId": "t3_1c4vwlp",
    "postLabel": null,
    "publishingDate": "2024-04-15T20:00:20.757000+0000",
    "postTitle": "What Are Your Moves Tomorrow, April 16, 2024",
    "postLink": "https://www.reddit.com/r/wallstreetbets/comments/1c4vwlp/what_are_your_moves_tomorrow_april_16_2024/",
    "commentCount": 8297,
    "upvoteCount": 331,
    "attachmentType": "customPost",
    "attachmentLink": "https://www.reddit.com/r/wallstreetbets/comments/1c4vwlp/what_are_your_moves_tomorrow_april_16_2024/"
  },
  "comments": [
    {
      "authorId": "t2_klxx9owmj",
      "author": "Maverick2937474838",
      "authorProfile": "https://www.reddit.com/user/Maverick2937474838",
      "commentId": "t1_kzq97qi",
      "link": "https://www.reddit.com/r/wallstreetbets/comments/1c4vwlp/what_are_your_moves_tomorrow_april_16_2024/kzq97qi/",
      "publishingDate": "2024-04-15T20:03:15+00:00",
      "commentBody": null,
      "upvotes": 166,
      "dislikes": 166,
      "downvotes": 167
    },
    {
      "authorId": "t2_4duki146",
      "author": "billyd1984texas",
      "authorProfile": "https://www.reddit.com/user/billyd1984texas",
      "commentId": "t1_kzqi6y1",
      "link": "https://www.reddit.com/r/wallstreetbets/comments/1c4vwlp/what_are_your_moves_tomorrow_april_16_2024/kzqi6y1/",
      "publishingDate": "2024-04-15T20:53:20+00:00",
      "commentBody": "Isreal needs to chill tf out. If they're so mad make a dis track like adults",
      "upvotes": 108,
      "dislikes": 108,
      "downvotes": 109
    },
    ....
  ]
}

Post and comment scraping covers two of the four output families. The remaining two live on profile pages.

How Do You Scrape Reddit Profile Posts and Comments?

The last two output families are a user's submitted posts and their comments both served from old.reddit.com/user/{username}/:

  • Profile posts.
  • Profile comments.

Both routes paginate through a next-button link rather than a cursor parameter, and both return data tied to a specific Reddit username. Only collect what your approved use case actually needs, and don't use this data for outreach, targeting, or re-identification.

Each profile post's link also fits scrape_post() directly, so chaining from a profile's post list into each post's own comments is straightforward, covered in more depth in our guide.

Paginate old Reddit Profile Posts

python
def parse_user_posts(response: ScrapeApiResponse) -> Dict:
    """parse user posts from user profiles"""
    selector = response.selector
    data = []
    for box in selector.xpath("//div[@id='siteTable']/div[contains(@class, 'thing')]"):
        author = box.xpath("./@data-author").get()
        link = box.xpath("./@data-permalink").get()
        timestamp = box.xpath("./@data-timestamp").get()
        publishing_date = (
            datetime.fromtimestamp(int(timestamp) / 1000.0).strftime("%Y-%m-%dT%H:%M:%S.%f%z")
            if timestamp else None
        )
        comment_count = box.xpath("./@data-comments-count").get()
        post_score = box.xpath("./@data-score").get()
        data.append({
            "authorId": box.xpath("./@data-author-fullname").get(),
            "author": author,
            "authorProfile": "https://www.reddit.com/user/" + author if author else None,
            "postId": box.xpath("./@data-fullname").get(),
            "postLink": "https://www.reddit.com" + link if link else None,
            "postTitle": box.xpath(".//p[@class='title']/a/text()").get(),
            "postSubreddit": box.xpath("./@data-subreddit-prefixed").get(),
            "publishingDate": publishing_date,
            "commentCount": int(comment_count) if comment_count else None,
            "postScore": int(post_score) if post_score else None,
            "attachmentType": box.xpath("./@data-type").get(),
            "attachmentLink": box.xpath("./@data-url").get(),
        })
    next_page_url = selector.xpath("//span[@class='next-button']/a/@href").get()
    return {"data": data, "url": next_page_url}


async def scrape_user_posts(username: str, sort: Union["new", "top", "controversial"], max_pages: int = None) -> List[Dict]:
    """scrape user posts"""
    url = f"https://old.reddit.com/user/{username}/submitted/?sort={sort}"
    response = await SCRAPFLY.async_scrape(ScrapeConfig(url, **BASE_CONFIG))
    data = parse_user_posts(response)
    post_data, next_page_url = data["data"], data["url"]
    while next_page_url and (max_pages is None or max_pages > 0):
        response = await SCRAPFLY.async_scrape(ScrapeConfig(next_page_url, **BASE_CONFIG))
        data = parse_user_posts(response)
        next_page_url = data["url"]
        post_data.extend(data["data"])
        if max_pages is not None:
            max_pages -= 1
    log.success(f"scraped {len(post_data)} posts from the {username} reddit profile")
    return post_data

test_user_post_scraping runs this against the Scrapfly profile with sort="top" and max_pages=3 requiring at least 25 posts back, a floor to test against not a promised count.

Example output
json
[
  {
    "authorId": "t2_saicgkn9",
    "author": "Scrapfly",
    "authorProfile": "https://www.reddit.com/user/Scrapfly",
    "postId": "t3_1h7ajw1",
    "postLink": "https://www.reddit.com/r/scrapfly/comments/1h7ajw1/comprehensive_guide_to_okhttp_for_java_and_kotlin/",
    "postTitle": "Comprehensive Guide to OkHttp for Java and Kotlin",
    "postSubreddit": "r/scrapfly",
    "publishingDate": "2024-12-05T16:35:44.000000",
    "commentCount": 0,
    "postScore": 3,
    "attachmentType": "link",
    "attachmentLink": "https://scrapfly.io/blog/guide-to-okhttp-java-kotlin/"
  },
  {
    "authorId": "t2_saicgkn9",
    "author": "Scrapfly",
    "authorProfile": "https://www.reddit.com/user/Scrapfly",
    "postId": "t3_1fv3am2",
    "postLink": "https://www.reddit.com/r/scrapfly/comments/1fv3am2/how_to_use_curl_get_requests/",
    "postTitle": "How to Use cURL GET Requests",
    "postSubreddit": "r/scrapfly",
    "publishingDate": "2024-10-03T11:54:40.000000",
    "commentCount": 0,
    "postScore": 3,
    "attachmentType": "link",
    "attachmentLink": "https://scrapfly.io/blog/how-to-use-curl-get-requests/"
  },
  ....
]

Parse old Reddit Profile Comments

python
def parse_user_comments(response: ScrapeApiResponse) -> Dict:
    """parse user comments from user profiles"""
    selector = response.selector
    data = []
    for box in selector.xpath("//div[@id='siteTable']/div[contains(@class, 'thing')]"):
        author = box.xpath("./@data-author").get()
        link = box.xpath("./@data-permalink").get()
        dislikes = box.xpath(".//span[contains(@class, 'dislikes')]/@title").get()
        upvotes = box.xpath(".//span[contains(@class, 'likes')]/@title").get()
        downvotes = box.xpath(".//span[contains(@class, 'unvoted')]/@title").get()
        data.append({
            "authorId": box.xpath("./@data-author-fullname").get(),
            "author": author,
            "authorProfile": "https://www.reddit.com/user/" + author if author else None,
            "commentId": box.xpath("./@data-fullname").get(),
            "commentLink": "https://www.reddit.com" + link if link else None,
            "commentBody": "".join(box.xpath(".//div[contains(@class, 'usertext-body')]/div/p/text()").getall()).replace("\n", ""),
            "attachedCommentLinks": box.xpath(".//div[contains(@class, 'usertext-body')]/div/p/a/@href").getall(),
            "publishingDate": box.xpath(".//time/@datetime").get(),
            "dislikes": int(dislikes) if dislikes else None,
            "upvotes": int(upvotes) if upvotes else None,
            "downvotes": int(downvotes) if downvotes else None,
            "replyTo": {
                "postTitle": box.xpath(".//p[@class='parent']/a[@class='title']/text()").get(),
                "postLink": "https://www.reddit.com" + box.xpath(".//p[@class='parent']/a[@class='title']/@href").get(),
                "postAuthor": box.xpath(".//p[@class='parent']/a[contains(@class, 'author')]/text()").get(),
                "postSubreddit": box.xpath("./@data-subreddit-prefixed").get(),
            }
        })
    next_page_url = selector.xpath("//span[@class='next-button']/a/@href").get()
    return {"data": data, "url": next_page_url}


async def scrape_user_comments(username: str, sort: Union["new", "top", "controversial"], max_pages: int = None) -> List[Dict]:
    """scrape user comments"""
    url = f"https://old.reddit.com/user/{username}/comments/?sort={sort}"
    response = await SCRAPFLY.async_scrape(ScrapeConfig(url, **BASE_CONFIG))
    data = parse_user_comments(response)
    post_data, next_page_url = data["data"], data["url"]
    while next_page_url and (max_pages is None or max_pages > 0):
        response = await SCRAPFLY.async_scrape(ScrapeConfig(next_page_url, **BASE_CONFIG))
        data = parse_user_comments(response)
        next_page_url = data["url"]
        post_data.extend(data["data"])
        if max_pages is not None:
            max_pages -= 1
    log.success(f"scraped {len(post_data)} comments from the {username} reddit profile")
    return post_data

parse_user_comments() walks each comment row the same way parse_user_posts() does, but attaches a replyTo object carrying the parent post's title, link, author, and subreddit so every comment stays tied to what it replied to.

The upvotes, dislikes, and downvotes fields come from the title attributes on old.reddit's likes, dislikes, and unvoted CSS classes. scrape_user_comments() paginates through the same next-button link as the profile posts function above.

Example output
json
[
  {
    "authorId": "t2_saicgkn9",
    "author": "Scrapfly",
    "authorProfile": "https://www.reddit.com/user/Scrapfly",
    "commentId": "t1_kzry9ar",
    "commentLink": "https://www.reddit.com/r/webscraping/comments/1c4jd72/where_to_begin_web_scraping/kzry9ar/",
    "commentBody": "You can check out our web scraping academy resource, (it's totally free and independent of our service), which has a visual roadmap and allows you to learn/dig branch by branch.",
    "attachedCommentLinks": [
      "https://scrapfly.io/academy",
      "https://webscraping.fyi/"
    ],
    "publishingDate": "2024-04-16T02:47:18+00:00",
    "dislikes": 7,
    "upvotes": 7,
    "downvotes": 8,
    "replyTo": {
      "postTitle": "Where to begin Web Scraping",
      "postLink": "https://www.reddit.com/r/webscraping/comments/1c4jd72/where_to_begin_web_scraping/",
      "postAuthor": "RasenTing",
      "postSubreddit": "r/webscraping"
    }
  },
  ....
]

With the unstable profile comment test on record, this guide owes you a clear map of what actually breaks a Reddit scraper and how to tell the failure modes apart.

Why Does Reddit Return 403 or Empty Scraper Output?

Five failure patterns account for most broken Reddit scraping runs. Knowing which one you're looking at determines whether the fix is a request setting, a selector, or a different route entirely.

  • HTTP 403, or a 200 JS challenge page. Either a small HTML block page, or a full 200 status carrying a JavaScript challenge interstitial instead of shreddit-post elements.
  • Rendered page, missing parser nodes. HTTP 200, but zero article[@data-post-id] or shreddit-post matches. Confirm the response actually contains the target content before changing any selector.
  • Pagination cursor missing or malformed. The first page parses fine, but no next batch ever arrives. Log the cursor value and the generated URL together to catch this.
  • Old Reddit route unavailable. Post metadata works, but comments or profile pages fail. Treat those as separate jobs with their own pass or fail state, not one combined result.
  • Deleted or hidden fields. Author, body, score, or link comes back null. Preserve the null rather than fabricating a default value.

A real developer hit this exact surface on r/ClaudeCode, asking how to scrape niche subreddit posts and comments, structured by title, upvotes, and theme, for an LLM (captured four months after posting). The lesson isn't the technique it's that schema validation not a bare 200 is what actually confirms a run like that produced usable data.

HTTP 403 From Modern or old Reddit

Check status, content type, response size, and a known selector like shreddit-post not just the status code before trusting a response. A 200 proves nothing on its own, a direct request to www.reddit.com returned 200 while serving nothing but a JavaScript challenge page, no shreddit-post in sight. Call it bot protection rather than naming a specific vendor unless you've captured a fresh signature on the exact hostname.

Scrapfly rendered subreddit page next to a plain request hitting a reCAPTCHA challenge, both HTTP 200
Same status code, different reality

Missing shreddit-post Selectors or Pagination Cursors

Assert on article[@data-post-id] and a non-empty more-posts-cursor before trusting a page, so a markup change fails loudly instead of returning an empty list. Log the cursor value and generated URL together to catch a malformed pagination request.

For selector specifics, see

Deleted Reddit Authors and Nullable Fields

Deleted content is normal on Reddit not an edge case. The test schemas mark authorId, author, authorProfile, and commentBody nullable for exactly this reason, so build every consumer to handle None by default and keep any hand written schema in sync with test.py.

Failure classes explain what breaks. The next section covers keeping the scraper from breaking again after this article ships.

How Do You Keep a Python Reddit Scraper Working?

A Reddit scraper is a maintenance contract, not a write once script:

  • Keep every endpoint and selector in one module.
  • Store sanitized fixtures only where permitted.
  • Run targeted tests on a schedule that fits your use case.
  • Alert on schema presence, not a bare 200.

No database, dashboard, or deployment pipeline required. Those are separate projects layered on top.

Pin the scrapfly-scrapers Commit and Dependency Versions

This article's code matches one state only:

  • Commit f35dc09, Python ^3.10, scrapfly-sdk[all] ^0.8.5
  • Run August 18, 2026 against both Reddit page shapes
  • asp=True, render_js=True, country="US", proxy_pool="public_residential_pool"

Pull a newer commit, re-run the tests first.

Validate Reddit Output Schemas in CI

Run each page type's test separately on a schedule, checking both item count and schema, not just exit status. Treat one failed run as a signal to investigate, not proof every route broke, and compare the failing assertion against runs before and after it.

python
from cerberus import Validator

def check_output(items: list, schema: dict, min_items: int, label: str) -> bool:
    """conceptual adaptation of the canonical Cerberus checks in test.py"""
    validator = Validator(schema, allow_unknown=True)
    for item in items:
        if not validator.validate(item):
            print(f"[{label}] schema failure: {validator.errors}")
            return False
    if len(items) < min_items:
        print(f"[{label}] only {len(items)} items, expected at least {min_items}")
        return False
    print(f"[{label}] passed: {len(items)} items validated")
    return True

# one check per page type, so a single failing route doesn't mask the other three
results = {
    "subreddit": check_output(subreddit_posts, subreddit_post_schema, min_items=50, label="subreddit"),
    "post_comments": check_output(post_comments, comment_schema, min_items=50, label="post"),
    "profile_posts": check_output(profile_posts, user_post_schema, min_items=25, label="profile_posts"),
    "profile_comments": check_output(profile_comments, user_comment_schema, min_items=2, label="profile_comments"),
}

With a maintenance contract in place, here are the questions readers ask most often.

FAQ

Can You Scrape Reddit Without an API Key?

Public Reddit pages don't need a Reddit API key, and the canonical scraper never authenticates against Reddit's API. But access is route dependent. A direct request from the research host returned a 200 JavaScript challenge page not real content while a Scrapfly managed request which needs its own key returned parsable HTML.

Can I scrape Reddit for sentiment analysis?

Reddit contains a vast amount of text-based data covering various topics and interests. These data can be utilized for sentiment analysis to evaluate theories or train the model.

Are there alternatives for Reddit?

Yes. There are different social media targets available similar to Reddit, such as How to Scrape Twitter (X.com) with Python in 2026, How to Scrape Instagram in 2026, and How to scrape Threads by Meta using Python (2026 Update). For more similar scraping targets, refer to our #scrapeguide blog tag.

Why Does Reddit Block httpx or Requests?

A plain httpx request to www.reddit.com with a browser like User-Agent header returned a 200 status carrying a JavaScript challenge page instead of real content, reproducible across repeated runs. old.reddit.com has its own failure mode, it can redirect a logged out session to a login wall, also on a 200. The Scrapfly managed configuration documented above is what actually returned parsable content for both.

Summary

Scraping Reddit in 2026 comes down to one decision and one habit. Pick your access route: the canonical Scrapfly configuration documented above, PRAW against the official API, or a direct request if your own network doesn't get blocked, then run the matching page type test and validate the schema before you publish or ship anything built on the output. Trust only the output that test actually produced, not what the code looks like it should return.

Scrapfly is the managed option this guide's canonical repository uses whenever a direct request returns a block page instead of the target content. Check out the Web Scraping API for the full configuration reference, and the scrapfly-scrapers reddit-scraper repository for the source this entire guide is built from.

Web Scraping API

Scrape any website with our powerful API. Anti-bot bypass, JavaScript rendering, and rotating proxies built-in.

Scale Your Web Scraping
Anti-bot bypass, browser rendering, and rotating proxies, all in one API. Start with 1,000 free credits.
No credit card required 1,000 free API credits Anti-bot bypass included
Not ready? Get our newsletter instead.