Alt text goes missing in boring ways. A CMS upload field is left blank, a lazy-loading plugin swaps the real <img> for a placeholder, a designer exports IMG_0423.jpg and someone pastes the filename into the alt box, or the logo link in the header loses its only text. Nothing breaks. The page loads, the sitemap validates, and a screen reader user hears "link" or a file name.
This post builds a small auditor in Python using only the standard library (urllib, html.parser, xml.etree, csv, concurrent.futures). It reads a sitemap or sitemap index, fetches each page, collects every <img> and <input type="image">, and flags missing alt attributes, filename or placeholder alt text, image-only links with no text, and a few cases worth a second look. Output is a CSV plus a console summary, and the exit code works in CI.
Google Search Central's image SEO best practices (showing "Last updated 2026-03-02 UTC"), the W3C WAI alt decision tree and decorative images tutorial, and Understanding WCAG 2.2 SC 1.1.1 Non-text Content (showing "Updated 06 September 2026") were all opened on 9 Nov 2026. Appie free-tool descriptions are as of 9 Nov 2026. The script was tested on Python 3.13.5 against a local fixture site and a live public sitemap; the output below is real.
Disclosure: Appie.run publishes this article. Two free tools mentioned near the end are ours, and one of the test runs below used our own sitemap. The script does not depend on any of them.
Where this fits
This is the next script in a small standard-library series. Earlier posts covered finding broken links and redirect chains in a sitemap and auditing title and meta description lengths. Same approach: parse what the server returns, report what is there, and leave the judgment to a person.
What the guidance actually says
Two sources matter here, and they answer different questions.
Accessibility (W3C WAI). WCAG 2.2 Success Criterion 1.1.1 says all non-text content presented to the user needs a text alternative that serves the equivalent purpose, with exceptions. One exception is pure decoration: it should be implemented so assistive technology can ignore it. The Understanding SC 1.1.1 page lists common failures, including:
- F65: omitting the
altattribute or text alternative onimgelements,areaelements andinputelements of type "image". - F30: using text alternatives that are not alternatives, such as filenames or placeholder text.
- F39: giving a decorative image a non-null alt such as
alt="spacer"oralt="image".
The WAI decorative images tutorial explains why "just leave it out" is not the fix for decorative images: use alt="" so screen readers can ignore the image, because when the attribute is missing some screen readers announce the file name instead. It also says whether an image is decorative or informative is a judgment only the author can make. The alt decision tree adds the case people forget most: if an image is inside a link or button and it would be hard to understand what the link does without it, the alt should describe the destination or action.
Search (Google). Google's image SEO best practices call alt text "the most important attribute when it comes to providing more metadata for an image." Google says it uses alt text together with computer vision and the page content to understand what an image shows, and that alt text on a linked image is useful as anchor text. The same page warns against filling alt attributes with keywords, since that is a poor experience and may cause a site to be seen as spam.
Notice what Google does not say. It does not say missing alt text lowers a page's ranking, and it does not promise that adding alt text gets you image traffic. The page frames alt text as helping Google understand the image, alongside filenames, captions, nearby text and the rest of the page. So this script treats alt problems as an accessibility issue first and a "help Google understand your images" issue second. It is not a ranking audit.
What the script flags
-
missing_alt: Meaning:<img>or<input type="image">with noaltattribute; What to check: Add a description, oralt=""if the image is decorative -
filename_alt: Meaning: Alt looks like a file name or camera default (IMG_0423.jpg,Screenshot 2026-...) or equals the image's file name; What to check: Replace with what the image shows or does -
placeholder_alt: Meaning: Alt is a generic word such as "image", "photo", "logo", "icon", "spacer"; What to check: Describe it, or usealt=""if decorative -
empty_alt_in_link: Meaning:alt=""on an image that is the only content of a link; What to check: The link now has no accessible name. Describe the destination -
link_has_no_text: Meaning: Same situation with the alt missing entirely; What to check: Same fix -
labelled_no_alt: Meaning: Noalt, butaria-labeloraria-labelledbyis set; What to check: Often fine for assistive tech;altis simpler and is the attribute Google's image docs describe -
hidden_no_alt: Meaning: Noalt, butrole="presentation"/noneoraria-hidden="true"; What to check: Probably decorative. WAI notesalt=""is more widely supported than the role -
long_alt: Meaning: Alt longer than 150 characters; What to check: Not an error, and not a WCAG or Google limit. Long descriptions of charts often belong in the page text -
empty_alt: Meaning:alt=""(reported only with--include-empty); What to check: Usually correct for decorative images. Review the list once -
http_error/fetch_failed: Meaning: The page did not load; What to check: Fix the page or remove it from the sitemap
A few things are deliberately ignored: markup inside <script>, <style>, <template> and <noscript> (lazy-load plugins often put a duplicate <img> in <noscript>), and CSS background images, which are not in the HTML as <img> at all. Google's image page notes it does not index CSS images, and WCAG treats CSS-only informative images as a separate failure (F3), so those need a manual look.
The script
Save as alt_audit.py. No pip install needed.
#!/usr/bin/env python3 """Find images with missing, empty, or placeholder alt text across a sitemap. Standard library only. Usage: python3 alt_audit.py https://example.com/sitemap.xml --out alt_report.csv """ import argparse import csv import re import sys import urllib.error import urllib.request import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor from html.parser import HTMLParser from urllib.parse import urljoin UA = "alt-audit/1.0 (+stdlib python)" SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}" PLACEHOLDERS = {"image", "img", "photo", "picture", "graphic", "logo", "icon", "banner", "spacer", "untitled", "alt", "placeholder", "null"} FILENAME_RE = re.compile(r"^[\w\-. ]+\.(jpe?g|png|gif|webp|avif|svg|bmp)$", re.I) CAMERA_RE = re.compile(r"^(img|dsc|image|screenshot|photo)[\s_\-]?\d+", re.I) def fetch(url, timeout=15): 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.status, resp.geturl(), resp.read().decode(charset, "replace") def sitemap_urls(sitemap_url, seen=None): """Return page URLs from a urlset, following sitemap indexes.""" seen = seen if seen is not None else set() if sitemap_url in seen: return [] seen.add(sitemap_url) _, _, body = fetch(sitemap_url) root = ET.fromstring(body.encode("utf-8")) if root.tag == SM_NS + "sitemapindex": urls = [] for sm in root.findall(SM_NS + "sitemap"): loc = sm.find(SM_NS + "loc") if loc is not None and loc.text: urls.extend(sitemap_urls(loc.text.strip(), seen)) return urls # direct <url><loc> children only, so image:loc entries are skipped return [u.find(SM_NS + "loc").text.strip() for u in root.findall(SM_NS + "url") if u.find(SM_NS + "loc") is not None and u.find(SM_NS + "loc").text] class ImgParser(HTMLParser): """Collect every <img> and <input type=image>, plus link context.""" def __init__(self, base): super().__init__(convert_charrefs=True) self.base = base self.images = [] self._skip = 0 # inside <script>/<style>/<template>/<noscript> self._links = [] # stack of open <a> elements def handle_starttag(self, tag, attrs): a = dict(attrs) if tag in ("script", "style", "template", "noscript"): self._skip += 1 return if self._skip: return if tag == "a" and "href" in a: self._links.append({"text": [], "imgs": []}) elif tag == "img" or (tag == "input" and (a.get("type") or "").lower() == "image"): src = a.get("src") or "" if a.get("data-src") and (not src or src.startswith("data:")): src = a["data-src"] # lazy-loaded image placeholder img = { "tag": tag, "src": urljoin(self.base, src) if src else "", "alt": a.get("alt"), # None means the attribute is absent "hidden": (a.get("role") in ("presentation", "none") or a.get("aria-hidden") == "true"), "aria_name": bool((a.get("aria-label") or "").strip() or a.get("aria-labelledby")), "in_link": bool(self._links), } self.images.append(img) if self._links: self._links[-1]["imgs"].append(img) def handle_startendtag(self, tag, attrs): if tag in ("img", "input"): self.handle_starttag(tag, attrs) def handle_endtag(self, tag): if tag in ("script", "style", "template", "noscript"): if self._skip: self._skip -= 1 elif tag == "a" and self._links and not self._skip: link = self._links.pop() text = "".join(link["text"]).strip() alts = "".join((i["alt"] or "") for i in link["imgs"]).strip() if link["imgs"] and not text and not alts: for i in link["imgs"]: i["empty_link"] = True def handle_data(self, data): if self._links and not self._skip: self._links[-1]["text"].append(data) def classify(img): """Return a list of issue codes for one image.""" alt = img["alt"] issues = [] if alt is None: issues.append("labelled_no_alt" if img["aria_name"] else "hidden_no_alt" if img["hidden"] else "missing_alt") elif not alt.strip(): issues.append("empty_alt_in_link" if img.get("empty_link") else "empty_alt") else: a = alt.strip() name = img["src"].rsplit("/", 1)[-1].split("?")[0] if a.casefold() in PLACEHOLDERS: issues.append("placeholder_alt") elif FILENAME_RE.match(a) or CAMERA_RE.match(a) or (name and a == name): issues.append("filename_alt") if len(a) > 150: issues.append("long_alt") if img.get("empty_link") and alt is None: issues.append("link_has_no_text") return issues def audit(url): rows = [] try: status, final_url, body = fetch(url) except urllib.error.HTTPError as e: return [{"page": url, "status": e.code, "image": "", "alt": "", "issues": "http_error"}], 0 except (urllib.error.URLError, TimeoutError) as e: return [{"page": url, "status": "error", "image": "", "alt": "", "issues": f"fetch_failed:{e}"}], 0 p = ImgParser(final_url) p.feed(body) p.close() for img in p.images: issues = classify(img) if issues: rows.append({"page": url, "status": status, "image": img["src"], "alt": "" if img["alt"] is None else img["alt"], "issues": ";".join(issues)}) return rows, len(p.images) def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("sitemap") ap.add_argument("--out", default="alt_report.csv") ap.add_argument("--workers", type=int, default=8) ap.add_argument("--limit", type=int, default=0, help="check only the first N URLs") ap.add_argument("--include-empty", action="store_true", help="also report alt=\"\" (often correct for decorative images)") args = ap.parse_args() try: urls = sitemap_urls(args.sitemap) except (urllib.error.URLError, ET.ParseError) as e: print(f"Could not read sitemap: {e}", file=sys.stderr) return 2 if args.limit: urls = urls[:args.limit] print(f"Checking {len(urls)} URLs from {args.sitemap}") with ThreadPoolExecutor(max_workers=args.workers) as pool: results = list(pool.map(audit, urls)) rows, total = [], 0 for page_rows, n in results: total += n rows.extend(r for r in page_rows if args.include_empty or r["issues"] != "empty_alt") with open(args.out, "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=["page", "status", "image", "alt", "issues"]) w.writeheader() w.writerows(rows) counts = {} for r in rows: for code in r["issues"].split(";"): counts[code] = counts.get(code, 0) + 1 pages = len({r["page"] for r in rowsSource:https://dev.to/appierun_66/find-images-missing-alt-text-across-your-sitemap-with-python-standard-library-only-5abcRelated posts
Check agent handoffs before a small fault reaches users

Blazor Native: HTML and CSS Without a Browser

I wrote the check that catches this bug. The check had the bug.
10 charts Helm dans un monorepo. Comment gérer leurs versions sans y passer des heures ?
Nuxt for Beginners: Build Fast Websites With Vue's Meta-Framework
