Alpine Housing Watch
data 15 Sep 2026, 10:22 UTC
100
homes at or under €50,000
in 15 towns · cheapest €12,000 (Balme, Locana) · tap a town
change

since the 14 Sep crawl, in band at €50,000 (court auctions excluded): 93 new · 7 not seen in the 15 Sep crawl · 0 unchanged — that is all 100 homes. No price moved.

Detail — 12 of 15 towns, 92 of 100 homes.
Tap a town or a row.
LocanaBobbio PelliceCeresTorinoTorre PellicePont CanaveseUsseglioVaralloBalmePont Saint MartinSaint VincentAosta
Green discs hold homes at or under €50,000 (court auctions excluded). Dotted circles were crawled and came back empty. Blank ground was never crawled.
CavaleseTione Di TrentoVillard De Lans
Green = has stock (15 at or under €50,000, court auctions excluded), dotted = crawled, nothing in band (101), blank = never crawled.
green = 15 places hold homes at or under €50,000 (auctions excluded) dotted = 101 crawled, nothing in band dashed = the detail window blank = never crawled

Not drawn: 2 crawled places the coordinate table cannot place — not counted among the dotted circles, and never drawn from a listing's own coordinate: Ile Maurice (Île Maurice, 74501, France: no place/boundary match in OSM); Tirol Other (crawl bucket for Tirol postals with no district seat (scope.py returns it as a fallback) — it is a grouping, not a place in OSM). The table's full wording is under “How this is computed”.

15 towns, ranked by stock, grouped by area

≈ straight-line distance from Torino, not a drive time. A row's price, size and €/m² all come from the one home it names — always a home the latest crawl still saw.
Townhomescheapest still seen · size · €/m²
Piemonte7 towns
1LocanaCanavese (Val Soana) · ≈43 km from Torino17€12,000 · 70 m² · €171/m²listing ↗
2Bobbio PelliceVal Pellice · ≈53 km from Torino · 3 more rustico in town13€13,000 · 360 m² · €36/m²rusticolisting ↗
3CeresValli di Lanzo · ≈36 km from Torino12€14,000 · 30 m² · €467/m²listing ↗
4Torinothe gateway city to these valleys · 7 not seen in the 15 Sep crawl10€29,700 · 33 m² · €900/m² · 19 auctions citylisting ↗
5Torre PelliceVal Pellice · ≈45 km from Torino10€39,500 · 89 m² · €444/m²listing ↗
6Pont CanaveseCanavese · ≈40 km from Torino9€14,000 · 38 m² · €368/m² · 7 auctionslisting ↗
7UsseglioValli di Lanzo · ≈41 km from Torino · 1 more rustico in town7€16,000 · 49 m² · €327/m²rusticolisting ↗
Trentino-Alto Adige/Südtirol1 town
8Cavalese≈323 km from Torino5€15,000 · 55 m² · €273/m²listing ↗
Piemonte (continued)2 towns
9VaralloValsesia · ≈94 km from Torino5€25,000 · 204 m² · €123/m²listing ↗
10BalmeValli di Lanzo · ≈45 km from Torino · 1 more rustico in town4€12,000 · 20 m² · €600/m²listing ↗
Valle d'Aosta / Vallée d'Aoste2 towns
11Pont Saint Martin≈60 km from Torino2€42,000 · 35 m² · €1,200/m²listing ↗
12Saint Vincent≈77 km from Torino2€50,000 · 76 m² · €658/m²rusticolisting ↗
Trentino-Alto Adige/Südtirol (continued)1 town
13Tione Di Trento≈259 km from Torino2€38,000 · 150 m² · €253/m²listing ↗
Valle d'Aosta / Vallée d'Aoste (continued)1 town
14Aosta≈80 km from Torino1€26,900 · 47 m² · €572/m² citylisting ↗
Auvergne-Rhône-Alpes1 town
15Villard De Lans≈167 km from Torino1€45,000 · 22 m² · €2,045/m²listing ↗
103 places with nothing under €50,000
How this is computed

The band

In band means asking price ≤ €50,000, property type house or apartment, not a rent, not a court auction — this exact filter, applied at build time:

SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND (is_auction IS NULL OR is_auction = 0) AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%';

15 towns hold a home at or under €50,000 (SELECT COUNT(DISTINCT city_slug) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND (is_auction IS NULL OR is_auction = 0) AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%';) and they hold 100 of them (SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND (is_auction IS NULL OR is_auction = 0) AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%';). The cheapest in band is €12,000 (SELECT MIN(asking_price_eur) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND (is_auction IS NULL OR is_auction = 0) AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%';). Those are the figures the surface prints.

The ceiling control re-runs that filter live. Every priced house or apartment row at or under €1,000,000 is embedded in this file (955 rows) and the surface filters it in the browser: move the ceiling and the headline, the ranked list, the town sheet, the coverage map and the change strip all re-scope to your number — the map's discs, its caption, its legend and its accessible label included, and the strip's three figures still adding up to the headline. Nothing is re-typed — the ceiling only replaces the ≤ bound, so a figure at the default (€50,000) is still the query above. Rents and non-residential rows are not embedded at all, so no setting of the control can pull them in.

Court auctions are excluded from that canonical figure. 36 more rows are in band with the auction flag set: SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%' AND is_auction = 1; The auctions switch on the surface adds them back on purpose, and says so while it is on; each auction row is then labelled on its card and in its town's row.

Rents are excluded everywhere. 35 rows carry a rental-category portal URL whose price is a monthly sum, not a sale price: SELECT COUNT(*) FROM housing_listing WHERE url LIKE '%/d/miet%' OR url LIKE '%-mieten/%'; The category segment is what does the excluding — /d/mietwohnungen/, /d/haus-mieten/, /d/gewerbeimmobilien-mieten/. An earlier version matched the bare substring miet anywhere in the URL, which also dropped two genuine for-sale flats whose slug says vermietet (a let flat offered for sale, an Innsbruck one and a Zell am See one); the two are embedded rows again and no in-band figure moves, because both ask far above the band.

Rustico flags. A row is flagged rustico when the listing that row NAMES carries the word in its own title — 8 in-band rows do. It is that listing's own flag, never a town-wide count: where a town holds rustici that are not the home the row names, they are said in words on the same row ("2 more rustico in town") instead of being lent to the price beside it. The flag rides the same listing as the price and the size, so a cheap asking price cannot be read as a move-in price: SELECT city_slug, COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND (is_auction IS NULL OR is_auction = 0) AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%' AND title LIKE '%rustic%' GROUP BY city_slug;

The ranked list, and why the map has two panels

The list is the answer: every town with stock under the ceiling, sorted by how much stock it has — one league table, stock first. Each row names one real home — the cheapest in that town under the ceiling, auctions excluded, that the latest crawl still saw — and prints that listing's asking price, that listing's advertised size and that listing's €/m², all three from one housing_listing row, with the listing ↗ link opening that same row at its portal. A price and a €/m² from two different properties are never printed side by side: that is how a row once implied a bargain nobody was selling. Area grouping is a heading inside that order, not an order of its own: an area heading appears wherever the area changes and says (continued) if it resumes further down, because sorting by area first put a town with 4 homes above a town with 5. The rank number is the row's position in reading order, with each town's own stock count in the homes column.

A home the latest crawl did not see is never sold to the reader as the cheapest. 7 of the 100 in-band homes are absent from the newest crawl window (SELECT DISTINCT substr(observed_at, 1, 10) FROM housing_price_snapshot ORDER BY 1;), so a row names the cheapest home the crawl still saw and says in words how many others it did not — “7 not seen in the 15 Sep crawl” on the row, “not seen in the 15 Sep crawl” on the card. The canonical MIN(asking_price_eur) in band is €12,000 (SELECT MIN(asking_price_eur) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND (is_auction IS NULL OR is_auction = 0) AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%';); the cheapest one the newest crawl still observed is €12,000, and that is what a row names. A price from a crawl we missed is not the freshest number on a page whose whole subject is what changed.

The headline prints the cheapest of exactly the rows its own count is taken from — the same ceiling and the same auction scope, at every setting. So while the auctions switch is off the headline's price is €12,000 (SELECT MIN(asking_price_eur) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND (is_auction IS NULL OR is_auction = 0) AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%';), and with it on the count grows to 136 and the price follows the auctions down to €7,790 (SELECT MIN(asking_price_eur) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%';) — a court auction is cheaper than any home on the market in this band, and a count that included it beside a price that did not would be two figures for one scope. The ranked rows do not do this: a row names the cheapest home in its town that the latest crawl saw, never an auction.

A row also respects your rule-outs. The home a row names is the cheapest in band that you have NOT ruled out, and when every in-band home in that town is ruled out the row says so and points at the Undo pinned in the town sheet, instead of advertising a listing the page just recorded as rejected. Rule-outs are fetched live from GET /api/ruleouts; none is baked into this file.

The detail map above the list is geography — the towns inside its window that hold stock at your setting are named there (12 at the €50,000 default); a town inside it with nothing in band is drawn as the same hollow dotted circle the coverage map uses, unnamed, and every crawled place is drawn on the coverage map below, including the window's own dots, and all of them are in the list.

Valleys and distances. Each row carries the valley from TOWNS.md where that table knows the town, and a rough straight-line distance from Torino: great-circle distance at 6371 km mean radius between the town's own point and the Torino point, both taken from src/scrappy/housing/data/town_coords.json — the same table, and the same points, that position the dots, reproduced with uv run python -m scrappy.housing.geo show. The area group a town is listed under is that table's own region field for it. It is a straight line, not a road distance: printed with the formula visible here rather than implied to be a drive time. Torino itself is labelled a city, because that is what the coordinate table calls it and what it is: it is not an alpine village, and its own court auctions are counted on its row.

Two panels, because one Alps-wide frame cannot name twelve towns that sit inside 115 map units of each other: the detail window is chosen by a search over candidate windows (most towns inside, then most homes, then the smallest window), and the coverage map below it draws every crawled place — green where it holds homes in band, dotted where nothing in band came back, and with the window's own dots drawn inside its dashed frame rather than left as blank ground. 1 map unit ≈ 1.02 km of ground at this latitude, equirectangular.

What the map counts mean

118 places have been crawled (SELECT COUNT(DISTINCT city_slug) FROM housing_listing;). 15 of them hold in-band stock. 101 are drawn as dotted circles: crawled, placed, and nothing in band — that is the same figure the coverage map draws, dot for dot.

Every crawled place that has coordinates is drawn on the coverage map, including the ones inside the detail window. The window is marked as a dashed region with its own label (12 towns · 92 homes) rather than by leaving its dots out: omitting them made twelve towns holding 92 of the 100 in-band homes render as blank ground, and blank on that map means never crawled — so the picture pointed at Trentino while the stock sat in the Soana/Lanzo/Pellice cluster. The map's caption, its accessible label, its legend and the dots it actually draws are one and the same figure (116 drawn = 101 dotted + 15 green at the €50,000 default), and all four follow the ceiling and auction controls live: move the ceiling to €25,000 and the discs, the caption, the legend and the label re-count themselves from the same embedded rows the headline re-counts, so the picture can neither overstate nor understate the stock it is drawn from. Blank means one thing only: never crawled.

The coverage map draws 116 of the 118 crawled places; the other 2 cannot be placed — the coordinate table documents them as unplaceable, they are named below and counted nowhere else. The caption on the surface carries the key and this setting's counts, and nothing else — that sentence and both named places live here, so the figure is on the page without spending a line of the first screen on prose. The dashed box on the map is the detail window — 12 towns and 92 of the 100 in-band homes — not an omission: its own dots are drawn inside it like every other place's. The green and dotted counts that the legend and the accessible label print move with the ceiling and the auctions switch, and each states which auction scope it used; the 116 of the 118 sentence does not move, because the crawl does not. Blank ground is the absence of a crawl and nothing else: a dotted circle is a place that was visited and held nothing in band, a green disc one that was visited and did, and the two unplaced places above are the only ones this map cannot show at all.

2 crawled places are not drawn — and not because they lack coordinates, which is what this section used to claim: one of them (tirol-other) carries lat/lon on its own rows. They are places the coordinate table documents as unplaceable, so drawing them would either invent a position or contradict the page's own disclosure. They are NOT counted among the dotted circles, because a dotted circle means crawled and empty:

none — 2 further crawled places with no in-band homes: Ile Maurice, Tirol Other

Why each is unplaced, in the coordinate table's own written words (same command):

  • ile-maurice (Ile Maurice) — Île Maurice, 74501, France: no place/boundary match in OSM | Île Maurice, France: no place/boundary match in OSM | overpass name=Île Maurice: no place/boundary match in OSM
  • tirol-other (Tirol Other) — crawl bucket for Tirol postals with no district seat (scope.py returns it as a fallback) — it is a grouping, not a place in OSM

116 crawled places are on the map, © OpenStreetMap contributors, ODbL. Blank ground means never crawled, which is not the same as crawled-and-empty: a dotted circle is the second state, a green disc the first, and the two crawled-but-unplaced places above are the one exception — named, never pretended to be unvisited.

What the crawl actually covers

1173 rows across 118 places (SELECT COUNT(*) FROM housing_listing;). Of those, 1073 are residential sale rows (SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent');), of which 1021 carry a price (SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND asking_price_eur IS NOT NULL;); 1109 rows carry a price of any kind (SELECT COUNT(*) FROM housing_listing WHERE asking_price_eur IS NOT NULL;), so 88 priced rows are land, commercial or rent and sit outside the band entirely.

Region counts from the portal are page-1 lower bounds — each TrovaCasa page shows at most 24 cards, and ORPI towns are detail-cap bounded samples. The band figures above are computed from the rows we hold, not from those page counts.

What changed: 93 of the 100 in-band listings first appeared in the newest crawl day (SELECT COUNT(*) FROM housing_listing WHERE date(first_seen) = (SELECT MAX(date(first_seen)) FROM housing_listing) AND property_type IN ('house','apartment') AND (listing_kind IS NULL OR listing_kind != 'rent') AND (is_auction IS NULL OR is_auction = 0) AND asking_price_eur IS NOT NULL AND asking_price_eur <= 50000 AND url NOT LIKE '%/d/miet%' AND url NOT LIKE '%-mieten/%';). Crawls are newest-first and page-capped, so a listing that left the window was pushed out by newer ones: that is not a sale and is not counted as one.

The strip under the headline is that diff, on the surface. New, not-seen and unchanged figures come from scrappy.housing.changes.build_changes, which compares the newest observation day in housing_price_snapshot with the most recent one before it — SELECT DISTINCT substr(observed_at, 1, 10) FROM housing_price_snapshot ORDER BY 1; The three figures are a partition of the SAME set the headline counts: new + not seen in the newest crawl + unchanged = the headline total, for the same ceiling and the same auction setting, and the strip names that scope in the same sentence. (Before this round the strip printed only two of the three parts — “93 of the 100 first appeared · 7 no longer listed” — which closed by coincidence at the €50,000 band and silently dropped 193 of the 821 homes at €1,000,000, reading as if the band had shrunk. A price move is not part of the partition — a home that moved was still observed in both crawls — so it is a separate sentence.) The strip reports what it can support and nothing more: with fewer than two observed days it says so instead of inventing a delta, and a listing absent from a page-capped, newest-first window is reported as not seen in the 15 Sep crawl — never as no longer listed and never as sold, because window-absence is not evidence of a sale. Nothing there reads the wall clock: the newest day is the data's own MAX(observed_at).

Where the dots come from

A dot is a place the crawl has actually visited. Positions are real coordinates, never guesses. The primary source is the committed town table src/scrappy/housing/data/town_coords.json — one point per crawled place, from OpenStreetMap (Nominatim, with an Overpass fallback; the TOWNS.md target towns the table knows are reused verbatim from their OSM place nodes). It is read offline by scrappy.housing.geo and reproduces with uv run python -m scrappy.housing.geo show. It places 116 of the 118 places the crawl covers. Any place the table does not cover at all falls back to its own coordinates in the database — 40 crawled places carry them (SELECT COUNT(DISTINCT city_slug) FROM housing_listing WHERE lat IS NOT NULL AND lon IS NOT NULL;) — and then to TOWNS.md. A slug the table documents as unplaceable is disclosed above instead, never drawn from a single listing's coordinate.

What is not known

  • Building condition is invisible in the data. The lowest price per square metre in band is €36.1/m² (€13,000 for 360 m², Bobbio Pellice) — almost certainly a rustico needing major renovation, and the effective cost per usable home is far higher. The rustico chip on a town's row is that caveat, printed where the cheap price is.
  • Connectivity is unknown for every town — no free, citable broadband dataset was verified, and an honest unknown beats a guess.
  • Some auctions are unlabelled — the flag comes from listing titles, so an auction without it in the title would slip into the band.
  • Asking prices are asking prices, not sale prices, and areas are as advertised by the portal.

What each card says is still unknown

Every card carries a collapsed list of the things a visit would settle — no photo on file, a short description, no address, no listing date — each with the reason it is missing, so a buyer can ring the agent about named gaps rather than read a score. The engine also computes a weighted visit-worthiness number for each listing (scrappy.housing.triage.UNKNOWN_WEIGHTS), and an earlier version of this page printed it as "evidence nn/100 unknown". It is deliberately NOT printed any more: 16 of the 17 in-band Locana listings carried the identical 65, so the figure separated nothing a buyer could act on, and a number that cannot be acted on is worse than the list of gaps it stood for. The engine value is unchanged and still available with uv run python -m scrappy.housing.triage.

Provenance

Source: data/alpine-housing.db, 1173 listings across 118 places, newest observation 2026-09-15T10:22:47Z. This page is rebuilt from that database by web/build_dashboard.py on every refresh; nothing on it is hand-typed. The build stamp is the newest last_seen in the database, not the time of the build, so rebuilding unchanged data reproduces this file byte for byte.

Ruled-out verdicts are not in this file. They live in the database table housing_rule_out, so they survive every refresh, and are fetched live from GET /api/ruleouts when the page opens — undo from the card, or scrappy housing ruleout <entity_key> <verdict> --undo. The generator calls the triage engine with ignore_rule_outs=True, so a verdict can never change this page's bytes. If the fetch fails the page says so instead of showing a silent “nothing ruled out”. The ceiling and the town you last opened are kept in the URL (?c=100000 and #locana) and in this browser's own storage, so both survive a reload, the back button and the phone's back gesture.