๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Use Cases

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.

S SparkProxy 3 20 min read
Share

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:

ProblemCan a proxy fix it?What fixes it
Your office IP is in the wrong countryYesA country-matched exit IP
Google rate-limits or challenges your checksYesRotating exits, pacing, retries
Results reflect your own search historyYes, partlyFresh identity per check: no cookies, no login
You need a specific city inside the countryNo`uule` in the request
Wrong language or country interfaceNo`gl` and `hl` set to the market
The client sees personalized resultsNoExplain it; report the logged-out SERP
Results shift between morning and eveningNoRun 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.

Free trial

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.

ParameterDocumented by Google?SetsExample
`q`YesThe 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-engineeredNamed location, down to city`uule=w+CAIQICI...`
`pws`NoCommonly 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.

ChoiceUse thisWhy
RotationRandom Rotation (port 11000), or the API's default rotationEach rank check is one independent search; a fresh exit per check avoids carrying identity across cities
Sticky SessionsOnly if you click through to page 2 of the same query in one browser sessionPage 2 from a different IP can look like a different searcher
CountrySame as `gl`Mixed signals produce unpredictable results
ConcurrencyLow single digits per cityThe job is small; bursts raise challenge rates more than they save time
RenderingOn (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:

  1. 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.
  2. Does the page name the target city? Look for the canonical city name (or its first part) near the footer.
  3. 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:

VerdictMeaningAction
`blocked`Challenge page, no resultsRetry up to twice; the next request gets a new exit automatically
`location_other`Google used a different locationDon't retry blindly; log it and check the `uule` and country
`location_unknown`No footer or city foundKeep the row, exclude it from reports, review a sample
HTTP error from the APIRequest failedRetry 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 monthCredits at 10 each, +20%Fits
1 business, 20 keywords, 3 cities, daily1,80021,600Starter ($49)
Agency, 50 keywords, 8 cities, daily12,000144,000Starter ($49)
Agency, 200 keywords, 25 cities, daily150,0001,800,000Pro ($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:

  1. Ask what they searched, exactly. "Plumber Austin" and "plumber near me" are different queries with different results.
  2. Ask whether they were signed in. Personal history can move a site several places. Ask them to repeat the search in a private window.
  3. Ask which device. If they're on a phone, add a mobile check (the API's device=mobile parameter) for that keyword.
  4. Check the neighborhood. A city-level uule gives 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.
  5. 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).

Special Discount ยท 20% off

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

Claim Discount

About the author

Written by the SparkProxy Technical Team, the engineers behind SparkProxy's proxy network (1M+ premium IPs across 80+ countries, HTTP/HTTPS and SOCKS5) and the SparkProxy Scraping API. Location-signal details and footer labels come from Google Search Help, gl and hl definitions from Google's Programmable Search JSON API reference, and canonical city names from the Google Ads API geotargets file dated 2026-10-06. The uule format is undocumented by Google and described here as reverse-engineered. Cost figures are illustrative models from stated assumptions, not measurements. Corrections: support@sparkproxy.io.

Keep reading

Related articles