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

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`](https://docs.vpndetection.io/integrations/python) 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](https://mullvad.net/en/servers), 300 Tor exits from the [Tor Project's list](https://check.torproject.org/torbulkexitlist), 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](https://app.vpndetection.io): 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](https://docs.vpndetection.io/api/lookup/lookupBatch), 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](https://vpndetection.io/databases/vpn-ip), 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](https://vpndetection.io/pricing)).

For the same job without Python, the [`vpndetection` CLI](https://docs.vpndetection.io/integrations/cli) reads a list on stdin. For live traffic, the [Django](https://vpndetection.io/blog/detect-vpn-users-django) post covers checking each request as it arrives.

