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.

By LookValid engineering. Published 2026-10-19.

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.

Best-case time for a file of unique addresses at 20 requests a second
Unique rowsMinimum run time
1005 seconds
1,00050 seconds
10,0008.3 minutes
100,00083.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 the error column.
  • A public address missing from the data returns found false 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

  1. Python documentation: csv, CSV File Reading and Writing, read 2026-10-19
  2. Python documentation: concurrent.futures, Launching parallel tasks, read 2026-10-19
  3. RFC 6585: Additional HTTP Status Codes (429 Too Many Requests), read 2026-10-19