Two ways urllib.robotparser gets robots.txt wrong

One refuses sites that welcome you. The other changes its answer depending on what order the rules happen to be written in. Both are measured below, on four Python versions.

We hit this building a small scraper. The robots.txt said, in full:

User-agent: *
Allow: /

Sitemap: https://example.com/sitemap.xml

And the scraper refused to read the site. Politely, deliberately, and completely wrongly.

The symptom

This is the whole reproduction. No scraper required:

import urllib.robotparser

rp = urllib.robotparser.RobotFileParser()
rp.set_url("https://hookforge.dev/robots.txt")
rp.read()

print(rp.can_fetch("my-crawler", "https://hookforge.dev/"))
# False

print(rp.disallow_all, rp.allow_all, len(rp.entries))
# True False 0

read() did not raise. It did not warn. It came back having decided that every URL on the site is off limits, and entries is empty because it never parsed a single line of the file.

Meanwhile the file is right there, and it allows everything:

$ curl -s -o /dev/null -w '%{http_code}\n' \
    -A 'my-crawler/1.0' https://hookforge.dev/robots.txt
200

The cause, from the source

It is eleven lines of CPython, and both halves of the problem are visible in them:

def read(self):
    """Reads the robots.txt URL and feeds it to the parser."""
    try:
        f = urllib.request.urlopen(self.url)
    except urllib.error.HTTPError as err:
        if err.code in (401, 403):
            self.disallow_all = True
        elif err.code >= 400 and err.code < 500:
            self.allow_all = True
        err.close()
    else:
        raw = f.read()
        self.parse(raw.decode("utf-8").splitlines())

First half: urllib.request.urlopen(self.url), with no headers. So robots.txt is requested as Python-urllib/3.13 — not as your crawler, and not as anything a bot-filtering rule was written with in mind.

Second half: a 403 sets disallow_all. That part is correct. RFC 9309 §2.3.1.3 says an "unavailable for legal reasons" or access-denied status on robots.txt means the crawler must assume complete disallow. The parser is doing exactly what the spec asks.

The bug is in the join between them. The 403 was about Python-urllib, not about your crawler — but by the time read() sees a status code, the user agent that earned it is gone, and a refusal aimed at one identity has been applied to another.

How often does it actually happen?

We first wrote "about half the web" and then went and measured, which is a better order to do those two things in. Fetching each site's robots.txt twice — once as urllib's default, once with a normal crawler UA:

Siteurllib default UAOwn UA
hookforge.dev403200false disallow
cloudflare.com403200false disallow
news.ycombinator.com200200fine
python.org200200fine
github.com200200fine
stackoverflow.com418418see below

Two in six, in a deliberately small and unscientific sample. Not half the web — but not rare either, and the sites it hits are the ones behind the CDNs that answer unknown user agents with a challenge. If your crawler mysteriously refuses one site in three and works fine on the rest, this is a cause worth eliminating early.

Stack Overflow is the interesting row. It answers everyone with 418, and 418 is a 4xx that is not 401 or 403 — so it lands in the allow_all = True branch. Their bot defence makes robotparser conclude that there are no rules at all, which is the opposite failure and arguably the worse one.

The fix

Fetch the file yourself, with the user agent you are actually going to crawl as, and hand the text to the parser. RobotFileParser.parse() takes a list of lines, so nothing else changes:

import urllib.error, urllib.parse, urllib.request, urllib.robotparser

UA = "my-crawler/1.0 (+https://example.com/bot)"

def robots_for(base):
    """A RobotFileParser for `base`, fetched with OUR user agent."""
    rp = urllib.robotparser.RobotFileParser()
    req = urllib.request.Request(base + "/robots.txt",
                                 headers={"User-Agent": UA})
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            body = r.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        # Now these statuses mean what the RFC says they mean, because they
        # were earned by the agent that is going to do the crawling.
        rp.disallow_all = e.code in (401, 403)
        rp.allow_all = not rp.disallow_all
        return rp
    except Exception:
        # Unreachable robots.txt is "no rules", not "no crawling" -- refusing
        # here makes the crawler useless against every site without one.
        rp.allow_all = True
        return rp
    rp.parse(body.splitlines())
    return rp

def allowed(url):
    parts = urllib.parse.urlparse(url)
    return robots_for(f"{parts.scheme}://{parts.netloc}").can_fetch(UA, url)

That fixes the fetching. It does not fix the matching — the rp.parse() on the last line is the same parser with the same ordering bug, which is the next section. If you need both, keep this function for getting the text and evaluate the rules yourself.

Three details worth keeping:

The second one: order changes the answer

That first bug is loud once you find it — the crawler refuses everything and you go looking. This one is quiet, and it is still there after you fix the first.

Two robots.txt files. Same two rules, opposite order:

>>> import urllib.robotparser as rp
>>>
>>> a = rp.RobotFileParser()
>>> a.parse(["User-agent: *", "Allow: /public/", "Disallow: /"])
>>> a.can_fetch("my-crawler", "https://example.com/public/x")
True
>>>
>>> b = rp.RobotFileParser()
>>> b.parse(["User-agent: *", "Disallow: /", "Allow: /public/"])
>>> b.can_fetch("my-crawler", "https://example.com/public/x")
False

Same site, same permission, opposite answers. RFC 9309 §2.2.2 is explicit that order carries no meaning: the most specific rule wins — the longest matching path — and Allow wins a tie. urllib.robotparser takes the first match instead.

And the second shape is the common one. Block everything, then open these paths is how a great many real robots.txt files are written — which means a first-match parser reads a deliberate, carefully-scoped crawling policy as a total ban. The sites it refuses are precisely the ones that went to the trouble of saying yes.

It is being fixed, and most Pythons do not have the fix

We only found this because a test asserted the bug was still there, and CI went red. Measured on every version we could reach:

Python/public/x with Disallow: / first
3.9.25Falsebug
3.11.16Falsebug
3.13.5Falsebug
3.13.15Truefixed

So the fix landed in a recent 3.13 patch. If you are on anything older — which is most deployed Python — you still have it, and you cannot tell from the major version.

The fix

Evaluate the rules yourself. It is about thirty lines and it removes the version question entirely:

import re

def rule_matches(path, rule):
    """Does this robots.txt path rule apply? Handles * and $."""
    if rule == "":
        return False                      # empty Disallow means "allow all"
    pattern = "".join(
        ".*" if ch == "*" else ("$" if ch == "$" else re.escape(ch))
        for ch in rule)
    if not rule.endswith("$"):
        pattern += ".*"
    return re.match(pattern, path) is not None

def robots_allows(rules, path):
    """RFC 9309: longest matching rule wins, Allow wins a tie.

    `rules` is [(path_rule, is_allow)] for the group that applies to you.
    """
    best_len, best_allow = -1, True
    for rule, allow in rules:
        if not rule_matches(path, rule):
            continue
        if len(rule) > best_len or (len(rule) == best_len and allow):
            best_len, best_allow = len(rule), allow
    return best_allow
>>> rules = [("/", False), ("/public/", True)]     # order does not matter
>>> robots_allows(rules, "/public/x"), robots_allows(rules, "/other")
(True, False)
>>> robots_allows(list(reversed(rules)), "/public/x")
True

Three details worth keeping: an empty Disallow: is the documented way to say allow everything and must not match anything; $ only anchors when it is the last character; and a group for a named user agent beats the * group entirely rather than adding to it.

The larger point

The first one is nobody's mistake. robotparser follows the RFC on status codes. The CDN is right to challenge an unrecognised agent. urlopen has always sent its own UA. Every piece behaves correctly and the result is a crawler that refuses a site which explicitly allowed it — silently, with no exception and no log line, which is why it survives code review.

The second one is a plain deviation from the spec, and it has the same shape: no error, no warning, just a False that looks exactly like a site saying no. Neither shows up in a test unless you go looking for it, because both fail in the direction that seems responsible.

The bugs that last are rarely inside a function. They are in the gap between two functions that each did exactly what they promised.

The scraper this came out of

listscrape.py pulls a repeated list off a page into CSV — one file, no dependencies, public domain, and it fetches robots.txt the way described above. It also builds a real DOM instead of running regexes over HTML, and tells you when a list is drawn by JavaScript rather than writing you an empty file.

Free, and it stays free. If the page you need is the awkward one — a login, pagination, a layout that changes every week — that is what the Website Scraper package is for, at a fixed 79€.

← HookForge: fixed-price automation scripts, delivered in 48h