I run a visa agency in Bali. The other half of my week goes into scrapers on Apify, and one of them reads Google Hotels: a place and two dates in, one row per property out.
A row carries four price numbers for the stay. totalPrice, priceBeforeTaxes, taxesAndFees, totalWithTaxes. Google labels none of them, because Google doesn't ship them as named fields β they come out of an unnamed array, and what each slot means is something you work out or guess.
I guessed. My own README said totalPrice includes taxes and fees. Then a $17 homestay made me go and count.
The row that didn't add up
Liang Homestay and Hostel, Ubud, two nights, 20β22 November 2026:
{
"name": "Liang Homestay and Hostel",
"pricePerNightDisplay": "$8",
"pricePerNight": 8.33,
"totalPriceDisplay": "$17",
"totalPrice": 17,
"priceBeforeTaxes": 13.77,
"taxes": 0,
"fees": 2.89,
"totalWithTaxes": 16.66,
"nights": 2,
"currency": "USD"
}
The headline total is above the all-in total. $17 against $16.66.
That is not a parsing bug and it is not a hotel doing something clever. It is two different numbers from two different places in the same response. Google's displayed total is a formatted string with its value next to it β "$17", 17 β rounded to the whole dollar. The itemisation is a separate four-number array that is not rounded at all: 13.77 + 0 + 2.89 = 16.66. Round 16.66 to the nearest dollar and you get the headline back.
Fourteen rows in my sample landed this way, every one of them a sub-$20 stay where a 34-cent rounding step is 2% of the price. On a $300 stay the same rounding is invisible. That is exactly why this bug class survives: it only shows up at the bottom of the price range, where nobody is testing.
The useful part wasn't the rounding. It was that 13.77 + 0 + 2.89 is not how I had described the fields.
755 rows, one rule, no exceptions
So I pulled every hotel row from 35 production runs between 14 and 21 October 2026 β 757 rows across Ubud, Barcelona, Bali-wide, Bangkok, Singapore, Tokyo, Paris and Lisbon, in USD, EUR and GBP. 755 of them carried both a headline total and a full itemisation.
Two rules held in 755 of 755:
totalPrice == priceBeforeTaxes + fees
totalWithTaxes - totalPrice == taxes
Not "mostly". Not "within a tolerance that lets me claim mostly". Every row, every city, every currency, at a tolerance of one unit of currency or 0.6%, whichever was larger β the slack is there only to absorb the whole-dollar rounding described above.
Read that as plain English and it's a design decision, not an accident. Google's four-number array is [base, taxes, fees, total], and the price it prints on the card is base plus fees, with the tax slot left outside. Whatever Google files under fees is inside the number you see; whatever it files under taxes is not. I am not going to tell you which real-world charge landed in which slot, because Google ships two unlabelled integers and I would be guessing.
Which means the one field people build their spreadsheet on, totalPrice, is systematically short by whatever is sitting in taxes. Here is how much that is:
| Destination | Rows |
taxes as share of all-in, median |
Max | Rows with zero tax |
|---|---|---|---|---|
| Ubud, Bali | 552 | 9.9% | 20.0% | 41 |
| Barcelona | 158 | 17.6% | 30.4% | 1 |
| Bali (wide) | 10 | 16.7% | 17.4% | 0 |
| Singapore | 10 | 12.1% | 16.6% | 0 |
| Tokyo | 5 | 9.2% | 20.0% | 1 |
| Paris | 5 | 16.1% | 25.9% | 0 |
| Lisbon | 5 | 14.8% | 15.8% | 0 |
| Bangkok | 10 | 9.1% | 15.0% | 0 |
712 of 755 rows had a non-zero tax slot. 277 had non-zero fees, and 238 had both.
A fresh run this morning, 21 October 2026, two locations, five hotels each, to make sure none of this is a historical artifact: 10 rows out of 10 had the tax outside the headline. Casa GrΓ cia in Barcelona came back at $128 headline, $170.41 all-in β a 24.9% gap, which on three nights is most of an extra night. The same run had Pita Maha Resort in Ubud at $208 headline against $249.98.
What it does to a ranking
I expected the sort order to fall apart. It mostly doesn't, and the reason is boring: inside one city the tax rate is near-constant, so a near-constant multiplier barely reorders anything. Across 14 Ubud runs with 37β40 distinct hotels each, moving from headline-per-night to all-in-per-night flipped 0.3% to 3.8% of all hotel pairs, and the membership of the top 10 and the top 20 survived 26 of 28 checks.
The one place it does bite is the single row anybody actually looks at. In 9 of those 14 runs the cheapest hotel changed identity:
- Cheapest by headline: Arjuna Homestay Ubud, $4.61/night β all-in $5.62
- Cheapest all-in: Santun Homestay, $5.08/night headline β all-in $5.54
Arjuna is 9% cheaper on the card and 1.4% dearer to actually stay in. The same pair came back the same way on four separate days, which is what convinced me this is a tax-rate difference between two properties rather than noise.
Cross-city is where the whole thing breaks, and it breaks badly. Bangkok's median tax share is 9.1%; Barcelona's is 17.6%. Build a "cheapest city for three nights" chart on the headline price and you have quietly handed Barcelona an 8-point discount it does not get.
What I changed
My README said totalPrice includes taxes and fees. It doesn't β it includes fees and excludes taxes, in all 755 rows I can check. That sentence is being replaced, and the column note now says which slot is outside the headline, because the alternative is a buyer summing the wrong column and finding out from their accountant.
The fields themselves I left alone. The temptation was to make totalPrice the all-in number so the obvious column is the correct one. I didn't, for the same reason I ship Booking.com's unnamed charge codes as integers instead of guessing names: if the dataset says $208 and the Google page says $208, a buyer can check me in ten seconds. If my totalPrice says $249.98 and the page says $208, every cross-check fails and the honest number looks like a bug. The headline stays the headline. totalWithTaxes sits next to it.
So the honest total is a one-liner on your side:
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("lergassy/google-hotels-scraper").call(run_input={
"locations": ["Barcelona, Spain"],
"checkIn": "2026-11-20",
"checkOut": "2026-11-22",
"adults": 2,
"currency": "USD",
"maxHotelsPerLocation": 40,
})
rows = [r for r in client.dataset(run["defaultDatasetId"]).iterate_items()
if r.get("type") == "hotel" and r.get("totalWithTaxes") is not None]
for r in sorted(rows, key=lambda r: r["totalWithTaxes"]):
headline, allin = r["totalPrice"], r["totalWithTaxes"]
print(f'{r["name"][:34]:34} {headline:8} -> {allin:8.2f}'
f' ({(allin / headline - 1) * 100:+.1f}%)')
Note the is not None filter rather than an or fallback. Two rows in my 757 were priceless β Google listed the property and quoted no rate for the dates, so totalPrice, priceBeforeTaxes and totalWithTaxes all came back null together. I deliberately do not paper over that by copying one field into another. A null makes you decide what to do with a property Google won't price. A quietly copied number makes your average look fine and your worst row wrong.
The same logic in curl, if you'd rather not install a client:
curl -s -X POST \
"https://api.apify.com/v2/acts/lergassy~google-hotels-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'content-type: application/json' \
-d '{"locations":["Lisbon, Portugal"],"checkIn":"2026-11-20","checkOut":"2026-11-22","maxHotelsPerLocation":20}' \
| jq -r '.[] | select(.type=="hotel")
| [.name, .totalPrice, .totalWithTaxes, (.taxes // 0)] | @tsv'
The scraper is Google Hotels Scraper if you want to reproduce the table above; the inputs in this post are the inputs I used.
When this is the wrong tool
Three cases where I'd tell you to look elsewhere.
You need the rate a specific hotel charges on its own site. Google's number is the cheapest offer it found across sellers for your dates, and the per-seller breakdown on the hotel page is quoted for Google's own default stay β roughly two weeks out β not the dates you asked for. The row stamps which dates those offers belong to, which is me admitting the limitation rather than hiding it, but it is still a limitation.
You need room-level detail. Bed type, cancellation policy, board basis, occupancy beyond adults-in-one-room: none of that is in the Google Hotels search response. Go to the seller.
You need a tax figure you can file. taxes and fees are Google's split for a display card. They are good enough to rank a market honestly and not good enough to be a line in a VAT return.
What they are good for is the thing I actually needed: knowing that the $128 on the card and the $170.41 you pay are both in the row, and knowing which one is which.













