tldextract
tldextract splits a hostname into subdomain, domain, and public suffix using the Public Suffix List, the same community-maintained file browsers use to decide cookie scope. Splitting on dots does not work: for forums.bbc.co.uk the suffix is co.uk and the domain is bbc, but a naive rsplit gives you co. The PSL knows that co.uk, org.kg, and thousands of other multi-label suffixes are places where anyone can register, so tldextract can tell you the actual registrable domain. It returns a small dataclass with subdomain, domain, suffix, and is_private fields plus derived properties like fqdn and top_domain_under_public_suffix. On first use it downloads the current list and caches it on disk, which is both the reason it stays accurate and the reason it surprises people in production.
The most accurate way to find the registrable domain in Python, and the right default for anything that groups or scopes by site. Configure the cache and disable the live fetch before it ships, or the first request in a cold container will be the one that pages you.
Use it if
- You group, deduplicate, or rate limit by site and need bbc.co.uk and forums.bbc.co.uk to resolve to the same owner while bbc.co.uk and example.co.uk stay separate
- You are writing security or anti-abuse logic that mirrors browser cookie scoping; the PSL is the same list browsers consult, so your boundary matches theirs
- You need to distinguish registry suffixes from private ones: with include_psl_private_domains=True, waiterrant.blogspot.com has suffix blogspot.com, which is what you want when each blog is a separate tenant
- You want the suffix list to stay current without a code change; the disk cache can be refreshed with tldextract --update or by pointing at your own mirror
- You cannot tolerate startup network I/O. With default settings the first extract in a process with a cold cache fetches publicsuffix.org over HTTPS, and cache_fetch_timeout defaults to None, meaning no timeout at all unless you set TLDEXTRACT_CACHE_TIMEOUT. In a container or a lambda that turns a string operation into an unbounded network call
- You cannot write to disk or want deterministic behavior across replicas. The cache lands in $HOME/.cache/python-tldextract, takes a file lock, and never expires, so two long-lived containers started a month apart can disagree about the suffix list
- Four runtime dependencies for a string split is too many. It pulls requests, requests-file, filelock, and idna; publicsuffixlist does the same lookup with no third-party dependencies and ships the list inside the package
- You expect validation or normalization. tldextract accepts anything: extract("not a url at all") puts the whole string in domain, and case is preserved, so "HTTP://WWW.EXAMPLE.COM" comes back with suffix 'COM'. Lowercase and validate before or after yourself
- You have code on registered_domain. It emits a DeprecationWarning as of 5.3.1 and the docstring says it is removed in 6.0.0; the replacement is top_domain_under_public_suffix
Setup reality
pip install tldextract pulls requests, requests-file, filelock, and idna, and needs Python 3.10 or newer, so old runtimes are out. The first call is the one that bites: with a cold cache it fetches the Public Suffix List from publicsuffix.org, falls back to a GitHub mirror, writes the parsed result under $HOME/.cache/python-tldextract (override with the TLDEXTRACT_CACHE environment variable or cache_dir), and holds a file lock while doing it. There is no fetch timeout by default. If the download fails, fallback_to_snapshot=True quietly uses the list bundled at release time, so you get an answer that may be months out of date with no error. For servers, construct one TLDExtract(suffix_list_urls=()) at import time and reuse it: building a new instance re-parses the whole list into a trie on its next call, which is tens of milliseconds you do not want per request.
Patterns
Split a URL into subdomain, domain, and suffixextract-basic
import tldextract
print(tldextract.extract("http://forums.news.cnn.com/"))
# ExtractResult(subdomain='forums.news', domain='cnn', suffix='com', is_private=False)
print(tldextract.extract("http://forums.bbc.co.uk/"))
# ExtractResult(subdomain='forums', domain='bbc', suffix='co.uk', is_private=False)The module-level extract() uses one shared extractor, so the suffix list is parsed once per process. It accepts bare hostnames as well as full URLs, and ignores the scheme, port, path, and query.
Get the domain someone actually registeredregistrable-domain
ext = tldextract.extract("http://forums.bbc.co.uk/path")
print(ext.top_domain_under_public_suffix) # 'bbc.co.uk'
print(ext.fqdn) # 'forums.bbc.co.uk'
print(ext.registered_domain) # same value, DeprecationWarningregistered_domain still works but warns and is documented as removed in 6.0.0; migrate to top_domain_under_public_suffix. Both return an empty string when there is no suffix, so localhost and IP addresses give '' rather than raising.
Stop it from downloading the list at runtimedisable-network-fetch
import tldextract
extract = tldextract.TLDExtract(suffix_list_urls=())
extract("http://www.google.com")
# uses the snapshot bundled with the installed version, no HTTP at allThis is the setting most production services want. Without it, the first call in a cold container fetches publicsuffix.org with no timeout by default, and a slow or blocked network turns a parse into a hang.
Build the extractor once and share itreuse-one-extractor
# module scope, imported everywhere
EXTRACT = tldextract.TLDExtract(suffix_list_urls=())
def site_of(url: str) -> str:
return EXTRACT(url).top_domain_under_public_suffixEach TLDExtract instance builds its own trie from the suffix list on first use, which costs tens of milliseconds. Creating one inside a request handler pays that on every call for no benefit.
Point the cache somewhere writablecustom-cache-location
import tldextract
extract = tldextract.TLDExtract(cache_dir="/var/cache/tldextract")
# or from the environment, no code change:
# export TLDEXTRACT_CACHE=/var/cache/tldextract
# export TLDEXTRACT_CACHE_TIMEOUT=2.0The default is $HOME/.cache/python-tldextract, which does not exist for a user with no home directory and fails on a read-only root filesystem. TLDEXTRACT_CACHE_TIMEOUT is the only way to bound the fetch without touching code.
Treat blogspot.com and github.io as suffixesprivate-domains
import tldextract
extract = tldextract.TLDExtract(include_psl_private_domains=True)
print(extract("waiterrant.blogspot.com"))
# ExtractResult(subdomain='', domain='waiterrant', suffix='blogspot.com', is_private=True)
print(tldextract.extract("waiterrant.blogspot.com"))
# ExtractResult(subdomain='waiterrant', domain='blogspot', suffix='com', is_private=False)Default is off, so every blog on blogspot.com looks like one site. Turn it on for per-tenant isolation, and use registry_suffix or top_domain_under_registry_suffix when you still need the part someone paid a registrar for.
Refresh the cached list on purposeupdate-suffix-list
# command line
# tldextract --update
# rm -rf $HOME/.cache/python-tldextract
import tldextract
extract = tldextract.TLDExtract()
extract.update(fetch_now=True)The cache never expires on its own, so a long-running container keeps whatever list it fetched at startup. Run the update as a deploy step or a cron job rather than hoping the process restarts often enough.
Skip re-parsing a URL you already splitextract-from-parsed-url
from urllib.parse import urlsplit
import tldextract
EXTRACT = tldextract.TLDExtract(suffix_list_urls=())
parts = urlsplit("https://a.b.example.com/path?q=1")
print(EXTRACT.extract_urllib(parts))
# ExtractResult(subdomain='a.b', domain='example', suffix='com', is_private=False)extract_urllib takes the netloc straight from a ParseResult or SplitResult and skips tldextract's own lenient URL splitting, so it is faster and it rejects nothing that urlsplit already accepted.
Deal with IPs, localhost, and junk inputhandle-non-domains
print(tldextract.extract("http://127.0.0.1/x").ipv4) # '127.0.0.1'
print(tldextract.extract("https://[2001:db8::1]/").ipv6) # '2001:db8::1'
res = tldextract.extract("http://localhost:8080")
print(res.suffix, res.domain) # '' localhost
print(tldextract.extract("not a url at all").domain)
# 'not a url at all'An empty suffix is the signal that there is no registrable domain. tldextract never raises on bad input, so check suffix before you trust domain, or you will happily store a chunk of free text as a site name.
Lowercase results before comparing themnormalize-case
res = tldextract.extract("HTTP://WWW.EXAMPLE.COM")
print(res)
# ExtractResult(subdomain='WWW', domain='EXAMPLE', suffix='COM', is_private=False)
site = res.top_domain_under_public_suffix.lower() # 'example.com'Suffix matching is case-insensitive but the returned strings keep the input's case. If you use the result as a dictionary key, a database value, or a cache key, normalize it or the same site shows up twice.
Add your own suffixes or use a local listcustom-suffix-list
import tldextract
corp = tldextract.TLDExtract(extra_suffixes=["internal", "corp.example"])
print(corp("http://api.svc.internal").suffix) # 'internal'
vendored = tldextract.TLDExtract(
suffix_list_urls=["file:///opt/psl/public_suffix_list.dat"],
cache_dir="/var/cache/tldextract",
fallback_to_snapshot=False,
)extra_suffixes adds to the PSL for internal TLDs that will never be in it. fallback_to_snapshot=False makes a missing or unreadable list an error instead of silently downgrading you to the bundled copy.
Check a hostname from the shellcli-usage
$ tldextract http://forums.bbc.co.uk
forums bbc co.uk
$ tldextract --json waiterrant.blogspot.com
$ tldextract --private_domains waiterrant.blogspot.com
$ tldextract --updateThe CLI reads the same cache as the library, so tldextract --update from a shell refreshes what your service will load next restart. --json is the form to pipe into jq when you are checking a list of hostnames.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| publicsuffixlist | PyPI | You want PSL lookups with no third-party dependencies and no runtime download; the list ships in the package and updates arrive as releases. |
| tld | PyPI | You prefer an API built around getting the TLD or registered domain with optional URL validation and failure modes you choose. |
| idna | PyPI | Your real problem is internationalized domain names: encoding, decoding, and validating punycode rather than splitting off the suffix. |