On this page
You have a CSV of signups, each with the address it came from, and the question is which of them came through a VPN, a proxy or Tor. This post screens the whole file with the official vpndetection package's lookup_batch, times it against the one-request-per-row loop most scripts use, and joins the answers back onto the rows in pandas. We ran every snippet against the live API on October 9, 2026, with Python 3.12, vpndetection 5.7.0 and pandas 3.0.6, and every output below is from those runs.
The file
Our test export has 1,052 rows: 300 Mullvad servers from Mullvad's own server list, 300 Tor exits from the Tor Project's list, 400 home broadband addresses on two cable ISPs, 50 rows that repeat one of those, a private address, and a typo, 203.0.113.300. Its columns are email and signup_ip.
One call for the whole file
pip install vpndetectionGet an API key in the console: a free one covers 50,000 lookups a month. Without a key, the API answers is_vpn alone, for 1,000 lookups a day per source address.
# screen.py
import csv
import os
import time
from vpndetection import VPNDetection
client = VPNDetection(api_key=os.environ.get("VPNDETECTION_API_KEY"))
with open("signups.csv", newline="") as f:
ips = [row["signup_ip"] for row in csv.DictReader(f)]
start = time.perf_counter()
results = client.lookup_batch(ips)
seconds = time.perf_counter() - start
print(f"{len(ips)} rows, {len(results)} addresses, {seconds:.1f} s")
for ip, result in results.items():
if isinstance(result, Exception):
print(f"{ip}: {result!r}")1052 rows, 1002 addresses, 0.2 s
203.0.113.300: VPNDetectionError(kind='bad_request', status=400, message='not a valid IP address')
lookup_batch returns a dict keyed by address, in the order each first appeared, so the 50 repeats collapse into the entries they repeat. The private address is answered in the process, with is_bogon set, and never reaches the API. The typo carries its own error as its value, and the other 1,001 answers are unaffected. Everything else goes to the batch endpoint in chunks of up to 1,000 addresses, sent in parallel, so this file took two requests: 1,000 addresses, then the typo.
Against the usual loop
The scripts that rank for this job call the API once per row, with no cache:
# naive.py: one request per row, no cache, as most scripts do it
import csv
import os
import time
from vpndetection import VPNDetection
client = VPNDetection(api_key=os.environ.get("VPNDETECTION_API_KEY"), cache=False)
with open("signups.csv", newline="") as f:
ips = [row["signup_ip"] for row in csv.DictReader(f)]
start = time.perf_counter()
failed = 0
for ip in ips:
try:
client.lookup(ip)
except Exception:
failed += 1
seconds = time.perf_counter() - start
print(f"{len(ips)} rows, {failed} failed, {seconds:.1f} s")1052 rows, 1 failed, 6.8 s
That loop made 1,051 requests where the batch made 2, and took 34 times as long, counted from the HTTP client's log on the same machine and the same list. Without its try, the typo would have stopped it partway through.
The two cost the same per address. The API charges each distinct address in a batch as one lookup, exactly what a single lookup costs, so the batch saves the 50 repeats, and the time.
Join the answers back
pandas does the rest:
# report.py
import os
import pandas as pd
from vpndetection import VPNDetection
client = VPNDetection(api_key=os.environ.get("VPNDETECTION_API_KEY"))
signups = pd.read_csv("signups.csv")
results = client.lookup_batch(signups["signup_ip"].tolist())
flags = ["is_vpn", "is_tor", "is_resproxy", "is_hosting"]
verdicts = pd.DataFrame(
[
{"signup_ip": ip, **{flag: result.flagged(flag) for flag in flags}}
for ip, result in results.items()
if not isinstance(result, Exception)
]
)
signups = signups.merge(verdicts, on="signup_ip", how="left")
print(signups[flags].sum().to_string())
print(f"needs a second look: {signups[flags].any(axis=1).sum()} of {len(signups)}")is_vpn 337
is_tor 311
is_resproxy 251
is_hosting 613
needs a second look: 648 of 1052
flagged() is True only for a flag that's there and true. A flag your plan doesn't include is None on the result, which means "not in your plan", not "checked, and no", and flagged() reads it as False. So on a plan without the proxy flags, is_resproxy sums to 0 for that reason alone: read result.is_resproxy is None before you trust a zero. The typo has no answer, so its row is empty after the merge.
These counts describe our test file, which is mostly VPN and Tor by design.
Inside an async pipeline
AsyncVPNDetection has the same lookup_batch:
# screen_async.py
import asyncio
import csv
import os
from vpndetection import AsyncVPNDetection
async def main():
with open("signups.csv", newline="") as f:
ips = [row["signup_ip"] for row in csv.DictReader(f)]
key = os.environ.get("VPNDETECTION_API_KEY")
async with AsyncVPNDetection(api_key=key) as client:
results = await client.lookup_batch(ips)
print(f"{len(results)} addresses")
asyncio.run(main())1002 addresses
When to stop calling the API
Every address in a batch is still a lookup, so a file of a few million addresses a day costs a few million lookups a day. Past that point, a download of the VPN IP database, refreshed daily and looked up locally, is cheaper, and the same package fetches it with client.database.download on a key that has the db.download scope (pricing).
For the same job without Python, the vpndetection CLI reads a list on stdin. For live traffic, the Django post covers checking each request as it arrives.


