How to check sitemap lastmod

A lastmod value must be W3C Datetime, and the published schema narrows that to xsd:date or xsd:dateTime. Check one value with python lastmod-check.py 2026-09-11, then check the spread across the file: a single date repeated on every URL is valid and tells a crawler nothing.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this after any change to the sitemap generator and on every release that moves the build to a new runtime. Two defects hide here. A value in a format no crawler accepts, which drops the field silently because the element is optional. And a value that is the build timestamp, which marks all 84 pages as changed today when two of them changed.

The second one costs more. A crawler that learns lastmod is wrong on a site stops using it there, and the field is the only cheap signal in a sitemap for which pages are worth refetching. Both defects pass every parser, so the check is on the values, not on the file.

Prerequisites

# python lastmod-audit.py <sitemap.xml|.xml.gz>
import sys, gzip, collections
import xml.etree.ElementTree as ET

NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
path = sys.argv[1]
raw = gzip.open(path, "rb").read() if path.endswith(".gz") else open(path, "rb").read()
root = ET.fromstring(raw)
entries = root.findall(NS + "url") or root.findall(NS + "sitemap")
vals = [(e.findtext(NS + "lastmod") or "").strip() for e in entries]
have = [v for v in vals if v]
counts = collections.Counter(have)
print("entries: %d, with lastmod: %d, without: %d" % (len(vals), len(have), len(vals) - len(have)))
print("distinct lastmod values: %d" % len(counts))
if have:
    print("oldest: %s  newest: %s" % (min(have), max(have)))
    top, n = counts.most_common(1)[0]
    print("most common: %s on %d of %d entries (%.0f%%)" % (top, n, len(have), 100.0 * n / len(have)))
    if n == len(have) and len(have) > 1:
        print("WARN every entry carries the same lastmod, so the field carries no signal")
# python lastmod-check.py <value> [<value> ...]
import sys, re
from lxml import etree

# tLastmod copied from https://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd
XSD = b"""<?xml version="1.0"?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <xsd:element name="lastmod" type="tLastmod"/>
  <xsd:simpleType name="tLastmod">
    <xsd:union>
      <xsd:simpleType><xsd:restriction base="xsd:date"/></xsd:simpleType>
      <xsd:simpleType><xsd:restriction base="xsd:dateTime"/></xsd:simpleType>
    </xsd:union>
  </xsd:simpleType>
</xsd:schema>"""
schema = etree.XMLSchema(etree.fromstring(XSD))
# The six granularities of https://www.w3.org/TR/NOTE-datetime
W3C = re.compile(r"^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$")
print("%-32s %-6s %-6s" % ("value", "W3C", "XSD"))
for v in sys.argv[1:]:
    doc = etree.fromstring(("<lastmod>%s</lastmod>" % v).encode())
    xsd_ok = schema.validate(doc)
    print("%-32s %-6s %-6s" % (v, "yes" if W3C.match(v) else "NO", "yes" if xsd_ok else "NO"))

Steps

  1. Step 1.

    Describe the spread of values in a real sitemap that keeps them per page.

    python lastmod-audit.py mdn-en-us.xml.gz
    
    entries: 14759, with lastmod: 14622, without: 137
    distinct lastmod values: 753
    oldest: 2023-02-18  newest: 2026-09-11
    most common: 2026-08-21 on 688 of 14622 entries (5%)

    753 distinct values across three and a half years is what a per page date looks like. 137 entries carry none, which is allowed. No single value holds more than 5% of the file, so the field separates pages instead of grouping them.

  2. Step 2.

    Run the same audit on a sitemap written by a static build.

    python lastmod-audit.py out/sitemap-check.xml
    
    entries: 84, with lastmod: 84, without: 0
    distinct lastmod values: 1
    oldest: 2026-09-11  newest: 2026-09-11
    most common: 2026-09-11 on 84 of 84 entries (100%)
    WARN every entry carries the same lastmod, so the field carries no signal

    This is the sitemap of the site you are reading. Every value is valid and the field is the build date, not a page date. Compare what matters: 753 distinct values out of 14,622 against 1 out of 84.

  3. Step 3.

    Validate candidate values against both definitions at once.

    python lastmod-check.py 2026-09-11 2026-09-11T17:33:30+02:00 2026-09 2026 2026-09-11T17:33Z 2026-09-11T17:33:30 2026-9-1 09/11/2026 2026-02-30
    
    value                            W3C    XSD   
    2026-09-11                       yes    yes   
    2026-09-11T17:33:30+02:00        yes    yes   
    2026-09                          yes    NO    
    2026                             yes    NO    
    2026-09-11T17:33Z                yes    NO    
    2026-09-11T17:33:30              NO     yes   
    2026-9-1                         NO     NO    
    09/11/2026                       NO     NO    
    2026-02-30                       yes    NO    

    The two columns disagree four times. 2026-09 and 2026 are granularities 1 and 2 of W3C Datetime, and the schema type is a union of xsd:date and xsd:dateTime, neither of which accepts them. 2026-09-11T17:33Z is granularity 4, without seconds, which xsd:dateTime requires. 2026-09-11T17:33:30 has no zone designator, so W3C refuses it and the schema does not. 2026-02-30 is a well-formed date that does not exist, and only the schema checks that. Ship values that pass both columns.

  4. Step 4.

    Put the same values through the date parser a generator is most likely to use.

    node -e "for (const v of ['2026-09-11','2026-09','2026','2026-9-1','09/11/2026','2026-02-30','2026-09-11T17:33Z']) { const d = new Date(v); console.log(v.padEnd(20), isNaN(d) ? 'Invalid Date' : d.toISOString()); }"
    
    2026-09-11           2026-09-11T00:00:00.000Z
    2026-09              2026-09-01T00:00:00.000Z
    2026                 2026-01-01T00:00:00.000Z
    2026-9-1             2026-08-31T21:00:00.000Z
    09/11/2026           2026-09-10T21:00:00.000Z
    2026-02-30           2026-03-02T00:00:00.000Z
    2026-09-11T17:33Z    2026-09-11T17:33:00.000Z

    Every one of them parses. Nothing here is an Invalid Date, so a generator that guards with isNaN(new Date(v)) lets all seven through. 2026-9-1 is read in local time and lands on 31 August, because this machine runs at Europe/Kiev, UTC+3. 2026-02-30 rolls over into March. A parser that accepts a value is not a validator for it.

  5. Step 5.

    Validate the values of a file built with the mistakes this produces.

    python lastmod-check.py 2026-09-11 2026-9-1 2026-09-11T17:33Z "Fri, 11 Sep 2026 01:34:47 GMT" "2026-09-11 17:33:30"
    
    value                            W3C    XSD   
    2026-09-11                       yes    yes   
    2026-9-1                         NO     NO    
    2026-09-11T17:33Z                yes    NO    
    Fri, 11 Sep 2026 01:34:47 GMT    NO     NO    
    2026-09-11 17:33:30              NO     NO    

    One of five is usable. The HTTP date came from a last-modified response header pasted straight through, and the last value has a space where the T belongs, which is what most database drivers return for a timestamp column.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | yes yes in both columns | The value is accepted by the note and by the schema | Ship it | | yes for W3C and NO for XSD | A year, a month, a time without seconds, or a date that does not exist | Write the full YYYY-MM-DD, or add seconds | | NO for W3C and yes for XSD | A timestamp with no zone designator | Append Z or the offset | | NO NO | A locale date, an HTTP date, or a space instead of T | Format from a date object, never from a string | | distinct lastmod values: 1 | The build stamped one date on the whole file | Take the date from the content, not from the run | | without: N | N entries omit the element | Allowed. A missing value beats a wrong one |

Common mistakes

Sign: A generator validates dates with new Date(value) and every bad value survives.Cause: JavaScript's parser is permissive by design. 2026-9-1, 09/11/2026 and 2026-02-30 all produce a real Date object, and 2026-02-30 silently becomes 2 March. None of the three is a valid lastmod. Validate the string against the schema type, or build the string with toISOString().slice(0, 10) and never parse user input at all.
Sign: Every URL carries today's date after every deploy, and the sitemap passes every validator.Cause: The generator used the build timestamp because it was the value nearest to hand. It is valid and it is worthless: a crawler comparing this week's file to last week's sees the whole site change on a release that touched one page. Take the date from the content, the commit that last touched the source file, or the record's updated_at column. Omitting the element is better than stamping the run.
Sign: The HTTP last-modified header of the page is copied into lastmod and the field disappears from every report.Cause: The header uses the HTTP date format, Fri, 11 Sep 2026 01:34:47 GMT, which fails both columns in step 5. The two fields answer the same question in different grammars. Convert, rather than copy, and check the header separately.

What to check next

FAQ

What date format does Google want in a sitemap?

W3C Datetime. 2026-09-11 and 2026-09-11T17:33:30+02:00 are both accepted and both validate against the schema type. Google uses the value when it is consistently accurate against the page, and stops using it on a site where it is not.

Is lastmod required?

No. It is optional on a url entry and on a sitemap entry in an index. Step 1 shows 137 of 14,759 URLs on a live site with no value. Leaving it out is better than publishing the run date.

Should lastmod include a time?

Only when the time is real. A date alone is valid and cannot be wrong by hours. Adding a time means adding a zone designator, because the schema accepts a bare timestamp and the W3C note does not.

Why does my valid date fail a validator?

The two definitions the protocol points at do not overlap exactly. A year, a month, or a time without seconds passes the W3C note and fails the schema, as step 3 shows. Use the full date, with seconds and a zone when a time is present.

Verified

Verified by Maks Vernypython 3.13.1lxml 6.0.2node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny