DEV Community

Cover image for How to Scrape Funda Property Listings in Python with Funda Scraper
ReventaData
ReventaData

Posted on Originally published at apify.com

How to Scrape Funda Property Listings in Python with Funda Scraper

By the end of this tutorial you will have a Python script that pulls Dutch homes for sale or for rent from funda.nl into structured rows: asking price, address, living area, rooms, bedrooms, energy label, agency and photo links.

Funda is a Dutch property portal, and its search pages are not friendly to a quick requests script. In my tests on 7 November 2026, plain HTTP clients got an "Access Denied" stub or a verification page instead of results. Funda Scraper (reventadata/funda-scraper) is the Apify Actor I built to deal with that. You send it a search, it returns one row per listing.

This guide runs it from Python with the Apify API client, and the sample rows below come from a real capture of a Utrecht rental search, not from an invented example.


Prerequisites

You need three things:

  1. Python 3.11 or newer (the Apify client for Python 3.x requires it).
  2. A free Apify account, which you can create with an email address or a GitHub login.
  3. Your API token, from Settings > API & Integrations in Apify Console.

Install the client and export the token:

pip install -U "apify-client~=3.0"
export APIFY_API_TOKEN="your_apify_token_here"
Enter fullscreen mode Exit fullscreen mode

No scraping code is needed on your side. The Actor does the fetching.


What you'll achieve

You will search funda.nl for rentals in Utrecht up to 1,500 EUR per month, read the rows in Python and print a one-line summary per listing, including a price per square metre that you work out yourself from two fields.

Funda lists more than homes, so a rental search also returns parking spaces and storage units. You will see how to spot them and how to filter them out.

The same script works for a buyer comparing asking prices across towns, a tenant watching new rentals, or an analyst building a table of energy labels by neighbourhood. Only the input changes.


Step 1: Open Funda Scraper

Open the Funda Scraper page on Apify Store. The Try for free button there opens the Actor in Apify Console. This tutorial uses the API from Python, so you only need the Actor name, reventadata/funda-scraper.


Step 2: Choose the search

The search is described by a handful of input fields:

  • offering: sale or rent. Anything else is refused before the run starts.
  • areas: a list of Funda area slugs such as amsterdam or utrecht, copied from the selected_area part of a funda.nl search URL. Each area is one search, and a listing found twice is kept once. Leave the list empty and the Actor searches the whole Netherlands.
  • objectType: empty (every type), house or apartment.
  • sort: relevance (Funda's default) or newest.
  • maxPrice and minPrice: in EUR, and for rentals per month.
  • minLivingAreaM2, maxLivingAreaM2, minBedrooms: size filters.
  • maxItems and maxPages: caps on rows and on pages per area.

A search with a maxPrice sends the price range to Funda, so Funda does that filtering. The living-area and bedroom limits are applied by the Actor after it has read a page, so they remove rows but do not reduce the number of pages fetched.

To search two towns for larger apartments for sale, the input would look like this:

{
    "offering": "sale",
    "areas": ["amsterdam", "utrecht"],
    "objectType": "apartment",
    "minLivingAreaM2": 60,
    "minBedrooms": 2,
    "maxItems": 30,
    "maxPages": 3
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Run it from Python

This script starts the Actor, waits for it, reads the dataset and prints two lines per listing:

import os
from apify_client import ApifyClient

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

# 2. Describe the search: rentals in Utrecht up to 1,500 EUR a month
run_input = {
    "offering": "rent",
    "areas": ["utrecht"],
    "maxPrice": 1500,
    "maxItems": 15,
    "maxPages": 1,
}

# 3. Start the Actor and wait for it to finish
run = client.actor("reventadata/funda-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.\n")

# 5. Print two lines per listing, with a price per square metre worked out here
for item in items:
    street = " ".join(
        part for part in (item.get("street"), item.get("houseNumber"), item.get("houseNumberSuffix")) if part
    )
    price, area = item.get("price"), item.get("livingAreaM2")
    per_m2 = f"{price / area:.1f} EUR/m2" if price and area else "no living area"
    print(f"{street}, {item.get('city')} | {price} EUR/month | {area} m2 | {per_m2}")
    print(f"  {item.get('propertyType')}, bedrooms: {item.get('bedrooms')}, energy label: {item.get('energyLabel')}")
Enter fullscreen mode Exit fullscreen mode

Fetching goes through an unblocking service, one page at a time, so keep maxPages small while you test.


Step 4: Read the output

The loop prints one block per listing. This is the start of its output on a capture dated 2026-11-07 (a Utrecht rental search with maxPrice 1,500, 15 rows on page 1):

Koningsweg 135 E, Utrecht | 290 EUR/month | None m2 | no living area
  parking, bedrooms: None, energy label: unknown
Hooft Graaflandstraat 2 A9, Utrecht | 1425 EUR/month | 27 m2 | 52.8 EUR/m2
  apartment, bedrooms: 1, energy label: A++++
Oranje Vrijbuiterslaan 55, Utrecht | 1132 EUR/month | 48 m2 | 23.6 EUR/m2
  apartment, bedrooms: 1, energy label: A
Enter fullscreen mode Exit fullscreen mode

The first listing is a parking space: no living area, no bedrooms. The Actor keeps it because objectType was left empty, and it reports the missing values as null rather than guessing.


Step 5: Add coordinates and the description (optional)

Set includeDetails to true and the Actor opens each kept listing's own page for latitude, longitude, the full Dutch description and the feature table. A listing whose page cannot be read still ships, with those four fields null.

The detail fields look like this on a capture of one rental listing's page (description and features shortened):

{
    "latitude": 52.074806,
    "longitude": 5.119643,
    "description": "Karakteristiek appartement met patio en uitzicht over de Vaartsche Rijn! ...",
    "features": {
        "Aangeboden sinds": "November 6, 2026",
        "Huurprijs": "€ 1.950 per maand (geen servicekosten)",
        "Waarborgsom": "€ 3.900 eenmalig",
        "Soort bouw": "Bestaande bouw",
        "Bouwjaar": "1920",
        "Wonen": "85 m²",
        "Aantal kamers": "2 kamers (1 slaapkamer)",
        "Verwarming": "Gehele vloerverwarming"
    }
}
Enter fullscreen mode Exit fullscreen mode

Understanding your results

Every row carries 38 fields. The ones you will use most:

  • price, priceCondition, priceType: the amount, and how Funda labels it (per_month for rent, kosten_koper for sale).
  • street, houseNumber, houseNumberSuffix, postalCode, city, district, neighbourhood: the address, split up.
  • livingAreaM2, plotAreaM2, rooms, bedrooms, energyLabel: size and energy label, as published. An unlabelled home shows "unknown".
  • agencyName, agencyUrl, agencyAssociation, brokerName: the listing agency, and the broker when the listing names one.
  • imageUrls, publishedAt, openHouses, promoText: photos, publication time, open-house windows, promo line.

One complete row from the same capture (imageUrls cut to its first entry):

{
    "id": "8178650",
    "url": "https://www.funda.nl/detail/huur/utrecht/appartement-hooft-graaflandstraat-2-a9/80987659/",
    "listingType": "rent",
    "price": 1425,
    "priceType": "regular",
    "priceCondition": "per_month",
    "currency": "EUR",
    "propertyType": "apartment",
    "constructionType": "resale",
    "status": "none",
    "street": "Hooft Graaflandstraat",
    "houseNumber": "2",
    "houseNumberSuffix": "A9",
    "postalCode": "3525VV",
    "city": "Utrecht",
    "municipality": "Utrecht",
    "province": "Utrecht",
    "district": "Wijk 07 Zuid",
    "neighbourhood": "Nieuw Hoograven-Zuid",
    "livingAreaM2": 27,
    "plotAreaM2": null,
    "rooms": 2,
    "bedrooms": 1,
    "energyLabel": "A++++",
    "publishedAt": "2026-10-05T15:49:58.8659613+02:00",
    "promoText": null,
    "imageUrls": [
        "https://cloud.funda.nl/tiara-media/64c806c0-d856-4ab7-be06-c84e811130e4/875e471e-0738-4a5c-bcf2-6d1f795314f4"
    ],
    "agencyName": "Otten en Stok Makelaars",
    "agencyUrl": "https://www.funda.nl/makelaar/63878-otten-en-stok-makelaars/",
    "agencyId": 63878,
    "agencyAssociation": "VN",
    "brokerName": null,
    "openHouses": [],
    "searchUrl": "https://www.funda.nl/zoeken/huur?selected_area=%5B%22utrecht%22%5D&price=%220-1500%22&search_result=1",
    "latitude": null,
    "longitude": null,
    "description": null,
    "features": null
}
Enter fullscreen mode Exit fullscreen mode

The last four fields are null without details.


Pro tips

  1. Filter by type early. In that capture 5 of 15 rows were parking spaces or storage. Set objectType to apartment or house to leave them out.
  2. Use newest for monitoring. sort: "newest" puts fresh listings first, which suits a schedule that checks for new homes.
  3. minPrice alone filters after the fetch. It is only sent to Funda together with maxPrice, so by itself it removes rows but does not save pages.
  4. Add includeDetails only when you need it. See Step 5: it costs one extra request per row.
  5. Compute your own ratios. There is no price-per-square-metre field. Divide price by livingAreaM2 yourself, as the script does, and skip rows where either is null.
  6. Mind the ceiling. Funda serves at most 666 pages of 15 results (9,990) for any one search. If a search is larger, the run logs a warning; narrow it with a price range, a type or a smaller area.

Troubleshooting

  • Zero rows, status SUCCEEDED. The site answered but nothing matched, or the area slug is unknown. The status message says so. Check the spelling against a funda.nl URL.
  • Zero rows, status FAILED. A malformed slug or a maxPrice below minPrice ends the run with a message to fix the input. Pages that could not be read also fail a run that saved nothing.
  • Fewer rows than expected. The log ends with Skipped N: reason lines for rows removed by your limits, and Pushed N row(s).. Read it with client.log(run.id).get().

Funda's terms of use restrict automated collection, so check that your use of the data complies with them and with data-protection law.


Next steps

For current pricing and details, see the Store page.

To repeat a search, save it as an Apify task and attach a schedule, which runs an Actor or task on a cron expression. Datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL (dataset docs), and a webhook can call your own endpoint when a run finishes (webhook docs).

What are you planning to search for on Funda? Drop a comment! 💬

About the author: reventadata builds and publishes data extraction Actors on the Apify Store.

Top comments (0)