Tutorials
Bulk-check a CSV of IP addresses in Python
You have a spreadsheet of addresses and want a country, a network owner and a risk score next to each one. This tutorial builds a Python script that does it with only the standard library. The part worth getting right is the pacing: it reads the rate limit from the product and never starts more requests than one key is allowed.
How the script is built
The script has three jobs: read the input, call the lookup for each unique address, and write the answers. Reading and writing use csv.DictReader and csv.DictWriter. The DictReader maps each row to a dict keyed by the header row, and its documentation says files should be opened with newline='' so that newlines inside quoted fields are read correctly 1.
The lookups wait on the network, so threads are the right tool. ThreadPoolExecutor runs calls in a pool of at most max_workers threads, and as_completed hands back each result as soon as it finishes 2. The pool is the part that gives you speed. The pacing, described next, is the part that keeps you inside the limit.
Pace to the real rate limit
One API key may make 200 requests in any 10 seconds, on every plan. That is 20 requests a second. A pool of 16 threads can easily outrun that, so the script includes a small sliding-window gate: before each request it checks how many requests started in the last 10 seconds and sleeps if the window is full.
If a request still gets HTTP 429, the script waits the number of seconds in the Retry-After header and tries again, up to four attempts. The 429 status means the sender made too many requests in a given time, and a server may include Retry-After to say how long to wait 3.
| Unique rows | Minimum run time |
|---|---|
| 100 | 5 seconds |
| 1,000 | 50 seconds |
| 10,000 | 8.3 minutes |
| 100,000 | 83.3 minutes |
Best-case time for a file of unique addresses at 20 requests a second
The times are rows divided by 20, so they are a floor, not a promise. Network delay and retries add to them.
The script
Save this as check_ips.py. It expects a column named ip and writes one output row per unique address. Set LOOKVALID_API_KEY in your environment first.
import csv
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
from collections import deque
from concurrent.futures import ThreadPoolExecutor, as_completed
URL = "https://YOUR_API_HOST/v1/ip"
KEY = os.environ["LOOKVALID_API_KEY"]
LIMIT, WINDOW = 200, 10 # requests per window, in seconds
WORKERS = 16
FIELDS = ["ip", "found", "country", "asn_org", "is_hosting",
"risk_score", "credits_charged", "error"]
class Pace:
"""Lets at most LIMIT requests start in any WINDOW seconds."""
def __init__(self, limit, window):
self.limit, self.window = limit, window
self.starts = deque()
self.lock = threading.Lock()
def wait(self):
while True:
with self.lock:
now = time.monotonic()
while self.starts and now - self.starts[0] >= self.window:
self.starts.popleft()
if len(self.starts) < self.limit:
self.starts.append(now)
return
pause = self.window - (now - self.starts[0])
time.sleep(pause)
pace = Pace(LIMIT, WINDOW)
def lookup(ip):
body = json.dumps({"ip": ip}).encode()
for _ in range(4):
pace.wait()
request = urllib.request.Request(
URL, data=body, method="POST",
headers={"Authorization": "Bearer " + KEY,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=30) as response:
result = json.load(response)
except urllib.error.HTTPError as err:
if err.code == 429:
time.sleep(float(err.headers.get("Retry-After", "1")))
continue
detail = json.load(err).get("error", {})
return {"ip": ip, "error": detail.get("code", str(err.code))}
except (urllib.error.URLError, TimeoutError):
return {"ip": ip, "error": "network"}
data = result.get("data") or {}
return {"ip": ip, "found": result["found"],
"country": data.get("country"), "asn_org": data.get("asn_org"),
"is_hosting": data.get("is_hosting"),
"risk_score": data.get("risk_score"),
"credits_charged": result["credits_charged"]}
return {"ip": ip, "error": "rate_limited"}
def main(source, target):
with open(source, newline="") as handle:
ips = sorted({row["ip"].strip() for row in csv.DictReader(handle)
if row.get("ip", "").strip()})
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = [pool.submit(lookup, ip) for ip in ips]
results = [future.result() for future in as_completed(futures)]
with open(target, "w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=FIELDS, restval="")
writer.writeheader()
writer.writerows(sorted(results, key=lambda row: row["ip"]))
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
check_ips.py
ip
8.8.8.8
10.0.0.1
Input file
Run it with python3 check_ips.py in.csv out.csv. The output for that input looks like this. The 8.8.8.8 values come from a captured example response:
ip,found,country,asn_org,is_hosting,risk_score,credits_charged,error
10.0.0.1,,,,,,,invalid_input
8.8.8.8,True,US,Google LLC,True,25,1,
Output file
Reading the results and the bill
- An address in a private or reserved range, such as 10.0.0.1, is refused with
invalid_input. The refusal is free and the script records it in theerrorcolumn. - A public address missing from the data returns
foundfalse and costs nothing. - A found address costs 1 credit. Ten thousand unique, found addresses therefore cost at most 10000 credits.
- Results are cached for your account for 7 days, so running the same file again costs nothing. The script also removes duplicate rows before it sends anything.
is_hosting marks addresses inside a published cloud or hosting range, and risk_score adds up the factors found. The example above scores 25 for a single hosting factor. A VPN flag can come back as null, which means unknown rather than no.
Try it on a short file first, then scale up. The IP product page lists every field in the response, and the quickstart shows how to create a key.
Sources
- Python documentation: csv, CSV File Reading and Writing, read 2026-10-19
- Python documentation: concurrent.futures, Launching parallel tasks, read 2026-10-19
- RFC 6585: Additional HTTP Status Codes (429 Too Many Requests), read 2026-10-19