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:
- Python 3.11 or newer (the
apify-client3.x package requires it). - A free Apify account and its API token.
- The Apify client for Python.
pip install -U "apify-client~=3.0" 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" 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, } 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.") 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"]}') 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" } 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"]}' ) 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 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, eachnullwhen the listing does not give it. -
sellerType: passes through Otodom's own advertiser type string (business,private_person,privateandrental_management_agencyappeared in the 20-row run;agencyanddeveloperalso appear on Otodom);isPrivateOwneris 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" } price is null and the development fields carry the "from" price per m², the unit count and the expected hand-over.
Pro tips
- 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
maxItemsormaxPagesstops it. Start withmaxPages: 1. - Filter at the source.
maxPrice,ownerType: "private"andsortByare 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 hadisPrivateOwnertrue. - Deduplicate by URL if you merge runs. The Actor returns each Otodom id once per run and drops a second id that carries a
urlit already kept (each page carries one such placeholder copy), but rows from different runs can repeat, so key onurl, as step 6 does. - Hidden prices. A seller can hide a price:
priceisnullandpriceHiddenistrue. - 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
maxPriceor check thelocationslug. - 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
proxyConfigurationif 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.

