Deep dive
How it works
In: a host name (and the list as text) · Out: its public suffix and registrable domain, or none · State: a trie of the rules, built once and read-only · Cost: one hash probe per label; about 60-200 ns per host (measured, M4 Pro)
- Normalize: drop one trailing dot (remember it), lowercase, convert every label to punycode. Reject empty labels (
.com,a..b) and IP literals: they have no suffix. - Split into labels and walk the trie from the right-most label.
- At depth
i, a*child with a rule matches withi+1labels; an exact child continues the walk; no child ends it. - An exact child holding an exception rule prevails outright: the suffix is the
ilabels above it (the rule minus its left-most label). - Otherwise the longest normal or wildcard match prevails; no match means the default rule
*: the last label alone. - Public suffix = the right-most
nlabels of the host, plus the trailing dot if there was one. - Registrable domain = suffix plus one more label; none if the host has no such label (it is a suffix).
- The node also stores ICANN or PRIVATE, so callers can ignore private rules.
The procedure is the "Formal algorithm" of the list wiki. Rules are Unicode in the file and must be compared in one form (the wiki says: lower-case, Punycode); a wildcard may only be the left-most label of a rule and does not imply the bare name (*.ck does not make ck registrable, nor test.ck).
Code
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr};
#[derive(Default)]
struct Node {
kids: HashMap<String, Node>, // key = one label; "*" = wildcard rule
rule: u8, // 0 none, 1 normal, 2 exception ("!")
private: bool, // rule is in the PRIVATE section
}
pub struct Psl { root: Node }
impl Psl {
/// Parse the .dat format: one rule per line, `//` comments, `!` exceptions.
/// Rules must already be punycode (the .dat file stores Unicode).
pub fn parse(dat: &str) -> Psl {
let (mut root, mut private) = (Node::default(), false);
for line in dat.lines() {
if line.starts_with("// ===BEGIN PRIVATE") { private = true; }
let rule = line.split_whitespace().next().unwrap_or("");
if rule.is_empty() || rule.starts_with("//") { continue; }
let (kind, name) = match rule.strip_prefix('!') { Some(r) => (2, r), None => (1, rule) };
let mut node = &mut root;
for label in name.rsplit('.') { // right-most label first
node = node.kids.entry(label.to_lowercase()).or_default();
}
node.rule = kind;
node.private = private;
}
Psl { root }
}
/// Labels in the public suffix of `labels` (given right-to-left) + PRIVATE flag.
fn suffix_len(&self, labels: &[&str]) -> (usize, bool) {
let (mut best, mut private) = (1, false); // default rule "*"
let mut node = &self.root;
for (i, label) in labels.iter().enumerate() {
if let Some(w) = node.kids.get("*") { // "*.x" matches any label at depth i
if w.rule == 1 && i + 1 > best { best = i + 1; private = w.private; }
}
match node.kids.get(*label) {
None => break,
Some(n) if n.rule == 2 => return (i, n.private), // exception: wins, drops leftmost label
Some(n) => {
if n.rule == 1 { best = i + 1; private = n.private; }
node = n;
}
}
}
(best, private)
}
/// (public suffix, is_private); None for IP literals and malformed names.
pub fn public_suffix(&self, host: &str) -> Option<(String, bool)> {
let (labels, dot, n, private) = self.split(host)?;
Some((labels[labels.len() - n..].join(".") + dot, private))
}
/// eTLD+1: suffix plus one label; None if the host is itself a suffix.
pub fn registrable_domain(&self, host: &str) -> Option<String> {
let (labels, dot, n, _) = self.split(host)?;
if labels.len() <= n { return None; }
Some(labels[labels.len() - n - 1..].join(".") + dot)
}
fn split(&self, host: &str) -> Option<(Vec<String>, &'static str, usize, bool)> {
let (h, dot) = match host.strip_suffix('.') { Some(h) => (h, "."), None => (host, "") };
let h = h.to_lowercase(); // IDNA to punycode must already have happened
if h.is_empty() || h.split('.').any(str::is_empty) { return None; } // ".com", "a..b"
if h.parse::<Ipv4Addr>().is_ok() || h.trim_matches(['[', ']']).parse::<Ipv6Addr>().is_ok() { return None; }
let labels: Vec<String> = h.split('.').map(String::from).collect();
let rev: Vec<&str> = labels.iter().rev().map(|s| s.as_str()).collect();
let (n, private) = self.suffix_len(&rev);
Some((labels, dot, n, private))
}
}
#[test]
fn publicsuffix_org_cases() {
let psl = Psl::parse("// ===BEGIN ICANN DOMAINS===\ncom\nuk\nco.uk\n*.ck\n!www.ck\njp\n*.kobe.jp\n!city.kobe.jp\n\
cn\ncom.cn\nxn--55qx5d.cn\n// ===BEGIN PRIVATE DOMAINS===\ngithub.io\n");
let reg = |h: &str| psl.registrable_domain(h);
let s = |x: &str| Some(x.to_string());
// from publicsuffix/list tests/test_psl.txt
assert_eq!(reg("COM"), None); // a suffix has no registrable domain
assert_eq!(reg("WwW.example.COM"), s("example.com")); // mixed case
assert_eq!(reg(".example.com"), None); // leading dot
assert_eq!(reg("example"), None); // unlisted TLD: default rule "*"
assert_eq!(reg("a.b.example.example"), s("example.example"));
assert_eq!(reg("test.ck"), None); // *.ck
assert_eq!(reg("a.b.test.ck"), s("b.test.ck"));
assert_eq!(reg("www.www.ck"), s("www.ck")); // !www.ck beats *.ck
assert_eq!(reg("www.city.kobe.jp"), s("city.kobe.jp"));
assert_eq!(reg("www.xn--85x722f.xn--55qx5d.cn"), s("xn--85x722f.xn--55qx5d.cn")); // 食狮.公司.cn
// beyond the test file
assert_eq!(reg("www.example.co.uk."), s("example.co.uk.")); // trailing dot kept
assert_eq!((reg("192.168.0.1"), reg("[::1]"), reg("a..com")), (None, None, None));
assert_eq!(psl.public_suffix("me.github.io"), Some(("github.io".into(), true)));
}
Rules must be punycode before parse (the .dat file is Unicode); IDNA conversion is the caller's job, since std has none. The same code passed all 77 non-null cases of tests/test_psl.txt against the full 2026-10-20 list (rules and cases punycoded first by a script).
Complexity and constants
- Lookup: O(labels), at most about 5 for real hosts; one
HashMapprobe per label plus one for the*child. - Build: the 2026-10-20 list parses into 11,161 trie nodes in about 2 ms (measured, M4 Pro, release build; nodes counted by script). Of its rules, 311 are wildcards and 8 exceptions.
Efficiency
- Cost: measured on an Apple M4 Pro, Rust 1.99.1, release build, the list above, a mix of 87 hosts (the test cases plus 10 common names):
registrable_domainas written, which lowercases, splits and joins into newStrings, takes about 200 ns (4.7-5.1 M/s). A variant on already-normalized input that returns a slice and allocates nothing took about 60-85 ns (12-16 M/s) on the same mix. Bound: allocator and hash probes, not memory bandwidth; the table is small enough to stay in cache. - Levers:
- zero-allocation lookup on normalized input — about 3x here (measured, above) — hot paths such as request routing and log processing
- compile the list into a static table at build time — libpsl's DAFSA shrinks the data from about 300 kB to about 50 kB (libpsl README) and removes the startup parse — many short-lived processes
- memoize the result per host — one probe instead of the walk (order of) — crawlers with millions of URLs on few hosts
- ICANN-only table — drops about 3,400 private rules (counted) — when tenants of shared platforms should count as one site
- refresh at most once a day — the publisher asks for this (publicsuffix.org) — any service that downloads the list
- Naive → standard → state of the art:
- last two labels of the host · O(1), wrong for
co.uk,*.ck,github.io - reversed-label trie or hash set built from the daily list · about 200 ns, 2 ms build (measured)
- list compiled into a static table or automaton, zero allocation · no startup parse and no allocation per lookup (crates such as
psldo this; speed not measured by me)
- last two labels of the host · O(1), wrong for
Implementation notes
- Match on labels, never on string suffixes:
notgithub.iomust not matchgithub.io. - Keep the trailing dot, since callers may treat
example.comandexample.com.as different names; the wiki asks for the distinction to be kept. - Store the section flag per node, not in a second list: CAs want ICANN-only, cookie jars want both.
- Load once into immutable memory shared by threads; refresh by swapping in a whole new table.
Pitfalls
- Treating
*.foo.comas also listingfoo.com: many libraries do, the list does not (wiki warning, issue 694). - Using the list as a check that a name is a valid domain. publicsuffix.org calls this dangerous: TLDs come and go, a stale copy rejects real ones.
- An unlisted TLD still yields a registrable domain through the default rule (
example.examplein the test file); validity checks need to treat "no rule matched" as a failure (wiki note 2). - IP literals:
1.2.3.4would get suffix4without the explicit check. - A stale shipped list lacks new shared-hosting domains, so their tenants share cookies; changes also take time to propagate. Ship an update path.
- Different list versions in different places: RFC 7489 warns that varied lists make interoperability difficult.
- A malformed entry request can harm your own site's cookies (maintainers' README).
Variants and alternatives
_psl TXT recordsthe wiki mentions them as the way registries validate ICANN-section changes.psl crate when startup time and size matter.In production
Per publicsuffix.org: Firefox and Chromium (cookies, search-or-URL, domain highlighting), Let's Encrypt (rate limits), Cloudflare (one account per domain), Tranco (domain aggregation), crawler-commons. The CA/Browser Forum Baseline Requirements ban wildcard certificates directly under a registry-controlled label and point to the ICANN section. Go's net/http/cookiejar takes a PublicSuffixList that x/net/publicsuffix implements.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| psl | Rust | addr-rs/psl | cargo add psl | — | — | — | — | list compiled to native code; republished daily when the list changes |
| publicsuffix | Rust | rushmorem/publicsuffix | cargo add publicsuffix | — | — | — | — | runtime-loaded list, updatable without a release |
| x/net/publicsuffix | Go | golang/net | go get golang.org/x/net/publicsuffix | — | — | — | — | pre-compiled snapshot; PublicSuffix and EffectiveTLDPlusOne |
| libpsl | C | rockdaboot/libpsl | brew install libpsl | — | — | — | — | built-in DAFSA, loads files, IDNA aware |
| tldextract | Python | john-kurkowski/tldextract | pip install tldextract | — | — | — | — | splits subdomain, domain, suffix; private section optional |
| publicsuffixlist | Python | ko-zu/psl | pip install publicsuffixlist | — | — | — | — | pure Python, built-in list plus updater, IDN aware |
Sources
- Public Suffix List, list page and daily download rule. site · learn more and uses
- publicsuffix/list wiki, Format (rule syntax, formal algorithm, ICANN and PRIVATE divisions). wiki · list and tests · test_psl.txt
- Barth, HTTP State Management Mechanism, RFC 6265, 2011, §5.3 step 5. RFC
- Kucherawy, Zwicky (eds.), DMARC, RFC 7489, 2015, §3.2 and Appendix A.6. RFC
- Herr, Levine (eds.), DMARC, RFC 9989, 2026 (obsoletes RFC 7489; DNS tree walk). RFC