Skip to main content

Command Palette

Search for a command to run...

Updating YouTube descriptions with the Data API without wiping the snippet

Updated
•7 min read•View as Markdown
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.

Sticky notes lined up beside a monitor on a desk

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.

A hand ticking items on a printed checklist

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.