How to Check Google Rankings in Another City
Check Google rankings in different city markets: build a uule string, pair it with gl, hl and a US proxy, confirm the location Google used, then run it daily.
To check Google rankings in different city markets, you need three things lined up in the same request: an exit IP in the right country, the gl and hl parameters set for that market, and a uule value naming the city. Leave out any one of them and Google fills the gap with its own guess. Then you get the familiar argument: you say you're number 3, the client in Austin says you're nowhere, and you're both looking at real screens.
This guide builds the fix as a working job. You'll encode a city from Google's own geotarget file, fetch the results page through SparkProxy, check which location Google actually applied, and schedule a keyword-by-city matrix that runs every morning and stores the results.
Why the client's screen and yours disagree
Google's help page on managing your location in Search lists four sources it combines to estimate where a searcher is: the device's own location, a saved home or work address, earlier activity, and the IP address, which Google describes as "roughly based on geography". Your laptop in Denver and your client's phone in Austin feed different values into every one of those.
A few other things pile on top:
- Account history. A signed-in client who has clicked your site before may see it higher than a stranger would.
- Device. Mobile and desktop result pages differ in layout and sometimes in order.
- A cached location. The same Google help page says google.com may reuse your last device location, "stored in a cookie set to expire after 6 hours."
- Time. Rankings move during the day, so two checks hours apart are two different measurements.
You can't recreate the client's personal history, and you shouldn't try. What you can reproduce is the clean, logged-out results page a new searcher in that city gets. That's the number worth reporting, and it's the one this pipeline produces.
What a city IP can and cannot do
The instinct is to buy an IP in Austin and search from it. It's a weak fix, for two reasons.
First, the IP is the coarsest signal on Google's list. Second, the "Austin" label on a proxy is a geolocation database's opinion, and databases often disagree about the same address (our guide to validating IP geolocation accuracy shows how to measure that). Our post on city-targeted proxies covers when paying for city-level IPs is still justified. For rank checks, the location is better carried in the request itself.
Here's the split of responsibilities:
| Problem | Can a proxy fix it? | What fixes it |
|---|---|---|
| Your office IP is in the wrong country | Yes | A country-matched exit IP |
| Google rate-limits or challenges your checks | Yes | Rotating exits, pacing, retries |
| Results reflect your own search history | Yes, partly | Fresh identity per check: no cookies, no login |
| You need a specific city inside the country | No | `uule` in the request |
| Wrong language or country interface | No | `gl` and `hl` set to the market |
| The client sees personalized results | No | Explain it; report the logged-out SERP |
| Results shift between morning and evening | No | Run all checks for a city in one window |
So the proxy's job here is narrow: put every request in the right country and keep Google answering. The city comes from somewhere else.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The three request controls: gl, hl and uule
Two of the three parameters are documented by Google. In the Programmable Search JSON API reference, gl is "Geolocation of end user" and takes "a two-letter country code", and hl "Sets the user interface language." The same names work on the regular results page URL, which is how rank trackers have used them for years.
uule is a different story. Google has never published it. It's a location string that rank-tracking tools reverse-engineered, and the version in common use starts with w+CAIQICI, then a single character that encodes the length of the location name, then that name in Base64. Because it's undocumented, it can stop working without notice, so the verification step later in this guide isn't optional.
| Parameter | Documented by Google? | Sets | Example |
|---|---|---|---|
| `q` | Yes | The query | `q=emergency+plumber` |
| `gl` | Yes (Programmable Search docs) | Country of the searcher | `gl=us` |
| `hl` | Yes (Programmable Search docs) | Interface language | `hl=en` |
| `uule` | No, reverse-engineered | Named location, down to city | `uule=w+CAIQICI...` |
| `pws` | No | Commonly used to request non-personalized results | `pws=0` |
Keep all three aligned with the exit IP. A US proxy, gl=us, hl=en and a Denver uule tell one consistent story. A German proxy with gl=us and a Denver uule tells three, and you have no idea which one Google believed.
Encoding a city from Google's geotarget file
The location name inside uule has to be Google's canonical name for the place, not whatever you type. Google publishes those names in the geotargets CSV on its Google Ads API developer site. The file we read on 30 October 2026 was geotargets-2026-10-06.csv, with the columns Criteria ID, Name, Canonical Name, Parent ID, Country Code, Target Type, Status and 273,674 data rows.
Use the file rather than guessing, because short city names are ambiguous. In that release, "Austin" matches a city in Manitoba (Austin,Manitoba,Canada), a neighborhood in Colorado (Austin,Colorado,United States) and the one you probably want (Austin,Texas,United States, Criteria ID 1026201). "Springfield" appears 31 times, 22 of them as US cities. Encode the wrong row and you'll get a perfectly valid result page for a town your client has never heard of.
This lookup filters by country and target type, and refuses to guess when more than one row survives:
import csv
def find_canonical(csv_path, city, region, country_code="US"):
"""Return the one active City row matching city + region, or raise."""
matches = []
with open(csv_path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if (row["Name"] == city
and row["Country Code"] == country_code
and row["Target Type"] == "City"
and row["Status"] == "Active"
and f",{region}," in f",{row['Canonical Name']},"):
matches.append(row)
if len(matches) != 1:
raise ValueError(f"{city}/{region}: {len(matches)} matches, pick by Criteria ID")
return matches[0]["Canonical Name"], matches[0]["Criteria ID"]
name, cid = find_canonical("geotargets-2026-10-06.csv", "Austin", "Texas")
print(name, cid) # Austin,Texas,United States 1026201
Then build the string. The length character comes from a 64-symbol table indexed by the byte length of the canonical name:
import base64
import string
UULE_KEYS = string.ascii_uppercase + string.ascii_lowercase + string.digits + "-_"
def make_uule(canonical_name: str) -> str:
raw = canonical_name.encode("utf-8")
if len(raw) >= len(UULE_KEYS):
raise ValueError("canonical name too long for the single-character length key")
return "w+CAIQICI" + UULE_KEYS[len(raw)] + base64.b64encode(raw).decode("ascii")
print(make_uule("Denver,Colorado,United States"))
# w+CAIQICIdRGVudmVyLENvbG9yYWRvLFVuaXRlZCBTdGF0ZXM=
Store the Criteria ID next to every result. When a client asks "which Austin?", you can answer with an ID instead of a shrug.
Choosing the proxy setup
You have two ways to send the request through SparkProxy, and the choice is mostly about whether you want to run browsers yourself.
The Scraping API (https://scrape.sparkproxy.io/api/v1, key in the X-API-Key header) handles rotation and rendering. render_js defaults to true and costs 5 credits, render_js=false is a plain fetch at 1 credit, and country_code adds 5. You pass the Google URL with gl, hl and uule already in it, and set country_code=US so the exit agrees with gl=us. If challenges pile up on the standard pool, proxy_type=premium switches to the premium pool at 10 credits without rendering or 25 with it, per the docs.
The proxy gateway (gateway.sparkproxy.io) is for teams already running Playwright or another browser. Port 11000 is Random Rotation, a new exit per request. Port 11002 is Sticky Sessions, which holds one exit for 5 minutes. Port 13000 is SOCKS5. Plans start from $75/month with unlimited bandwidth, and they're billed by plan and thread count, not by gigabytes, so a heavy rendered results page costs the same as a light one.
| Choice | Use this | Why |
|---|---|---|
| Rotation | Random Rotation (port 11000), or the API's default rotation | Each rank check is one independent search; a fresh exit per check avoids carrying identity across cities |
| Sticky Sessions | Only if you click through to page 2 of the same query in one browser session | Page 2 from a different IP can look like a different searcher |
| Country | Same as `gl` | Mixed signals produce unpredictable results |
| Concurrency | Low single digits per city | The job is small; bursts raise challenge rates more than they save time |
| Rendering | On (the API default) | You need the footer and the local pack, which a rendered page shows reliably |
The broader trade-offs between proxy types for rank work are in datacenter proxies for SEO rank tracking, so we won't repeat them here.
Your first single-city check
Run one check by hand before you script anything. This sends a Denver results page for "emergency plumber" through the API with the response envelope turned on:
UULE='w+CAIQICIdRGVudmVyLENvbG9yYWRvLFVuaXRlZCBTdGF0ZXM='
TARGET="https://www.google.com/search?q=emergency+plumber&gl=us&hl=en&pws=0&uule=$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$UULE")"
curl -sG "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: $SPARKPROXY_API_KEY" \
--data-urlencode "url=$TARGET" \
--data-urlencode "country_code=US" \
--data-urlencode "json_response=true" \
--data-urlencode "tag=rank:denver:emergency-plumber" \
-o denver.json
Two details trip people up. The uule value contains + and =, so it must be URL-encoded inside the target URL, or Google receives a broken string and quietly ignores it. And with json_response=true the page arrives as Base64 in the body field of the envelope, next to job_id, status_code, duration_ms and credits_used. Decode it before you search it for anything.
Don't use format=json for this. In the SparkProxy API, format only converts the page content (html, md, mdx, json, screenshot, pdf). The envelope with the status code and credit count comes only from json_response=true.
The same request in Python, which the rest of the pipeline builds on:
import base64
import os
import urllib.parse
import requests
API = "https://scrape.sparkproxy.io/api/v1"
def serp_url(query, uule, gl="us", hl="en"):
params = {"q": query, "gl": gl, "hl": hl, "pws": "0", "uule": uule}
return "https://www.google.com/search?" + urllib.parse.urlencode(params)
def fetch_serp(query, uule, country="US", tag=None):
params = {
"url": serp_url(query, uule),
"country_code": country,
"json_response": "true",
}
if tag:
params["tag"] = tag
r = requests.get(API, params=params,
headers={"X-API-Key": os.environ["SPARKPROXY_API_KEY"]},
timeout=120)
r.raise_for_status()
env = r.json()
html = base64.b64decode(env["body"]).decode("utf-8", errors="replace")
return env["status_code"], env["credits_used"], html
urlencode handles the + and = in the uule value for you, which removes the most common silent failure.
Confirming the location Google applied
This is the step most tutorials skip, and it's the one that makes the numbers defensible. Google's help page says the bottom of a results page shows the location it used and where that came from, with labels such as "From your device", "Based on your past activity" and "From your internet address".
Check every page for three things before you trust its rankings:
- Was it a results page at all? Google's block page contains the sentence "Our systems have detected unusual traffic from your computer network." A page like that has no listings, so a naive parser records "not ranked", which looks exactly like a ranking drop.
- Does the page name the target city? Look for the canonical city name (or its first part) near the footer.
- Do local results agree? If the page has a local pack, the business addresses should be in or near the target city.
import re
BLOCK_MARKERS = (
"Our systems have detected unusual traffic",
"/sorry/index",
)
FOOTER_LABELS = (
"From your internet address",
"From your device",
"Based on your past activity",
"Based on your places",
)
def classify_page(html, canonical_name):
if any(m in html for m in BLOCK_MARKERS):
return "blocked"
city = canonical_name.split(",")[0]
text = re.sub(r"<[^>]+>", " ", html)
tail = text[-20000:] # the location footer sits near the end of the page
if city in tail:
return "location_ok"
if any(label in tail for label in FOOTER_LABELS):
return "location_other" # Google states a location, but not ours
return "location_unknown"
How uule shows up in that footer isn't documented by Google, and the footer's wording can change, so treat the three outcomes as a triage, not a proof. Store location_other and location_unknown pages and read a sample by eye during your first week. If a city keeps coming back location_other, the usual cause is an unencoded uule, a canonical name typed by hand, or an exit country that disagrees with gl.
We couldn't run a live measurement of how often Google honours uule while writing this (our test endpoint was unavailable on 30 October 2026), so we're not quoting a success rate. Measure your own on a pilot: 10 keywords, 3 cities, 3 mornings.
Scripting a keyword-by-city matrix
Once single checks verify, the job is a loop over two lists. Keep the city list in a file with the Criteria ID, so encoding happens once and the ambiguity check never reruns at 6am:
CITIES = [
# (label, canonical name from geotargets CSV, criteria id)
("denver", "Denver,Colorado,United States", "1014485"),
("austin", "Austin,Texas,United States", "1026201"),
("chicago", "Chicago,Illinois,United States", "1016367"),
]
KEYWORDS = ["emergency plumber", "water heater repair", "drain cleaning"]
TRACKED_DOMAIN = "sparkproxy.io" # replace with the client's domain
Finding the domain's position needs a parser. Google's markup changes often, so this one relies on a single stable-ish pattern, an organic result is a link that wraps an
Daily runs, storage and retries
Storage. SQLite is enough until you have tens of thousands of rows a day. Keep the raw HTML: when a client disputes a number three weeks later, you can re-read the exact page instead of arguing from memory.
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS checks (
checked_at TEXT, keyword TEXT, city TEXT, criteria_id TEXT,
status_code INTEGER, credits INTEGER, verdict TEXT,
position INTEGER, top10 TEXT, html TEXT
)"""
def save(rows, path="ranks.db"):
con = sqlite3.connect(path)
con.execute(SCHEMA)
con.executemany(
"INSERT INTO checks VALUES (:checked_at, :keyword, :city, :criteria_id,"
" :status_code, :credits, :verdict, :position, :top10, :html)", rows)
con.commit()
con.close()
Retries. Separate three failure types, because each needs a different response:
| Verdict | Meaning | Action |
|---|---|---|
| `blocked` | Challenge page, no results | Retry up to twice; the next request gets a new exit automatically |
| `location_other` | Google used a different location | Don't retry blindly; log it and check the `uule` and country |
| `location_unknown` | No footer or city found | Keep the row, exclude it from reports, review a sample |
| HTTP error from the API | Request failed | Retry with backoff; the docs say credits are refunded on failure |
import time
def check_with_retry(keyword, label, canonical, cid, attempts=3):
for n in range(attempts):
try:
row = check_one(keyword, label, canonical, cid)
except requests.RequestException:
row = None
if row and row["verdict"] != "blocked":
return row
time.sleep(20 * (n + 1))
return row or {"keyword": keyword, "city": label, "verdict": "failed"}
Scheduling. Run every city in one window, at the same local time each day, so day-over-day changes reflect rankings and not the clock. A cron entry at 06:00 server time:
0 6 * * * cd /opt/rank-check && /usr/bin/python3 run_daily.py >> logs/run.log 2>&1
Reporting. Day-over-day movement, counting only rows where Google applied the right city:
SELECT city, keyword,
MAX(CASE WHEN date(checked_at) = date('now') THEN position END) AS today,
MAX(CASE WHEN date(checked_at) = date('now', '-1 day') THEN position END) AS yesterday
FROM checks
WHERE verdict = 'location_ok'
GROUP BY city, keyword
ORDER BY city, keyword;
Report blocked and unknown rows as gaps, never as "not ranking". A missing measurement and a lost ranking are different facts.
Monthly cost for a typical agency list
Prices below are in US dollars, read from SparkProxy's Scraping API page on 30 October 2026: Starter $49 for 250,000 credits and 50 concurrent requests, Growth $99 for 1,000,000 credits and 100, Pro $249 for 3,000,000 and 200, Scale $599 for 8,000,000 and 400. New accounts get 1,000 free credits, enough to run the pilot above.
A rendered check with country_code costs 5 + 5 = 10 credits. The workloads below are illustrative, with a 20% allowance for retries:
| Workload (assumed) | Checks per month | Credits at 10 each, +20% | Fits |
|---|---|---|---|
| 1 business, 20 keywords, 3 cities, daily | 1,800 | 21,600 | Starter ($49) |
| Agency, 50 keywords, 8 cities, daily | 12,000 | 144,000 | Starter ($49) |
| Agency, 200 keywords, 25 cities, daily | 150,000 | 1,800,000 | Pro ($249) |
Failed requests are refunded, so the retry allowance is a ceiling, not an expected cost. If you switch to the gateway and your own browsers, the bill becomes the proxy plan instead (starting from $75/month, unlimited bandwidth, with 5%, 10% or 15% off for 3, 6 or 12 month terms), plus the servers that run the browsers.
For volumes well beyond this, with many search engines and verticals, proxies for SERP scraping at scale covers the throughput side.
When the numbers still don't match
Sometimes your verified location_ok row still says position 3 and the client still sees position 9. Work through these in order, cheapest first:
- Ask what they searched, exactly. "Plumber Austin" and "plumber near me" are different queries with different results.
- Ask whether they were signed in. Personal history can move a site several places. Ask them to repeat the search in a private window.
- Ask which device. If they're on a phone, add a mobile check (the API's
device=mobileparameter) for that keyword. - Check the neighborhood. A city-level
uulegives a city-level answer. A client 15 miles from downtown may see a different local pack, and that's a geo-grid question; our guide to local SEO geo-grid tracking covers it. - Check the time. Compare against a check run within the same hour.
If all five line up and the gap persists, save both pages and compare the top ten. Usually the difference turns out to be a local pack or a featured result pushing organic listings down, which is a reporting question, not a tracking error.
For the parsing side of Google results pages beyond a single position, see how to scrape Google search results.
Frequently asked questions
FAQ
Send the search with gl and hl set for the country, a uule value encoding the city's canonical name from Google's geotargets file, and an exit IP in the same country. Then confirm the city named at the bottom of the results page before you record the position.
No. Google documents gl and hl but has never published uule. It was reverse-engineered by rank-tracking tools, so it can change without notice, which is why every check should verify the location Google applied.
Google combines device location, saved addresses, past activity and IP address to place a searcher, and signed-in history and device type also change results. Your checks should reproduce the logged-out page for their city, then you can explain any remaining gap as personalization.
Usually not. A country-matched proxy plus a correct uule puts the city in the request, which is more precise than a city IP's geolocation label. City IPs are worth considering only when you can't control the request URL.
Random Rotation. Each check is one independent search, so a fresh exit per request avoids carrying identity between cities. Use Sticky Sessions only when one browser session clicks through several pages of the same query.
With the SparkProxy Scraping API, 50 keywords across 8 cities daily is about 12,000 rendered, country-pinned checks a month. At 10 credits each plus a 20% retry allowance, that's 144,000 credits, which fits the $49 Starter plan (prices as of 30 October 2026).
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
How to See Google Ads in Other Countries Without Clicking
How to see Google ads in other countries: Ads Preview first, then a clean browser behind a country proxy, no clicks, and a daily script that logs ad copy.
Crawlee Proxy Configuration: Rotation That Keeps Sessions
Crawlee proxy setup with proxyUrls, newUrlFunction and tieredProxyUrls: how SessionPool binds a proxy, and how to stop the session-retired loop on login crawls.
Build Your Own Rank Tracker With Python and Proxies
Build your own rank tracker in Python: a keyword CSV, SERP fetches through rotating proxies or the Scraping API, Postgres history and a weekly movement report.
