How to Scrape Hotel Prices and Build a Hotel Database (2026)
Build a hotel database and track nightly rates over time: search a destination, read each property, store rate snapshots and monitor changes — with working Python, Node, curl and SQL.
What people actually mean by hotel data
"Scrape hotel prices" usually covers two different jobs. The first is building a hotel database: the list of properties in a market, with name, address, star rating, review score and facilities. That list changes slowly. The second is rate collection: what one room costs for one stay, for a given number of guests, seen from a given country. That number changes many times a day.
Keep the two apart from the start. If you store a price on the hotel row, you lose its history the next time you collect it, and you can't say which dates or guest count it was for. Hotels are entities. Prices are observations of those entities, and each one is tied to a search context.
- Revenue management: compare your own rates with the competitive set for the same dates.
- Rate parity: check that the rate an OTA shows matches the rate you agreed.
- Market research: count how many properties a city has, at what price levels and review scores.
- Travel products: fill a comparison or deals page with current availability.
Why hotel prices are hard to scrape
A hotel price has no meaning without its context. The same room shows a different number depending on check-in date, number of nights, adults, rooms, currency, the country the visitor appears to be in, whether they are logged in, and sometimes the device. Without dates, many OTAs show no price at all.
Then there is the delivery. Results pages are rendered by JavaScript, cards load lazily, and the large booking sites run serious bot protection. A plain HTTP request from a datacenter IP often gets a challenge page instead of results. And those pages count: a challenge page is a real response from the target.
Rule of thumb: every price you store should carry the full search context it was collected under. If you can't recreate the query, you can't compare the number.
Step 1 — Build the hotel list with a destination search
Irmu has a dedicated Booking.com search endpoint. You give it a destination the way you'd type it into Booking's search box, and optionally a stay. It returns one row per property card on the first page of results. It costs a flat 30 credits. `premium`, `country` and `city` change where the request comes from, not what it costs.
Every field is text, exactly as Booking prints it (`"€ 212"`, `"9.1"`). A field the card doesn't show is left out of that row. Results always come from the English site.
curl -G "https://app.irmu.com/api/booking/search" \
-H "Authorization: Bearer $IRMU_API_KEY" \
--data-urlencode "destination=Rome" \
-d checkin=2026-11-11 -d checkout=2026-11-12 \
-d adults=2 -d rooms=1 -d currency=EUR -d country=it{
"destination": "Rome",
"checkin": "2026-11-11",
"checkout": "2026-11-12",
"adults": 2,
"rooms": 1,
"hotels": [
{
"name": "Hotel Artemide",
"url": "https://www.booking.com/hotel/it/artemide.en-gb.html",
"price": "€ 212",
"priceInfo": "1 night, 2 adults",
"taxes": "+€ 12 taxes and charges",
"address": "Termini, Rome",
"distance": "1.2 km from centre",
"reviewScore": "9.1",
"reviewWord": "Wonderful",
"reviewCount": "3,214 reviews",
"stars": "4 out of 5 Property rating"
}
],
"credits_charged": 30,
"status": "done"
}Covering a whole market
One search returns the first page of results, so a big city needs more than one query. Split the market into smaller searches: neighbourhoods, districts, landmarks and nearby towns. Then deduplicate on the property URL. The property URL is the most reliable key you have. Strip the query string and the language suffix before you compare URLs.
If a search comes back as `202`, the crawl is paid for and still running. Collect it from `GET /crawl/{job}` for free. That endpoint returns the crawl envelope, so the rows are under `data` there instead of `hotels`.
import os, re, time, requests
API = "https://app.irmu.com/api"
HEADERS = {"Authorization": f"Bearer {os.environ['IRMU_API_KEY']}"}
def canonical(url: str) -> str:
url = url.split("?")[0]
return re.sub(r"\.[a-z]{2}(-[a-z]{2})?\.html$", ".html", url)
def search(destination: str, **stay) -> list[dict]:
r = requests.get(f"{API}/booking/search", headers=HEADERS,
params={"destination": destination, **stay}, timeout=180)
body = r.json()
if r.status_code == 202: # still running: collect, free
job = body["job_id"]
for _ in range(60):
time.sleep(3)
body = requests.get(f"{API}/crawl/{job}", headers=HEADERS, timeout=60).json()
if body.get("status") == "done":
return body.get("data") or []
return []
r.raise_for_status()
return body.get("hotels") or []
areas = ["Rome Termini", "Trastevere, Rome", "Prati, Rome", "Monti, Rome", "Testaccio, Rome"]
hotels: dict[str, dict] = {}
for area in areas:
for row in search(area, checkin="2026-11-11", checkout="2026-11-12",
currency="EUR", country="it"):
hotels.setdefault(canonical(row["url"]), row)
print(len(hotels), "unique properties")Step 2 — Get property details and room rates
Search cards are a summary. For each property, the hotel endpoint reads the property page itself. It returns a structured address, a numeric rating, a price with `amount`, `currency`, `original` and `discount`, a room-by-room list of prices, facilities, images, nearby points of interest, review subscores and a sample of reviews. This also costs a flat 30 credits.
One detail matters for your budget: when the crawler can't read a property page, the credits are refunded. The reply has `hotel: null`, `credits_charged: 0` and an `error` explaining why. Treat that as a retry later, not as an empty hotel.
const API = "https://app.irmu.com/api";
const headers = { Authorization: `Bearer ${process.env.IRMU_API_KEY}` };
export async function getHotel(url, { checkin, checkout, adults = 2, currency = "EUR", country }) {
const qs = new URLSearchParams({ url, checkin, checkout, adults: String(adults), currency });
if (country) qs.set("country", country);
const res = await fetch(`${API}/booking/hotel?${qs}`, { headers });
const body = await res.json();
if (body.hotel == null) {
// unreadable page: refunded (credits_charged: 0) — queue for a later retry
return { ok: false, error: body.error, charged: body.credits_charged };
}
return { ok: true, hotel: body.hotel, charged: body.credits_charged };
}{
"hotel": {
"name": "Relais La Maison de Luxe B&B",
"address": { "streetAddress": "Via Nazionale 10", "addressLocality": "Rome", "addressCountry": "IT" },
"rating": { "score": 8.7, "word": "Fabulous", "count": 426 },
"price": { "amount": 112, "currency": "EUR", "original": 140, "discount": 28 },
"rooms": [{ "name": "Double Room", "prices": [{ "amount": 112, "currency": "EUR", "original": 140, "discount": 28 }] }],
"facilities": [{ "items": ["Free WiFi"] }],
"pointsOfInterest": ["Termini 600 m"]
},
"credits_charged": 30,
"status": "done"
}Sites without a dedicated endpoint
For hotel chains' own sites, smaller OTAs or regional portals, use the general crawl endpoint. Start by reading structured data: many hotel pages publish a `Hotel` or `LodgingBusiness` JSON-LD block with name, address and rating. That block is far more stable than the visible markup. For prices, which are rarely in JSON-LD, use `ai_query` to describe the fields you want.
Set `country` to the market you're pricing for, and use `premium=true` if the standard pool gets turned away. Keep in mind that on `/crawl`, unlike the Booking endpoints, premium and city targeting change the price.
curl -G "https://app.irmu.com/api/crawl" \
-H "Authorization: Bearer $IRMU_API_KEY" \
--data-urlencode "url=https://www.example-hotel.com/rooms?arrive=2026-11-11&depart=2026-11-12&adults=2" \
-d js=true -d premium=true -d country=it -d wait_until=domcontentloaded \
--data-urlencode "ai_query=List each room type with name, nightly price, currency, cancellation policy and whether breakfast is included"Step 3 — Design the hotel database
Use two tables. `hotels` holds one row per property, keyed on the canonical URL. `rate_snapshots` holds one row per observation, with the full search context. Store the price as a number together with its currency. Keep the original text too, because you will want it the day a parser changes.
create table hotels (
id bigserial primary key,
source text not null default 'booking',
url text not null unique, -- canonical, no query string
name text not null,
street text,
locality text,
country char(2),
review_score numeric(3,1),
review_count integer,
first_seen timestamptz not null default now(),
last_seen timestamptz not null default now()
);
create table rate_snapshots (
id bigserial primary key,
hotel_id bigint not null references hotels(id),
checkin date not null,
nights smallint not null,
adults smallint not null,
rooms smallint not null,
point_of_sale char(2), -- the country parameter used
currency char(3) not null,
room_name text,
amount numeric(10,2) not null,
original numeric(10,2),
raw_price text, -- exactly as the site printed it
captured_at timestamptz not null default now()
);
create index on rate_snapshots (hotel_id, checkin, captured_at desc);Step 4 — Monitor prices on a schedule
A daily job is enough for most rate-shopping. Pick a grid of stays (for example, the next 30 check-in dates, one night, two adults), collect each tracked property once per stay, and insert a snapshot. Price changes then come from a query, not from a second scrape.
Do the budget maths before you start. 50 properties × 30 check-in dates = 1,500 hotel calls a day at 30 credits each, which is 45,000 credits a day. Most teams start with fewer dates for far-off stays and more for the next two weeks, when prices move the most.
-- rate changes since the previous capture for the same stay
select h.name, s.checkin, s.amount, s.currency,
s.amount - lag(s.amount) over w as change
from rate_snapshots s
join hotels h on h.id = s.hotel_id
window w as (partition by s.hotel_id, s.checkin, s.nights, s.adults, s.room_name
order by s.captured_at)
order by s.captured_at desc
limit 100;What you are billed for
A response the target served is a billed request, even if it was a 404, a 500 or an anti-bot page. A search that finds nothing is billed too, because an empty `hotels` list with `status: done` is Booking's real answer. You are not billed when Irmu couldn't serve the request for internal reasons, for domains Irmu doesn't cover, or when the hotel endpoint couldn't read a property page (that one is refunded).
- Empty results: billed. Check your destination spelling and dates before you retry.
- `hotel: null` with `credits_charged: 0`: refunded. Retry later.
- `202`: already paid for. Collect it for free from `/crawl/{job}` and don't start a new request.
Legal and ethical limits
Prices and property facts shown publicly are the usual target of rate-shopping. Even so, read each site's terms, keep your request rate reasonable, and don't republish reviews or photos as your own content. Reviews can contain personal data. If you store them, have a reason to, keep only what you need, and follow GDPR or your local equivalent. This is not legal advice. Check with your counsel before you build a product on someone else's data.
Start small
Run one destination search and one hotel call for a single stay. Check the fields against the live page, then load them into the two tables above. Once the numbers match what a traveller sees, add more stays to the grid. The free plan includes enough monthly credits to test this end to end.
Keep reading
How to Scrape Google Maps (2026 Guide)
Collect business listings, ratings and reviews from Google Maps reliably — without maintaining a browser farm.
GuidesScrape Amazon Product Data with Python
A practical walkthrough for pulling prices, buy box, stock and reviews from Amazon at scale.
EngineeringCloudflare Bypass: What Actually Works in 2026
A technical look at Turnstile, TLS fingerprinting and why most open-source bypasses stopped working.
Start building with Irmu today
200 free credits every month, no card required. Every API, every integration, one key.