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 rows})
print(f"{total} images on {len(urls)} pages; {len(rows)} rows flagged on {pages} pages")
for code, n in sorted(counts.items(), key=lambda kv: -kv[1]):
print(f" {code:<20} {n}")
print(f"Report: {args.out}")
return 1 if rows else 0
if __name__ == "__main__":
sys.exit(main())
A few design choices worth knowing:
-
Sitemap indexes are followed recursively, with a
seenset so a sitemap that lists itself does not loop. -
Only
<url><loc>entries are pages. If your sitemap includes image sitemap entries (<image:loc>), those are image files, not pages, so the script skips them instead of trying to parse a JPEG as HTML. -
Lazy-loaded images often ship
src="data:..."with the real URL indata-src. The report shows thedata-srcURL so you can find the image. -
Empty alt is quiet by default.
alt=""is the correct markup for decorative images, so reporting every one would bury the real problems. The exception is an image that is the only content of a link, which is always reported. - The HTML is the first server response. Images injected by client-side JavaScript are not seen. If your gallery is rendered in the browser, check it there.
-
Exit codes:
0when nothing is flagged,1when at least one row is,2when the sitemap cannot be read.
Testing it against a local fixture
To exercise every flag, I built a small site: a sitemap index pointing at two child sitemaps (five URLs, one with an <image:image> entry). The pages include a good <picture> hero, a decorative divider with alt="", a product page with a missing alt, an IMG_0423.jpg alt, an alt="image" and a lazy-loaded GIF, a contact form with an <input type="image"> submit button, an icon-only social link with alt="" next to a GitHub link that has visible text, a blog post with a logo link and no alt, an image with role="presentation", one with only aria-label, a very long chart description, a "Screenshot 2026-11-03 at 10.14.22" alt, a document.write image inside <script>, and a 404.
Serve it locally:
cd site
python3 -m http.server 8766 --bind 127.0.0.1
Then run the audit from another terminal:
python3 alt_audit.py http://127.0.0.1:8766/sitemap.xml --out alt_report.csv
echo "exit code: $?"
Real output on Python 3.13.5:
Checking 5 URLs from http://127.0.0.1:8766/sitemap.xml
15 images on 5 pages; 12 rows flagged on 4 pages
missing_alt 4
filename_alt 2
placeholder_alt 1
empty_alt_in_link 1
http_error 1
link_has_no_text 1
hidden_no_alt 1
labelled_no_alt 1
long_alt 1
Report: alt_report.csv
exit code: 1
And the CSV:
page,status,image,alt,issues
http://127.0.0.1:8766/products.html,200,http://127.0.0.1:8766/img/dashboard-overview.png,,missing_alt
http://127.0.0.1:8766/products.html,200,http://127.0.0.1:8766/img/IMG_0423.jpg,IMG_0423.jpg,filename_alt
http://127.0.0.1:8766/products.html,200,http://127.0.0.1:8766/img/pricing-chart.png,image,placeholder_alt
http://127.0.0.1:8766/products.html,200,http://127.0.0.1:8766/img/cli-demo.gif,,missing_alt
http://127.0.0.1:8766/contact.html,200,http://127.0.0.1:8766/img/send-arrow.png,,missing_alt
http://127.0.0.1:8766/contact.html,200,http://127.0.0.1:8766/img/x-icon.svg,,empty_alt_in_link
http://127.0.0.1:8766/old-page.html,404,,,http_error
http://127.0.0.1:8766/blog/launch-notes.html,200,http://127.0.0.1:8766/img/acme-logo.svg,,missing_alt;link_has_no_text
http://127.0.0.1:8766/blog/launch-notes.html,200,http://127.0.0.1:8766/img/flourish.svg,,hidden_no_alt
http://127.0.0.1:8766/blog/launch-notes.html,200,http://127.0.0.1:8766/img/latency-graph.png,,labelled_no_alt
http://127.0.0.1:8766/blog/launch-notes.html,200,http://127.0.0.1:8766/img/latency-graph-2.png,"A line chart of p95 latency by week for the API, the web app and the background workers from January to October, showing a steady decline after the cache change in June, with the web app improving most and the workers improving least overall",long_alt
http://127.0.0.1:8766/blog/launch-notes.html,200,http://127.0.0.1:8766/img/screenshot-2.png,Screenshot 2026-11-03 at 10.14.22,filename_alt
Notes on that result:
-
15 images, 11 flagged, plus one row for the 404. The good hero, the header logo link with
alt="Acme home", the decorative divider and the GitHub icon (its link already says "GitHub") were not reported. The<noscript>copy of the lazy GIF and the<img>inside thedocument.writestring were ignored, as intended. -
The X icon link has
alt=""and nothing else inside the<a>, so the link has no name. That is the decision-tree case: describe the destination, for examplealt="Acme on X". -
The blog logo got two codes,
missing_alt;link_has_no_text, because the image is missing alt and is the only thing in the link. -
labelled_no_alton the latency chart is a judgment call. Anaria-labelgives screen readers a name, so this is not the same as missing alt. I would still move the text toalt, or better, put the numbers in the page body. -
long_alton the second chart is a prompt, not a rule. WAI's guidance for complex images is to put the information elsewhere on the page.
Running again with --include-empty adds the two decorative images so you can confirm they really are decorative:
Checking 5 URLs from http://127.0.0.1:8766/sitemap.xml
15 images on 5 pages; 14 rows flagged on 5 pages
missing_alt 4
empty_alt 2
filename_alt 2
placeholder_alt 1
empty_alt_in_link 1
http_error 1
link_has_no_text 1
hidden_no_alt 1
labelled_no_alt 1
long_alt 1
Report: alt_all.csv
exit code: 1
$ grep 'empty_alt' alt_all.csv | grep -v in_link
http://127.0.0.1:8766/index.html,200,http://127.0.0.1:8766/img/divider-wave.png,,empty_alt
http://127.0.0.1:8766/contact.html,200,http://127.0.0.1:8766/img/gh-icon.svg,,empty_alt
When the sitemap URL is wrong, the script stops early:
Could not read sitemap: HTTP Error 404: File not found
exit code: 2
Running it on a real sitemap
I also ran it against a live public sitemap, our own https://appie.run/sitemap.xml, with two workers. It is a small marketing site, so the result is short:
Checking 74 URLs from https://appie.run/sitemap.xml
73 images on 74 pages; 0 rows flagged on 0 pages
Report: appie_alt.csv
exit code: 0
A clean result is worth reading carefully. It means every <img> in the server HTML has a non-placeholder alt or a correct alt="". It does not mean the alt text is good. A script can tell you alt="Dashboard" exists; only a person can tell whether that describes what the screenshot shows. Use the CSV as a list of pages to open, and spot-check a few unflagged pages by hand too.
On sites you do not own, keep --workers low and use --limit for a first pass:
python3 alt_audit.py https://example.com/sitemap.xml --out alt_report.csv --workers 2 --limit 50
If you do not know where the sitemap lives, the free Sitemap Finder and Checker reads robots.txt Sitemap lines and the usual sitemap paths, then reports which files load and how many URLs they list. If you want to see the URL list in a browser before running anything, the Sitemap URL Extractor takes a sitemap URL or pasted XML and returns one URL per line; for a sitemap index, it lists the child sitemaps so you can run each one. Both ask for a captcha before they run.
Fixing what it finds
A rough order that works for most teams:
-
Image-only links and image buttons first (
empty_alt_in_link,link_has_no_text,missing_alton<input type="image">). These block navigation, not just description. The alt should say where the link goes or what the button does ("Search", "Acme home"), not what the icon looks like. - Missing alt on content images. Write what the image adds to the page. The WAI decision tree is a good five-minute checklist for each one.
- Filename and placeholder alts. These usually come from one CMS field or one template. Fix the source, then rerun.
-
Decorative images without
alt="". Add the empty attribute so screen readers skip them. -
Everything else (
labelled_no_alt,long_alt, the--include-emptylist) as a review pass.
Two things to avoid. Do not paste the target keyword into every alt; Google's page names keyword stuffing explicitly. And do not bulk-fill alts with the page title or product name, which turns ten different images into ten identical descriptions.
Extending it
-
Duplicate alts per page. Group by page and flag when several different
srcvalues share the same alt, a common sign of a template filling alt with the product name. -
Compare with captions. Collect
<figcaption>text and flag images whose alt exactly repeats the caption, which screen reader users hear twice. -
Inline SVG. Google's image page suggests a
<title>element for inline<svg>. Add ansvghandler that checks for<title>,aria-labeloraria-hidden. -
Diff runs. Save the CSV per deploy and diff it, so a template change that drops alt shows up as a spike in
missing_alt.
FAQ
Does missing alt text hurt Google rankings?
Google does not say that. Its image SEO page says alt text is the most important attribute for image metadata and that Google uses it, with computer vision and page content, to understand an image. Treat alt text as something that helps Google understand images and helps people using screen readers, not as a ranking switch.
Should every image have descriptive alt text?
No. Decorative images should have alt="" so assistive technology can skip them. W3C WAI says whether an image is decorative is the author's call, based on why the image is on the page.
Is alt="" the same as no alt attribute?
No. alt="" tells assistive technology to ignore the image. With no alt attribute at all, WAI notes that some screen readers announce the file name instead. WCAG lists omitting alt on img as failure F65.
What about role="presentation" instead of alt=""?
WAI's decorative images tutorial says the null alt is more widely supported than the ARIA role, which is why the script reports hidden_no_alt for review.
Will this see images added by React or a lazy-load script?
It sees whatever the server sends. It reads data-src when src is a data URI placeholder, but images inserted only by JavaScript will not appear.
Can I run it in CI?
Yes. It exits 1 when any row is flagged and 2 when the sitemap cannot be read. To fail the build only on missing_alt and link problems, filter the CSV's issues column before deciding.
Wrap-up
Missing alt text is easy to introduce and easy to miss, because nothing visibly breaks. Run this after template changes, CMS migrations and big content uploads, fix the link and button cases first, and have a person read the alt text that remains. The script finds the gaps; writing a good description is still a human job.
RG, Founder, Appie.run
Top comments (0)