Introduction to pslr

library(pslr)

What pslr does

The Public Suffix List (PSL) is a community-curated list of the domain suffixes under which Internet users can directly register names. pslr bundles a pinned snapshot of that list and implements the official prevailing-rule algorithm to answer two core questions about a hostname:

public_suffix("www.example.co.uk")
#> [1] "co.uk"
registrable_domain("www.example.co.uk")
#> [1] "example.co.uk"

The matcher is compiled with cpp11 and needs no external system library. Hostname canonicalization (case folding and Unicode/IDNA handling) is delegated to the punycoder package.

Terminology

The prevailing rule is chosen as: an exception beats a wildcard, the longest match beats shorter matches, and the implicit default applies only when nothing else does.

public_suffix("a.b.kobe.jp") # a wildcard match under kobe.jp
#> [1] "b.kobe.jp"
public_suffix("city.kobe.jp") # an exception match under kobe.jp
#> [1] "kobe.jp"

Choosing a section

section selects which rules are eligible. Filtering happens before prevailing-rule selection, so asking for one section never silently borrows a rule from the other.

# github.io is a PRIVATE rule sitting under the ICANN suffix io.
public_suffix("user.github.io", section = "all") # default scope, both sections
#> [1] "github.io"
public_suffix("user.github.io", section = "icann") # the ICANN rule for io
#> [1] "io"
public_suffix("user.github.io", section = "private")
#> [1] "github.io"

section = "private" fall-through

When you restrict to a section and the host matches no explicit rule there, the query falls through to the implicit default rule rather than failing. A plain ICANN host queried under section = "private" therefore resolves to its own last label via the default rule:

public_suffix("example.com", section = "private")
#> [1] "com"

To distinguish “no explicit rule matched” from a real match, combine the section with unknown = "na" (below).

Unknown-suffix policy

By default an unlisted suffix is handled by the implicit * rule, so a made-up TLD still yields a public suffix. Pass unknown = "na" to require an explicit rule and get NA otherwise.

public_suffix("example.madeuptld") # default rule
#> [1] "madeuptld"
public_suffix("example.madeuptld", unknown = "na") # explicit-only
#> [1] NA

Explicit-membership queries

is_public_suffix() reports whether a host is itself a public suffix. Under the default policy an unlisted single label is TRUE via the implicit rule; use unknown = "na" to test explicit list membership instead.

is_public_suffix("co.uk")
#> [1] TRUE
is_public_suffix("madeuptld") # TRUE via the implicit default rule
#> [1] TRUE
is_public_suffix("madeuptld", unknown = "na") # explicit membership only
#> [1] NA

Unicode and ASCII output

Input may be ASCII, Unicode, or A-label (xn--) hostnames; equivalent spellings canonicalize to the same answer. Output is ASCII A-labels by default; pass output = "unicode" to decode them.

public_suffix("example.рф") # ASCII A-label by default
#> [1] "xn--p1ai"
public_suffix("example.рф", output = "unicode") # decoded to Unicode
#> [1] "рф"
public_suffix("example.xn--p1ai") # the A-label spelling agrees
#> [1] "xn--p1ai"

Terminal dots

A single terminal root dot is preserved on hostname-shaped output, so a fully-qualified name round-trips:

public_suffix("www.example.com.")
#> [1] "com."
registrable_domain("www.example.com.")
#> [1] "example.com."

Extracting and inspecting

suffix_extract() splits each host into subdomain, registrant label, and suffix; public_suffix_rule() reports which rule prevailed, useful for auditing.

suffix_extract("blog.user.github.io")
#>                 input                host subdomain domain    suffix
#> 1 blog.user.github.io blog.user.github.io      blog   user github.io
#>   registrable_domain
#> 1     user.github.io
public_suffix_rule(c("www.ck", "a.b.kobe.jp", "example.madeuptld"))
#>               input        host_ascii      rule      kind rule_section
#> 1            www.ck            www.ck   !www.ck exception        icann
#> 2       a.b.kobe.jp       a.b.kobe.jp *.kobe.jp  wildcard        icann
#> 3 example.madeuptld example.madeuptld         *   default         <NA>
#>   public_suffix_ascii
#> 1                  ck
#> 2           b.kobe.jp
#> 3           madeuptld

All query functions are vectorised, length- and name-preserving, and NA-safe. Invalid input (URLs, IPv6, empty labels, dotted-decimal IPv4 literals, …) is NA by default; pass invalid = "error" to abort on the first invalid element.

Refresh and the active list

The package ships with a pinned snapshot, so it works fully offline and the bundled list is the default for every query. psl_refresh() is the only function that touches the network: an explicit, HTTPS-only, validated download into a user cache. psl_use() chooses which list backs the session.

# Download and validate a fresh list into the user cache, then activate it:
psl_refresh(activate = TRUE)

# Switch the active list for this session:
psl_use("cache") # the latest refreshed snapshot
psl_use("bundled") # back to the shipped snapshot
psl_use("path", path = "my_list.dat") # a custom file

Activation is session-only and validated before any state changes; a failed refresh never replaces a working cache or active list.

Multiple lists with engines

psl_use() switches the one session-global list every query sees by default. psl_engine() instead builds a self-contained engine you can hold in a variable and query independently — so you can work with several lists at once, or give a component its own list, without mutating global session state. Every query function takes an engine = argument that threads a specific engine through that one call.

engine <- psl_engine("bundled")
public_suffix("example.co.uk", engine = engine)
#> [1] "co.uk"
suffix_extract("www.example.co.uk", engine = engine)
#>               input              host subdomain  domain suffix
#> 1 www.example.co.uk www.example.co.uk       www example  co.uk
#>   registrable_domain
#> 1      example.co.uk

An engine holds a compiled matcher backed by a C++ external pointer, which does not serialize across R sessions or parallel worker processes: saving and reloading an engine, or sending one to a worker, does not carry the matcher. This is why engines are described as process-local. To persist or ship an engine, record its snapshot descriptor with psl_version() and rebuild the engine in the target process:

# In the target process, rebuild from a recorded snapshot file:
engine <- psl_engine("path", path = "my_list.dat")

Reproducibility

A public-suffix result depends on both which list answered and how hosts were normalized. psl_version() reports both — the source-snapshot provenance and the runtime normalization identifiers — so a result can be reproduced later. Record this row alongside reproducibility-sensitive output.

psl_version()
#>    source
#> 1 bundled
#>                                                                                                                   url
#> 1 https://raw.githubusercontent.com/publicsuffix/list/9186eeeda85cef35b1551d00731464939c765cab/public_suffix_list.dat
#>   path            retrieved_at            list_date
#> 1 <NA> 2026-06-15 16:18:34 UTC 2026-06-13T21:47:08Z
#>                                     commit   size
#> 1 9186eeeda85cef35b1551d00731464939c765cab 332703
#>                                                                  checksum
#> 1 sha256:54fb5c65a1e21aad963acd74a204370b5f517071e8b8e140c48de40727f0171c
#>   normalizer normalizer_version         normalization_profile unicode_version
#> 1  punycoder              1.2.0 uts46-nontransitional-std3-v1          16.0.0

psl_rules() exposes the active rule table itself:

nrow(psl_rules("icann"))
#> [1] 6933
head(psl_rules("private"), 3)
#>      rule canonical_rule   kind section labels
#> 1  co.krd         co.krd normal private      2
#> 2 edu.krd        edu.krd normal private      2
#> 3  art.pl         art.pl normal private      2

If the shipped index was generated under a different normalization profile or Unicode version than the installed punycoder, the list is transparently rebuilt in memory from source on activation, so an index is never mixed with hosts normalized under a different profile.

The list drifts from the live upstream over time, so psl_outdated() offers an offline staleness check against the active list_date — the cue to consider a psl_refresh(). It never touches the network; the snapshot age in days is returned in the "age_days" attribute.

psl_outdated() # older than the 180-day default?
#> [1] FALSE
#> attr(,"age_days")
#> [1] 35.04048
attr(psl_outdated(), "age_days") # active snapshot age, in days
#> [1] 35.04048

Security and scope notes

See also

pslr is part of a small ecosystem of R packages by the same author:

Acknowledgments

pslr serves the Public Suffix List, maintained by Mozilla and the wider community under the Mozilla Public License 2.0, and delegates host normalization (UTS #46 / IDNA) to the sibling punycoder package. Its matcher is built on cpp11.

The full list of credits — prior art, dependencies, the standards this code implements, and the data sources it serves — is in ACKNOWLEDGMENTS.md.