Home › Networking › DNS & naming
algorithm low

Public Suffix List

Problem Software that must know which part of a host name belongs to one owner (cookies, rate limits, crawl politeness, mail policy, certificate rules) cannot read it off the dots, because co.uk is shared and example.com is not.

Find the registrable domain (eTLD+1) of a host name by matching the longest rule in a community-maintained list of shared suffixes.

O(labels) per lookup, one hash probe per label; list build O(total rule labels)
dnsdomain-namestriecookieseTLD+1wildcard-rules
Overview

Where does "one owner" start in a.b.example.co.uk? DNS does not say: co.uk is shared, example.co.uk is not. The Public Suffix List names every suffix under which strangers register. Match the host, take the longest rule, add one label: the registrable domain (eTLD+1).

Key facts

Use when

  • Browsers: no cookie for a public suffix (RFC 6265 §5.3), site grouping, URL-bar highlighting (same-origin policy).
  • Crawlers and analytics: per-site politeness, dedup and grouping by registrable domain (URL frontier).
  • Certificate authorities: wildcard checks and rate limits (X.509).
  • Mail: the DMARC organizational domain (DKIM, SPF, DMARC).
  • Firewalls: refuse allowlists like *.github.io.

Avoid when

  • You need to know whether a name is a valid domain: ask the DNS, not a snapshot.
  • A signal published by the domain owner exists (DMARC's DNS tree walk, RFC 9989).

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)

  1. 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.
  2. Split into labels and walk the trie from the right-most label.
  3. At depth i, a * child with a rule matches with i+1 labels; an exact child continues the walk; no child ends it.
  4. An exact child holding an exception rule prevails outright: the suffix is the i labels above it (the rule minus its left-most label).
  5. Otherwise the longest normal or wildcard match prevails; no match means the default rule *: the last label alone.
  6. Public suffix = the right-most n labels of the host, plus the trailing dot if there was one.
  7. Registrable domain = suffix plus one more label; none if the host has no such label (it is a suffix).
  8. 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 HashMap probe 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_domain as written, which lowercases, splits and joins into new Strings, 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 psl do this; speed not measured by me)

Implementation notes

  • Match on labels, never on string suffixes: notgithub.io must not match github.io.
  • Keep the trailing dot, since callers may treat example.com and example.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.com as also listing foo.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.example in the test file); validity checks need to treat "no rule matched" as a failure (wiki note 2).
  • IP literals: 1.2.3.4 would get suffix 4 without 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

DNS tree walkDMARC in RFC 9989 replaces the list with DNS queries for policy records, so the domain owner decides.
_psl TXT recordsthe wiki mentions them as the way registries validate ICANN-section changes.
DAFSA / static compiled tablelibpsl and the psl crate when startup time and size matter.
Radix triea compact trie over characters instead of labels; the plain label trie is simpler and as fast here.

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

namelangrepoinstallverdictlicencehealthas_ofnotes
pslRustaddr-rs/pslcargo add psl————list compiled to native code; republished daily when the list changes
publicsuffixRustrushmorem/publicsuffixcargo add publicsuffix————runtime-loaded list, updatable without a release
x/net/publicsuffixGogolang/netgo get golang.org/x/net/publicsuffix————pre-compiled snapshot; PublicSuffix and EffectiveTLDPlusOne
libpslCrockdaboot/libpslbrew install libpsl————built-in DAFSA, loads files, IDNA aware
tldextractPythonjohn-kurkowski/tldextractpip install tldextract————splits subdomain, domain, suffix; private section optional
publicsuffixlistPythonko-zu/pslpip 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

Used by

egress proxy filtering · plannedForcing sandbox network traffic through a proxy with domain allowlists, TLS SNI checks and request logging
same origin policy · plannedOrigin model, CORS, cookies SameSite and site isolation boundaries in browsers

Systems (4):

Alternatives and related