Skip to content
VPNDetection

Check a list of IPs for VPNs in Python: screening a signup export

Screen a CSV of signup IPs for VPNs, Tor and proxies with one lookup_batch call: 1,052 rows in 0.2 s, against 6.8 s for the usual per-row loop.

Published 2 min read

On this page
  1. The file
  2. One call for the whole file
  3. Against the usual loop
  4. Join the answers back
  5. Inside an async pipeline
  6. When to stop calling the API

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

bash
pip install vpndetection

Get 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.

python
# 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:

python
# 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:

python
# 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:

python
# 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.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Try it on your own traffic.

The detection these posts describe is one API call away, with SDKs for every major language.

00 · No key

Start without signing up.

Paste it into a terminal. No account needed. 1k daily allowance per user, answering ip and is_vpn.

curl "https://api.vpndetection.io/45.83.91.1"
{
"ip": "45.83.91.1",
"is_vpn": true
}
01 · Free key

Sign up and get 50,000 requests a month.

No card and no sales call: the key is issued on signup.
Every request flags VPN IPs.
Upgrade only when you outgrow the allowance.