Looking up a barcode in Python is a single HTTP request. Doing it for fifty thousand barcodes without getting rate-limited, burning your quota on codes you already know are missing, or silently writing empty rows into your database — that takes a bit more care.

This guide starts with the three-line version and builds up to something you'd be comfortable running against a real catalogue. Every snippet works as written. If you want the background on barcode formats first, see our guide to barcode types.

The three-line version

The simplest possible lookup
import requests

r = requests.get(
    "https://barcodereport.com/api/search_barcode",
    params={"barcode": "9780132350068"},
    headers={"X-API-Key": "YOUR_API_KEY"},
)
print(r.json())
Response
{
  "barcode": "9780132350068",
  "old_barcode": "0132350068",
  "title": "Criminology",
  "authors": "Steven E. Barkan",
  "number_of_pages": "656",
  "publish_date": "March 13, 2008",
  "price_currency": "USD",
  "brand": null,
  "category": null,
  "image_url": null,
  "min_price": null
}

Flat JSON, one object, no envelope to unwrap. Fields we don't hold for that product come back as null.

Send the key in an X-API-Key header rather than a ?api_key= query parameter. Query strings end up in server logs, proxy logs and browser history. Authorization: Bearer YOUR_API_KEY works too.

Loading your key from the environment

Never commit a key. The pattern that costs nothing to adopt:

config.py
import os

API_KEY = os.environ["BARCODE_API_KEY"]     # raises immediately if unset
BASE_URL = "https://barcodereport.com/api"
export BARCODE_API_KEY="your_key_here"

Using os.environ[...] rather than .get() is deliberate: you want a loud failure at startup, not a confusing 401-shaped bug three functions deep.

A client that handles reality

Barcode lookup flow in Python: cache check, API request, error branch, store result

Four things will happen in production that the three-line version doesn't survive:

  1. Some barcodes won't be in the database.
  2. You'll exceed 60 requests per minute.
  3. Your monthly allowance will run out.
  4. The network will fail.

Each needs different handling, and the API tells you which is which through a machine-readable code field.

barcode_client.py
import os
import time
import requests

BASE_URL = "https://barcodereport.com/api"


class QuotaExceeded(Exception):
    """Monthly allowance is gone. Retrying will not help."""


class BarcodeClient:
    def __init__(self, api_key=None, timeout=10):
        self.api_key = api_key or os.environ["BARCODE_API_KEY"]
        self.timeout = timeout
        # A Session reuses the TCP connection, which matters a lot in a loop.
        self.session = requests.Session()
        self.session.headers.update({"X-API-Key": self.api_key})

    def lookup(self, barcode, max_retries=3):
        """Return a product dict, or None if the barcode isn't in the database."""
        barcode = "".join(ch for ch in str(barcode) if ch.isdigit())
        if not barcode:
            raise ValueError("Barcode must contain digits")

        for attempt in range(max_retries):
            try:
                r = self.session.get(
                    f"{BASE_URL}/search_barcode",
                    params={"barcode": barcode},
                    timeout=self.timeout,
                )
            except requests.RequestException:
                if attempt == max_retries - 1:
                    raise
                time.sleep(2 ** attempt)
                continue

            if r.status_code == 200:
                return r.json()

            body = r.json() if r.content else {}
            code = body.get("code")

            # A miss is a normal outcome, not an error.
            if code == "not_found":
                return None

            # Per-minute rate limit. The server tells us how long to wait.
            if code == "rate_limited":
                wait = int(r.headers.get("Retry-After", 60))
                time.sleep(wait)
                continue

            # Monthly allowance gone. Retrying will never succeed.
            if code == "quota_exceeded":
                raise QuotaExceeded(body.get("error"))

            if code == "invalid_key":
                raise PermissionError("API key rejected — check or rotate it")

            # 5xx: worth retrying with backoff.
            if r.status_code >= 500:
                if attempt == max_retries - 1:
                    r.raise_for_status()
                time.sleep(2 ** attempt)
                continue

            r.raise_for_status()

        return None

The distinction that catches people out: 429 is the per-minute rate limit, 403 quota_exceeded is your monthly allowance. They look similar and need opposite handling — one means "wait a moment and try again", the other means retrying is pointless until you upgrade or the cycle resets. Branch on code, never on the message text, which can be reworded.

Knowing your allowance before you start

Before processing a large batch, check what's left. This endpoint doesn't consume a request:

Check remaining allowance
def account(self):
    r = self.session.get(f"{BASE_URL}/account", timeout=self.timeout)
    r.raise_for_status()
    return r.json()

info = client.account()
print(f"{info['usage_remaining']} of {info['plan_limit']} lookups left")

Successful lookups also carry X-Quota-Limit and X-Quota-Remaining headers, so you can track consumption as you go:

remaining = r.headers.get("X-Quota-Remaining")
if remaining is not None and int(remaining) < 100:
    logging.warning("Allowance nearly exhausted: %s left", remaining)

Caching: the single biggest saving

Product metadata for a barcode barely changes. Every repeat lookup is an allowance you didn't need to spend. A SQLite cache is about fifteen lines and will typically cut usage by an order of magnitude on real workloads:

cache.py
import json
import sqlite3

class BarcodeCache:
    def __init__(self, path="barcodes.db"):
        self.conn = sqlite3.connect(path)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS lookups (
                barcode TEXT PRIMARY KEY,
                payload TEXT,           -- JSON, or NULL for a known miss
                fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
        self.conn.commit()

    def get(self, barcode):
        row = self.conn.execute(
            "SELECT payload FROM lookups WHERE barcode = ?", (barcode,)
        ).fetchone()
        if row is None:
            return "MISSING"                      # never looked up
        return json.loads(row[0]) if row[0] else None   # None = known miss

    def put(self, barcode, product):
        self.conn.execute(
            "INSERT OR REPLACE INTO lookups (barcode, payload) VALUES (?, ?)",
            (barcode, json.dumps(product) if product else None),
        )
        self.conn.commit()

Note that misses are cached too, as a NULL payload. Re-querying a barcode you already know isn't there still costs a request, because the search runs either way. On a catalogue with a lot of obscure codes this is a large share of the waste.

Processing a CSV of thousands of barcodes

The pattern most people actually need — read a file, look up what isn't cached, stay inside the rate limit, write results, and be safe to re-run after a crash:

enrich_catalog.py
import csv
import logging
import time

from barcode_client import BarcodeClient, QuotaExceeded
from cache import BarcodeCache

logging.basicConfig(level=logging.INFO)

RATE_LIMIT_PER_MIN = 60
MIN_INTERVAL = 60 / RATE_LIMIT_PER_MIN     # ~1 request/second, stays under the limit


def enrich(infile, outfile):
    client, cache = BarcodeClient(), BarcodeCache()
    stats = {"cached": 0, "found": 0, "missing": 0}

    with open(infile, newline="") as f_in, open(outfile, "w", newline="") as f_out:
        writer = csv.DictWriter(
            f_out, fieldnames=["barcode", "title", "brand", "category", "image_url"]
        )
        writer.writeheader()

        for row in csv.DictReader(f_in):
            barcode = "".join(ch for ch in row["barcode"] if ch.isdigit())
            if not barcode:
                continue

            product = cache.get(barcode)

            if product == "MISSING":               # not cached — call the API
                started = time.monotonic()
                try:
                    product = client.lookup(barcode)
                except QuotaExceeded:
                    logging.error("Allowance exhausted — stopping at %s", barcode)
                    break                          # cache keeps everything so far
                cache.put(barcode, product)
                # Self-throttle so we never trip the rate limit.
                time.sleep(max(0, MIN_INTERVAL - (time.monotonic() - started)))
            else:
                stats["cached"] += 1

            if product:
                stats["found"] += 1
                writer.writerow({
                    "barcode": barcode,
                    "title": product.get("title") or "",
                    "brand": product.get("brand") or "",
                    "category": product.get("category") or "",
                    "image_url": product.get("image_url") or "",
                })
            else:
                stats["missing"] += 1

    total = stats["found"] + stats["missing"]
    rate = (stats["found"] / total * 100) if total else 0
    logging.info("Done: %s found, %s missing (%.1f%% match), %s from cache",
                 stats["found"], stats["missing"], rate, stats["cached"])


if __name__ == "__main__":
    enrich("input.csv", "enriched.csv")

Three things worth pointing out:

  • It's re-runnable. Everything already looked up is in the cache, so a crash or a quota stop costs you nothing on the next run.
  • It self-throttles to stay under 60 requests a minute rather than reacting to 429s after the fact.
  • It reports a match rate, which is the number that actually tells you whether a data source fits your catalogue.

If you're processing tens of thousands of codes as a one-off rather than building an ongoing integration, a bulk CSV lookup is usually simpler than writing a script at all — you upload the file and get results back.

ISBNs are a special case, handled for you

Books have two identifiers: the old 10-digit ISBN and the current 13-digit one. You don't need to convert between them — a lookup matches on both the current barcode and the superseded one, so 0132350068 and 9780132350068 return the same record. Book records also carry authors, number_of_pages and publish_date. There's more detail in our ISBN lookup guide.

Reading barcodes from images

Worth clearing up a common confusion: decoding a barcode from an image and looking up what it identifies are separate jobs. To decode, use pyzbar (a Python wrapper around ZBar):

Decode, then look up
from pyzbar.pyzbar import decode
from PIL import Image

for symbol in decode(Image.open("shelf-photo.jpg")):
    number = symbol.data.decode()
    product = client.lookup(number)
    print(number, "->", product["title"] if product else "not found")

pyzbar gives you the digits. The lookup turns those digits into a product. You need both.

Expect nulls, and design for them

The single most common mistake is assuming every field is populated. It isn't. Coverage is deepest on title; brand, category, image_url and min_price are present on a smaller share of records.

# Fragile — raises or writes "None" into your database.
name = product["brand"] + " " + product["title"]

# Fine.
name = " ".join(filter(None, [product.get("brand"), product.get("title")]))

Treat barcode and title as reliably present and everything else as a bonus, and run a sample of your own codes to measure the real match rate before you build on top of it.

Frequently asked questions

Send a GET request to a barcode lookup API with the requests library, passing the code as a barcode parameter and your key in an X-API-Key header. Against the Barcode Report API the endpoint is https://barcodereport.com/api/search_barcode and the response is a flat JSON object with title, brand, category, image and price where available. The full snippet is at the top of this guide.

Barcode Report's free plan includes 100 lookups a month at no cost and no card is required, which is enough to test an integration on your own barcodes. Paid plans start at 10,000 lookups a month when you need production volume.

A 429 with code rate_limited means you exceeded 60 requests per minute — wait for the number of seconds in the Retry-After header and continue. A 403 with code quota_exceeded means your monthly allowance is used up, and retrying will not succeed until the cycle resets or you upgrade. They need opposite handling, so branch on the code field rather than the status alone.

Self-throttle to roughly one request per second to stay under the 60-per-minute limit, cache every result including misses so re-runs cost nothing, and check your remaining allowance with the account endpoint before starting. For large one-off jobs a bulk CSV upload is usually simpler than scripting it.

Yes, but that is a separate step from looking it up. Use pyzbar to decode the barcode in the image into a number, then pass that number to a lookup API to get the product data. Decoding tells you what the code says; the lookup tells you what it identifies.

Because we do not hold every field for every product. Coverage is deepest on the product title and thinner on brand, category, image and price. Write your code so any field except barcode and title may be null, and measure the real match rate against a sample of your own barcodes before building on it.

Next steps

The client above — session reuse, error codes, backoff, and a cache that makes re-runs free — is most of what a production barcode integration needs. Everything after that is your own data model.