AIScan parser lab cover: Why robots.txt checkers disagree. Google and OpenAI logos, 18 of 21 cases where all 5 parsers matched the spec
AIScan parser lab cover: Why robots.txt checkers disagree. Google and OpenAI logos, 18 of 21 cases where all 5 parsers matched the spec
AI Readiness

Why robots.txt Checkers Disagree About GPTBot: 5 Parsers, 15 Files, Tested (2026)

Robots.txt checkers disagree about GPTBot: 5 parsers, 15 files tested. Version tokens, a BOM and files over 500 KiB split them. Raw verdicts and a spec checker.

12 Nov 2026 19 min read
#robots.txt#AI crawlers

This guide covers B2 · Bot Access, D1 · Discoverability.

Table of contents

Robots.txt checkers disagree about GPTBot because each one is built on a parser library, and the libraries do not agree on the rules. We ran 15 small robots.txt files through five parsers that checker tools are commonly built on. On 21 test cases all five matched RFC 9309 and Google's documented behaviour on 18. The three they missed are the ones that decide real verdicts: a version number in the User-agent line, a byte order mark at the start of the file, and a file over 500 KiB. Python's built-in parser also changed its answer on five cases between Python 3.12 and 3.13.

Below are the files, the raw verdicts, the RFC 9309 rule behind each row, and a short spec-aware checker you can copy. It matches the reference column on all 21 cases.

Quick summary

QuestionAnswerSource
Do robots.txt parsers agree?On 18 of 21 cases all five matched the spec. They split on 2 cases and all five got a third wrongAIScan parser lab, 15 files, 21 cases, 12 Nov 2026
Most common missA file over 500 KiB: all five read past the limit Google stops atGoogle robots.txt spec, read 12 Nov 2026
User-agent: GPTBot/1.1Google reads it as GPTBot. Three of five parsers ignored the groupGoogle spec ("googlebot/1.2 ... equivalent to googlebot")
UTF-8 byte order mark before line 1Google skips it. Three of five parsers lost the first groupSame lab, row 12
Allow and Disallow of equal lengthAllow wins, and all five agreedRFC 9309 §2.2.2
Python urllib.robotparser3.11.18 and 3.12.3 missed 8 of 21 cases; 3.13.18 missed 3Same lab, three Python builds
Does a parser verdict tell you what a bot did?No. Only your server logs show thatFind AI crawlers in your server logs

Short answer for a site owner: if two checkers disagree about your file, the file almost certainly has one of the three problems above, and each has a one-line fix. Run npx aiscan-cli yoursite.com (or paste your address at aiscan.site, free, no account) and read check B2 (explicit AI bot rules) and D1 (robots.txt served and readable) on the bot access and discoverability pages. Then fix the file with the table at the end of this post.

Short answer for someone building a checker: behave like Google's open-source matcher. Read the product token up to the first character that is not a letter, - or _. Strip a leading BOM. Stop parsing at 500 KiB. Merge every group that names the agent. Pick the longest matching rule, and let Allow win a tie.

Why this matters to anyone who checks AI crawler access

People ask one question about their robots.txt more than any other: is GPTBot (or ClaudeBot, or PerplexityBot) allowed? In the last month three public threads show people building tools to answer it: a resolver run over 122 well-known sites for 19 AI crawlers, a checker for 14 AI crawlers, and a robots.txt tester looking for feedback. Each sits on a parser. When the parser reads a file differently from the crawler, the tool gives a confident wrong answer.

The rules themselves are settled. RFC 9309 (September 2022) standardised the Robots Exclusion Protocol. Google's robots.txt specification documents how Google applies it, including choices the RFC leaves to the crawler, such as the 500 KiB limit. The gaps are in the libraries.

This post owns one question: how parsers match agents and paths. Three neighbouring questions have their own posts. What Python does with a 403 or 5xx response is covered in Python's robots.txt parser treats a 403 as disallow-all. The new AI-training policy token is covered in the __usage_ai-training token, tested. A leftover Disallow: / is covered in your robots.txt blocks everything by accident.

How we tested

Everything ran locally on 12 Nov 2026. We fetched nothing from anyone else's site, so every file is one we wrote, each isolating one edge case.

ParserVersionLanguageWhere it shows up
urllib.robotparserPython 3.13.18 (also 3.12.3 and 3.11.18)Python standard libraryScripts, notebooks, quick checkers
Protego0.7.0PythonScrapy's default robots.txt parser
robotspy0.13.0PythonStandalone checkers and CLIs
robotstxt1.1.2PythonStandalone checkers. Not a wrapper around Google's C++ code: it is a separate pure-Python package
robots-parser (npm)3.0.1JavaScript (Node 24.22.1)Node crawlers and web-based testers

Method. Each file was saved as UTF-8 bytes and decoded the way a plain fetcher does (decode("utf-8"), no BOM stripping). Each parser then got the same text, the same agent product token and the same URL on https://example.com. That gave 21 cases (file × agent × path). The reference column applies RFC 9309 and, where the RFC leaves a choice, Google's specification page.

What we could not run. We meant to build Google's open-source matcher (the C++ google/robotstxt library) as a sixth column. The sandbox for this run does not allow compiling and running outside code, so we dropped it. We read its source for the three cases where parsers split, and we quote what it does below, but we did not run it.

The 15 files

FileWhat it isolatesThe rule that decides it
01User-agent: GPTBot/1.1 then Disallow: /, plus User-agent: * / Allow: /Google: "googlebot/1.2 and googlebot* are equivalent to googlebot"
02User-agent: gptbot (lower case)RFC 9309 §2.2.1: case-insensitive matching
03Disallow: /page and Allow: /pageRFC 9309 §2.2.2: on a tie, allow SHOULD be used
04Disallow: /docs/ above Allow: /docs/public/RFC 9309 §2.2.2: the longest match wins
05Disallow: /*.pdf$ and Disallow: /tmp*RFC 9309 §2.2.3: * and $
06Disallow: (empty) for GPTBot, Disallow: / for *an empty rule matches nothing
07Two GPTBot groups with a * group between themRFC 9309 §2.2.1: matching groups MUST be combined
08User-agent: GPTBot, blank line, User-agent: *, Disallow: /RFC 9309 §2.2 grammar allows empty lines between user-agent lines
09A UTF-8 BOM before User-agent: GPTBotGoogle ignores a leading BOM
10CRLF line endingsRFC 9309 §2.2: CR, LF or CRLF
11A Sitemap: line inside the GPTBot groupRFC 9309 §2.2.4: other records must not interfere
12Disallow: /café/RFC 9309 §2.2.2: compare percent-encoded UTF-8
13630,054 bytes: an Allow: / group, padding comments, then User-agent: GPTBot / Disallow: / at the endRFC 9309 §2.5 and Google's 500 KiB limit
14User-agent: * / Disallow: /, then User-agent: GPTBot / Allow: /a named group beats *
15User-agent: GPTBot # OpenAI stacked on User-agent: ClaudeBot, Disallow: /comments are ignored; stacked lines share a group

The results, case by case

A ✗ marks a verdict that differs from the reference column.

#File (agent, path)RFC 9309 / Googleurllib 3.13.18Protegorobotspyrobotstxtrobots-parser
101-version-token (GPTBot, /)blockallow ✗allow ✗blockallow ✗block
202-lowercase-agent (GPTBot, /private/x)blockblockblockblockblockblock
303-equal-length-tie (GPTBot, /page)allowallowallowallowallowallow
404-longest-vs-first (GPTBot, /docs/public/a)allowallowallowallowallowallow
505-wildcards (GPTBot, /files/report.pdf)blockblockblockblockblockblock
605-wildcards (GPTBot, /files/report.pdf?v=2)allowallowallowallowallowallow
705-wildcards (GPTBot, /tmp-cache/x)blockblockblockblockblockblock
806-empty-disallow (GPTBot, /)allowallowallowallowallowallow
907-split-groups (GPTBot, /b/x)blockblockblockblockblockblock
1007-split-groups (GPTBot, /c)allowallowallowallowallowallow
1108-agent-no-rules (GPTBot, /)blockblockblockblockblockblock
1209-utf8-bom (GPTBot, /)blockallow ✗allow ✗allow ✗blockblock
1310-crlf (GPTBot, /private/x)blockblockblockblockblockblock
1411-sitemap-in-group (GPTBot, /private/x)blockblockblockblockblockblock
1512-non-ascii-path (GPTBot, /caf%C3%A9/menu)blockblockblockblockblockblock
1612-non-ascii-path (GPTBot, /café/menu)blockblockblockblockblockblock
1713-over-500kib (GPTBot, /)allowblock ✗block ✗block ✗block ✗block ✗
1814-specific-beats-star (GPTBot, /)allowallowallowallowallowallow
1914-specific-beats-star (Googlebot, /)blockblockblockblockblockblock
2015-stacked-with-comment (GPTBot, /)blockblockblockblockblockblock
2115-stacked-with-comment (ClaudeBot, /)blockblockblockblockblockblock

Tally of departures from the reference, out of 21:

ParserWrongWhich rows
robots-parser 3.0.1117 (over 500 KiB)
robotspy 0.13.0212 (BOM), 17
robotstxt 1.1.221 (version token), 17
Protego 0.7.031, 12, 17
urllib.robotparser, Python 3.13.1831, 12, 17
urllib.robotparser, Python 3.12.3 and 3.11.1881, 3, 4, 5, 7, 9, 12, 17

The good news comes first. Every parser handled case, CRLF, comments, stacked agents, empty Disallow:, the Sitemap: line inside a group, the non-ASCII path in both encodings, and the named-group-beats-* rule. If your checker is wrong, it is wrong on one of the three cases below.

Disagreement 1: a version number in the User-agent line

File 01 says User-agent: GPTBot/1.1 with Disallow: /, and a * group allows everything. Google's specification says "All non-matching text is ignored (for example, both googlebot/1.2 and googlebot* are equivalent to googlebot)". Google's matcher implements this in ExtractUserAgent, which keeps only letters, - and _ from the start of the value. So for Google-style matching, GPTBot is blocked.

robotspy and robots-parser agreed and blocked GPTBot. urllib.robotparser, Protego and robotstxt treated GPTBot/1.1 as a different name, fell through to *, and said GPTBot was allowed.

RFC 9309 §2.2.1 says the product token in a user-agent line may contain only letters, underscores and hyphens, so GPTBot/1.1 is not a valid line. The fix belongs in the file: write User-agent: GPTBot. Bot documentation pages give full user-agent strings such as GPTBot/1.1, and people paste them in. The AI crawler user-agent list gives the bare token for each crawler, which is what goes in robots.txt.

Disagreement 2: a byte order mark before the first line

File 09 is two lines, User-agent: GPTBot and Disallow: /, saved with a UTF-8 byte order mark (the bytes EF BB BF). Many Windows editors add one silently. Google's specification says Google "ignores invalid lines in robots.txt files, including the Unicode Byte Order Mark (BOM) at the beginning", and its matcher skips the BOM byte by byte. For Google, GPTBot is blocked.

robotstxt and robots-parser coped. urllib.robotparser, Protego and robotspy read the first line as a field named user-agent, did not recognise it, dropped the whole group, and reported GPTBot as allowed.

So a site that blocks GPTBot in its first group can see a checker built on Scrapy's parser say the opposite. The fix is to save robots.txt as UTF-8 without a BOM. If the first group must stay first, a comment line at the top also works: a stray BOM then spoils only the comment.

Disagreement 3: everything after 500 KiB

File 13 is 630,054 bytes. It opens with User-agent: * / Allow: /, runs 9,000 comment lines, and ends with User-agent: GPTBot / Disallow: /. RFC 9309 §2.5 says "The parsing limit MUST be at least 500 kibibytes", so a crawler may stop there. Google says "Content which is after the maximum file size is ignored." For Google, the GPTBot group past byte 512,000 does not exist, and GPTBot falls back to Allow: /.

All five parsers read the whole file and blocked GPTBot. That is the only row where every library agreed with each other and not with Google. Other crawlers may set a higher limit (the RFC sets only a minimum), so this row has no single right answer for every bot. Within Google's documented behaviour, the checkers are wrong.

Files this large are rare and usually generated: one rule per URL, or a plugin appending to the file on every save. The fix is to put named AI crawler groups near the top and merge per-URL rules into directory rules. Our D1 check confirms the file is served and readable. It does not judge its size.

The surprises in the rows every parser got right

Eighteen rows agreed, but several of them surprise people who write robots.txt by hand. They are worth knowing before you edit a live file.

A blank line does not end a group (file 08). Many people separate User-agent: GPTBot from User-agent: * with a blank line and expect GPTBot to get a group with no rules, which would mean "allowed everywhere". The RFC 9309 grammar allows empty lines between user-agent lines, so the two lines form one group and GPTBot inherits Disallow: /. All five parsers blocked GPTBot, and so does Google's matcher, which ignores blank lines. If you want GPTBot allowed, give it a rule: Allow: /.

Two groups for the same bot are one group (file 07). RFC 9309 §2.2.1 says matching groups "MUST be combined into one group". A GPTBot group at the top and another at the bottom both apply, so /b/ stays blocked. Files grow this way when a plugin appends its own group and nobody merges them. Every current parser merged them; only Python 3.12 and older did not.

A named group replaces * and does not add to it (file 14). User-agent: GPTBot with Allow: / frees GPTBot from a site-wide Disallow: /, and Googlebot stays blocked. The reverse applies too: if you give GPTBot a group to block one folder, it no longer inherits anything from *, including your other Disallow lines. Google's spec says it directly: "User agent specific groups and global groups (*) are not combined."

An empty Disallow: allows everything (file 06). It reads like "disallow", but an empty value matches no path. RFC 9309 §2.2.2 says that when no rule matches, "the URI is allowed".

Allow wins a tie (file 03). When Allow: /page and Disallow: /page are the same length, RFC 9309 says the allow rule "SHOULD be used", and Google says it "uses the least restrictive rule". All five current parsers agreed. Python 3.12 did not.

$ only anchors the end of the path (file 05). Disallow: /*.pdf$ blocks /files/report.pdf and leaves /files/report.pdf?v=2 allowed, because the query string is part of what gets matched. If you mean every PDF, drop the $.

Python changed its answer between 3.12 and 3.13

Yesterday's training-token lab found that Python's parser takes the first matching rule, so Disallow: / above Allow: /press-kit/ wins. That held on Python 3.11.18 and 3.12.3. On 3.13.18 the same module picks the longest match, lets Allow win ties, and understands * and $. The source shows it directly: RuleLine.applies_to now returns the match length, and Entry.allowance keeps the best one.

CaseRFC 9309 / GooglePython 3.11.18Python 3.12.3Python 3.13.18
3: equal-length Allow and Disallowallowblockblockallow
4: /docs/ blocked, /docs/public/ allowedallowblockblockallow
5: /*.pdf$ on /files/report.pdfblockallowallowblock
7: /tmp* on /tmp-cache/xblockallowallowblock
9: second GPTBot group's /b/blockallowallowblock
1: GPTBot/1.1blockallowallowallow
12: BOMblockallowallowallow
17: over 500 KiBallowblockblockblock

The older builds also used only the first GPTBot group in file 07 and ignored the second. In practice, a checker on Python's standard library gives different answers depending on the server's Python version. Pin the version, or use a library that states its matching rules.

Which parser should a checker behave like?

If you are...Behave likeWhy
Checking Google's view of a fileGoogle's open-source matcher, or Google's spec pageIt is the code Google published as the reference for its crawler
Checking OpenAI, Anthropic or Perplexity crawlersRFC 9309, with Google's three choices as the likely readingNone of them publishes a parser; RFC 9309 is the shared baseline
Building in PythonProtego 0.7.0 or robotspy 0.13.0, plus a BOM strip and a 500 KiB cutBoth handle longest match, wildcards and merging; each missed 1 or 2 of the 3 hard cases
Building in Noderobots-parser 3.0.1, plus a 500 KiB cutOnly the size limit missed in our 21 cases
Using urllib.robotparserPython 3.13 or later onlyOlder builds missed 8 of 21

The three fixes are small. Most of the remaining risk is in how a checker fetches the file (status codes, redirects, content type) rather than how it parses it.

A spec-aware checker in about 50 lines

This checker applies every rule in the reference column. On our 21 cases it matched the reference 21 times. It uses only the Python standard library and does no fetching: give it the bytes you got from /robots.txt. Status handling stays your job, and RFC 9309 §2.3.1 says what to do with 4xx and 5xx responses.

import re
from urllib.parse import quote, unquote, urlsplit

LIMIT = 500 * 1024  # RFC 9309 section 2.5: parse at least 500 KiB; Google stops there

def norm(path):
    # compare percent-encoded UTF-8, keep reserved characters as written
    return quote(unquote(path), safe="/?=&*$%:@!+,;~")

def groups(raw: bytes):
    text = raw[:LIMIT].decode("utf-8", "ignore").lstrip("")
    out, agents, rules_seen = [], [], False
    for line in re.split(r"\r\n|\r|\n", text):
        line = line.split("#", 1)[0].strip()
        if ":" not in line:
            continue                      # blank and junk lines never end a group
        key, val = (s.strip() for s in line.split(":", 1))
        key = key.lower()
        if key == "user-agent":
            if rules_seen:
                agents, rules_seen = [], False
            if not agents:
                out.append((agents, []))
            agents.append("*" if val.startswith("*") else re.match(r"[A-Za-z_-]*", val).group().lower())
        elif key in ("allow", "disallow") and out:
            rules_seen = True
            out[-1][1].append((key == "allow", norm(val)))
    return out

def rule_regex(pattern):
    end = pattern.endswith("$")
    body = re.escape(pattern.rstrip("$")).replace(r"\*", ".*")
    return re.compile(body + ("$" if end else ""))

def allowed(raw: bytes, agent: str, url: str) -> bool:
    agent = re.match(r"[A-Za-z_-]*", agent).group().lower()
    gs = groups(raw)
    rules = [r for a, rs in gs if agent in a for r in rs]          # merge every named group
    if not any(agent in a for a, _ in gs):
        rules = [r for a, rs in gs if "*" in a for r in rs]        # fall back to *
    parts = urlsplit(url)
    path = norm((parts.path or "/") + ("?" + parts.query if parts.query else ""))
    best_len, verdict = -1, True
    for allow, pattern in rules:
        if pattern and rule_regex(pattern).match(path):
            n = len(pattern)
            if n > best_len or (n == best_len and allow):          # longest wins, allow wins ties
                best_len, verdict = n, allow
    return verdict

Usage: allowed(open("robots.txt", "rb").read(), "GPTBot", "https://example.com/docs/"). Pass the bare product token (GPTBot), not the full user-agent header. Its limits: it measures rule length on the encoded pattern, it does not implement Google's special cases for AdsBot-Google, and it keeps /robots.txt subject to the rules, where RFC 9309 says that path is always allowed. Add that one line if you need it.

Fix your own file: the mistakes behind every split row

MistakeWhat checkers doFix
User-agent: GPTBot/1.1 (version pasted from bot docs)3 of 5 ignore the groupUser-agent: GPTBot
File saved with a BOM3 of 5 drop the first groupSave as UTF-8 without BOM, or start with a comment line
Named AI groups at the end of a huge fileGoogle never reads themPut named groups first, merge per-URL rules
Disallow: / above Allow: /path/Python 3.12 and older ignore the AllowPut Allow lines first, or rely on longest match only where you know the parser
Two separate GPTBot groupsPython 3.12 and older read only the firstMerge them into one group

On WordPress, robots.txt is often generated by a plugin rather than edited by hand, so the BOM and the oversized file usually come from the generator. ThinkRank edits robots.txt and robots meta from one screen instead of two plugins competing to write the file, and it migrates settings from Rank Math, Yoast, AIOSEO and SEOPress. Yoast and Rank Math also have file editors; whichever you use, check the saved bytes, not the preview. For a ready-made set of AI crawler groups, start from the robots.txt starter for AI crawlers.

What this post does not measure

A parser verdict is not a crawler's behaviour. We tested libraries, not GPTBot, ClaudeBot or Googlebot. The only evidence of what a bot actually fetched is in your access logs, and finding AI crawlers in server logs shows how to read them. We did not run Google's C++ matcher (see the method section). We did not count how many live sites have each mistake: AIScan scans store check results, not robots.txt bodies, and we crawled no one else's files to build a count. The 15 files are minimal on purpose. Real files combine several of these cases, and combinations can fail in ways a single-case file does not show.

Your next step

Run npx aiscan-cli yoursite.com, or paste your address at aiscan.site, free and without an account. Read B2 for your named AI crawler groups and D1 for whether the file is served and readable. If two checkers still disagree about your file, look for a version number in a User-agent line, a BOM, or a file over 500 KiB, in that order. Every other bot access and discoverability fix is linked from /guides.

Frequently asked questions

One robots.txt checker says GPTBot is blocked and another says it is allowed. Which is right?

Usually the one that behaves like Google's matcher. In our lab the parsers split only on three things: a version number in the User-agent line (GPTBot/1.1), a byte order mark before the first line, and a file over 500 KiB. Check your file for those three, fix it, and both checkers should agree.

My checker says GPTBot is allowed, but my robots.txt has User-agent: GPTBot/1.1 with Disallow: /. Why?

Three of the five parsers we tested treat GPTBot/1.1 as a different name and fall back to the * group. Google reads it as GPTBot. Write the bare product token, User-agent: GPTBot, and every parser agrees.

My first robots.txt group is ignored by Python and Scrapy-based checkers. What is wrong?

The file probably starts with a UTF-8 byte order mark. urllib.robotparser, Protego and robotspy read the first line as an unknown field and drop that group. Google skips the BOM. Save the file as UTF-8 without BOM, or put a comment line first.

Rules at the end of my large robots.txt work in every checker but Google seems to ignore them. Why?

Google stops reading robots.txt after 500 KiB, and all five parsers we tested read past that point. Move your named crawler groups to the top and merge per-URL rules into directory rules so the file shrinks.

Does a blank line between User-agent: GPTBot and User-agent: * give GPTBot its own group?

No. RFC 9309 allows empty lines between user-agent lines, so the two lines form one group and GPTBot gets the * rules. All five parsers and Google's matcher agree. Add Allow: / under GPTBot if you want it allowed.

Is Python's urllib.robotparser safe for checking AI crawler access?

Only on Python 3.13 or later in our test. Python 3.11.18 and 3.12.3 missed 8 of our 21 cases because they use the first matching rule and ignore wildcards. Python 3.13.18 missed 3, the same three as Protego.

Does a parser verdict prove what GPTBot actually did on my site?

No. A parser tells you what the file says, not what a crawler fetched. Your server access logs are the only record of what a bot requested and what status it got.

Will fixing these robots.txt issues change my AIScan score?

Possibly. Check B2 counts explicit groups for named AI crawlers, and D1 checks that robots.txt is served and readable. Re-scan after the edit with npx aiscan-cli yoursite.com and read both checks.

M Asif Rahman

About the author

Entrepreneur, curious about AI

I have been building companies for a long time, and lately most of my curiosity goes to AI. AIScan is a small personal project I built on a flight from Dubai to Narita. I use it to understand how ready the web is for AI agents, and I share what the scans teach me.

Related guides

Browse every guide by check and platform