
Run the same Google Shopping query from two US cities and the page often does not agree with itself. The merchants change. The lowest price changes. “Pickup today” in one metro becomes a multi-day delivery estimate in the other. A SKU that looks in stock nationally is unavailable for local pickup.
Price-monitoring and scraping teams hit this constantly. A country-US collection returns HTTP 200, the parser fills merchant and price columns, and the dashboard looks healthy. A store operator in the second city says those merchants and delivery dates are not what shoppers there see. The scrape did not fail loudly. It sampled the wrong place.
Call the rows on that grid an offer set: the visible merchants, prices, currency, shipping lines, and pickup lines for one query at one vantage point. Google Shopping is that offer set. It is not a page of blue links. City SERP divergence measures organic URLs and the local pack. This page measures Shopping results by city — local inventory and delivery, not rank.
What follows is a method you can re-run: a 12-query basket, a column schema that stores the city you actually landed in, and a Jaccard overlap on merchant-plus-price tuples. It is free to cite. This article does not publish first-party overlap percentages. We did not measure Chicago against Miami (or any other pair) for this ship. Re-run before you cite a number.
[!NOTE] How to read this page. The basket, the schema, and the overlap formula are the citable artifacts. Reuse them with attribution to Aethyn and a link back here. Do not attribute invented percentages to this page. Offer feeds, shipping promises, and consent walls drift — the same rule as the city SERP protocol.
If you are building the collector rather than the comparison, start with how to scrape Google Shopping. If the product is an ongoing price series, see price monitoring.
What causes Google Shopping results to differ by city?
Some of this is directly observable on the public UI. Some of it is a reasonable inference from that UI. The list below separates the two. It is not a claim about Google’s internal ranking or ads auction.
Local inventory (observable). Offer cards often show whether an item is available nearby. Two metros can list different in-stock merchants for the same query because the warehouses and stores behind those cards are not the same.
Store pickup (observable). “Pickup today” and similar chips are location-shaped. A query that is a good pickup candidate (paper towels, batteries) will often diverge more by city than a query that is usually shipped.
Delivery estimates (observable). Shipping lines and ETAs change with destination. A two-day promise in one city and a longer estimate in another is a different offer even when the merchant name matches.
Merchant availability (partly observable, partly inferred). Which sellers appear is visible. Why a given seller is eligible in one city and absent in another is not fully documented on the page. Treat presence or absence as a fact in your table; do not invent a ranking explanation.
Geographic personalization (inferred from observation). Holding the query, device class, language, and parser constant, and changing only the network location, is enough for many queries to return a different offer set. That is an empirical result you can reproduce. It is not a published Google “personalization” spec.
ZIP / store selection (observable in the client). Shopping and Maps-adjacent surfaces remember a store, a ZIP, or a delivery address in cookies or similar client state. If that state survives onto a new IP, page 2 of a grid can describe a different market than page 1.
Cookies and session state (observable in the collector). Consent walls, store pickers, and location cookies bind to the browser profile and the IP that earned them. Rotate the exit mid-pagination and the offer set is no longer a single vantage point.
Country still matters. Currency, tax display, and which merchants are allowed to sell into a market often follow the country, not the metro. The mistake is using country as a stand-in for local inventory, pickup, and delivery.
Country vs city vs ISP targeting
Targeting is a location hypothesis, not a proxy brand. Use the coarsest level that matches the mechanism you can name. Finer targeting shrinks the pool you can draw from.
| Job | Country | City | ISP / network |
|---|---|---|---|
| National catalog, currency, merchant eligibility | Appropriate | Usually unnecessary | No |
| Google Shopping results by city (this protocol) | Control leg only | Required | No, unless the hypothesis is the network |
| Local inventory / store pickup | Too coarse | Appropriate | No |
| Delivery estimates / shipping lines | Too coarse when ETA is the metric | Appropriate | No |
| Carrier-shaped pages, CDN edge, ISP-specific offers | Too coarse | Maybe | Appropriate when you can name the network as the variable |
| Classic local-pack / organic rank | See city SERP | Required for local intent | Rarely |
On Aethyn, country routing is Premium; city and ISP go on the Elite username. Other providers expose the same ideas with different syntax. City and ISP targeting covers when country is enough and how to verify the exit. Asking for a city is not the same as landing there.
Protocol (run this, then cite your numbers)
Hold these constants across both cities: query string, device class (desktop), language, User-Agent, parser version, and collection window (same UTC day unless you are studying time of day). Change only the city targeting and the sticky session id.
Residential proxies for Google Shopping are the usual way to make that city change look like a household visit. Datacenter ranges are often challenged before the offer grid renders. The Playwright residential setup is the browser half; this page is the comparison half.
- Pin the city. Encode country and city on the proxy session you will hold for the pass. On Aethyn Elite the username shape is
aethyn-XXXXX-country-us-city-chicago-session-shopchi-lifetime-30. Premium country routing will refuse a city rather than ignore it. Suffix order is in the targeting docs. - Verify the exit before the first Shopping request. Fetch
https://ipinfo.io/json(or equivalent) through the same session. Recordresolved_city,resolved_region,resolved_isp, andexit_ip. Ifresolved_citydoes not match what you asked for, rotate the session id and re-check, or keep the row and label it a country fallback. Never label a fallback row as Chicago. - Hold sticky for the flow. Lifetime is minutes, 1–1440, default 30. A Shopping fetch plus a couple of pages is usually well inside 30 minutes. Sticky is a hold on a rotating-pool residential IP, not a dedicated or static address.
- Fetch with asserts. Status 200 is not success. Reject consent walls,
/sorry/interstitials, empty offer grids, and deny titles before you write merchants. Soft 200s corrupt overlap the same way they corrupt other scraper datasets. - Parse into the schema below. Merchant, price, currency, shipping or pickup. Do not store organic URLs as offers. Do not store reviewer identities.
- Repeat the other city with a new session id and a new verify step. Do not reuse the first city’s sticky id.
- Compute overlap only on paired, assert-ok rows. Formula below. If either side failed asserts, the pair does not exist.
The collector implementation — Playwright, classification, rotate vs sticky — is how to scrape Google Shopping. On Aethyn, HTTP 5499 (Elite) or SOCKS5 3499 is the city sample; Premium 2099 / 1099 is the country-level control leg, not the city sample. Same residential pool, both protocols.
The 12-query basket (citable)
Run every query in both cities. Keep the strings exact so someone else can reproduce the basket.
The three classes are analysis labels we assigned. They are not a claim about how Google internally buckets queries.
| ID | Query | Class |
|---|---|---|
| O01 | wireless earbuds | local_offer |
| O02 | standing desk | local_offer |
| O03 | air purifier | local_offer |
| O04 | winter jacket | local_offer |
| P05 | paper towels | store_pickup |
| P06 | gallon milk | store_pickup |
| P07 | AA batteries | store_pickup |
| P08 | printer paper | store_pickup |
| N09 | HDMI cable | national_control |
| N10 | usb c charger | national_control |
| N11 | notebook 70 page | national_control |
| N12 | ethernet cable | national_control |
Local-offer (O01–O04). Goods that are often sold by multiple merchants with location-shaped shipping or availability. These are where city-level Shopping research usually lives.
Store-pickup (P05–P08). Goods people commonly buy for same-day pickup. If local inventory is the mechanism, this slice should move more than the national controls. If it does not, check the collector before you write a “no divergence” headline.
National-control (N09–N12). Commodity cables and stationery that are widely shipped and less tied to a neighborhood store. They are a sanity check on the collector, not a claim that Google treats them as “national.” If HDMI-cable merchant overlap between two distant cities collapses, you probably changed a header, hit a consent wall, or parsed a soft block — not “city retail.”
Do not report a headline divergence number that mixes O/P with N. Median Jaccard per class, then discuss.

Recommended first pair for a US study: Chicago and Miami. They are far enough apart that local inventory is unlikely to be identical, and both are large enough that city targeting usually resolves. They are examples, not a measurement on this page. Use the cities you actually sell into. A 50-city grid is usually mostly bandwidth you will not use.
Public web only. Respect robots.txt, rate limits, and Terms. This protocol samples publicly rendered Shopping grids. It is not a login, checkout, or engagement scraper.
Column schema (store the vantage point)
Every row is one query × one city × one retrieval × one offer card. Without the vantage columns, overlap is not auditable. requested_city is what you asked the proxy for. resolved_city is what an IP lookup reported. Overlap is undefined until both sides assert_ok.
| Column | Type | Rule |
|---|---|---|
query_id | string | O01–N12 from the basket |
query_text | string | Exact string sent |
query_class | enum | local_offer / store_pickup / national_control |
requested_city | string | What you put on the session |
requested_country | string | ISO-ish, e.g. us |
resolved_city | string | From ipinfo (or equivalent), not the request |
resolved_region | string | State/region if present |
resolved_isp | string | org / ASN label |
exit_ip | string | The address you actually used |
collected_at | datetime | UTC ISO-8601 |
sticky_session_id | string | Unique per city pass |
sticky_ttl_min | int | 1–1440, default 30 |
http_status | int | Logged, not trusted |
assert_ok | bool | Parser saw a real offer grid, not a wall |
merchant | string | Visible merchant name |
price | string | Visible price, normalized |
currency | string | ISO-ish, e.g. USD |
shipping | string | Shipping line, or empty |
pickup | string | Pickup line, or empty |
notes | string | CAPTCHA, consent, fallback, parser miss |

Measuring overlap (Jaccard)
After both cities assert clean for a query, build a set of tuples (merchant_normalized, price_normalized) per city. Normalize merchant names (trim, case-fold) and prices (strip currency symbols, keep two decimals) the same way on both sides, and document that step.
Offer Jaccard:
|set(A) ∩ set(B)| / |set(A) ∪ set(B)|
- Report median Jaccard per class, not one blended number
- Optionally report pickup-line Jaccard on P05–P08
- Drop pairs where either side failed asserts
- Publish
collected_atnext to any figure - If resolved city ≠ requested city, exclude the pair or report it in a fallback slice — do not mix fallbacks into the city slice
Fictional example (not a measurement). Suppose after normalization city A has three offers: (loop audio, 79.00), (north store, 82.50), (metro hub, 79.00). City B has (loop audio, 79.00), (bay shop, 84.00), (metro hub, 81.00). The intersection is one tuple (loop audio at 79.00). The union is five tuples. Jaccard = 1/5 = 0.20. Metro Hub appears in both cities but at different prices, so it does not count as a match.
Do not cite 0.20, or any other number, as Aethyn data. We did not collect those rows. Replace them with your run and your collected_at.
Sticky sessions, cookies, and store alignment
Local offers are not only “which IP.” Store pickers, ZIP cookies, and consent cookies persist on the client. If you rotate the exit mid-pagination, page 2 can be a different market than page 1 and Jaccard is meaningless. Use a sticky session per city pass, then a new session for the next city.
Lifetime is in minutes (1–1440, default 30). Size it to the flow, not the job: a twelve-query basket with one page each is a few minutes. Extending TTL does not make the IP dedicated. When the window ends, the pool may hand you a different household in the same city — verify again if the job continues.

Do not pin ISP unless the hypothesis is the network (a carrier offer page, a CDN edge). Extra constraints shrink availability. City is the lever for Shopping offer divergence; ISP is the lever for “through whom.”
What this page is not
It is not a ranked list of proxy vendors. It is not a Python tutorial — scraping Google Shopping with Playwright covers the collector. It is not a claim that anyone measured a specific merchant-overlap percentage between two cities. If you need a number for a slide, run the basket, store the schema, and put your collected_at on the slide.
It is also not a reason to ignore rate limits. Residential exits look like households; they are not a reason to query like a botnet. Scale by widening the city sample and pacing, not by hammering one sticky IP.
Common questions about this article
What is Google Shopping city price divergence?
Does this page publish Chicago versus Miami overlap percentages?
Why Elite city targeting instead of Premium country?
How long should the sticky session last?
Is this protocol free to cite?
Guides, integrations & docs
Continue reading

City SERP Divergence: A Re-Runnable Protocol (2026)
City SERP divergence is the measurable difference in local results for the same query when the exit metro changes. This page publishes the protocol, a 16-query basket, and the column schema — not invented overlap percentages.

Country-Level Targeting Isn't Enough: City and ISP Proxies Explained
Country targeting fails silently. The page renders, the price parses, the row lands in your warehouse — describing a market nobody sells into. Here's when you need city or ISP granularity, when you don't, and how to prove the exit landed where you asked.

How to Use Residential Proxies with Playwright (2026)
A Playwright residential proxy is a browser context whose traffic exits through an ISP-assigned household IP. Credentials go in fields, rotation is a new context, and locale must match the exit.
Pin the metro before you trust the price
City-level residential targeting is how you sample the offer set a household in that market actually sees. Verify the city you landed in, then fetch.