---
title: "Bulk-check a CSV of IP addresses in Python"
url: "https://lookvalid.com/blog/tutorial/bulk-validate-csv-in-python.md"
canonical_url: "https://lookvalid.com/blog/tutorial/bulk-validate-csv-in-python"
last_updated: "2026-10-22"
type: "how-to"
summary: "A standard-library Python script that reads a CSV, runs one IP lookup per unique row with threads paced to the API rate limit, and writes a results CSV."
related: ["https://lookvalid.com/products/ip.md", "https://lookvalid.com/docs/quickstart.md", "https://lookvalid.com/docs/credits-and-billing.md", "https://lookvalid.com/tools/ip-address-lookup.md"]
---

# Bulk-check a CSV of IP addresses in Python

A standard-library Python script that reads a CSV, runs one IP lookup per unique row with threads paced to the API rate limit, and writes a results CSV.

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

Best-case time for a file of unique addresses at 20 requests a second

| Unique rows | Minimum run time |
| --- | --- |
| 100 | 5 seconds |
| 1,000 | 50 seconds |
| 10,000 | 8.3 minutes |
| 100,000 | 83.3 minutes |

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.

check_ips.py

```python
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])
```

Input file

```
ip
8.8.8.8
10.0.0.1
```

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:

Output file

```
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,
```

## 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](https://lookvalid.com/products/ip.md) lists every field in the response, and the [quickstart](https://lookvalid.com/docs/quickstart.md) shows how to create a key.

## Sources

1. [Python documentation: csv, CSV File Reading and Writing](https://docs.python.org/3/library/csv.html), read 2026-10-19
2. [Python documentation: concurrent.futures, Launching parallel tasks](https://docs.python.org/3/library/concurrent.futures.html), read 2026-10-19
3. [RFC 6585: Additional HTTP Status Codes (429 Too Many Requests)](https://www.rfc-editor.org/rfc/rfc6585.html), read 2026-10-19

By LookValid engineering. Published 2026-10-19, updated 2026-10-19.

## Related

- [IP Intelligence API](https://lookvalid.com/products/ip.md)
- [Quickstart](https://lookvalid.com/docs/quickstart.md)
- [Credits and billing](https://lookvalid.com/docs/credits-and-billing.md)
- [Free IP address lookup](https://lookvalid.com/tools/ip-address-lookup.md)
