{"id":674,"date":"2026-07-17T02:09:00","date_gmt":"2026-07-16T23:09:00","guid":{"rendered":"https:\/\/mexela.com\/blog\/export-youtube-comments-python-ytdlp-proxy\/"},"modified":"2026-07-22T00:20:42","modified_gmt":"2026-07-21T21:20:42","slug":"export-youtube-comments-python-ytdlp-proxy","status":"publish","type":"post","link":"https:\/\/mexela.com\/blog\/export-youtube-comments-python-ytdlp-proxy\/","title":{"rendered":"Export YouTube Comments to CSV With Python and yt-dlp"},"content":{"rendered":"<p><!-- mexela-platform-guide:start --><\/p>\n<p class='mexela-answer'>For a one-video, bounded public comment export, Python can ask yt-dlp for comment metadata without downloading media, limit the returned sample, neutralize spreadsheet formulas, and write explicit UTF-8 CSV. Use an optional proxy only when the approved network route requires one. For a maintained application that needs documented thread IDs, paging, ordering, and API errors, prefer the official YouTube Data API <code>commentThreads.list<\/code> method.<\/p>\n<p>The program below exports at most 200 comments from one <code>VIDEO_URL<\/code>. It writes six fields and protects every cell whose first character is <code>=<\/code>, <code>+<\/code>, <code>-<\/code>, or <code>@<\/code>. It does not download video or audio, recover deleted comments, or promise that yt-dlp can extract comments from every public video.<\/p>\n<h2>Choose the comment source from the required output<\/h2>\n<p>The official <a href='https:\/\/developers.google.com\/youtube\/v3\/docs\/commentThreads\/list' data-source='primary'><code>commentThreads.list<\/code> reference<\/a> returns structured thread resources, costs 1 quota unit per call, accepts <code>maxResults<\/code> from 1 to 100, and supplies <code>nextPageToken<\/code> when another page exists. It is the better choice when a product needs stable API semantics, controlled ordering, or explicit error details.<\/p>\n<p>A thread response can include a top-level comment and a subset of replies; it is not automatically every reply. The API reference also documents a <code>403 commentsDisabled<\/code> error for a video with comments disabled. Treat that as an availability state, not an invitation to switch routes until something returns.<\/p>\n<p>yt-dlp is useful for a small reviewed metadata job where the extractor already supports the public page. The official <a href='https:\/\/github.com\/yt-dlp\/yt-dlp\/blob\/master\/README.md' data-source='primary'>yt-dlp README<\/a> documents <code>--write-comments<\/code>\/<code>--get-comments<\/code>, the YouTube <code>max_comments<\/code> extractor argument, and HTTP, HTTPS, or SOCKS proxy URLs. Extractor behavior can change when YouTube changes, so pin the installed version in the run log.<\/p>\n<h2>Install the tool and declare the boundaries<\/h2>\n<p>Create a virtual environment, then install a reviewed release with <code>python -m pip install yt-dlp<\/code>. Set <code>VIDEO_URL<\/code> to one approved public watch URL. Set <code>PROXY_URL<\/code> only when the project requires that route; otherwise leave it unset. A proxy URL containing credentials belongs in an environment variable or secret manager, not the program or CSV.<\/p>\n<p>The script keeps <code>COMMENT_LIMIT = 200<\/code> in source so reviewers can see the ceiling. It passes that value through the YouTube <code>max_comments<\/code> extractor argument and slices the returned list again before writing. The second limit is intentional defensive behavior if extractor semantics change or the returned object contains more rows than requested.<\/p>\n<h2>Run the complete Python exporter<\/h2>\n<pre data-check='python'><code class='language-python'>import csv\nimport os\nimport sys\nfrom datetime import datetime, timezone\nfrom yt_dlp import YoutubeDL\nVIDEO_URL = os.environ.get(\"VIDEO_URL\", \"\").strip()\nPROXY_URL = os.environ.get(\"PROXY_URL\", \"\").strip()\nCOMMENT_LIMIT = 200\nOUTPUT_PATH = \"youtube_comments.csv\"\nFIELDNAMES = [\n    \"video_id\",\n    \"comment_id\",\n    \"parent_id\",\n    \"text\",\n    \"published_at\",\n    \"captured_at\",\n]\ndef safe_cell(value):\n    text = \"\" if value is None else str(value)\n    if text.startswith((\"=\", \"+\", \"-\", \"@\")):\n        return \"'\" + text\n    return text\ndef normalize_comment(video_id, comment, captured_at):\n    timestamp = comment.get(\"timestamp\")\n    published_at = \"\"\n    if isinstance(timestamp, (int, float)):\n        published_at = datetime.fromtimestamp(\n            timestamp, tz=timezone.utc\n        ).isoformat()\n    raw = {\n        \"video_id\": video_id,\n        \"comment_id\": comment.get(\"id\"),\n        \"parent_id\": comment.get(\"parent\"),\n        \"text\": comment.get(\"text\") or \"\",\n        \"published_at\": published_at,\n        \"captured_at\": captured_at,\n    }\n    return {name: safe_cell(raw.get(name)) for name in FIELDNAMES}\ndef main():\n    if not VIDEO_URL:\n        raise ValueError(\"VIDEO_URL is required\")\n    if not VIDEO_URL.startswith((\"https:\/\/www.youtube.com\/\", \"https:\/\/youtu.be\/\")):\n        raise ValueError(\"VIDEO_URL must be a YouTube watch URL\")\n    options = {\n        \"skip_download\": True,\n        \"getcomments\": True,\n        \"quiet\": True,\n        \"extractor_args\": {\n            \"youtube\": {\"max_comments\": [str(COMMENT_LIMIT)]}\n        },\n    }\n    if PROXY_URL:\n        options[\"proxy\"] = PROXY_URL\n    with YoutubeDL(options) as ydl:\n        info = ydl.extract_info(VIDEO_URL, download=False)\n    video_id = info.get(\"id\")\n    if not video_id:\n        raise RuntimeError(\"yt-dlp returned no video ID\")\n    comments = info.get(\"comments\") or []\n    if not comments:\n        raise RuntimeError(\n            \"No comments returned; they may be disabled, unavailable, \"\n            \"or unsupported by this extractor version\"\n        )\n    captured_at = datetime.now(timezone.utc).isoformat()\n    rows = [\n        normalize_comment(video_id, comment, captured_at)\n        for comment in comments[:COMMENT_LIMIT]\n    ]\n    with open(OUTPUT_PATH, \"w\", newline=\"\", encoding=\"utf-8\") as handle:\n        writer = csv.DictWriter(handle, fieldnames=FIELDNAMES, extrasaction=\"raise\")\n        writer.writeheader()\n        writer.writerows(rows)\n    print(f\"Wrote {len(rows)} comments to {OUTPUT_PATH}\")\nif __name__ == \"__main__\":\n    try:\n        main()\n    except Exception as error:\n        print(\n            f\"Export failed ({type(error).__name__}). \"\n            \"Check the video, comment availability, yt-dlp version, and route.\",\n            file=sys.stderr,\n        )\n        sys.exit(1)<\/code><\/pre>\n<p>In PowerShell, run <code>$env:VIDEO_URL='https:\/\/www.youtube.com\/watch?v=VIDEO_ID'<\/code>, optionally set <code>$env:PROXY_URL='http:\/\/user:password@host:port'<\/code>, and execute <code>python export-comments.py<\/code>. The success message states the number of rows and output filename. The failure path deliberately avoids printing the exception text because a network error can contain the configured proxy URL.<\/p>\n<p>Explicit <code>newline=\"\"<\/code> lets Python&#8217;s CSV module manage line endings correctly, including comments containing line breaks. Explicit <code>encoding=\"utf-8\"<\/code> preserves multilingual text. <code>DictWriter<\/code> quotes commas, quotes, and newlines where required, while <code>extrasaction=\"raise\"<\/code> prevents an accidental new field from silently changing the export contract.<\/p>\n<h2>Neutralize spreadsheet formulas in every column<\/h2>\n<p>CSV is data, but spreadsheet applications may interpret a leading <code>=<\/code>, <code>+<\/code>, <code>-<\/code>, or <code>@<\/code> as a formula. A public comment can deliberately begin with one of those characters. IDs can also begin with a hyphen, so protecting only the comment text is incomplete. <code>safe_cell<\/code> converts each value to text and prefixes an apostrophe when the first character is risky.<\/p>\n<p>The exact header is <code>video_id,comment_id,parent_id,text,published_at,captured_at<\/code>. One illustrative sanitized row is:<\/p>\n<table>\n<thead>\n<tr>\n<th>video_id<\/th>\n<th>comment_id<\/th>\n<th>parent_id<\/th>\n<th>text<\/th>\n<th>published_at<\/th>\n<th>captured_at<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>demoVideo01<\/td>\n<td>demoComment01<\/td>\n<td>root<\/td>\n<td>&#039;=HYPERLINK(&#8220;https:\/\/example.invalid&#8221;,&#8221;click&#8221;)<\/td>\n<td>2026-07-15T10:00:00+00:00<\/td>\n<td>2026-07-17T09:30:00+00:00<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The leading apostrophe is part of the exported value; it tells common spreadsheet software to display the following characters as text. Retain the original text only in a protected raw source if the approved analysis needs byte-for-byte evidence. Do not remove the first character, because that would alter the comment.<\/p>\n<h2>Interpret an empty or partial export correctly<\/h2>\n<p>Zero rows can mean comments are disabled, the video is unavailable to the current session, consent or authentication is required, the extractor changed, or the public page returned no supported comments. The script reports a single nonzero failure rather than writing a plausible-looking empty file. Record the yt-dlp version with <code>python -m yt_dlp --version<\/code> and the capture time before investigating.<\/p>\n<p>The yt-dlp limit is an upper bound, not a statement that the first 200 rows represent every audience response. Comment sorting, replies, moderation, deletion, pinned comments, and extractor behavior influence the sample. Label the file \u201cbounded yt-dlp public sample,\u201d state the requested cap, and never call it a complete thread archive.<\/p>\n<p>If replies are essential, inspect the official API resource model. The <a href='https:\/\/developers.google.com\/youtube\/v3\/docs\/commentThreads' data-source='primary'>commentThread resource documentation<\/a> explains that a thread may not contain every reply and points to <code>comments.list<\/code> for retrieving all replies to a top-level comment. That explicit relationship is preferable to inferring completeness from an extractor list.<\/p>\n<h2>Use a proxy only for the approved route requirement<\/h2>\n<p>The <code>proxy<\/code> option sends yt-dlp network requests through the supplied URL. It does not enable comments, remove moderation, or grant access to private or deleted material. Verify the route before the export with the <a href='\/blog\/test-if-your-proxy-is-working\/'>proxy check procedure<\/a>, then keep the same route for the bounded job so errors are reproducible.<\/p>\n<p>Do not write <code>PROXY_URL<\/code> to the CSV, console, exception message, or report. If authentication fails, separate URL encoding, credential validity, and endpoint availability using the <a href='\/blog\/proxy-authentication-username-password-vs-ip-auth\/'>proxy authentication guide<\/a>. Repeated retries can duplicate work and add load; stop, diagnose, and rerun once the route is known.<\/p>\n<h2>Review the file before opening it in a spreadsheet<\/h2>\n<p>Check that the header is exact, the row count matches the success message, comment IDs are unique where present, and every row has the same video ID. Open a copy in a plain-text editor first. Then inspect cells beginning with apostrophes in the target spreadsheet to confirm they display as text rather than executing.<\/p>\n<p>Comment text and channel-linked identifiers may be personal data. Keep only fields necessary for the stated analysis, restrict access, set a deletion date, and avoid publishing raw comments in a report. Google&#8217;s <a href='https:\/\/developers.google.com\/youtube\/terms\/developer-policies' data-source='primary'>YouTube API Services developer policies<\/a> remain the authority for applications using API data; yt-dlp use also requires a project-specific review of applicable terms and rights.<\/p>\n<p>If the approved export needs one stable country route and a fixed address until the CSV closes, compare Mexela&#8217;s <a href='https:\/\/mexela.com\/proxy-pricing\/'>proxy plans<\/a> by country and session persistence. The proxy is transport configuration, not a way to expand the comment scope.<\/p>\n<h2>Frequently asked questions<\/h2>\n<div class='mexela-faq'>\n<h3>Why protect IDs as well as comment text?<\/h3>\n<p>The safety rule applies to every spreadsheet cell. An identifier or timestamp-like value can also begin with a formula-trigger character, so the exporter runs every field through the same function.<\/p>\n<h3>Will yt-dlp always return 200 comments?<\/h3>\n<p>No. Two hundred is a ceiling. The video may have fewer supported comments, comments may be disabled or unavailable, and extractor or session conditions can produce fewer rows or an explicit failure.<\/p>\n<h3>When should the official API replace this script?<\/h3>\n<p>Use <code>commentThreads.list<\/code> when you need documented pagination, ordering, resource IDs, quota behavior, explicit API errors, or a maintained production integration.<\/p>\n<h3>Does the proxy retrieve removed comments?<\/h3>\n<p>No. It changes the request route only and does not make private, deleted, moderated, or otherwise unavailable comments accessible.<\/p>\n<\/div>\n<p><!-- mexela-platform-guide:end --><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Export a bounded public YouTube comment sample with Python and yt-dlp, optional proxy routing, UTF-8 CSV output, and formula-injection protection.<\/p>\n","protected":false},"author":1,"featured_media":675,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[47],"tags":[491,533,486,489,488,490],"_links":{"self":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/674"}],"collection":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/comments?post=674"}],"version-history":[{"count":2,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/674\/revisions"}],"predecessor-version":[{"id":858,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/674\/revisions\/858"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/media\/675"}],"wp:attachment":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/media?parent=674"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/categories?post=674"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/tags?post=674"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}