Updating YouTube descriptions with the Data API without wiping the snippet

The first script I wrote to bulk-update video descriptions also cleared the tags on every video it touched. Nothing errored. The API returned 200, the new description was live, and the tags were gone.
That is documented behaviour rather than a bug. videos.update treats the parts you send as the new state of those parts, and the method reference says so directly: "If you are submitting an update request, and your request does not specify a value for a property that already has a value, the property's existing value will be deleted."
So there is no such thing as a description-only update. What follows is the read-modify-write shape that makes the write safe, a validator worth running before it, and the quota arithmetic that caps how many videos you can process in a day.
part selects what you overwrite, not what you patch
The part parameter is often read as a field mask. It is not. It selects which resource parts the request is writing, and whatever you supply becomes the entirety of those parts.
Two consequences follow. First, sending part=snippet with only description populated will drop tags, defaultLanguage, and anything else that lived in the snippet. Second, snippet.title and snippet.categoryId are required whenever you send a snippet part, so a body containing only a description is rejected outright. The request that succeeds and the request that quietly destroys data look almost identical.
Read, merge, then write
The fix is to fetch the current snippet, replace one key, and send back a body assembled from an explicit allowlist. Copying the whole snippet straight from the read response also works, but it carries read-only fields such as publishedAt and thumbnails back to the server, and it will happily carry back a field you did not intend to preserve.
from googleapiclient.discovery import build
# The snippet properties that videos.update actually accepts.
MUTABLE_SNIPPET_FIELDS = (
"title",
"description",
"tags",
"categoryId",
"defaultLanguage",
)
def fetch_snippet(youtube, video_id: str) -> dict:
response = youtube.videos().list(part="snippet", id=video_id).execute()
items = response.get("items", [])
if not items:
raise LookupError(f"no video returned for id {video_id!r}")
return items[0]["snippet"]
def merge_description(current: dict, description: str) -> dict:
merged = {key: current[key] for key in MUTABLE_SNIPPET_FIELDS if key in current}
merged["description"] = description
missing = [key for key in ("title", "categoryId") if not merged.get(key)]
if missing:
raise ValueError(f"snippet missing required field(s): {', '.join(missing)}")
return merged
def write_snippet(youtube, video_id: str, snippet: dict) -> dict:
return youtube.videos().update(
part="snippet",
body={"id": video_id, "snippet": snippet},
).execute()
Keeping MUTABLE_SNIPPET_FIELDS as a named tuple rather than inlining the comprehension is worth the extra line. When a field is added to the resource later, there is one place to change and one place to review.
Validate the string before it reaches the API
The API will not tell you that your timestamps are unusable. It stores whatever text you send, and the chapter behaviour is derived from that text afterwards. Two things are worth checking locally.
Descriptions are capped at 5,000 characters, per YouTube's own help page. And for chapters to work at all, the chapter documentation requires the first timestamp to be 00:00, at least three timestamps listed in ascending order, and a minimum chapter length of 10 seconds.
All four of those are cheap to assert:
import re
MAX_DESCRIPTION_CHARS = 5000
MIN_CHAPTER_SECONDS = 10
MIN_CHAPTERS = 3
TIMESTAMP_LINE = re.compile(r"^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s+(\S.*)$")
def _seconds(match: re.Match) -> int:
hours, minutes, seconds = match.group(1), match.group(2), match.group(3)
return int(hours or 0) * 3600 + int(minutes) * 60 + int(seconds)
def check_description(text: str) -> list[str]:
problems = []
if len(text) > MAX_DESCRIPTION_CHARS:
problems.append(f"{len(text)} chars, over the {MAX_DESCRIPTION_CHARS} limit")
stamps = [
_seconds(match)
for match in (TIMESTAMP_LINE.match(line.strip()) for line in text.splitlines())
if match
]
if not stamps:
return problems
if stamps[0] != 0:
problems.append("first timestamp is not 00:00")
if len(stamps) < MIN_CHAPTERS:
problems.append(f"{len(stamps)} timestamps, chapters need {MIN_CHAPTERS}")
for earlier, later in zip(stamps, stamps[1:]):
if later <= earlier:
problems.append(f"timestamps not ascending at {later}s")
break
if later - earlier < MIN_CHAPTER_SECONDS:
problems.append(f"chapter under {MIN_CHAPTER_SECONDS}s at {earlier}s")
break
return problems
Returning a list of strings rather than raising keeps the function usable in a batch report, where you want every failing video named in one pass instead of stopping at the first.
One caveat on the regex: it only treats a line as a chapter mark if a label follows the timestamp. A bare 12:30 sitting in a sentence is ignored, which is the behaviour you want when a description mentions a runtime in prose.

Quota decides your batch size
Quota is the constraint that bites on a back catalogue. The cost table puts videos.list at 1 unit and videos.update at 50, and a project's default allocation is 10,000 units per day across those endpoints.
| Step | Method | Units |
|---|---|---|
| Read current snippet | videos.list |
1 |
| Write merged snippet | videos.update |
50 |
| Per video | 51 |
That is 196 full read-and-write cycles before the daily allocation runs out, and the allocation resets at midnight Pacific. A 400-video back catalogue is a three-day job, so the script needs to be resumable rather than fast.
Which makes a dry run the default mode, not a flag you remember to use:
def run(youtube, jobs, apply: bool = False) -> int:
"""jobs: iterable of (video_id, new_description). Returns units spent."""
units = 0
for video_id, description in jobs:
problems = check_description(description)
if problems:
print(f"{video_id} SKIP {'; '.join(problems)}")
continue
current = fetch_snippet(youtube, video_id)
units += 1
if current.get("description") == description:
print(f"{video_id} SAME")
continue
delta = len(description) - len(current.get("description", ""))
print(f"{video_id} {'WRITE' if apply else 'PLAN '} {delta:+d} chars")
if apply:
write_snippet(youtube, video_id, merge_description(current, description))
units += 50
return units
Validating before the read means a malformed description costs nothing. Comparing against the current value means a re-run over an already-processed batch costs 1 unit per video instead of 51, which is what makes the job resumable after it hits the ceiling.
Where the draft text comes from
The script does not care how the string was produced. Mine come from three places depending on the video: hand-written for anything unusual, a per-format template checked into the repo next to the script, and a generator when there is a backlog and the useful thing is a starting point rather than an empty field. AllyHub is what I have wired in at the moment, though for a channel with a stable format a template file does the same job with fewer moving parts.
Whichever it is, the string still goes through check_description() before it reaches the API. A generator has no way of knowing where this particular video's chapters actually fall, so timestamps are the one field that never survives a round trip through one unedited.

Questions worth answering before you run it
Is there a field mask that avoids the read?
Not on this method. part selects resource parts, and there is no updateMask on videos.update, so the read is load-bearing rather than defensive. The 1 unit it costs is 2% of the 51 you spend per video anyway.
Can you cache snippets locally and skip the read?
You can, and on a large catalogue the saving is real. The risk is that the cache goes stale the moment anyone edits a video in Studio, and a stale cache turns a merge into exactly the silent overwrite the read was there to prevent. If you do cache, store the etag from the read alongside the snippet and treat a mismatch as a cache miss.
What happens if the write fails halfway through a batch?
Each videos.update is independent, so a failure leaves earlier videos updated and later ones untouched. That is survivable as long as the job is idempotent, which the current == description check above gives you for free: re-running the same batch skips everything already written.
The habit that matters here is smaller than any of the code. Before a script writes to a resource you did not fully read, check whether the API's update is a patch or a replacement. For videos.update it is a replacement, and the tags you lose will not show up in the response.




