DEV Community

appierun
appierun

Posted on

Audit Title and Meta Description Lengths from a Sitemap with Python (Standard Library Only)

A sitemap can be valid XML and still ship titles like Home or meta descriptions that are empty, identical across fifty product pages, or long enough to get cut mid-sentence in a SERP. None of that shows up as a sitemap error. You only see it when you open each page, or when Search Console shows a title Google rewrote for you.

This post builds a small auditor in Python using only the standard library (html.parser, urllib, xml.etree, csv, concurrent.futures). It reads a sitemap or sitemap index, fetches every listed URL, extracts the <title> and meta name="description", measures their character lengths, flags missing and empty values, and marks duplicate titles or descriptions across the set. The output is a CSV you can sort in a spreadsheet.

Google Search Central docs on title links and snippets / meta descriptions were opened on 7 Nov 2026. Appie free-tool descriptions are as of 7 Nov 2026. The script was tested on Python 3.13.5 against a local fixture site, shown below.

Disclosure: Appie.run publishes this article. Two free tools mentioned near the end are ours. The script does not depend on them.

Where this fits

Earlier posts in this series covered extracting JSON-LD structured data with the standard library and finding broken links and redirect chains in a sitemap. Those check machine-readable markup and HTTP behavior. Titles and meta descriptions are the human-facing SERP text. Same pattern: parse what the server returns, print what is there, fail loudly when the page is incomplete or out of a practical length band.

What Google actually says about length

This matters, because a lot of SEO checklists still treat "60 characters" or "160 characters" as if Google published those numbers as rules. The current docs do not.

On title links (opened 7 Nov 2026), Google says every page should have a <title>, that the text should be descriptive and concise, and that you should avoid vague titles, keyword stuffing, and repeated boilerplate. It also says: there is no limit on how long a <title> element can be, but the title link is truncated in Google Search results as needed, typically to fit the device width. Google may also rewrite the title link using on-page headings, og:title, anchor text and other sources when it detects issues.

On snippets and meta descriptions (opened 7 Nov 2026), Google says snippets are primarily created from page content, and that Google sometimes uses the meta description when it describes the page better. Again: there is no limit on how long a meta description can be, but the snippet is truncated as needed to fit the device. Unique, page-specific descriptions are preferred over site-wide boilerplate. Keyword lists are less likely to be shown as a snippet. The docs even call out a "too short" example as a bad pattern.

So this script does not claim a Google-official character cutoff. It uses a practical audit band as a starting point for triage:

  • <title>: Practical band (defaults): 30 to 60 characters; Why it is useful anyway: Catches Home / Contact stubs and essay-length titles that almost always truncate.
  • meta description: Practical band (defaults): 70 to 160 characters; Why it is useful anyway: Catches empty pitches and keyword dumps; puts the important sentence first.

Override the bands with flags when your brand suffix or language needs a different window. The point of the band is to surface outliers for a human edit, not to score pages against a Google rule that does not exist.

The script

Save this as title_meta_audit.py. It should run on Python 3.8 or later; we tested it on 3.13.5.

#!/usr/bin/env python3
"""Audit <title> and meta description lengths for every URL in an XML sitemap.

Standard library only. Usage:
    python3 title_meta_audit.py https://example.com/sitemap.xml --out report.csv
    python3 title_meta_audit.py /path/to/sitemap.xml --out report.csv
    python3 title_meta_audit.py sitemap.xml --limit 20

Practical length bands (NOT Google rules; Google documents no fixed character
limit and truncates to fit the device). Defaults:
    title: 30-60 characters
    meta description: 70-160 characters
Override with --title-min/--title-max/--desc-min/--desc-max.
"""
import argparse
import csv
import gzip
import sys
import threading
import time
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from html.parser import HTMLParser

NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
UA = "title-meta-audit/1.0 (+https://example.com/contact)"


class TitleMetaParser(HTMLParser):
    """Collect <title> text and name=description meta content from <head>."""

    def __init__(self):
        super().__init__()
        self.title_parts = []
        self.description = None
        self._in_head = False
        self._in_title = False
        self._done = False

    def handle_starttag(self, tag, attrs):
        if self._done:
            return
        name = tag.lower()
        attr = {k.lower(): (v or "") for k, v in attrs}
        if name == "head":
            self._in_head = True
            return
        if name == "body":
            self._done = True
            return
        if not self._in_head:
            return
        if name == "title":
            self._in_title = True
            return
        if name == "meta":
            meta_name = (attr.get("name") or "").strip().lower()
            if meta_name == "description" and self.description is None:
                self.description = attr.get("content", "")

    def handle_endtag(self, tag):
        name = tag.lower()
        if name == "title":
            self._in_title = False
        if name == "head":
            self._in_head = False
            self._done = True

    def handle_data(self, data):
        if self._in_title and not self._done:
            self.title_parts.append(data)

    @property
    def title(self):
        return "".join(self.title_parts).strip()


def fetch_bytes(src, timeout):
    if src.startswith(("http://", "https://")):
        req = urllib.request.Request(src, headers={"User-Agent": UA})
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = resp.read()
    else:
        with open(src, "rb") as fh:
            data = fh.read()
    return gzip.decompress(data) if data[:2] == b"\x1f\x8b" else data


def sitemap_urls(src, timeout, seen=None, depth=0):
    seen = seen if seen is not None else set()
    if src in seen or depth > 3:
        return []
    seen.add(src)
    root = ET.fromstring(fetch_bytes(src, timeout))
    locs = [el.text.strip() for el in root.iter(NS + "loc") if el.text]
    if root.tag == NS + "sitemapindex":
        urls = []
        for child in locs:
            urls += sitemap_urls(child, timeout, seen, depth + 1)
        return urls
    return locs


def fetch_html(url, timeout):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        charset = resp.headers.get_content_charset() or "utf-8"
        return resp.read().decode(charset, errors="replace"), resp.geturl(), resp.status


def classify(title, description, title_min, title_max, desc_min, desc_max):
    issues = []
    title_len = len(title) if title is not None else 0
    desc_len = len(description) if description is not None else 0

    if not title:
        issues.append("missing title")
    else:
        if title_len < title_min:
            issues.append(f"title too short ({title_len} < {title_min})")
        elif title_len > title_max:
            issues.append(f"title too long ({title_len} > {title_max})")

    if description is None:
        issues.append("missing meta description")
    elif description.strip() == "":
        issues.append("empty meta description")
    else:
        if desc_len < desc_min:
            issues.append(f"description too short ({desc_len} < {desc_min})")
        elif desc_len > desc_max:
            issues.append(f"description too long ({desc_len} > {desc_max})")

    return issues, title_len, desc_len


def audit_one(url, timeout, bands):
    title_min, title_max, desc_min, desc_max = bands
    try:
        html, final_url, status = fetch_html(url, timeout)
    except urllib.error.HTTPError as e:
        return {
            "url": url,
            "final_url": "",
            "status": e.code,
            "title": "",
            "title_len": 0,
            "description": "",
            "desc_len": 0,
            "issues": f"HTTP error {e.code}",
        }
    except Exception as e:
        return {
            "url": url,
            "final_url": "",
            "status": 0,
            "title": "",
            "title_len": 0,
            "description": "",
            "desc_len": 0,
            "issues": f"error: {e}",
        }
    parser = TitleMetaParser()
    parser.feed(html)
    title = parser.title
    description = parser.description
    issues, title_len, desc_len = classify(
        title, description, title_min, title_max, desc_min, desc_max
    )
    return {
        "url": url,
        "final_url": final_url,
        "status": status,
        "title": title,
        "title_len": title_len,
        "description": description if description is not None else "",
        "desc_len": desc_len,
        "issues": "; ".join(issues) if issues else "",
    }


def main(argv=None):
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("sitemap", help="Sitemap URL or local XML path")
    p.add_argument("--out", default="title_meta_report.csv")
    p.add_argument("--timeout", type=float, default=15.0)
    p.add_argument("--workers", type=int, default=4)
    p.add_argument("--delay", type=float, default=0.25, help="Seconds between request starts")
    p.add_argument("--limit", type=int, default=0, help="Audit at most N URLs (0 = all)")
    p.add_argument("--title-min", type=int, default=30)
    p.add_argument("--title-max", type=int, default=60)
    p.add_argument("--desc-min", type=int, default=70)
    p.add_argument("--desc-max", type=int, default=160)
    args = p.parse_args(argv)

    bands = (args.title_min, args.title_max, args.desc_min, args.desc_max)
    try:
        urls = sitemap_urls(args.sitemap, args.timeout)
    except Exception as e:
        print(f"Failed to read sitemap: {e}", file=sys.stderr)
        return 2
    if args.limit and args.limit > 0:
        urls = urls[: args.limit]

    print(
        f"auditing {len(urls)} URLs with {args.workers} workers "
        f"(title {args.title_min}-{args.title_max}, "
        f"description {args.desc_min}-{args.desc_max}; practical bands)"
    )

    lock = threading.Lock()
    last_start = [0.0]
    rows = []

    def gated(url):
        with lock:
            now = time.monotonic()
            wait = args.delay - (now - last_start[0])
            if wait > 0:
                time.sleep(wait)
            last_start[0] = time.monotonic()
        return audit_one(url, args.timeout, bands)

    with ThreadPoolExecutor(max_workers=args.workers) as pool:
        futures = {pool.submit(gated, u): u for u in urls}
        for fut in as_completed(futures):
            rows.append(fut.result())

    # Preserve sitemap order
    by_url = {r["url"]: r for r in rows}
    ordered = [by_url[u] for u in urls if u in by_url]

    # Duplicate title / description across pages (after all fetches)
    title_map = defaultdict(list)
    desc_map = defaultdict(list)
    for r in ordered:
        if r["title"]:
            title_map[r["title"]].append(r["url"])
        if r["description"]:
            desc_map[r["description"]].append(r["url"])

    for r in ordered:
        extras = []
        if r["title"] and len(title_map[r["title"]]) > 1:
            extras.append("duplicate title")
        if r["description"] and len(desc_map[r["description"]]) > 1:
            extras.append("duplicate description")
        if extras:
            r["issues"] = (
                (r["issues"] + "; " if r["issues"] else "") + "; ".join(extras)
            )

    for r in ordered:
        flag = r["issues"] or "ok"
        print(
            f"{r['status']} {r['url']}  "
            f"title={r['title_len']} desc={r['desc_len']}  {flag}"
        )

    fields = [
        "url",
        "final_url",
        "status",
        "title",
        "title_len",
        "description",
        "desc_len",
        "issues",
    ]
    with open(args.out, "w", newline="", encoding="utf-8") as fh:
        w = csv.DictWriter(fh, fieldnames=fields)
        w.writeheader()
        w.writerows(ordered)

    ok = sum(1 for r in ordered if not r["issues"])
    bad = len(ordered) - ok
    print(f"{ok} ok, {bad} with issues; report: {args.out}")
    print(
        "Note: length bands are a practical audit starting point, not a Google rule. "
        "Google documents no fixed character limit for titles or meta descriptions."
    )
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

Local test

Serve a small fixture site with pages that miss titles, miss descriptions, go too short or too long, and share duplicate SERP text. Example pages (abbreviated):

<!-- good.html: title 43, description 125 -->
<title>Pricing Plans for Growing SaaS Teams | Acme</title>
<meta name="description" content="Compare Free, Starter and Standard plans. See credit limits, included tools and when to upgrade as your support volume grows.">

<!-- short-title.html: title "Home" (4 chars) -->
<title>Home</title>
<meta name="description" content="Welcome to Acme, the product platform that helps product and GTM teams ship clearer release notes and track customer feedback in one place.">

<!-- long-title.html: title 130 chars -->
<title>Acme Blog: Complete Guide to Customer Discovery Interviews, SAFE Scripts, Note Templates and Follow-up Emails for Founders in 2026</title>

<!-- no-desc.html: title present, no meta description -->
<title>About Acme: Our Story and Team</title>

<!-- short-desc.html -->
<title>Contact Acme Support and Sales</title>
<meta name="description" content="Get in touch.">

<!-- long-desc.html: description 423 chars -->
<title>Acme Features Overview for Product Teams</title>
<meta name="description" content="Explore every Acme feature in depth: ...without reinventing the wheel every sprint.">

<!-- no-title.html: description only -->
<meta name="description" content="Docs for integrating Acme with your stack, including API keys, webhooks and MCP server setup for agent workflows.">

<!-- empty-desc.html -->
<title>Careers at Acme: Open Roles</title>
<meta name="description" content="">

<!-- dup-a.html and dup-b.html share the same title and description -->
<title>Acme Product Updates and Changelog</title>
<meta name="description" content="Release notes and product updates from the Acme team. See what shipped this month and what is coming next for customers.">
Enter fullscreen mode Exit fullscreen mode

A sitemap.xml lists those ten pages plus a deliberate 404 (missing.html). A tiny http.server on 127.0.0.1:8766 serves the folder.

python3 test_server.py
python3 title_meta_audit.py http://127.0.0.1:8766/sitemap.xml --out report.csv --delay 0.05
Enter fullscreen mode Exit fullscreen mode

Real output from Python 3.13.5 on 7 Nov 2026 (exit code 1 because issues were found):

auditing 11 URLs with 4 workers (title 30-60, description 70-160; practical bands)
200 http://127.0.0.1:8766/good.html  title=43 desc=125  ok
200 http://127.0.0.1:8766/short-title.html  title=4 desc=139  title too short (4 < 30)
200 http://127.0.0.1:8766/long-title.html  title=130 desc=135  title too long (130 > 60)
200 http://127.0.0.1:8766/no-desc.html  title=30 desc=0  missing meta description
200 http://127.0.0.1:8766/short-desc.html  title=30 desc=13  description too short (13 < 70)
200 http://127.0.0.1:8766/long-desc.html  title=40 desc=423  description too long (423 > 160)
200 http://127.0.0.1:8766/no-title.html  title=0 desc=113  missing title
200 http://127.0.0.1:8766/empty-desc.html  title=27 desc=0  title too short (27 < 30); empty meta description
200 http://127.0.0.1:8766/dup-a.html  title=34 desc=120  duplicate title; duplicate description
200 http://127.0.0.1:8766/dup-b.html  title=34 desc=120  duplicate title; duplicate description
404 http://127.0.0.1:8766/missing.html  title=0 desc=0  HTTP error 404
1 ok, 10 with issues; report: /workspace/tmp/title-meta-audit/report.csv
Note: length bands are a practical audit starting point, not a Google rule. Google documents no fixed character limit for titles or meta descriptions.
Enter fullscreen mode Exit fullscreen mode

Only good.html passed the default band with unique text. The two changelog pages passed the length checks on their own, then failed the duplicate pass once both had been fetched. That second pass is why the script waits until every URL is in before printing the final flags.

CSV columns: url, final_url, status, title, title_len, description, desc_len, issues. Sample rows:

url,status,title_len,desc_len,issues
http://127.0.0.1:8766/good.html,200,43,125,
http://127.0.0.1:8766/short-title.html,200,4,139,title too short (4 < 30)
http://127.0.0.1:8766/long-desc.html,200,40,423,description too long (423 > 160)
http://127.0.0.1:8766/dup-a.html,200,34,120,duplicate title; duplicate description
http://127.0.0.1:8766/missing.html,404,0,0,HTTP error 404
Enter fullscreen mode Exit fullscreen mode

How the parser works

html.parser.HTMLParser only collects while it is inside <head>, and it stops at </head> or <body>. That keeps footer widgets and body copy from polluting the report.

The title is the concatenated text nodes inside <title>, stripped. The description is the first meta whose name is description (case-insensitive). Later duplicate description tags are ignored; fix the first one.

Character length is Python's len() on the Unicode string after decode. That is a character count, not a rendered pixel width. Google truncates by device width, not by a published character quota, which is another reason the bands are labeled as a practical starting point.

Sitemap reading matches the earlier posts in this series: xml.etree walks {http://www.sitemaps.org/schemas/sitemap/0.9}loc nodes, follows a sitemap index up to depth 3, and accepts .gz payloads. Local files and http(s) URLs both work as the sitemap argument.

Reading the report

A few rules of thumb once you have the CSV:

  • Missing or empty title. Google will invent a title link from headings, og:title or anchors. Write a real <title> that matches the main visible heading.
  • Title too short. Home, Blog, Pricing and similar stubs fail the lower band on purpose. Add the page topic and a short brand suffix.
  • Title too long. Put the distinctive part first. Brand suffixes are fine; keyword essays are not. Google may rewrite verbose titles anyway.
  • Missing or empty meta description. Not a ranking penalty by itself, but you give up a chance to pitch the page. Prefer a unique sentence over nothing.
  • Description too short or too long. Aim for a readable pitch with the important claim first. Truncation is width-based; front-loading still helps.
  • Duplicate title or description. Common after CMS templates reuse one string. Google's title and snippet docs both warn against boilerplate. Differentiate the pages people cannot tell apart in results.
  • HTTP errors. Fix or remove those URLs from the sitemap before you worry about meta length. Pair this script with the broken-link / redirect-chain checker if status codes are noisy.

Polite defaults and running it on a real site

Defaults: 4 workers, 0.25 seconds between request starts, 15 second timeout. Raise --workers and lower --delay on your own staging box if the server can take it. On a client site, ask first and keep the defaults. Change the contact URL in the UA string so the site owner can reach you.

python3 title_meta_audit.py https://www.example.com/sitemap.xml --limit 50 --out sample.csv
python3 title_meta_audit.py https://www.example.com/sitemap.xml --title-min 25 --title-max 70 --desc-min 50 --desc-max 180 --out custom-bands.csv
Enter fullscreen mode Exit fullscreen mode

Use --limit for a first pass. For large indexes, audit one child sitemap at a time.

Known limits

  • Server HTML only. Titles or descriptions injected only by client-side JavaScript will not appear. View-source what the server returns.
  • Character count, not pixels. Wide characters and different fonts change how much fits in a SERP. The band is a triage tool.
  • First meta name="description" only. Unusual pages with several description tags need a human look.
  • No Open Graph / Twitter Card check. Those are separate preview signals; use the OG/Twitter checker from this series for link previews.
  • No robots.txt handling. The script requests what the sitemap lists.
  • Duplicate detection is exact. Trailing spaces or tiny copy edits count as different strings.

Free tools for the steps around the script

If you want a URL list in the browser before you run anything:

  • Sitemap URL Extractor (ours, free; checked 7 Nov 2026). Paste sitemap XML or enter a sitemap URL. You get one URL per line from urlset <loc> tags. Given a sitemap index, it lists child sitemap locations; open a child and run it again for that file's page URLs. Useful when you want to sample a subset into a shell loop before a full audit.

When a page's meta description is empty or too thin and you need draft Q&A from the page body itself:

  • Webpage to FAQ Generator (ours, free; checked 7 Nov 2026). Paste a single public page URL. It builds FAQs from the text on that page and is instructed to skip claims the page does not make. That will not write your meta description for you, but it is a fast way to surface page-specific sentences you can turn into a unique description.

Both ask for a captcha before each run. Neither replaces the length audit this script performs.

FAQ

Is a title over 60 characters "wrong"?
Not by Google's docs. Google documents no fixed character limit and truncates to fit the device. The default upper band is a practical flag so you review titles that will almost always truncate. Raise --title-max if your brand needs more room.

Does a missing meta description hurt rankings?
Google does not require one. It may still generate a snippet from page content. A unique description is still worth writing for pages you care about in search results.

Why flag duplicates if both pages are "in band"?
Because identical titles and descriptions make results hard to tell apart. Google's own title and snippet guidance warns against repeated or boilerplate text across pages.

Can I use Beautiful Soup or lxml?
Yes, if you already depend on them. This series stays on the standard library so the script runs anywhere Python runs, with no pip install.

Will it see meta tags added by React or Vue after load?
Only if they are present in the HTML response. Most crawlers see the first response body.

Can I run it in CI?
Yes. It exits with 1 when any URL has an issue, 2 when the sitemap cannot be read, and 0 when every URL is clean under your chosen bands.

Wrap-up

Title and meta description problems rarely show up in sitemap validators. They show up as vague SERP text, Google-rewritten titles, and pages that look the same in results. Run this auditor after template changes, migrations and bulk CMS edits. Pair it with the JSON-LD and broken-link scripts when you want a fuller technical pass. If you need a URL list from a sitemap in the browser first, the free Sitemap URL Extractor is enough to get started.

RG, Founder, Appie.run

Top comments (0)