Location, network and abuse information for any IP address in one offline file.
No API, no rate limit, no lookup leaving the machine.
pip install "plevin[db,full]">>> import plevin
>>> found = plevin.lookup("1.1.1.1") # str, int, bytes or ipaddress object
>>> found.place.city.name, found.place.city.region.name, found.place.country.name
('Brisbane', 'Queensland', 'Australia')
>>> found.network.asn, found.network.operator.brand, found.network.cidr
(13335, 'Cloudflare', '1.1.1.0/24')
>>> exit_node = plevin.lookup("185.220.101.1")
>>> exit_node.abuse.service, exit_node.abuse.risk, exit_node.abuse.is_tor_exit_node
('tor_exit_node', 0.99, True)lookupalways answers aResultand raisesValueErrorfor non-addresses;foundis false where the file knows nothing of the address.- Integers up to
0xFFFFFFFFare v4, solookup(1)is0.0.0.1. - No dependencies.
fulladdspycountry,babelandphonenumbers(andtzdataon Windows) for country names, currency, calling code, languages and local time.
The database ships as its own wheel and is found automatically. With several installed, the richest wins.
| extra | size | carries |
|---|---|---|
plevin[db] |
18.7 MB | every field |
plevin[place] |
6.3 MB | city, region, postal, coordinates, metro |
plevin[network] |
7.4 MB | ASN, operator, routing, registry |
plevin[abuse] |
4.1 MB | abuse level, service and provider |
plevin[country] |
378 KB | country code |
Your own file: PLEVIN_DB=/path/to/plevin.plv, plevin.use(path), or
plevin.Plevin(path) for a separate instance. .built, .selection and .fields
describe it.
place, network and abuse are None where the build carries none of them. A
missing leaf is None, never "" or 0.
Place(
lat=-27.4675, lon=153.0281, accuracy=200, confidence=36, granularity='city',
city=City(
id=2174003, name='Brisbane', ascii=None,
population=2780063, elevation=27, postal='4000', postal_partial=None,
timezone='Australia/Brisbane', type='regional capital', capital='region',
region=Region(id=2152274, code='04', iso='AU-QLD', name='Queensland',
type='State'),
district=District(id=7839562, code='31000', name='Brisbane'),
metro=None,
),
country=Country(
code='AU', name='Australia', official=None, common=None, iso3='AUS',
numeric='036', flag='🇦🇺', continent='OC', currency='AUD',
currency_name='Australian Dollar',
calling_code='+61', languages=('en',), european_union=False,
driving_side='left',
),
time=Time(
abbreviation='AEST', local='2026-08-13T19:20:00+10:00', utc_offset='+10:00',
is_dst=False, dst_start=None, dst_end=None,
),
)| field | meaning |
|---|---|
accuracy |
radius in km |
confidence |
0 to 100 |
capital |
which capital the city is, if any |
ascii |
plain-ASCII spelling, only where it differs from name |
region.iso |
ISO 3166-2; region.code is the GeoNames admin1 code |
postal_partial |
leading part of postal, where a source knows only that much |
granularity |
city; region or country leaves city only its region, country and timezone |
country, time |
derived; names, currency, calling code, languages and clock need the full extra |
continent |
AF, AN, AS, EU, NA, OC or SA |
european_union |
member states and their outermost regions (Réunion, Guadeloupe, Åland, …) |
languages |
official and de facto languages, one entry per language, most spoken first |
time |
from city.timezone; None where the zone is unknown |
Network(
asn=13335, handle='CLOUDFLARENET', prefix=24, cidr='1.1.1.0/24',
start='1.1.1.0', end='1.1.1.255', rir='apnic', country='AU', since=2011,
rpki='valid', roas=1,
operator=Operator(
company='Cloudflare, Inc.', brand='Cloudflare', domain='cloudflare.com',
website='https://cloudflare.com', category='cdn', tier=2, peering=354,
cone=1022, scope='Global', rir='arin', since=2010,
street='101 Townsend St', state='CA', postal='94107-1934', country='US',
abuse_email='abuse@cloudflare.com', city=City(id=5391959, name='San Francisco'),
),
carrier=Carrier(user_type='cdn', user_count=18, mcc=None, mnc=None,
is_mobile=False),
)| field | meaning |
|---|---|
cidr |
the announced prefix the address falls in |
rir |
registry of the address, which may differ from the ASN's (operator.rir) |
country |
country the registry lists for the block, not where it is used (place.country) |
since |
year the registry lists for the block |
rpki |
valid, invalid or unknown (announced, no ROA); roas counts agreeing ROAs, or conflicting ones where invalid |
brand |
company without legal form: GOOGLE, Google LLC → Google |
domain |
registered domain of website, else of abuse_email where the mailbox is the operator's own: its name matches, or a gov, mil, edu body; free mail and social sites never count |
website |
stored as host and path, read as https:// |
tier |
1 transit-free, 2 has customers, 3 edge |
peering |
internet exchange count |
cone |
ASNs in its customer cone, itself included: 1 is a network with no customers (CAIDA AS Rank) |
city |
the registered address's city: its GeoNames id and name, nothing else |
carrier.user_type |
what the range is used for: the feed that names the range, else the operator's category |
carrier.user_count |
estimated internet users behind the ASN (APNIC) |
carrier.mcc, mnc |
mobile country and network code where a carrier's name matches; mnc only where one network code fits |
category |
residential, business, hosting, education, government, military, cdn, content, infrastructure, cellular, search_engine_spider, traveler, transit, exchange, non-profit |
Unannounced space (about a seventh of routable IPv4) still answers: asn, rpki and
roas are None, cidr is the registry block, handle and operator its holder.
ARIN and LACNIC publish no holders, so their unannounced space has no name. Reserved
space has no network at all.
>>> found = plevin.lookup("36.50.238.1")
>>> found.network.asn, found.network.cidr, found.network.handle, found.network.country
(None, '36.50.238.0/23', 'GMTECH-BD', 'BD')Abuse(
provider='Tor', service='tor_exit_node', evidence='measured',
threat='spam', level='high', risk=0.99, network_risk=0.88, last_seen_days=1,
is_malicious=True, is_anycast=False, is_satellite=False, is_crawler=False,
is_hosting_provider=True, is_proxy=False, is_public_proxy=False,
is_residential_proxy=False, is_anonymous_vpn=False, is_tor_exit_node=True,
is_private_relay=False, is_anonymous=True,
)| field | meaning |
|---|---|
risk |
0 to 0.99 for the address; None means never seen, not zero |
network_risk |
the same for the whole ASN |
level |
low ≥ 0.40, medium ≥ 0.60, high ≥ 0.80, else None |
is_malicious |
a level is set |
service |
tor_exit_node, private_relay, anonymous_vpn, residential_proxy, public_proxy |
evidence |
published, measured, reported, inferred (strongest first) |
threat |
what the address was reported for: botnet, malware, phishing, bruteforce, web_attack, spam, scanner |
provider |
who runs the service: the feed's name, else the network's brand |
is_crawler |
a published search engine or AI crawler range |
| booleans | derived from service and the carrier type |
| baseline | a service, is_anycast or is_satellite known for the whole ASN reaches every address in it; risk, level and threat stay per address |
risk combines what the service is worth on its own with every feed that reported
the address: each agreeing source raises it, feeds sharing an upstream count once. An
unreported Tor exit already reads high; one on four blocklists reads higher. A public
proxy on a residential or cellular line reads as residential_proxy, inferred.
No database needed:
>>> found = plevin.lookup("2606:4700::1111")
>>> found.number, found.ip, found.expanded
(50543257672059871404715951523469725969, '2606:4700::1111',
'2606:4700:0000:0000:0000:0000:0000:1111')
>>> plevin.lookup("::ffff:8.8.8.8").tunnel, plevin.lookup("::ffff:8.8.8.8").embedded_ipv4
('ipv4-mapped', '8.8.8.8')
>>> found = plevin.lookup("8.8.8.8")
>>> found.as_ipv4_mapped, found.as_6to4, found.as_nat64
('::ffff:8.8.8.8', '2002:808:808::', '64:ff9b::808:808')| field | meaning |
|---|---|
ip, expanded |
the shortest spelling; expanded only for v6 (None for v4) |
arpa |
reverse DNS name |
is_global |
plus is_private, is_loopback, is_multicast, is_reserved, is_link_local, is_unique_local, is_documentation, is_shared, is_benchmark (IANA registries); none set means global |
tunnel |
ipv4-mapped, 6to4, teredo, nat64 or None; embedded_ipv4 is the v4 inside |
decimal_ipv4 |
a guess: v4 written as decimal into the last four hextets |
as_* |
v6 forms of a v4 address; None for v6 |
Off by default: the only part of a lookup that leaves the machine.
>>> plevin.lookup("8.8.8.8", dns=True).dns
Dns(
asked='8.8.8.8', hostnames=('dns.google',),
ipv4_addresses=('8.8.4.4', '8.8.8.8'),
ipv6_addresses=('2001:4860:4860::8888', '2001:4860:4860::8844'),
alias=None, zone='8.8.8.in-addr.arpa', zone_primary='ns1.google.com',
zone_contact='dns-admin@google.com', is_confirmed=True, is_signed=True,
)- Names:
hostnamesare the PTRs,ipv4_addresses/ipv6_addresseswhat the first one resolves to forward. - Checks:
is_confirmedmeans forward-confirmed reverse DNS;is_signedmeans DNSSEC. - Zone:
zone,zone_primaryandzone_contactcome from the reverse zone's SOA. - Queries: PTR and SOA, then A and AAAA, raced across the system resolver plus 1.1.1.1, 8.8.8.8 and 9.9.9.9, with TCP on truncation.
- Cache: answers are kept for one hour.
>>> found = plevin.system("AS13335") # 'AS13335', 'as13335' or 13335
>>> found.handle, found.network.operator.brand, found.abuse.network_risk
('CLOUDFLARENET', 'Cloudflare', 0.14)
>>> [one.asn for one in plevin.search("hetzner")]
[24940, 212317, 213230, 215859]
>>> routes = plevin.routes("AS13335")
>>> len(routes.ipv4), len(routes.ipv6), routes.ipv4_addresses
(1411, 884, 592896)| call | answers |
|---|---|
system(asn) |
System: handle, network, operator, carrier, ASN abuse; falsy if unknown |
search(text, limit=20) |
Systems whose handle or company matches; word starts first, then reach |
routes(asn) |
Routes: every announced prefix as a Span, widest first, plus space covered |
Full file, one core:
| operation | speed |
|---|---|
| open | 1 ms, memory-mapped |
| first lookup | 30 ms |
| repeats | 2,060,000/s |
| random v4 | 9,000/s cold, 50,000/s warm |
system |
160,000/s |
routes |
90–120 ms, then cached |
search |
2 ms, after 0.22 s building the index |
Blocks decode lazily, only as far as a lookup reaches, and stay decoded. Files are
read-only and shared across processes and threads. Plevin(path).file.row(value, wide)
gives the raw rows without models.
- Mini file: the lookup in one 280-line file, plain dictionaries.
- JavaScript: the same reader for Node, Deno, Bun, Workers and browsers.
- Builder: sources, file format, custom selections.
cd python
uv run pytest # 255 tests, 100% branch coverage
uv run mypy && uv run basedpyright
uvx ruff check . ../plevin_mini.py --config pyproject.toml
uv build --wheelApache 2.0, see LICENSE. The database carries the licenses of its sources.