Vectorized, tldextract-compatible URL domain parsing for Polars.
Project description
polars-tldextract
Accurate URL domain parsing for Polars, as a native Rust expression plugin.
Splitting a hostname into subdomain / domain / public suffix is not a string operation. www.bbc.co.uk and
blog.cloudflare.com look identical to a regex, but the registrable domain is bbc.co.uk in one and cloudflare.com
in the other — "last two labels" is wrong half the time. Getting it right requires the
Public Suffix List, and in Python that means
tldextract — an excellent library, but a Python function. Inside
Polars it can only be driven through Expr.map_elements, one interpreter round-trip per row.
This package implements the same algorithm in Rust and exposes it as ordinary Polars expressions. It is built to produce
identical output to tldextract, not merely similar output — see Correctness.
import polars as pl
import polars_tldextract as tld
df = pl.DataFrame({
"url": [
"https://www.bbc.co.uk/news/technology",
"github.com",
"https://blog.cloudflare.com:443/page/2/",
"127.0.0.1",
None,
]
})
df.with_columns(
tld.fqdn("url").alias("fqdn"),
tld.registrable_domain("url").alias("registrable_domain"),
tld.suffix("url").alias("suffix"),
)
┌─────────────────────────────────────────┬─────────────────────┬────────────────────┬────────┐
│ url ┆ fqdn ┆ registrable_domain ┆ suffix │
╞═════════════════════════════════════════╪═════════════════════╪════════════════════╪════════╡
│ https://www.bbc.co.uk/news/technology ┆ www.bbc.co.uk ┆ bbc.co.uk ┆ co.uk │
│ github.com ┆ github.com ┆ github.com ┆ com │
│ https://blog.cloudflare.com:443/page/2/ ┆ blog.cloudflare.com ┆ cloudflare.com ┆ com │
│ 127.0.0.1 ┆ 127.0.0.1 ┆ null ┆ null │
│ null ┆ null ┆ null ┆ null │
└─────────────────────────────────────────┴─────────────────────┴────────────────────┴────────┘
Install
pip install polars-tldextract
# or
uv add polars-tldextract
Prebuilt wheels cover Linux (glibc and musl, x86_64 and aarch64), macOS (Intel and Apple Silicon), and Windows (x64 and arm64). There is one wheel per platform rather than one per Python version, because the extension is built against the stable ABI. An sdist is published too, so anything else builds from source given a Rust toolchain — see CONTRIBUTING.md.
Usage
Six expressions, all taking a string column of URLs or bare hostnames:
https://www.bbc.co.uk/news |
||
|---|---|---|
tld.extract |
{"www", "bbc", "co.uk", false} |
struct: subdomain, domain, suffix, is_private |
tld.fqdn |
www.bbc.co.uk |
the whole hostname |
tld.registrable_domain |
bbc.co.uk |
what you register — domain.suffix |
tld.subdomain |
www |
|
tld.domain |
bbc |
the registrable label, tldextract's domain field |
tld.suffix |
co.uk |
the public suffix |
Nulls, and the one exception
The five single-value expressions return null for a part that does not exist. That matters in a DataFrame: an empty string is a value, so two rows that both failed to parse would compare equal and join to each other.
tld.extract is the exception, and deliberately so — it reproduces tldextract.ExtractResult verbatim, empty
strings and all. Reach for it when porting existing tldextract code and you want the behavior unchanged; reach for
anything else when the result is going into a join, a group-by, or a comparison.
fqdn vs. registrable_domain
The two differ on hosts that have no registrable domain. registrable_domain is strict — no recognized suffix means
null, so an IP or a .local name drops out. fqdn just gives you the hostname:
tld.fqdn("url") # "127.0.0.1", "localhost", "printer.local"
tld.registrable_domain("url") # null, null, null
fqdn is also the normalized netloc — scheme, userinfo, port, path, query and fragment stripped, trailing root labels
dropped, and the non-ASCII IDNA separators folded to . — so ftp://user:pw@ftp.gnu.org:2121/pub becomes
ftp.gnu.org. Casing and punycode spelling are preserved, exactly like tldextract.
Expression namespace
Importing the package registers a .tld namespace:
df.with_columns(pl.col("url").tld.registrable_domain())
df.filter(pl.col("url").tld.suffix() == "org")
Scalars
For code that isn't holding a DataFrame — the same Rust core, no Polars round-trip:
tld.extract_scalar("https://www.bbc.co.uk/news")
# ('bbc.co.uk', 'bbc', 'co.uk') (registrable_domain, domain, suffix), nulls for absences
tld.extract_scalar_full("https://www.bbc.co.uk/news")
# ('www', 'bbc', 'co.uk', False) (subdomain, domain, suffix, is_private), tldextract-faithful
Private suffixes
The Public Suffix List has an ICANN section and a private section. Like tldextract, the private section is off by
default:
tld.extract_scalar("pola-rs.github.io")
# ('github.io', 'github', 'io')
tld.extract_scalar("pola-rs.github.io", include_private=True)
# ('pola-rs.github.io', 'pola-rs', 'github.io')
Every expression takes the same include_private keyword.
Performance
200,000 URLs, measured with just bench:
| throughput | vs. map_elements |
|
|---|---|---|
tldextract via Expr.map_elements |
94k rows/s | — |
polars_tldextract, parallel=False |
2.05M rows/s | 21.7× |
polars_tldextract, parallel=True |
21.9M rows/s | 232.8× |
AMD Ryzen 9 3950X (16 cores / 32 threads), 32 GB RAM, Linux 6.18 (WSL2), Python 3.12.13, Polars 1.43.
Measure a release build. just bench builds one; a plain maturin develop is unoptimized and roughly 15× slower on
this workload, which measures the profile rather than the code.
Columns of 100k rows or more are split across rayon threads; pass parallel=False to force
single-threaded. The threshold sits above the streaming engine's morsel size, so when Polars is already calling the
plugin from several of its own worker threads each call stays single-threaded rather than nesting a fan-out inside it.
The parallel figure scales with core count — 233× reflects 32 threads, and a 4-core laptop will land far below it. It is also by far the noisiest of the three, swinging ~20% run to run with thread scheduling while the single-threaded number holds within a couple of percent. The single-threaded number is the one to reason about when Polars is already saturating your cores.
A caveat worth stating plainly: if your column has far fewer distinct URLs than rows, a dict built over
Series.unique() plus replace_strict can still beat any per-row approach, including this one. This package wins on
columns with high cardinality, and on code you would rather not write.
Correctness
The point of this package is not "fast domain parsing" — it is "fast domain parsing you can swap in without your results
moving". tests/test_parity.py asserts (subdomain, domain, suffix) equals tldextract's answer, for both settings of
include_private, over four corpora:
- Hand-written edge cases: schemes, userinfo, ports, IPv4, bracketed IPv6, trailing root labels, the three non-ASCII IDNA dot characters, IDN in Unicode and punycode spellings, mixed case, and degenerate input.
- Every rule in the Public Suffix List — each of ~9,750 rules turned into three concrete hosts, ~29,000 cases.
Wildcard rules (
*.ck) get a concrete label and exception rules (!www.ck) have their marker stripped so the exception path is genuinely taken. This is the check that catches divergence no hand-written suite would find. - 200,000 randomly assembled URLs.
- A fixture set drawn from a production pipeline.
Both sides are pointed at the same list file, so a disagreement can only be an algorithm difference — never two different snapshots.
If you find an input where this package and tldextract disagree, that is a bug here. Please
open an issue with the input.
The suffix list
A snapshot of the Public Suffix List is compiled into the binary, so there is no network access, no cache directory, and
no first-call latency spike. tld.psl_version() reports which snapshot is in use.
To supply your own list at startup, point POLARS_TLDEXTRACT_PSL at a .dat file:
export POLARS_TLDEXTRACT_PSL=/path/to/public_suffix_list.dat
It is read once, on first use, so set it before the first extraction.
Refreshing without a restart
The list changes several times a week. A long-lived process — a notebook, a cluster, a service — would otherwise be stuck with whatever list it read when it parsed its first URL, so two functions replace it in place:
# Download the current list and load it into this process.
tld.refresh_psl()
# ...and keep a copy, so the next run need not go back to the network.
tld.refresh_psl(save_to="psl.dat")
# Or load one you already have: a path, a Path, or the list text itself.
tld.load_psl("psl.dat")
Both return the new VERSION: stamp, and take effect for every extraction that starts after they return. A query
already in flight keeps the list it began with, so no single column is ever parsed against two different lists.
refresh_psl is the only function here that touches the network, and only when you call it — importing the package
still does nothing. Point it at an internal mirror with tld.refresh_psl(url=...) if outbound access is restricted.
An unreadable file, an unparseable list, or one missing the ===BEGIN ICANN DOMAINS=== / ===BEGIN PRIVATE DOMAINS===
markers raises ValueError and leaves the working list untouched. The marker check matters more than it looks: a
list without them parses as one undifferentiated section, and every private suffix would quietly start counting as an
ICANN one — wrong output, no signal.
Compatibility
| Python | 3.10+ — one abi3 wheel covers all versions |
| Polars | 1.37+ — the plugin FFI ABI is (0, 1) and unchanged across that range |
| Linux | manylinux2014 and musllinux_1_2, x86_64 and aarch64 |
| macOS | x86_64 (10.12+) and arm64 (11.0+) |
| Windows | x64 and arm64 |
If a future Polars release bumps the plugin ABI, this package fails loudly at load rather than miscomputing.
Contributing
Contributions are welcome — see CONTRIBUTING.md for the development loop, the parity requirement, and
how to refresh the suffix list. docs/architecture/overview.md explains how this
implementation maps onto tldextract's, which is worth reading before changing the algorithm.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
Two third-party works are included or drawn upon and keep their own terms — the Public Suffix List (MPL-2.0) and the
tldextract algorithm (BSD-3-Clause). See NOTICE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file polars_tldextract-0.2.0.tar.gz.
File metadata
- Download URL: polars_tldextract-0.2.0.tar.gz
- Upload date:
- Size: 233.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8c988bc17ad319c7201b07f85111983b77a641acd25f1b0d3aa318e6751523a8
|
|
| MD5 |
1788c2b7ddee9795bd6de64896067ebc
|
|
| BLAKE2b-256 |
405a9de08474a8f01511314eb505f1d74cd9611f0872daa939d8d837e1d905d4
|
File details
Details for the file polars_tldextract-0.2.0-cp310-abi3-win_arm64.whl.
File metadata
- Download URL: polars_tldextract-0.2.0-cp310-abi3-win_arm64.whl
- Upload date:
- Size: 4.6 MB
- Tags: CPython 3.10+, Windows ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
34e5cc46d324b8d6ba0e21fe1b1910508500eac32829a5ab5595cf1afdd469c2
|
|
| MD5 |
b8982dc2815be33c31a9bb2902ab8244
|
|
| BLAKE2b-256 |
e65a070b4873c1de2d2fee53a6ffa59232249d02c5f856df608dacb93691431a
|
File details
Details for the file polars_tldextract-0.2.0-cp310-abi3-win_amd64.whl.
File metadata
- Download URL: polars_tldextract-0.2.0-cp310-abi3-win_amd64.whl
- Upload date:
- Size: 5.0 MB
- Tags: CPython 3.10+, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
266bf7b7fdeda935c77688ab21c3e41a66a94bb779e07ec86b221c683f37362b
|
|
| MD5 |
adebf1ce77f4f5ce857210eb0d5f7858
|
|
| BLAKE2b-256 |
ce191c4211d2b5fa94d9e528a56d14267fd5c0d025995fa4dc6729f8fec6a040
|
File details
Details for the file polars_tldextract-0.2.0-cp310-abi3-musllinux_1_2_x86_64.whl.
File metadata
- Download URL: polars_tldextract-0.2.0-cp310-abi3-musllinux_1_2_x86_64.whl
- Upload date:
- Size: 4.8 MB
- Tags: CPython 3.10+, musllinux: musl 1.2+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fa74a99438d6d715b4e7ba1c27407268d8b5042317abb5b5d0661a9aa73c0039
|
|
| MD5 |
a00b3e27fa0231d8128490a925d269f3
|
|
| BLAKE2b-256 |
2d3f3eb3ea81e1bcc24f581892d6dac6a52e611054ccb1a2d198886b61293570
|
File details
Details for the file polars_tldextract-0.2.0-cp310-abi3-musllinux_1_2_aarch64.whl.
File metadata
- Download URL: polars_tldextract-0.2.0-cp310-abi3-musllinux_1_2_aarch64.whl
- Upload date:
- Size: 4.4 MB
- Tags: CPython 3.10+, musllinux: musl 1.2+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d53851d68bbad0e05e7fd62eee5987002acb8a1942d021f45a16c52e7e3ebb04
|
|
| MD5 |
483ac8f7d3f6e05a383cd34199113e84
|
|
| BLAKE2b-256 |
7731f395008bc1bb66c330f8469f453130b40996f05cf287bb9086f3b242a6ab
|
File details
Details for the file polars_tldextract-0.2.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: polars_tldextract-0.2.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 4.6 MB
- Tags: CPython 3.10+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ddfa7ad3e1c287e72207b4b8f5ee6bdcb8df5f730c20d4d0fe21b30c1d3477e0
|
|
| MD5 |
a8700821f4bed6bc69e93d532b12095a
|
|
| BLAKE2b-256 |
c45c2a0657e31e2f8be8f2af02512e55adfba650dbe23ef256baa56865f016b5
|
File details
Details for the file polars_tldextract-0.2.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: polars_tldextract-0.2.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 4.2 MB
- Tags: CPython 3.10+, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aa4b4e6f3fd63404a1a57a387df88760c35f41664bbd1e60f9d4850773108bcf
|
|
| MD5 |
99b17d13579a220747885f5269f7c685
|
|
| BLAKE2b-256 |
8fc3bd994fec9352f63ff32deb1c809efe931203a8914cc66789f265360e03d4
|
File details
Details for the file polars_tldextract-0.2.0-cp310-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: polars_tldextract-0.2.0-cp310-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 4.1 MB
- Tags: CPython 3.10+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7eba91e4d7aefe7b1e45ed0175c472e582ee69b51e7b4a5b3598505a47cefd3b
|
|
| MD5 |
b3de88071b110315e25859114e25f3b4
|
|
| BLAKE2b-256 |
ae23cb14f68965cf361fb4dd3fdb7f82afccdc2b531fd78a1f0e05b49e209604
|
File details
Details for the file polars_tldextract-0.2.0-cp310-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: polars_tldextract-0.2.0-cp310-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 4.4 MB
- Tags: CPython 3.10+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7f0f3f3928b832161fe9f0ff0dddf17dbed5c138a54c46fc6540a905ae91b0f9
|
|
| MD5 |
5fa94032b6df74bd36737249fd841f38
|
|
| BLAKE2b-256 |
b3ca6341b732733a1508ab8fed696b25ca77c8d0d6a761fe7ec8cabd304ce685
|