This Python package enables developers to retrieve domain registration information and check domain availability over the WHOIS (port 43) and RDAP/HTTP protocols. It's a useful tool for web developers and domain name registrars.
Python port of monovm/whois-php, which is based on the WHMCS domain Whois class. No third-party dependencies — standard library only.
pip install whois-pythonYou can use this class to check the availability of one or multiple domains.
from monovm_whois import Checker
# Single domain whois
result1 = Checker.whois("monovm.com")
# Single domain whois without specifying TLD
result2 = Checker.whois("monovm")
# Multiple domains whois
result3 = Checker.whois(["monovm", "google.com", "bing"])A dict with domains as keys and status as values.
| Status | Meaning |
|---|---|
available |
The registry says the domain is not registered |
unavailable |
The domain is registered |
premium |
The registry flagged the name as premium/reserved |
invalid |
Not a usable domain name, or no WHOIS server is known for the TLD |
error |
The lookup failed or the server declined to answer — retry, never a verdict |
unknown |
The lookup returned nothing usable |
error is deliberately not folded into available: a rate-limited or blocked server tells you
nothing about the domain.
result1 = {"monovm.com": "unavailable"}
result2 = {
"monovm.com": "unavailable",
"monovm.net": "unavailable",
"monovm.org": "unavailable",
"monovm.info": "unavailable",
}
result3 = {
"monovm.com": "unavailable",
"monovm.net": "unavailable",
"monovm.org": "unavailable",
"monovm.info": "unavailable",
"google.com": "unavailable",
"bing.com": "unavailable",
"bing.net": "unavailable",
"bing.org": "unavailable",
"bing.info": "unavailable",
}A premium result means the registry withheld the record and flagged the name as reserved — for
example nic.ir-class names under IRNIC. A registered domain whose record the registry did
disclose is unavailable, not premium.
When the TLD is not specified in the domain string (e.g. monovm instead of monovm.com), the
Checker class will automatically look up a list of popular TLDs for the entered name.
You can customize this list by passing an options dict as the second argument of whois.
from monovm_whois import Checker
result = Checker.whois("monovm", {"popularTLDs": [".com", ".net", ".org", ".info"]})popular_tlds is accepted as an alias, and a leading dot is optional ('net' == '.net').
from monovm_whois import WhoisHandler
whois_handler = WhoisHandler.whois("monovm.com")After initiating the handler you will have access to the following methods:
| Method | Description |
|---|---|
is_available() |
Returns True if the domain is available for registration (uses enhanced detection) |
is_premium() |
Returns True if the registry flagged the name as premium/reserved |
is_valid() |
Returns True if the domain can be looked up |
get_whois_message() |
Returns the whois server message, including availability, validation or the domain whois information |
get_raw_whois_message() |
Same message with the HTML escaping and <br /> tags removed |
get_tld() |
Returns the top level domain of the entered domain as a string |
get_sld() |
Returns the second level domain of the entered domain as a string |
get_availability_details() |
Returns detailed information about how availability was determined (debug method) |
Every method is also exposed under its PHP camelCase name (isAvailable, getWhoisMessage,
getTld, …) so code can be moved over from the PHP package unchanged.
🟢 is_available() reports the verdict the detection engine reached during the lookup.
The engine asks a chain of rules in order and takes the first conclusive answer:
- Unsupported or unwell server — an IP-number registry banner, a busy server, a timeout.
- Explicit unavailability —
Status: registered,Status: connect, redemption and pending delete states, registry restriction notices; including keys padded with dots. - Registration evidence — enough record fields (
Registrar:,Name Server:,Creation Date:…), or an RDAP record. - Server declined — rate limiting, a blocked client, port 43 retired in favour of RDAP.
- Premium marker — the TLD definition's
premiumtext, from the server definitions. - Registry marker — the TLD definition's
availabletext. - Availability keywords — "no match", "not found", "no entries found", "is free" and 30 more, in several languages, ignoring comment and banner lines.
- No-match patterns — regex forms of the above, plus a genuine RDAP 404.
- TLD-specific patterns — registries phrase availability differently.
- Explicit status fields —
Status: available,Registration status: available. - Default to registered — with no positive evidence, the name is assumed taken.
The order matters more than the rules. Everything that could mean "this is not an answer" or "this
is a registration" is asked before anything that could mean "available", so availability is only
ever reported on positive evidence and is never the fallback. A reply that carries no verdict — a
rate-limit notice, a blocked client, an HTTP 403, a retired endpoint, an empty read — raises
WhoisServerError rather than being read as "free"; see
Differences from the PHP package.
available = whois_handler.is_available()🟢 is_valid() checks whether the entered domain name is valid and can be looked up.
valid = whois_handler.is_valid()🟢 get_whois_message() retrieves the WHOIS information of a domain. It returns a
string that includes the WHOIS server message, which may contain information about the
availability and validation of the domain, as well as its WHOIS information.
message = whois_handler.get_whois_message()🟢 get_tld() extracts the top level domain (TLD) of a given domain. For example, if
the domain name passed to the handler is monovm.com, the method returns .com. Similarly, if
the domain name is monovm.co.uk, the method returns .co.uk.
tld = whois_handler.get_tld()🟢 get_sld() returns the second level domain of the entered domain as a string. For
example, in the domain name monovm.com, the second level domain is monovm.
sld = whois_handler.get_sld()🟢 get_availability_details() provides detailed debugging information about how the
domain availability was determined. It returns a dict containing the result of each detection
method:
details = whois_handler.get_availability_details()
# {
# 'original_library_result': False,
# 'contains_no_verdict_markers': False,
# 'contains_unsupported_tld_messages': False,
# 'contains_unavailability_indicators': False,
# 'contains_registration_indicators': False,
# 'contains_availability_keywords': True,
# 'is_response_too_short': False,
# 'contains_no_match_patterns': True,
# 'tld_specific_patterns': False,
# 'domain_status_indicators': False,
# 'final_availability': True, # or 'unsupported_tld' / 'no_verdict'
# 'whois_message_length': 1234,
# 'whois_message_preview': 'No match for domain example123.com...',
# }import monovm_whois
monovm_whois.whois("monovm.com") # -> {'monovm.com': 'unavailable'}
monovm_whois.is_available("monovm.com") # -> False
monovm_whois.lookup("monovm.com") # -> WhoisHandlerThe package installs a monovm-whois command (also runnable as python -m monovm_whois):
$ monovm-whois monovm.com bing
monovm.com unavailable
bing.com unavailable
bing.net unavailable
bing.org available
bing.info unavailable
$ monovm-whois monovm.com --json
{
"monovm.com": "unavailable"
}
$ monovm-whois monovm.com --record # print the full WHOIS record
$ monovm-whois monovm --tlds .com,.dev # TLDs to try when none is givenEvery entry point accepts the same transport options, either as keyword arguments or inside the
Checker.whois options dict:
| Option | Default | Description |
|---|---|---|
socket_timeout |
10.0 |
Connect/read timeout for port 43 lookups (seconds) |
http_timeout |
60.0 |
Total timeout for RDAP/HTTP lookups (seconds) |
verify_ssl |
False |
Verify TLS certificates on RDAP/HTTP lookups |
override_path |
None |
Extra JSON file merged over the bundled server list |
definitions_path |
bundled | Replace the bundled server list entirely |
unicode_query_tlds |
{".de"} |
TLDs whose registry wants Unicode, not punycode, for IDNs |
from monovm_whois import Checker, WhoisHandler
WhoisHandler.whois("monovm.com", socket_timeout=5, verify_ssl=True)
Checker.whois("monovm.com", {"socket_timeout": 5})verify_ssl is off by default to match the PHP client: several registry RDAP endpoints still
serve incomplete certificate chains. Turn it on when you only query well-behaved registries.
Point MONOVM_WHOIS_DEFINITIONS at a JSON file, or pass override_path. Its entries are merged
on top of the bundled dist.whois.json, so you can add a TLD or replace an existing server:
[
{
"extensions": ".example,.test",
"uri": "socket://whois.example.test",
"available": "No match for"
}
]| Field | Meaning |
|---|---|
extensions |
Comma separated TLDs this entry serves |
uri |
socket://host[:port] for WHOIS port 43, or an https://…/domain/ RDAP base |
available |
Text that appears when the domain is unregistered |
premium |
Optional: text that marks a premium/reserved name |
available_when_empty |
Optional: "true" for registries that answer an unregistered name with nothing but a banner |
IDNs work in either form, and each registry is queried the way it expects:
Checker.whois("bücher.com") # -> {'bücher.com': 'unavailable'}
Checker.whois("xn--bcher-kva.com") # -> {'xn--bcher-kva.com': 'unavailable'}
Checker.whois("münchen.de") # -> {'münchen.de': 'unavailable'}Punycode is used on the wire because Verisign and most registries answer "No match" to a Unicode
query — which would look like availability. DENIC is the exception and gets the Unicode form; add
more with unicode_query_tlds. Result keys keep the form you passed in.
Input is normalised before use, so URLs, mixed case, subdomains and stray whitespace all work:
Checker.whois("HTTPS://WWW.Example.COM/pricing?x=1") # -> {'example.com': 'unavailable'}870+ extensions ship with the package, served over WHOIS port 43 or RDAP. Inspect them at runtime:
from monovm_whois import Whois
Whois().supported_tlds() # ['.abogado', '.ac', '.academy', ...]
Whois().can_lookup(".dev") # TrueThe classes above are a facade. Underneath, each concern is a separate object, and
LookupService is the only thing that knows the whole sequence — find the definition, pick a
transport, choose the query form, fetch, classify, format. It owns none of the steps:
| Module | Responsibility | Extension point |
|---|---|---|
definitions |
Where TLD server definitions come from | DefinitionRepository — JSON file, in-memory, or chained |
resolver |
Which suffix a host name ends with | TldResolver, longest-suffix first |
transport |
How to talk to a registry | Transport per protocol, chosen by TransportFactory on URI scheme |
detection |
What the reply means | An ordered chain of DetectionRule objects walked by DetectionEngine |
formatting |
How the record is rendered | RecordFormatter — HTML or plain text |
service |
The sequence | LookupService, everything injected |
So customising is assembly, not subclassing:
from monovm_whois import Checker, LookupService, TransportFactory
from monovm_whois.definitions import InMemoryDefinitionRepository
service = LookupService(
repository=InMemoryDefinitionRepository(
{".internal": {"uri": "socket://whois.corp.example", "available": "Domain not found"}}
),
transports=TransportFactory.default(socket_timeout=2),
)
Checker.whois("anything.internal", {"service": service})Adding a protocol is a registration rather than an edit to the lookup path:
from monovm_whois import Transport, TransportFactory
from monovm_whois.transport import RawResponse
class MyProtocolTransport(Transport):
schemes = ("myproto",)
def fetch(self, query, endpoint):
return RawResponse(my_client.lookup(query), endpoint=endpoint)
factory = TransportFactory.default().register(MyProtocolTransport())Adding a detection signal is a new rule, inserted where its priority belongs:
from monovm_whois import DetectionEngine, DetectionRule, Verdict
class MyRegistryRule(DetectionRule):
name = "my registry"
def evaluate(self, context):
if "SPECIAL-RESERVED" in context.response.significant:
return Verdict.REGISTERED
return None # defer to the rest of the chain
engine = DetectionEngine((MyRegistryRule(),) + DetectionEngine.default_rules())
engine.explain("...", ".com").rule_name # which rule decided, and whyThe chain's order is the safety policy. Everything that could mean "this is not an answer" or "this is a registration" is asked before anything that could mean "available", because the one unacceptable mistake is calling a registered domain free:
unsupported/unwell server → explicit unavailability → registration evidence → server declined
→ premium marker → registry marker → availability keyword → no match
→ tld-specific availability → status field → default to registered
Note where the two marker rules sit. A definition's available marker is a plain substring, and
some are a single word (.it uses AVAILABLE), so a marker is treated as a hint and asked only
once a real record has been ruled out. Availability is never the fallback.
The detection logic is a port of the PHP original, and 48 of the 60 recorded test responses classify identically. The differences below are deliberate.
That is the one mistake this library must not make, and the PHP original makes it whenever a server replies with something other than a record — because "fewer than two registration fields" was treated as evidence of availability. Every such reply (a rate-limit notice, a blocked client, an HTTP 403, a retired endpoint, a legal preamble, a truncated read) has no registration fields either. This port removes that inference and instead:
- raises
WhoisServerErrorwhen the server declines to answer — rate limiting (request limit exceeded,Maximum query rate reached), a blocked client (Requests of this client are not permitted), or port 43 retired in favour of RDAP; - raises
WhoisServerErroron HTTP 401/403/405/406/429 and 5xx, while still treating an RDAP 404 as the "no such domain" answer it is; - raises
UnsupportedTldErrorwhen the reply is an IP-number registry banner (RIPE, APNIC, ARIN, LACNIC, AFRINIC) — the TLD is mapped to the wrong server, and those answer%ERROR:101: no entries foundto every domain query; - returns an error for an empty or whitespace-only reply instead of calling it available;
- recognises records whose keys are padded with dots (
status.............: Registered), which Traficom (.fi) and NIC Monaco use and a plainstatus:match misses; - reports premium/reserved names as unavailable. PHP re-analyses the
"No WHOIS information available."placeholder there and returnstrue; hereis_available()isFalseandis_premium()isTrue; - reports a junk or empty domain string as invalid rather than available.
Registries that genuinely answer an unregistered name with nothing but a banner opt in per TLD
with available_when_empty, so the inference applies only where it is the documented behaviour
and never overrides a refusal.
The PHP tables match several bare words anywhere in a reply. Registry replies are not just records — they also carry legal banners, field names and prose — so each of these reported a registered domain as free, or an unregistered one as taken:
| Pattern | Where it went wrong | Now |
|---|---|---|
available |
Bare, in 81 per-TLD lists. Matches "Notice, available at https://…", and Traficom prints available.........: <date> on registered .fi domains |
Anchored patterns requiring an assertion about the domain; per-TLD patterns match data lines, not the banner |
404 |
Matched a registrant's street number, a phone number, a registry object id | Anchored to the RDAP and HTTP shapes that mean it; .ec and .shop now match "errorCode" |
registered |
Bare, in the .uk list — and Nominet's free reply reads "This domain name has not been registered", so every free .uk domain read as taken |
The affirmative forms: Registered on: and the explicit sentence |
not exist |
Matched prose such as "a cached copy may not exist" | Requires a subject: "the domain/object/name does not exist" |
free |
Matched a registry's "free FAQ" footer link | is free, status: free |
status:\tavailable |
Transcribed into a Python raw string, so the tab became a literal backslash-t and never matched |
A real tab, which also restores agreement with PHP |
---not found |
Listed in both the availability and unavailability tables, unavailability checked first, so "Not found: free.sx" came back registered — and it is the very marker .io and .sg rely on |
Availability table only |
Two further cases are not about patterns but about precedence:
- A reserved name could read as free, because IRNIC announces one as "This domain is only available for registration under certain conditions". Premium is now a verdict asked before every availability rule.
- Identity Digital's
.ioterms of use say "If too many queries are received…" — a conditional about policy, which turned every free.iolookup into a rate-limit error. Refusal detection now skips conditional sentences.
Each of these answered "not found" for every domain, so every domain under them looked free. Replacements were verified against the live registries in both directions:
| TLDs | Was | Now |
|---|---|---|
.es .com.es .nom.es .gob.es .edu.es |
whois.crsnic.net (Verisign does not serve .es) |
whois.nic.es — Red.es retired its public port 43, so lookups now error instead of lying |
.online, .site |
whois.centralnic.com |
whois.nic.online, whois.nic.site |
.li |
whois.nic.li (refuses most clients) |
whois.nic.ch:4343 (SWITCH serves .li there) |
.shop |
whois.nic.shop (port 43 retired 2026-05-01) |
https://rdap.gmoregistry.net/rdap/domain/ |
.ad |
whois.ripe.net |
whois.nic.ad |
.asso.mc, .tm.mc |
whois.ripe.net |
whois.nic.mc |
.com.tw .net.tw .org.tw |
whois.twnic.net (now answers from APNIC) |
whois.twnic.net.tw |
.ru.com |
whois.verisign-grs.com |
whois.centralnic.com |
.com.ru .net.ru .org.ru .pp.ru |
whois.ripn.net (private zones are absent from it) |
whois.nic.ru |
.gt .com.gt .net.gt .org.gt .ind.gt .edu.gt .gob.gt .mil.gt |
an HTML page that is now a JavaScript app | removed — IANA publishes no WHOIS server, so these report invalid |
- Input is normalised: case, whitespace, a trailing root dot, URLs, ports and subdomains
(
https://www.example.co.uk/x→example.co.uk). The TLD is matched longest-suffix first. - IDNs are converted to punycode for the query, except for registries in
unicode_query_tlds. Whois.lookup()returnsNone(instead offalse) when no server is known for the TLD.- Errors raise typed exceptions —
WhoisErrorand its subclassesUnsupportedTldError,WhoisServerError,WhoisConnectionError,DefinitionsError,InvalidDomainError,EmptyResponseError— rather than a generic exception. - Bad input raises
TypeError/ValueErrorwith a message instead of failing silently: a non-string domain, an unknown option key, an emptypopularTLDs. - Duplicate domains in one
Checker.whois()call are looked up once. - Parsed server definitions are cached, so bulk checks don't re-read the JSON per domain.
- Detection patterns are compiled once at import rather than per call.
- Timeouts, TLS verification, the server list and the IDN query form are configurable.
get_availability_details()gainscontains_no_verdict_markers, andfinal_availabilitycan be"no_verdict".get_availability_details()['whois_message_length']counts characters; PHP'sstrlencounts bytes, so the two differ for responses containing non-ASCII text.
Checkercomposes aWhoisrather than extending it. It could never substitute for one — its constructor requires a domain — so the inheritance only obscured that. The client ischecker.client.WhoisHandler.is_available()reports the verdict reached during the lookup instead of re-running detection over the display message, and no longer flipsis_valid()as a side effect.- A premium name that came with a full record is reported
unavailable, notpremium: the registry showed a registration, so the name is taken.premiumis for the case where the registry withholds the record. monovm_whois.utilswas split intomonovm_whois.names(domain semantics) andmonovm_whois.text(presentation helpers). The re-exports onmonovm_whoisare unchanged.
Three corpora, all replayed offline:
tests/fixtures/live_registry.json— 26 replies captured verbatim from 15 registries, one registered and one unregistered name per TLD, run through the whole lookup path including each TLD's own definition marker. Every pattern fix above was caught or confirmed here; two of them were found only because a real registry does something no synthetic case predicted.tests/fixtures/php_parity.json— 60 responses, each stored with both the PHP verdict and per-method flags and the reviewed verdict expected here. Any drift in either implementation fails the build; every divergence carries a recorded direction and reason, and every individual detection flag that differs from PHP's carries its own.tests/test_pattern_regressions.py— one test per defect above, plus assertions that no pattern table may reintroduce a bare dangerous word.
1350+ offline tests run at 100% line and branch coverage with no network access. The network-marked
tests additionally query live registries; those pace themselves and skip rather than fail when a
registry refuses to answer, since a throttled socket says nothing about this code.
git clone https://github.com/monovm/whois-python
cd whois-python
pip install -e ".[dev]"
pytest # offline only, 100% coverage
pytest --cov --cov-report=term-missing
RUN_NETWORK_TESTS=1 pytest # also query real registries (throttling is skipped, not failed)
ruff check . && ruff format --check . && mypyIf you want to add support for a new TLD, extend functionality or correct a bug, feel free to create a new pull request at the GitHub repository.
For support, email dev@monovm.com.