Table of contents
- Quick summary
- Why this matters to anyone who checks AI crawler access
- How we tested
- The 15 files
- The results, case by case
- Disagreement 1: a version number in the User-agent line
- Disagreement 2: a byte order mark before the first line
- Disagreement 3: everything after 500 KiB
- The surprises in the rows every parser got right
- Python changed its answer between 3.12 and 3.13
- Which parser should a checker behave like?
- A spec-aware checker in about 50 lines
- Fix your own file: the mistakes behind every split row
- What this post does not measure
- Your next step
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
| Question | Answer | Source |
|---|---|---|
| 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 wrong | AIScan parser lab, 15 files, 21 cases, 12 Nov 2026 |
| Most common miss | A file over 500 KiB: all five read past the limit Google stops at | Google robots.txt spec, read 12 Nov 2026 |
User-agent: GPTBot/1.1 | Google reads it as GPTBot. Three of five parsers ignored the group | Google spec ("googlebot/1.2 ... equivalent to googlebot") |
| UTF-8 byte order mark before line 1 | Google skips it. Three of five parsers lost the first group | Same lab, row 12 |
| Allow and Disallow of equal length | Allow wins, and all five agreed | RFC 9309 §2.2.2 |
Python urllib.robotparser | 3.11.18 and 3.12.3 missed 8 of 21 cases; 3.13.18 missed 3 | Same lab, three Python builds |
| Does a parser verdict tell you what a bot did? | No. Only your server logs show that | Find 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.
| Parser | Version | Language | Where it shows up |
|---|---|---|---|
urllib.robotparser | Python 3.13.18 (also 3.12.3 and 3.11.18) | Python standard library | Scripts, notebooks, quick checkers |
| Protego | 0.7.0 | Python | Scrapy's default robots.txt parser |
| robotspy | 0.13.0 | Python | Standalone checkers and CLIs |
| robotstxt | 1.1.2 | Python | Standalone checkers. Not a wrapper around Google's C++ code: it is a separate pure-Python package |
| robots-parser (npm) | 3.0.1 | JavaScript (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
| File | What it isolates | The rule that decides it |
|---|---|---|
| 01 | User-agent: GPTBot/1.1 then Disallow: /, plus User-agent: * / Allow: / | Google: "googlebot/1.2 and googlebot* are equivalent to googlebot" |
| 02 | User-agent: gptbot (lower case) | RFC 9309 §2.2.1: case-insensitive matching |
| 03 | Disallow: /page and Allow: /page | RFC 9309 §2.2.2: on a tie, allow SHOULD be used |
| 04 | Disallow: /docs/ above Allow: /docs/public/ | RFC 9309 §2.2.2: the longest match wins |
| 05 | Disallow: /*.pdf$ and Disallow: /tmp* | RFC 9309 §2.2.3: * and $ |
| 06 | Disallow: (empty) for GPTBot, Disallow: / for * | an empty rule matches nothing |
| 07 | Two GPTBot groups with a * group between them | RFC 9309 §2.2.1: matching groups MUST be combined |
| 08 | User-agent: GPTBot, blank line, User-agent: *, Disallow: / | RFC 9309 §2.2 grammar allows empty lines between user-agent lines |
| 09 | A UTF-8 BOM before User-agent: GPTBot | Google ignores a leading BOM |
| 10 | CRLF line endings | RFC 9309 §2.2: CR, LF or CRLF |
| 11 | A Sitemap: line inside the GPTBot group | RFC 9309 §2.2.4: other records must not interfere |
| 12 | Disallow: /café/ | RFC 9309 §2.2.2: compare percent-encoded UTF-8 |
| 13 | 630,054 bytes: an Allow: / group, padding comments, then User-agent: GPTBot / Disallow: / at the end | RFC 9309 §2.5 and Google's 500 KiB limit |
| 14 | User-agent: * / Disallow: /, then User-agent: GPTBot / Allow: / | a named group beats * |
| 15 | User-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 / Google | urllib 3.13.18 | Protego | robotspy | robotstxt | robots-parser |
|---|---|---|---|---|---|---|---|
| 1 | 01-version-token (GPTBot, /) | block | allow ✗ | allow ✗ | block | allow ✗ | block |
| 2 | 02-lowercase-agent (GPTBot, /private/x) | block | block | block | block | block | block |
| 3 | 03-equal-length-tie (GPTBot, /page) | allow | allow | allow | allow | allow | allow |
| 4 | 04-longest-vs-first (GPTBot, /docs/public/a) | allow | allow | allow | allow | allow | allow |
| 5 | 05-wildcards (GPTBot, /files/report.pdf) | block | block | block | block | block | block |
| 6 | 05-wildcards (GPTBot, /files/report.pdf?v=2) | allow | allow | allow | allow | allow | allow |
| 7 | 05-wildcards (GPTBot, /tmp-cache/x) | block | block | block | block | block | block |
| 8 | 06-empty-disallow (GPTBot, /) | allow | allow | allow | allow | allow | allow |
| 9 | 07-split-groups (GPTBot, /b/x) | block | block | block | block | block | block |
| 10 | 07-split-groups (GPTBot, /c) | allow | allow | allow | allow | allow | allow |
| 11 | 08-agent-no-rules (GPTBot, /) | block | block | block | block | block | block |
| 12 | 09-utf8-bom (GPTBot, /) | block | allow ✗ | allow ✗ | allow ✗ | block | block |
| 13 | 10-crlf (GPTBot, /private/x) | block | block | block | block | block | block |
| 14 | 11-sitemap-in-group (GPTBot, /private/x) | block | block | block | block | block | block |
| 15 | 12-non-ascii-path (GPTBot, /caf%C3%A9/menu) | block | block | block | block | block | block |
| 16 | 12-non-ascii-path (GPTBot, /café/menu) | block | block | block | block | block | block |
| 17 | 13-over-500kib (GPTBot, /) | allow | block ✗ | block ✗ | block ✗ | block ✗ | block ✗ |
| 18 | 14-specific-beats-star (GPTBot, /) | allow | allow | allow | allow | allow | allow |
| 19 | 14-specific-beats-star (Googlebot, /) | block | block | block | block | block | block |
| 20 | 15-stacked-with-comment (GPTBot, /) | block | block | block | block | block | block |
| 21 | 15-stacked-with-comment (ClaudeBot, /) | block | block | block | block | block | block |
Tally of departures from the reference, out of 21:
| Parser | Wrong | Which rows |
|---|---|---|
| robots-parser 3.0.1 | 1 | 17 (over 500 KiB) |
| robotspy 0.13.0 | 2 | 12 (BOM), 17 |
| robotstxt 1.1.2 | 2 | 1 (version token), 17 |
| Protego 0.7.0 | 3 | 1, 12, 17 |
| urllib.robotparser, Python 3.13.18 | 3 | 1, 12, 17 |
| urllib.robotparser, Python 3.12.3 and 3.11.18 | 8 | 1, 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.
| Case | RFC 9309 / Google | Python 3.11.18 | Python 3.12.3 | Python 3.13.18 |
|---|---|---|---|---|
| 3: equal-length Allow and Disallow | allow | block | block | allow |
4: /docs/ blocked, /docs/public/ allowed | allow | block | block | allow |
5: /*.pdf$ on /files/report.pdf | block | allow | allow | block |
7: /tmp* on /tmp-cache/x | block | allow | allow | block |
9: second GPTBot group's /b/ | block | allow | allow | block |
1: GPTBot/1.1 | block | allow | allow | allow |
| 12: BOM | block | allow | allow | allow |
| 17: over 500 KiB | allow | block | block | block |
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 like | Why |
|---|---|---|
| Checking Google's view of a file | Google's open-source matcher, or Google's spec page | It is the code Google published as the reference for its crawler |
| Checking OpenAI, Anthropic or Perplexity crawlers | RFC 9309, with Google's three choices as the likely reading | None of them publishes a parser; RFC 9309 is the shared baseline |
| Building in Python | Protego 0.7.0 or robotspy 0.13.0, plus a BOM strip and a 500 KiB cut | Both handle longest match, wildcards and merging; each missed 1 or 2 of the 3 hard cases |
| Building in Node | robots-parser 3.0.1, plus a 500 KiB cut | Only the size limit missed in our 21 cases |
Using urllib.robotparser | Python 3.13 or later only | Older 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
| Mistake | What checkers do | Fix |
|---|---|---|
User-agent: GPTBot/1.1 (version pasted from bot docs) | 3 of 5 ignore the group | User-agent: GPTBot |
| File saved with a BOM | 3 of 5 drop the first group | Save as UTF-8 without BOM, or start with a comment line |
| Named AI groups at the end of a huge file | Google never reads them | Put named groups first, merge per-URL rules |
Disallow: / above Allow: /path/ | Python 3.12 and older ignore the Allow | Put Allow lines first, or rely on longest match only where you know the parser |
| Two separate GPTBot groups | Python 3.12 and older read only the first | Merge 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.

About the author
M Asif Rahman
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
ChatGPT Says It Can't Access Your Website: Five Causes and How to Tell Which
When ChatGPT says it cannot open your page, one of five things happened, and a short script of four curl requests sorts four of them in about a minute. The fifth is that ChatGPT never tried. This…
Control which AI crawlers reach your site: a robots.txt starter
Most sites that "block AI" with a robots.txt file end up with one of two outcomes: a file that blocks nothing because a bot name is misspelled, or a file that blocks the crawlers that would have sent…
Stealth Crawling vs User-Driven Fetching in 2026: What 75 Sites Actually Do
On 4 August 2025 Cloudflare removed Perplexity from its verified bot list and published the evidence. Perplexity's answer was that fetching a page because a person asked for it is not the same act as…
