How to scrape Amazon product data in Python, and what a proxy changes

We expected blocks and got almost none. What a residential exit changes is which price, stock and delivery location the page shows.

8 min readTested on requests 2.34 · Python 3.12
The same Amazon product page through three exits: US exits show a price and In Stock with a US delivery ZIP, a German exit shows no price and cannot be shipped.One product page, three exits-country-usDelivering to Highland… 44143$79.99In Stock-country-us-city-chicagoDelivering to Chicago 60619$79.99In Stock-country-deDeliver to Germanyno priceCannot be shippedMeasured on our gateway, October 14, 2026

In short

Request amazon.com/dp/ASIN with browser-like headers through an exit in the marketplace’s country, and treat a response as good only when it contains the product title. Amazon sets the delivery location from the IP, so the exit decides which price and availability you collect.

USER-country-us-session-a1b2c3d4check for #productTitle

Most guides open with “Amazon blocks every datacenter IP”. We tested that before repeating it. From a server in a German data center we requested four product pages in a loop, 80 requests without a pause. Then we sent requests through US residential exits, a new exit for each one.

FromRequestsProduct pageOther
Datacenter IP, browser headers80800
Datacenter IP, python-requests User-Agent440
US residential exits, browser headers28271 × 200 with a 3.7 KB page
US residential exits, python-requests defaults440
Fig. 1 — amazon.com product pages, October 4, 2026. “Product page” means the HTML contained the product title.

No CAPTCHA in any of them. At this volume, Amazon served product pages to a data center, and to a script that announced itself as python-requests. That will not hold at any volume: we did not test thousands of requests an hour, and a single IP that does that is easy to rate-limit. But it does mean a proxy isn’t what makes a small scraper work.

Two things did go wrong, and neither was a block page with a clear status. One response came back 200 with 3.7 KB of HTML and no product in it. And from exits outside the US the page loaded fine but had no price. The next section is about that one.

Amazon chooses a delivery location from the IP address of the request, down to the ZIP code, and shows price and availability for that location. We requested the same product through four exits:

ExitDelivery location on the pagePriceAvailability
-country-usDelivering to Highland … 44143$79.99In Stock
-country-us-city-chicagoDelivering to Chicago 60619$79.99In Stock
-country-deDeliver to GermanynoneCannot be shipped to your location
-country-gbDeliver to United KingdomnoneCannot be shipped to your location
Fig. 2 — amazon.com/dp/B09B8V1LZ3, October 14, 2026. The city exit is -country-us-city-chicago.

So a scraper on a server in Frankfurt collects “no price, not available” for a product any US shopper can buy. That is the real job of the proxy here: put the request where the shopper is. For amazon.com use US exits, for amazon.de German ones. Add a city when you compare delivery or stock between regions.

The scraper

One file. It needs requests, beautifulsoup4 and lxml, and reads your proxy credentials from the environment.

amazon.py
import os
import re
import time
import uuid

import requests
from bs4 import BeautifulSoup

USER = os.environ["PROXY_USER"]
PASS = os.environ["PROXY_PASS"]

HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
}


def proxy(country: str = "us", city: str = "") -> dict:
    # A new session label per call = a new exit IP per request (sticky port).
    user = f"{USER}-country-{country.lower()}"
    if city:
        user += f"-city-{city.lower()}"
    url = f"http://{user}-session-{uuid.uuid4().hex[:8]}-lifetime-3:{PASS}@via.proxies.vision:10000"
    return {"http": url, "https": url}


def fetch(asin: str, country: str = "us", city: str = "", attempts: int = 3) -> str | None:
    for attempt in range(attempts):
        try:
            r = requests.get(f"https://www.amazon.com/dp/{asin}", headers=HEADERS,
                             proxies=proxy(country, city), timeout=40)
        except requests.RequestException:
            time.sleep(attempt + 1)  # a dropped exit: try the next one
            continue
        if r.status_code == 404:
            return None  # the product doesn't exist
        if r.status_code == 200 and 'id="productTitle"' in r.text:
            return r.text
        time.sleep(2 * (attempt + 1))  # a 200 without a product is not a success
    return None


def parse(html: str) -> dict:
    soup = BeautifulSoup(html, "lxml")

    def text(selector: str) -> str:
        el = soup.select_one(selector)
        return el.get_text(" ", strip=True) if el else ""

    rating = re.search(r"([\d.]+) out of", text("#acrPopover .a-icon-alt"))
    return {
        "title": text("#productTitle"),
        "price": text("#corePrice_feature_div .a-offscreen"),
        "rating": rating.group(1) if rating else "",
        "reviews": re.sub(r"\D", "", text("#acrCustomerReviewText")),
        "availability": text("#availability"),
        "location": text("#glow-ingress-block").replace("Update location", "").strip(),
    }


if __name__ == "__main__":
    for country, city in [("us", ""), ("us", "chicago"), ("de", "")]:
        html = fetch("B09B8V1LZ3", country, city)
        print(country, city, parse(html) if html else "no product page")
        time.sleep(2)

A new exit per request

proxy() adds a random session label to the username and uses the sticky port, so every request leaves from a different IP, whatever HTTP client you use. Without it a client that keeps connections open reuses one exit; we measured that here.

Success means a product title

fetch() accepts a response only if it contains id="productTitle". That one check catches every kind of non-product page, whatever its status code, and the retry goes out through a new exit.

Selectors change

The selectors in parse() worked on October 14, 2026. Amazon changes its markup and tests layouts, so treat an empty field as a signal to look at the HTML, and keep the raw pages for a while so you can re-parse them.

Run with a US exit, a Chicago exit and a German one, it printed (titles shortened):

txt
us  {'title': 'Amazon Echo Dot (newest model) - …', 'price': '$79.99', 'rating': '4.7', 'reviews': '200847', 'availability': 'In Stock', 'location': 'Delivering to Highland ... 44143'}
us chicago {'title': 'Amazon Echo Dot (newest model) - …', 'price': '$79.99', 'rating': '4.7', 'reviews': '200847', 'availability': 'In Stock', 'location': 'Delivering to Chicago 60619'}
de  {'title': 'Amazon Echo Dot (newest model) - …', 'price': '', 'rating': '4.7', 'reviews': '200847', 'availability': 'This item cannot be shipped to your selected delivery location. …', 'location': 'Deliver to Germany'}
  1. 1.Go slowly

    Our numbers come from small runs. Start with a few requests a second across all exits, watch the share of responses without a product title, and slow down when it rises.

  2. 2.One marketplace, one country

    Match the exit to the marketplace: -country-us for amazon.com, -country-de for amazon.de, -country-gb for amazon.co.uk. Otherwise you collect the “cannot be shipped” version of every page.

  3. 3.Count the traffic

    A product page was 250 to 360 KB compressed in our runs, about 3,000 pages per GB. Keep gzip on (requests does by default) and don’t download images.

  4. 4.Route only what needs it

    If all you need is a title or a rating, which don’t depend on location, a plain server may do. Send the location-dependent requests through residential exits and save the per-GB traffic for those.

Amazon’s conditions of use restrict automated access, and the rules for collecting public data differ by country. Stick to public product data and check what applies to you before running this at volume.

Do I need proxies to scrape Amazon?
Not for a handful of pages. In our test, 80 product pages in a row loaded from a single datacenter IP without a CAPTCHA. You need proxies when you want the page as a shopper in a specific country or city sees it, or when you send enough requests that one IP stands out.
Why is the price missing or different in my scraper?
Amazon picks the delivery location from your IP address. From an exit outside the US, amazon.com showed our test product without a price and marked it as not shippable. Use an exit in the marketplace's country.
How much proxy traffic does an Amazon product page use?
In our runs a product page was 250 to 360 KB compressed, so about 3,000 pages per GB. Only the HTML is downloaded; images and scripts are not, unless you use a headless browser.
How do I know a request was blocked?
Don't trust the status code. Check that the product title element is in the HTML. One of our requests returned 200 with a 3.7 KB page and no product; a status check would have counted it as a success.
Is scraping Amazon allowed?
Amazon's conditions of use restrict automated access, and the rules for collecting public data differ by country. Collect only public product data, keep your request rate low, and check the rules that apply to you before you run this at scale.

Residential proxies, pay per GB

HTTP and SOCKS5. Country, city and sticky sessions in the username. No subscription.