DEV Community

Cover image for How to Scrape Otodom Property Listings with Python and Apify
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape Otodom Property Listings with Python and Apify

By the end of this tutorial you will have a short Python script that pulls property listings from Otodom.pl, Poland's real-estate portal, into plain Python dictionaries: price, area, rooms, district, seller type and the link to each ad. We use the Apify Actor Otodom Scraper (reventadata/otodom-scraper) and the Apify Python client, so there is no browser to drive and no HTML to parse.

Browsing Otodom by hand works for one flat. It stops working when you want every rental in Krakow listed this week, or a price-per-square-metre comparison across districts. If you are wondering how to scrape Otodom without maintaining your own crawler, this is one way to do it, and the worked example ranks Krakow rents by price per square metre.

This Actor is unofficial and not affiliated with Otodom.

Prerequisites

You need three things:

  1. Python 3.11 or newer (the apify-client 3.x package requires it).
  2. A free Apify account and its API token.
  3. The Apify client for Python.
pip install -U "apify-client~=3.0"
Enter fullscreen mode Exit fullscreen mode

Copy your token from the API & Integrations settings in Apify Console and export it, so the script never contains it:

export APIFY_API_TOKEN="your_apify_token_here"
Enter fullscreen mode Exit fullscreen mode

What you'll achieve

You will run the Otodom Scraper from Python and get one dictionary per listing, ready for a spreadsheet or a database. Each row carries the asking price (or the monthly rent on rent searches), area in m², rooms, floor, street, city and district, the seller type, the agency or developer that published the ad, and when the ad was created.

A concrete use case: you are choosing a rental in Krakow and want to know which listings are cheapest per square metre, because a low rent for a tiny flat is not a bargain. Otodom's own price-per-m² field is empty on rent listings, so we compute it ourselves in step 6.

Step 1: Open the Actor

Go to the Otodom Scraper page on Apify Store. We will drive the Actor from Python, so there is nothing to configure on that page.

Step 2: Configure the search

Every input below is a real field of the Actor:

Field What it does
transaction buy or rent.
propertyType mieszkanie (apartment) or dom (house).
location Otodom's location slug path, copied from a search URL on otodom.pl, or cala-polska for all of Poland.
maxPrice Optional upper price limit in PLN.
ownerType any or private (private owners only).
sortBy relevance, priceAsc or newest.
maxItems Cap on rows.
maxPages Optional cap on result pages.

The four fields with fixed choices (transaction, propertyType, ownerType, sortBy) are enums: a value outside the list is refused before the run starts. To find a location, search on otodom.pl and copy the part of the URL after the property type, for example malopolskie/krakow/krakow/krakow.

Here is the start of our script, which sets up the client and a rental search in Krakow under 3,000 PLN, newest first:

import os
from apify_client import ApifyClient

# 1. Initialize the client with your token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. Describe the search: rent, apartments, Krakow, max 3000 PLN, newest first
run_input = {
    "transaction": "rent",
    "propertyType": "mieszkanie",
    "location": "malopolskie/krakow/krakow/krakow",
    "maxPrice": 3000,
    "sortBy": "newest",
    "maxItems": 20,
    "maxPages": 1,
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Run the Actor

Add the call. It starts the Actor on Apify, waits for it to finish and then reads the rows from the run's default dataset:

# 3. Start the Actor and wait for it to finish
run = client.actor("reventadata/otodom-scraper").call(run_input=run_input)
if run is None or run.status != "SUCCEEDED":
    status = run.status if run else "unknown"
    raise SystemExit(f"Run did not succeed (status: {status}).")

# 4. Read the rows from the run's default dataset
items = client.dataset(run.default_dataset_id).list_items().items
print(f"Retrieved {len(items)} listings.")
Enter fullscreen mode Exit fullscreen mode

The run happens on Apify's servers, not on your machine. The Actor reads the data Otodom embeds in its own search pages, so there is no browser in the loop, and it fetches without a proxy unless you switch one on in the proxyConfiguration input. In the example run on 2026-11-14, the Actor returned 20 listings in 11.3 seconds.

Step 4: Print the first rows

for item in items[:3]:
    if item["price"] is None:
        price_text = "no price published"
    else:
        price_text = f'{item["price"]:,.0f} {item["currency"]}'
    area_text = "area n/a" if item["areaM2"] is None else f'{item["areaM2"]:g} m2'
    place = ", ".join(p for p in (item["district"], item["city"]) if p)
    print(item["title"])
    print(f'  {price_text} | {area_text} | rooms: {item["rooms"]}')
    print(f'  {place} | {item["sellerType"]}')
    print(f'  {item["url"]}')
Enter fullscreen mode Exit fullscreen mode

Prices are printed as numbers with the currency from the row, and joining only the non-empty location parts avoids printing None when a neighbouring town has no district. A listing without a published price prints "no price published" instead of crashing, which matters in step 6.

Step 5: Look at one row

This is the first row from the live example run of 2026-11-14 using the input above, copied verbatim from the dataset:

{
  "id": "68634101",
  "url": "https://www.otodom.pl/pl/oferta/semi-studio-z-przestrzenia-sypialniana-39-m2-ID4DYRD",
  "title": "Semi studio z przestrzenią sypialnianą 39 m2",
  "propertyType": "FLAT",
  "transaction": "RENT",
  "price": 2650,
  "currency": "PLN",
  "priceHidden": false,
  "pricePerSquareMeter": null,
  "priceFromPerSquareMeter": null,
  "areaM2": 39,
  "rooms": 2,
  "roomsLabel": "TWO",
  "floor": "GROUND",
  "street": "ul. Tadeusza Romanowicza",
  "voivodeship": "małopolskie",
  "city": "Kraków",
  "district": "Podgórze",
  "neighborhood": "Zabłocie",
  "sellerType": "business",
  "agencyName": "VAVELIA",
  "agencyType": "AGENCY",
  "advertiserName": "VAVELIA",
  "contactNames": [
    "Katarzyna Nowak"
  ],
  "isPrivateOwner": false,
  "isPromoted": false,
  "isExclusiveOffer": false,
  "features": [
    "BALCONY",
    "PARKING_SPOT",
    "SECURE_BUILDING",
    "GROUND_FLOOR"
  ],
  "investmentState": null,
  "investmentUnits": null,
  "investmentAreaFromM2": null,
  "investmentAreaToM2": null,
  "investmentDelivery": null,
  "createdAt": "2026-11-14T14:35:33+00:00",
  "pushedUpAt": null,
  "imageUrl": "https://ireland.apollo.olxcdn.com/v1/files/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmbiI6InNiM212bzJqa29lbjItQVBMIiwidyI6W3siZm4iOiJlbnZmcXFlMWF5NGsxLUFQTCIsInMiOiIxNiIsImEiOiIwIiwicCI6IjEwLC0xMCJ9XX0.Ymaj_Ja5kPat8QdTL5emeG7Bec8sB5sV8ONQ_e_dwzU/image;s=1280x1024;q=80",
  "searchUrl": "https://www.otodom.pl/pl/wyniki/wynajem/mieszkanie/malopolskie/krakow/krakow/krakow?priceMax=3000&by=LATEST&direction=DESC"
}
Enter fullscreen mode Exit fullscreen mode

Notice price: on a rent search it is the monthly rent. pricePerSquareMeter is null here; in that 20-row run it was null on every listing.

Step 6: Rank rents per square metre

Now the worked example. Rows without a price or an area are skipped, and the same ad is kept once even if it shows up under two ids:

# 5. Rank rents by price per square metre. Otodom leaves its own
# pricePerSquareMeter empty on rent listings, so we compute it.
unique = {}
for item in items:
    if item["price"] is None or not item["areaM2"]:
        continue  # developments and hidden prices have no total price
    unique.setdefault(item["url"], item)  # one ad can arrive under two ids

ranked = sorted(unique.values(), key=lambda i: i["price"] / i["areaM2"])
print(f"{len(items)} rows, {len(ranked)} with a price and an area. Cheapest per m2:")
for item in ranked[:5]:
    per_m2 = item["price"] / item["areaM2"]
    place = item["district"] or item["city"]
    print(
        f'{per_m2:6.1f} PLN/m2 | {item["price"]:6.0f} PLN | '
        f'{item["areaM2"]:5.1f} m2 | {place} | {item["sellerType"]}'
    )
Enter fullscreen mode Exit fullscreen mode

Running this snippet over the 20 rows from the 2026-11-14 live run prints the following. Because the Krakow search also includes neighbouring municipalities such as Zielonki and Wieliczka where district is null, the snippet falls back to city. Your numbers will differ, because your run reads whatever Otodom lists when you start it.

20 rows, 20 with a price and an area. Cheapest per m2:
  44.1 PLN/m2 |   1500 PLN |  34.0 m2 | Swoszowice | business
  46.3 PLN/m2 |   2600 PLN |  56.1 m2 | Zielonki | business
  46.8 PLN/m2 |   2900 PLN |  62.0 m2 | Prądnik Biały | business
  48.0 PLN/m2 |   2400 PLN |  50.0 m2 | Grzegórzki | business
  49.6 PLN/m2 |   2800 PLN |  56.4 m2 | Grzegórzki | business
Enter fullscreen mode Exit fullscreen mode

Understanding your results

Each row has 37 fields; the ones you will use most:

  • price, currency: sale price or monthly rent, in PLN on every listing sampled.
  • areaM2, rooms, floor: size and layout, as Otodom states them.
  • city, district, neighborhood, street: location levels, each null when the listing does not give it.
  • sellerType: passes through Otodom's own advertiser type string (business, private_person, private and rental_management_agency appeared in the 20-row run; agency and developer also appear on Otodom); isPrivateOwner is a plain true/false.
  • agencyName, advertiserName, contactNames: the public names shown on the ad. Phone numbers are not included.
  • createdAt, pushedUpAt: when the ad was first created and when it was last bumped.

New developments have no total price. This excerpt (selected fields) of a development row from a Warsaw sale capture dated 2026-11-07 shows what you get instead:

{
  "title": "Piastova Osada",
  "propertyType": "INVESTMENT",
  "transaction": "SELL",
  "price": null,
  "currency": "PLN",
  "priceHidden": false,
  "areaM2": null,
  "priceFromPerSquareMeter": 13400.0,
  "investmentState": "IN_BUILDING",
  "investmentUnits": 106,
  "investmentAreaFromM2": 26.4,
  "investmentAreaToM2": 71.8,
  "investmentDelivery": "2027-Q4",
  "agencyType": "DEVELOPER"
}
Enter fullscreen mode Exit fullscreen mode

price is null and the development fields carry the "from" price per m², the unit count and the expected hand-over.

Pro tips

  1. Cap your runs. Otodom reported 561 result pages for apartments for sale in Warsaw on 2026-11-07, and the Actor walks every page the site reports unless maxItems or maxPages stops it. Start with maxPages: 1.
  2. Filter at the source. maxPrice, ownerType: "private" and sortBy are sent to Otodom with the request, so you only fetch the listings you asked for. In a check on 2026-11-07, ownerType: "private" returned a page where all 37 listings had isPrivateOwner true.
  3. Deduplicate by URL if you merge runs. The Actor returns each Otodom id once per run and drops a second id that carries a url it already kept (each page carries one such placeholder copy), but rows from different runs can repeat, so key on url, as step 6 does.
  4. Hidden prices. A seller can hide a price: price is null and priceHidden is true.
  5. Only apartments and houses are offered. Other Otodom categories were not tested.

Troubleshooting

  • The run succeeded but the dataset is empty. A search that loads normally and matches nothing ends successfully with a status message saying so. Loosen maxPrice or check the location slug.
  • The run failed with a message about the location. An unknown location slug stops the run with an error. Copy the slug from a real otodom.pl search URL.
  • The run failed and says pages could not be read. The Actor makes up to three attempts per page (stopping if the run clock is low) and marks failed pages. With zero rows stored it fails; with some rows it keeps them. Enable proxyConfiguration if needed (25/25 pages on 2026-11-07 and the 2026-11-14 live run worked without one).
  • Where to read the log: client.log(run.id).get() returns it. For help, use the Actor's Store page.

Next steps

For current pricing and details, see the Store page.

To repeat a search, save it as a task and put it on an Apify schedule, which starts Actors and tasks on a cron expression (schedules docs). Check Otodom's terms and the rules that apply to your use of the data before you build on it.

What are you planning to scrape from Otodom? Drop a comment.

Top comments (0)