Three Reddit scraping jobs in Python: a single post with its comment tree, a subreddit listing, and search results. Every request below ran against the Chocodata API on 27 July 2026 with Python 3.13.7 and requests 2.34.2, and every output block is what my terminal actually printed.
Why is it hard to scrape Reddit?
Reddit blocks datacenter IPs on the public JSON views, renders the logged-out site from JavaScript so the HTML you download holds no scores, and serves the same content through several front ends that do not all expose vote counts. The one that costs the most time is comment pagination, because a thread returns a slice of its discussion by default and a partial tree is indistinguishable from a complete one unless you check the count.
Prerequisites
- A free Chocodata API key. Sign up, confirm the email, copy the key from the dashboard. Free, no card.
-
Python 3.9+ and
requests.
python -m venv .venv && source .venv/bin/activate
pip install requests
- The Reddit URL you want to scrape, copied from the address bar.
Tested with Python 3.13.7 and requests 2.34.2 in July 2026.
How to scrape Reddit posts?
Scraping a Reddit post takes the thread URL and returns the post plus its comment tree in one call.
1. GET the post endpoint with the thread URL
Pass the post URL as url and read the fields off the top level of the response, because there is no envelope around them.
import time
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
POST_URL = "https://www.reddit.com/r/webscraping/comments/p64rqq/linkedin_scraping/"
def get_json(path, tries=3, **params):
"""One GET, retried, so a batch run survives a dropped response."""
params["api_key"] = API_KEY
for attempt in range(tries):
r = requests.get(f"{BASE}/{path}", params=params, timeout=60)
if r.ok:
return r.json()
time.sleep(2)
r.raise_for_status()
data = get_json("reddit/post", url=POST_URL)
print(list(data.keys()))
print(data["post"]["title"])
['post', 'comments', 'comments_returned', '_meta']
LinkedIn Scraping
HTTP 200 in 5.04s. Four top-level keys, and post is where the thread lives.
2. Pull the post object off the top level
The post object carries score, ratio, comment count, author and body, so one request answers the engagement questions.
post = data["post"]
print(f"{post['score']} points at {post['upvote_ratio']:.2f} ratio")
print(f"{post['num_comments']} comments | u/{post['author']['username']} | {post['created'][:10]}")
print(f"{post['domain']} | locked={post['is_locked']} | body {len(post['body'])} chars")
12 points at 0.93 ratio
17 comments | u/pizzihut | 2021-08-17
self.webscraping | locked=None | body 224 chars
author is a nested object, not a string. That trips code written against the subreddit endpoint, where the same field is a plain username.
3. Flatten the nested reply tree
Comments come back as a tree where each comment holds its own replies list, so recursion turns it into rows you can store.
def flatten(comments, depth=0, out=None):
out = [] if out is None else out
for c in comments:
out.append({"depth": depth, "author": c["author"]["username"],
"score": c["score"], "body": c["body"]})
flatten(c["replies"], depth + 1, out)
return out
rows = flatten(data["comments"])
for c in rows[:4]:
print(" " * c["depth"], f"[{c['score']:>2}] {c['author']}: {c['body'][:46]}")
print(f"{len(rows)} flattened / {data['comments_returned']} returned "
f"/ {post['num_comments']} reported")
print("truncated:", data["_meta"]["truncated"])
[ 3] [deleted]: I need to scan profiles of generel information
[ 3] pizzihut: I need to scan profiles of generel information
[ 2] nubela: Try Proxycurl! It has a dedicated Linkedin Scr
[ 1] pizzihut: Hi! Just tried the free subscription, but it s
8 flattened / 8 returned / 17 reported
truncated: True
Four top-level threads flattened to 8 comments against the 17 Reddit reports, and _meta.truncated says so explicitly. Assert on comments_returned before you treat a thread as complete. Listings behave differently, because there the pagination handle is a cursor.
How to scrape Reddit subreddits?
Scraping a subreddit returns the community listing as an array of posts plus a cursor for the next batch.
1. Fetch the first 25 posts
Send the subreddit name and the response returns the listing under posts with the sort it used.
sub = get_json("reddit/subreddit", subreddit="webscraping")
print(sub["sort"], "|", sub["total_results"], "posts |", sub["after_cursor"])
for p in sub["posts"][:2]:
print(f"{p['score']:>4} {p['num_comments']:>4} {p['upvote_ratio']:.2f} {p['title'][:44]}")
hot | 25 posts | dDNfMXV3YWxodg==
7 12 1.00 I own a wood factory and want to use webscra
2 8 0.67 Vinted cloudfare detection
HTTP 200 in 1.87s. Each post carries 13 fields including awards, domain, external_url and permalink. Adding sort="top" with t="week" returned 16 posts for the week, led by an 83-point thread.
2. Page with after_cursor
Feed after_cursor back as after to walk deeper, and stop when the cursor comes back empty.
import time
def fetch_pages(subreddit, n_pages=2, delay=1.0):
cursor, seen = None, []
for _ in range(n_pages):
params = {"subreddit": subreddit}
if cursor:
params["after"] = cursor
page = get_json("reddit/subreddit", **params)
seen.extend(page["posts"])
cursor = page["after_cursor"]
if not cursor:
break
time.sleep(delay)
return seen
allp = fetch_pages("webscraping", 2)
print(len(allp), "posts |", len({p["id"] for p in allp}), "unique ids")
50 posts | 50 unique ids
Fifty rows and fifty distinct ids, so the cursor advances cleanly with no overlap between pages. The id set is the check worth keeping, because a cursor that stalls silently returns duplicates rather than an error.
3. Write the listing to CSV
Select the fields you need and write rows, since keeping the whole payload per post buys nothing for trend work.
import csv
FIELDS = ["id", "title", "score", "num_comments", "upvote_ratio",
"author", "created", "permalink"]
with open("subreddit_posts.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
for p in allp:
w.writerow({k: p.get(k) for k in FIELDS})
print(f"wrote {len(allp)} rows to subreddit_posts.csv")
print("mean score:", round(sum(p["score"] for p in allp) / len(allp), 1))
wrote 50 rows to subreddit_posts.csv
mean score: 11.3
A mean of 11.3 across 50 posts is the baseline that makes a 83-point thread readable as an outlier. Search covers the same content across communities rather than inside one.
How to scrape Reddit search results?
Scraping Reddit search results returns a ranked mix of communities and posts for a keyword.
1. Query the search endpoint
Send the query and the response returns positioned results with the type of each hit.
search = get_json("reddit/search", q="web scraping")
print(search["query"], "|", search["sort"], "|", search["total_results"], "results")
for r in search["results"][:4]:
print(f"{r['position']:>3} {r['result_type']:<9} {r['title'][:44]}")
web scraping | relevance | 25 results
1 subreddit webscraping
2 subreddit WebScrapingInsider
3 subreddit Scraping the web
4 post The Complete Web Scraping & Anti-Bot Bypass
HTTP 200 in 2.55s under the default relevance sort.
2. Filter on result_type
Split the results by result_type so community hits and post hits go into different pipelines.
from collections import Counter
posts = [r for r in search["results"] if r["result_type"] == "post"]
subs = [r for r in search["results"] if r["result_type"] == "subreddit"]
print(len(subs), "communities |", len(posts), "posts")
for name, n in Counter(p["subreddit"] for p in posts).most_common(5):
print(f"r/{name:<20} {n}")
3 communities | 22 posts
r/webscraping 2
r/scrapingtheweb 2
r/programming 2
r/automation 1
r/PythonLearning 1
Three communities and 22 posts, with the topic spread thin past the top three subreddits.
3. Sort by new for monitoring
Pass sort="new" when the job is monitoring rather than research, because relevance ordering hides anything posted this morning.
newest = get_json("reddit/search", q="web scraping", sort="new")
fresh = [r for r in newest["results"] if r["result_type"] == "post"]
for r in fresh[:4]:
print(r["created"][:16], "|", f"r/{r['subreddit']}", "|", r["title"][:40])
2026-07-27T12:00 | r/LocalLLaMA | XYZAILab/XYZ-Aquila-mini ยท Hugging Face
2026-07-27T10:23 | r/jobsearch | Roast my resume
2026-07-27T09:14 | r/BotNation | What's the best way to learn web scrapin
2026-07-27T08:38 | r/hireforgigs | [for hire] I'm available: Web Designer/D
Same-day results, so a cron job on this call is a working keyword alert.
The part that breaks
There is no data envelope. Every field sits at the top level, so the reflex from other APIs fails immediately:
KeyError: 'data'
author changes shape between endpoints. On a post it is an object, on a listing it is a string:
AttributeError: 'dict' object has no attribute 'lower'
Normalise it once at the parse boundary with a["username"] if isinstance(a, dict) else a.
Vote counts are not on every surface. Search results can return score as null, and sorting on it raises:
TypeError: '<' not supported between instances of 'NoneType' and 'NoneType'
Use key=lambda r: r["score"] or 0 or filter the nulls out before sorting.
Comment trees arrive truncated by default. comments_returned was 8 against a num_comments of 17, with _meta.truncated set to True. Treat a thread as partial unless those two numbers agree.
Full script
"""Scrape Reddit posts, subreddit listings and search results.
Python 3.9+, requests. Tested 27 July 2026 with 3.13.7 / requests 2.34.2.
"""
import csv
import time
from collections import Counter
import requests
BASE = "https://api.chocodata.com/api/v1"
API_KEY = "YOUR_API_KEY"
def get_json(path, tries=3, **params):
"""One GET, retried, so a batch run survives a dropped response."""
params["api_key"] = API_KEY
for attempt in range(tries):
r = requests.get(f"{BASE}/{path}", params=params, timeout=60)
if r.ok:
return r.json()
time.sleep(2)
r.raise_for_status()
def author_name(a):
return a["username"] if isinstance(a, dict) else a
def scrape_post(post_url):
data = get_json("reddit/post", url=post_url)
post = data["post"]
def flatten(comments, depth=0, out=None):
out = [] if out is None else out
for c in comments:
out.append({"depth": depth, "author": author_name(c["author"]),
"score": c["score"], "body": c["body"]})
flatten(c["replies"], depth + 1, out)
return out
comments = flatten(data["comments"])
if data["comments_returned"] < post["num_comments"]:
print(f"[warn] {data['comments_returned']}/{post['num_comments']} comments returned")
return post, comments
def scrape_subreddit(subreddit, n_pages=2, delay=1.0, path="subreddit_posts.csv"):
cursor, seen = None, []
for _ in range(n_pages):
params = {"subreddit": subreddit}
if cursor:
params["after"] = cursor
page = get_json("reddit/subreddit", **params)
seen.extend(page["posts"])
cursor = page["after_cursor"]
if not cursor:
break
time.sleep(delay)
fields = ["id", "title", "score", "num_comments", "upvote_ratio",
"author", "created", "permalink"]
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
for p in seen:
w.writerow({k: p.get(k) for k in fields})
return seen
def search_reddit(query, sort=None):
params = {"q": query}
if sort:
params["sort"] = sort
results = get_json("reddit/search", **params)["results"]
posts = [r for r in results if r["result_type"] == "post"]
return posts, Counter(p["subreddit"] for p in posts)
if __name__ == "__main__":
post, comments = scrape_post(
"https://www.reddit.com/r/webscraping/comments/p64rqq/linkedin_scraping/")
print(post["title"], "|", post["score"], "points |", len(comments), "comments")
listing = scrape_subreddit("webscraping", n_pages=2)
print(len(listing), "posts written |",
"mean score", round(sum(p["score"] for p in listing) / len(listing), 1))
posts, where = search_reddit("web scraping")
print(len(posts), "search posts |", where.most_common(3))
Summary
One request each covers the three jobs: a thread URL returns the post and its comment tree in about five seconds, a subreddit name returns 25 posts with a cursor that pages cleanly to 50 unique ids, and a keyword returns 25 ranked results split across communities and posts. Any language with an HTTP client works the same way, so Node, Go and Ruby need no special handling. The thing to build in from the start is the comments_returned check, because a truncated thread parses perfectly and quietly loses most of the discussion.
FAQ
Do I need a Reddit account or OAuth token?
No, the requests above authenticate with a Chocodata key and never touch Reddit credentials or an app registration.
Why is score null on some Reddit results?
Not every Reddit surface exposes vote counts, so search results return identity, subreddit and timestamps with score left null rather than guessed.
Is scraping Reddit legal?
Public pages are not automatically illegal to scrape, but Reddit's user terms restrict automated collection, which makes it a contract question rather than a criminal one, and this is not legal advice.










Top comments (0)