Get Reddit Post Comments with Python: Nested Replies to CSV
replies is the field that decides whether you have a comment or a conversation. On the JSON from post-details, every returned node can carry an array of more nodes, and those nodes can carry more. That forest is the payload. A CSV with a depth column is a working copy of it. It is not a certified archive of every reply the thread ever had.
The request that produces the forest takes one query field: a public discussion url. Copying anything on this page does not send a lookup. A call happens only when you run it with a key you control.
The comment is a tree, not a list
Gateway responses wrap the operation in {code, message, data}. HTTP 2xx means the bytes arrived. Gateway code 200 means the platform accepted the call. Neither is the comment tree. Inside data, read meta.status before you touch body. When that status is not 200, stop — do not flatten an error payload into fake comments.
On a usable payload, body.post is the submission: id, title, author, subreddit, selftext, created_utc, score, ups, upvote_ratio, num_comments, permalink, and url. body.post_comments is an array of top-level comment objects, not a count of every node in the forest.
Each comment node is the same shape, nested:
id— keep it as a string.author— may be missing or empty; leave it missing.content— the text. Thread comments do not usebodyfor this.up_votes,score,created_utc— snapshots. Nested copies of these were observed as strings, so coerce before you add or sort.replies— an array of the same node type, or empty.
There is no reliable parent_id sitting on the node. Parentage is the path you walked: a root's parent is the post; a nested node's parent is the comment whose replies array contained it. Drop that while flattening and a spreadsheet of "comments" becomes a bag of sentences.
subreddit-comments looks adjacent and is not the same object. Its rows use body for text, carry link_permalink / parent_id as listing fields, and the observed replies value was an empty string — not a tree. Parse the two operations separately.
meta.totalComments and post.num_comments are reported counters. A checked historical wallstreetbets thread (p0esdp) reported meta.totalComments=194 while returning 76 top-level objects plus whatever nested replies arrived with them. Three numbers, three meanings: reported total, top-level length, traversed-node count. None of them is a certificate that every reply is present.
Request the thread by URL
Sign in and copy an authorized key from the dashboard. Put it in OPENDATA_API_KEY on the server. Snippets that show YOUR_API_KEY are placeholders. The key rides in the query string, so those URLs do not belong in tickets, screenshots, or browser JavaScript. No Reddit cookie or login session is required for this read-only call.
url must be a full discussion link containing /r/<community>/comments/<post-id>/. A subreddit homepage is the wrong object — that belongs to posts, which returns a listing, not a reply tree. The documented example is:
https://www.reddit.com/r/wallstreetbets/comments/p0esdp/do_hedge_funds_beat_the_market_i_analyzed_the
Public examples do not guarantee the thread is still populated. If you do not already have a discussion URL, you can pick one from a posts listing and only then call post-details. That second call is still one url. It is not comment pagination.
Install requests and run the script below server-side. Transport failures, a non-2xx HTTP status, a gateway code other than 200, or a meta.status other than 200 are three different stops. Retry only the first. A well-formed tree that is smaller than totalComments is not a transport failure.
Walk replies without dropping depth
The walk is depth-first. Yield a row, then recurse into replies with depth + 1 and the current id as parent. If replies is missing or not a list, stop on that node rather than inventing children. Empty content or author stays empty.
CSV columns that preserve the forest: post_id, comment_id, parent_id, depth, author, text, created_utc, source_post_url, observed_at_utc. depth=0 is a direct reply to the post. Comment links, if you want them, are constructed from the post permalink plus comment id — they are not a returned link field.
The endpoint returns JSON, not a spreadsheet. Prefix cells that look like formulas (=, +, -, @) if someone will open the file in Excel. Create the file; do not overwrite yesterday's snapshot. The observed_at column is the only honest timestamp for "when we saw this tree."
import csv
import os
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin
import requests
BASE = "https://api.opendata-api.com/api/project/reddit"
THREAD_URL = (
"https://www.reddit.com/r/wallstreetbets/comments/p0esdp/"
"do_hedge_funds_beat_the_market_i_analyzed_the"
)
def read_thread():
key = os.environ.get("OPENDATA_API_KEY", "").strip()
if not key:
raise RuntimeError("Missing OPENDATA_API_KEY")
try:
response = requests.get(
f"{BASE}/post-details",
params={"key": key, "url": THREAD_URL},
timeout=30,
)
except requests.RequestException:
raise RuntimeError("Transport failure; inspect redacted logs") from None
if not 200 <= response.status_code < 300:
raise RuntimeError(f"HTTP failure: {response.status_code}")
try:
envelope = response.json()
except ValueError:
raise RuntimeError("Expected JSON") from None
if not isinstance(envelope, dict) or envelope.get("code") != 200:
raise RuntimeError("Gateway business failure")
payload = envelope.get("data")
if not isinstance(payload, dict):
raise RuntimeError("Unexpected operation payload")
meta = payload.get("meta")
if not isinstance(meta, dict) or meta.get("status") != 200:
raise RuntimeError("Operation failed or has an unexpected status")
body = payload.get("body")
if (
not isinstance(body, dict)
or not isinstance(body.get("post"), dict)
or not isinstance(body.get("post_comments"), list)
):
raise RuntimeError("Expected post details and a comments array")
return payload
def walk(nodes, parent_id, depth):
if not isinstance(nodes, list):
raise RuntimeError("Expected a replies array")
for node in nodes:
if not isinstance(node, dict):
raise RuntimeError("Expected a comment object")
comment_id = str(node.get("id") or "")
replies = node.get("replies", [])
if replies is None:
replies = []
if not isinstance(replies, list):
raise RuntimeError("Unexpected replies structure")
yield {
"comment_id": comment_id,
"parent_id": parent_id,
"depth": depth,
"author": node.get("author"),
"text": node.get("content"),
"created_utc": node.get("created_utc"),
}
yield from walk(replies, comment_id, depth + 1)
def spreadsheet_cell(value):
if isinstance(value, str) and value.lstrip().startswith(("=", "+", "-", "@")):
return "'" + value
return value
def export_thread():
payload = read_thread()
post = payload["body"]["post"]
post_id = str(post.get("id") or "")
post_url = urljoin("https://www.reddit.com", post.get("permalink") or THREAD_URL)
observed_at = datetime.now(timezone.utc).isoformat()
rows = list(walk(payload["body"]["post_comments"], post_id, 0))
fields = [
"post_id", "comment_id", "parent_id", "depth", "author", "text",
"created_utc", "source_post_url", "observed_at_utc",
]
output = Path("reddit_comments.csv")
if output.exists():
raise RuntimeError("Output already exists; archive it first")
with output.open("x", encoding="utf-8", newline="") as target:
writer = csv.DictWriter(target, fieldnames=fields)
writer.writeheader()
for row in rows:
row.update(
post_id=post_id,
source_post_url=post_url,
observed_at_utc=observed_at,
)
writer.writerow({k: spreadsheet_cell(row.get(k)) for k in fields})
print({
"reported": payload["meta"].get("totalComments"),
"top_level": len(payload["body"]["post_comments"]),
"traversed": len(rows),
})
if __name__ == "__main__":
export_thread()
Print those three counts and keep them separate. Do not subtract them to invent a missing-comment total. IDs stay strings so a spreadsheet does not turn them into numbers.
A CSV is not the full discussion
Do not treat HTTP 200, gateway code 200, or meta.status 200 as "we got every comment." Those layers say the call ran. Coverage is the tree you actually walked.
Do not subtract totalComments from the row count and label the difference deleted. You do not know why a node is absent.
Do not send cursor to post-details. Listing cursors from posts or subreddit-comments do not expand this tree. There is no documented missing-comment parameter. Official-client helpers such as replace_more are not capabilities of this API.
Do not treat a flattened CSV as the conversation. Depth and parent_id let you rebuild a tree in a notebook; they do not restore replies that never arrived. Sentiment scores, keyword alerts, and "how many unique users complained" are jobs you run downstream — and reply-count is not unique-user-count.
Do not mix shapes: content plus replies[] on the thread, body plus link_permalink on the community comment feed. Do not invent a keyword-search operation; this project does not declare one. Do not retry a well-formed partial tree as if transport failed. Do not put the platform key in a notebook you share; query URLs leak it.
Use the rows as source-linked evidence for a discussion you already chose. Label coverage honestly. Re-pull later if you need a newer snapshot; do not cache one CSV as the thread.
The Reddit data API and the post-details reference stay the field-level notes for this payload.