ispra-idrogeo is a small, typed, async Python library for IdroGEO, the platform where ISPRA (Italy's national environmental agency) publishes what it knows about landslides and floods in Italy. With it, a script can download the national landslide inventory and the hazard maps, keep them up to date, and ask the IdroGEO API for risk figures about any region, province or town. No browser, no copy-pasted links, no account.
async with IdroGEO() as idrogeo:
folder = await idrogeo.fetch(opendata("frane_piff")) # 721,365 landslides, one point each
Contents
- What is IdroGEO, in 30 seconds
- What this library does
- Who is it for
- Quick start
- What you can download
- Where downloads are saved
- How it works
- Recipes
- Example notebooks
- The traps we already fell into
- API at a glance
- Contributing · Ideas to work on
- Data licence and credits
What is IdroGEO, in 30 seconds
Italy is one of the European countries most exposed to landslides and floods. For decades, ISPRA and the regions have mapped every landslide they found into one national database, IFFI (Inventario dei Fenomeni Franosi in Italia), and the river basin authorities have drawn hazard maps (the PAI plans) of where the ground may slide and where rivers may flood. ISPRA joins the regional maps into national mosaics, and puts everything on IdroGEO: a web map, open-data files and a public REST API.
flowchart LR
R["🗺️ Regions and<br/>basin authorities<br/>surveys · PAI plans"] --> I["ISPRA<br/>IFFI inventory<br/>national mosaics"]
I --> W["IdroGEO<br/>open data (daily ZIPs)<br/>REST API"]
W -->|ispra-idrogeo| Y["📁 Shapefiles / GeoJSON<br/>on your disk"]
W -->|ispra-idrogeo| J["🧾 JSON<br/>risk figures, code lists"]
Y --> U["Your work<br/>GIS · models · dashboards"]
J --> U
Some of what is in there, straight from the API:
| Italy, November 2026 | |
|---|---|
| Landslides mapped in IFFI | 721,365 |
| Land in high or very high landslide hazard (PAI P3 + P4) | 9.5 % of the country, 28,801 km² |
| People living there | 1.28 million |
| People living in areas that can flood once in 100–200 years (P2) | 6.8 million, 11.5 % of the population |
| Dated landslide events recorded since 2018 | 1,290 |
What this library does
| Feature | In plain words | |
|---|---|---|
| 📦 | Every open-data file | IFFI points, polygons and lines, diffuse-landslide areas, deep-seated deformations, dated events, reports, the two national hazard mosaics, the monitoring readings. |
| 🗺️ | Italy or one area | The whole country, or one of 19 regions, or a province that publishes on its own (Bolzano, Trento, the Sicilian ones). Shapefile or GeoJSON. |
| 💾 | A folder that stays | Files go to a persistent folder and are unpacked next to themselves, ready for GeoPandas or QGIS. |
| 🔁 | Download once, then only what changed | Each file remembers its ETag and Last-Modified. The next run asks "changed?" and gets a 304 for a few hundred bytes. |
| 🛡️ | Never half a file | Downloads stream to .part and are renamed when complete; ZIPs unpack beside the old folder and are swapped in whole. Memory stays flat even for 1.2 GB. |
| 📊 | The REST API | Landslide counts and hazard and risk indicators for Italy, macro-regions, regions, provinces, municipalities; every municipality at once, kept on disk; full landslide records; monitoring sites and webcams; code lists; paged searches. |
| 🔄 | Retries built in | Network errors, 429 and 5xx are retried with exponential backoff. Bring your own httpx.AsyncClient if you want to tune it. |
| 🧪 | Pure functions too | URL builders, request builders and parsers have no network, for your own HTTP stack. |
One dependency: httpx.
Who is it for
- Researchers and students in geology, hydrology, civil engineering and remote sensing who want IFFI or PAI in a notebook.
- GIS analysts and data engineers who keep a copy of the national layers and want it refreshed every night without downloading 3 GB every night.
- Civil protection, planning and infrastructure teams who need the risk figures of a town, a province or a region, from a script.
- Journalists and civic-tech projects telling the story of landslides and floods in Italy with numbers you can check.
It started inside Limen, a multi-hazard risk monitor for Italy, where IFFI landslides and PAI hazard are inputs of a landslide-risk model, and was extracted so that anyone can use it.
Quick start
1. Install
pip install ispra-idrogeo # downloads, API, the idrogeo command
pip install "ispra-idrogeo[geo]" # + GeoDataFrames and GeoParquet (geopandas, pyarrow)
Python 3.12 or newer. No account and no API key: IdroGEO open data is public.
2. Download your first layer
import asyncio
from ispra_idrogeo import IdroGEO, opendata
async def main() -> None:
async with IdroGEO() as idrogeo:
folder = await idrogeo.fetch(opendata("frane_piff", "molise"))
print(folder) # ~/.cache/ispra-idrogeo/frane_piff_molise_opendata
print(sorted(p.name for p in folder.iterdir()))
# ['frane_piff_opendataPoint.cst', '….dbf', '….prj', '….shp', '….shx']
asyncio.run(main())
That's it: a shapefile with the 23,949 landslides mapped in Molise, one point
each. Run it again and it costs one 304 Not Modified.
3. Look at it
With pip install "ispra-idrogeo[geo]" matplotlib:
from ispra_idrogeo.geo import add_labels, read
labels = await idrogeo.code_labels(["tipo_movim"]) # inside the `async with` above
landslides = add_labels(read(folder), labels) # the folder from step 2
print(landslides.crs) # EPSG:32632 (WGS 84 / UTM 32N)
print(landslides["tipo_movim_label"].value_counts().head(3))
# slow earth flow 9168
# complex 7606
# rotational/translational slide 4279
landslides.plot(column="tipo_movim_label", markersize=1, legend=True, figsize=(10, 8))
landslides.to_parquet("molise.parquet") # GeoParquet, for DuckDB, QGIS, BigQuery…
Each point carries the landslide's IFFI id (id_frana), its type of movement
(tipo_movim, nome_tipo), and the region, province, municipality and river
basin district it falls in.
3b. Or from the command line
idrogeo fetch frane_piff --area molise # prints the unpacked folder
idrogeo summary pir comuni 72006 | jq .popidp2_p # 1.278
idrogeo municipalities --max-age-days 30 > comuni.jsonl
idrogeo landslides --since 2026-11-01T00:00:00Z --select '*' > changed.jsonl
idrogeo landslides --municipality 65135 # Sarno's 53 landslides
idrogeo sites --filter stato_monitoraggio=eq.1 --select uid,point
idrogeo labels tipo_movimento --lang it
idrogeo hazard 40.829 14.617 # P4, Sarno, 12.3°
Downloads print the path they wrote, with a progress bar on stderr when it is
a terminal (--progress / --no-progress to choose); everything else prints JSON, one object
per line for lists (JSON Lines), ready for jq, a file or a database loader.
idrogeo --help and idrogeo <command> --help list every option. An error
prints one line on stderr and exits with status 1.
4. Ask the API
async with IdroGEO() as idrogeo:
bari = await idrogeo.summary("pir", "comuni", 72006) # ISTAT code of Bari
print(bari["nome"], bari["pop_res021"], bari["popidp2_p"])
# Bari 315948 1.278 → 1.3 % of residents in flood hazard P2
What you can download
| You write | What it is | Italy, zipped |
|---|---|---|
opendata("frane_piff") |
IFFI landslide identification points, one per landslide | 38 MB |
opendata("frane_poly") |
IFFI landslide polygons (the outline of each landslide) | 573 MB |
opendata("frane_line") |
IFFI landslides too narrow for a polygon | 18 MB |
opendata("aree_poly") |
areas of diffuse landslides (many small ones together) | 236 MB |
opendata("dgpv_poly") |
deep-seated gravitational slope deformations | 4 MB |
opendata("eventi") |
dated landslide events (points) | 1 MB |
opendata("segnalazioni") |
reports | small |
mosaic("pai_frane") |
PAI landslide hazard mosaic 2020-2021 | 1.2 GB |
mosaic("idraulica") |
flood hazard mosaic 2020 | 0.75 GB |
sensor_data() |
monitoring network readings, CSV (bzip2) |
Every opendata layer comes:
- for Italy (
area="ispra", the default), for one region (REGIONS, by ISTAT code:REGIONS[16] == "puglia") or for a province that publishes on its own (PROVINCES: Bolzano, Trento, the nine Sicilian ones); - as a shapefile (default) or GeoJSON (
fmt="geojson").
opendata("frane_poly", "toscana") # one region, shapefile
opendata("frane_piff", "trento", "geojson") # one autonomous province, GeoJSON
opendata("eventi") # all of Italy
Coordinates are in EPSG:32632 (WGS 84 / UTM 32N), for every area.
Where downloads are saved
In a folder that should survive restarts, because the national files weigh hundreds of MB:
- the
directoryyou pass tofetchordownload; - otherwise
IDROGEO_DATA_DIR, if set; - otherwise
~/.cache/ispra-idrogeo(or$XDG_CACHE_HOME/ispra-idrogeo).
~/.cache/ispra-idrogeo/
├── frane_piff_ispra_opendata.zip # the download, kept to revalidate it
├── frane_piff_ispra_opendata.zip.meta.json # URL, ETag, Last-Modified
└── frane_piff_ispra_opendata/ # unpacked, ready to read
├── frane_piff_opendataPoint.shp
└── …
In Docker, point it at a volume so the data survives the container:
services:
app:
environment:
IDROGEO_DATA_DIR: /data/idrogeo
volumes:
- idrogeo-data:/data/idrogeo
volumes:
idrogeo-data:
How it works
Download once, then only what changed
ISPRA rebuilds the inventory files every day
and sends an ETag and a Last-Modified with each one. The library
saves them next to the file and sends them back the next time: the server
answers 304 Not Modified if the copy you have is still current.
sequenceDiagram
autonumber
participant You as Your script
participant C as IdroGEO client
participant D as Download folder
participant S as idrogeo.isprambiente.it
You->>C: fetch(opendata("frane_poly"))
C->>D: read frane_poly_….zip.meta.json
D-->>C: nothing yet
C->>S: GET /opendata/frane/frane_poly_ispra_opendata.zip
S-->>C: 200, ~570 MB, ETag "abc", Last-Modified …
C->>D: stream to .part → rename, save ETag, unpack
C-->>You: Path to the folder
Note over You,S: the next day
You->>C: fetch(opendata("frane_poly"))
C->>D: read meta.json → ETag "abc"
C->>S: GET … If-None-Match: "abc"
S-->>C: 304 Not Modified (no body)
C-->>You: same folder, nothing downloaded
What fetch decides
flowchart TD
start(["fetch(dataset, directory)"]) --> m{"file and meta.json<br/>on disk, same URL?"}
m -- no --> g["GET, no conditions"]
m -- yes --> cg["GET with If-None-Match<br/>and If-Modified-Since"]
g --> s{"answer"}
cg --> s
s -- 304 --> u{"unpacked folder<br/>exists?"}
s -- 200 --> w["stream to .part → rename<br/>save ETag / Last-Modified"]
s -- 404 --> e(["IdrogeoError:<br/>layer or area does not exist"])
s -- "429 / 5xx / network" --> r["wait 2 s, 4 s, 8 s…<br/>and try again"] --> s
w --> x["unpack into folder.part<br/>swap it in whole"]
u -- yes --> done(["return the folder"])
u -- no --> x
x --> done
A reader never finds half a file or half a shapefile, and a ZIP member that
would land outside its folder (../../etc/…) is refused.
The building blocks
The library has two layers. Use the high-level client, or call the pure functions with any HTTP library you like.
flowchart LR
subgraph yours["Your code"]
H["httpx.AsyncClient<br/>(timeouts, proxy, pooling)"]
end
subgraph lib["ispra_idrogeo"]
direction TB
CL["client.py<br/>IdroGEO · Download"]
SO["sources.py<br/>opendata · mosaic · sensor_data<br/>REGIONS · PROVINCES"]
AP["api.py<br/>summary_path · search_params · parse"]
CA["cache.py<br/>default_directory · validators · extract"]
ER["errors.py<br/>IdrogeoError"]
end
H --> CL
CL --> SO
CL --> AP
CL --> CA
sources.py,api.py: pure functions, no network. They build URLs and requests and read answers. Easy to test, easy to reuse.cache.py: the folder, the.meta.jsonvalidators and the safe unpacking.client.py:IdroGEOwires them to yourhttpx.AsyncClient.
Recipes
Keep a national copy fresh, every night
from ispra_idrogeo import LANDSLIDE_LAYERS, IdroGEO, mosaic, opendata
async with IdroGEO() as idrogeo:
for layer in LANDSLIDE_LAYERS:
got = await idrogeo.download(opendata(layer))
print(layer, "updated" if got.changed else "unchanged")
for name in ("pai_frane", "idraulica"):
await idrogeo.fetch(mosaic(name))
The first run downloads about 3 GB. Put it in a cron job: the following runs cost a few kilobytes, plus whatever ISPRA really changed.
Only what changed since last time
The nightly download above re-reads a whole file when anything in it changed. To keep your own copy (a database, a GeoParquet file) up to date, ask the API for the records changed since your last run instead:
from datetime import UTC, datetime
from ispra_idrogeo import IdroGEO
since = datetime(2026, 11, 1, tzinfo=UTC) # saved from your last run
async with IdroGEO() as idrogeo:
changed = [f async for f in idrogeo.iter_landslides(modified_since=since, select=["*"])]
print(len(changed)) # 20 landslides changed in two days, with their geometry
next_since = max(datetime.fromisoformat(f["modified"]) for f in changed)
- Save the largest
modifiedyou received and pass it next time. The filter is "at or after", so the last record comes back once more: write byid_frana(upsert) and it does no harm. select=["*"]brings every field and the geometry:geomin EPSG:32632,geom_polygon/geom_linestringwhen there is one,pointin lon/lat.iter_eventsandsitestakemodified_sincetoo.- A removed landslide does not show up as changed: it just stops being listed. Now and then, compare your ids with the full list, one call of about 15 seconds for 721,377 ids:
ids = {f["id_frana"] async for f in idrogeo.iter_landslides(select=["id_frana"], page_size=100_000)}
Watch for changes
idrogeo watch --once >> changes.jsonl # one round, e.g. every hour from cron
idrogeo watch --kind events --interval 900 # or keep running, every 15 minutes
Each line is a landslide, an event or a monitoring site that changed since
the previous round, with "kind" saying which. The first run starts from
now (or from --since); after that <dir>/watch.json remembers where each
kind stands, so a stopped watch resumes without missing or repeating a
change. In Python, ispra_idrogeo.watch.changes(idrogeo, kind, mark) does
one round.
Landslides per region (the map above)
from ispra_idrogeo import IdroGEO
async with IdroGEO() as idrogeo:
for region in await idrogeo.summary("iffi", "regioni"):
counts = await idrogeo.summary("iffi", "regioni", region["cod_reg"])
hazard = await idrogeo.summary("pir", "regioni", region["cod_reg"])
density = counts["frane_totali"] / hazard["ar_kmq"] * 100
print(f"{region['nome']:<22} {counts['frane_totali']:>8,} {density:5.0f} per 100 km²")
# Piemonte 37,786 149 per 100 km²
# Valle d'Aosta 5,812 178 per 100 km²
# Lombardia 141,970 595 per 100 km²
# …
summary("iffi", …) gives landslide counts (also by type, in
frane_stats); summary("pir", …) gives hazard and risk indicators: areas,
residents, families, buildings, businesses and cultural heritage in each
landslide (*_fr_*) and flood (*_idr_*) hazard class. Levels are
"italia", "macroregioni", "regioni", "province" and "comuni", by
ISTAT code.
Every municipality, in one table
from datetime import timedelta
from ispra_idrogeo import IdroGEO
async with IdroGEO() as idrogeo:
rows = await idrogeo.municipalities("pir", max_age=timedelta(days=30))
print(len(rows)) # 7899
worst = sorted(rows, key=lambda r: r["pop_fr_p3"] + r["pop_fr_p4"], reverse=True)[:3]
for r in worst:
print(r["nome"], r["pop_fr_p3"] + r["pop_fr_p4"], f"{r['popfrp3p4p']:.1f} %")
# Napoli 42390 4.6 % ← residents in landslide hazard P3 + P4, and their share
# Genova 30148 5.4 %
# Sanremo 14607 27.6 %
One call lists the municipalities, then one call each asks for its
indicators, 8 at a time: about a minute and 17 MB for all of Italy. Each
answer is saved as <directory>/pir_comuni/<ISTAT code>.json, so the next
run reads it back from disk (under two seconds), an interrupted run resumes
where it stopped, and max_age decides when an answer is old enough to ask
again. The indicators come from the 2020-2021 hazard mosaics and the 2021
census, so a monthly refresh is plenty.
municipalities("iffi") does the same with landslide counts, and
summaries(summary, "regioni" | "province" | "comuni") with any level.
Translate the codes
The data stores codes (tipo_movimento = 4); the API has 73 code lists that
say what they mean, in Italian, English, French and German. decode adds a
<field>_label next to every coded field it knows:
from ispra_idrogeo import IdroGEO, decode
async with IdroGEO() as idrogeo:
labels = await idrogeo.code_labels(lang="en") # one call per code list
record = decode(await idrogeo.landslide("0017680500"), labels)
print(record["tipo_movimento_label"], "·", record["stato_attivita_1liv_label"])
# rapid debris flow · active/reactivated/suspended
It works on monitoring sites too (strumentazione = [15, 22, 25] becomes
['inclinometer', 'topographic monitoring', 'Satellite SAR interferometry'])
and on the rows of a shapefile, where the field is called tipo_movim.
CODE_DICTIONARIES lists which code list decodes which field, and
labels("cause", "it") gives one code list as {code: label}. A code that
is not in its list becomes None rather than a guess.
Walk through every landslide or event
from datetime import date
from ispra_idrogeo import IdroGEO, event_filter, landslide_filter
async with IdroGEO() as idrogeo:
active_sinkholes = landslide_filter(movement=6, activity=100)
print(len([f async for f in idrogeo.iter_landslides(active_sinkholes)])) # 217
tuscany_2023 = event_filter(region=9, start=date(2023, 1, 1), end=date(2024, 1, 1))
async for event in idrogeo.iter_events(tuscany_2023): # 8 events
print(event["uid"], event["computed_date"], event["comune_nome"])
iter_landslides and iter_events ask for one page after another
(page_size, 1,000 by default) and stop at the last one. They sort by a
unique key (id_frana, uid) so pages do not overlap. An empty filter
matches everything; search_landslides and search_events return a single
page, if you prefer to page yourself.
For the landslides of an area, iter_landslides_in checks each record's
own region, province or municipality, because the search has no reliable
area filter (see the traps). It reads the whole inventory with a few fields,
about 20 seconds for Italy, and gives the exact counts (Campania 55,254,
Sarno 53):
async with IdroGEO() as idrogeo:
sarno = [f async for f in idrogeo.iter_landslides_in(municipality=65135)]
debris_flows = idrogeo.iter_landslides_in(region=15, filters=landslide_filter(movement=5))
landslide_filter, event_filter and site_filter build the filters, because each
search reads them its own way: landslides want {"tipo_movimento": 6} but
{"stato_attivita_1liv": "eq.100"}, events and sites want operators
everywhere, and a range of dates is an "and" of two conditions. Codes are
ISTAT codes for areas and the code lists' codes for the rest; a list of
codes means "any of these". You can still pass a plain dict if you know
the syntax.
One landslide, in full
async with IdroGEO() as idrogeo:
record = await idrogeo.landslide("0017680500")
print(len(record), record["stato"]) # 142 validato_ispra
# activations, causes, state and velocity, damage, interventions, precursors…
The full record of a validated landslide. Many fields are often empty, so
check before you rely on them. An unknown id raises IdrogeoError (IdroGEO
answers 401 to it, not 404).
API rows on a map
frame turns API rows into a GeoDataFrame in the right coordinate system:
from ispra_idrogeo.geo import frame
async with IdroGEO() as idrogeo:
sites = frame(await idrogeo.sites()) # "point": lon/lat, EPSG:4326
cams = frame(await idrogeo.webcams(), geometry="geom") # EPSG:3857, as IdroGEO sends it
changed = frame(
[f async for f in idrogeo.iter_landslides(modified_since=since, select=["*"])],
geometry="geom_polygon", # or "geom" (point, EPSG:32632), "geom_linestring"
)
Who is watching a landslide: monitoring sites and webcams
async with IdroGEO() as idrogeo:
active = await idrogeo.sites({"stato_monitoraggio": "eq.1"})
print(len(active)) # 558 of 1,063 sites are active
site = active[0]
print(site["uid"], site["point"], site["id_frane_correlati"])
# SIABR001 [14.03, 42.24] ['0680013800'] ← lon/lat, and its IFFI landslides
for station in await idrogeo.stations(site["uid"]):
print(station["uid"], station["strumentazione"])
cams = await idrogeo.webcams() # 14, all in Lombardy
A site is a place where an agency (often an ARPA) monitors a landslide.
It has stations, and each station has instruments (inclinometers,
piezometers, rain gauges…, decoded by the strumento_monitoraggio
dictionary). id_frane_correlati links 880 sites to their IFFI landslides,
so you can tell which landslides someone is already watching. Look a site up
by its uid ("SIPIE001"), not by its id.
Is this place at risk? Hazard at a point
async with IdroGEO() as idrogeo:
here = await idrogeo.hazard_at(40.829, 14.617) # the hills above Sarno
print(here.landslide, here.flood, here.municipality_name, here.slope_deg)
# P4 None Sarno 12.3
async with IdroGEO() as idrogeo:
venice = await idrogeo.hazard_at(45.438, 12.330)
print(venice.flood, venice.flood_scenarios) # P3 ('P3', 'P2', 'P1')
One request, about a second, to IdroGEO's public map service: no 1.2 GB
mosaic to download. You get the PAI landslide class (P4 very high,
P3 high, P2 medium, P1 moderate, AA attention area), the flood
scenarios that reach the point (P3 frequent floods, P2 every 100–200
years, P1 rare and extreme), the municipality (ISTAT code, ready for
summary("pir", "comuni", code)) and the slope in degrees.
lithology=True adds the rock type, but takes about 16 seconds more.
Landslides near a point
async with IdroGEO() as idrogeo:
near = await idrogeo.landslides_near(40.838, 14.630, radius_m=500)
for f in near[:3]:
print(f["id_frana"], f["nome_tipo"], f["nome_com"], f["distance_m"], f["shapes"])
# 0640689600 Colamento rapido Quindici 397.7 ['polygon', 'point'] ← a rapid debris flow
Every IFFI landslide within radius_m (up to 5 km), nearest first, with its
type and area, distance_m (0 when the point is on it) and the shapes it is
mapped as. The distance is computed from the real outline, not from a
centre point. Also: idrogeo hazard 40.829 14.617 and idrogeo near 40.838 14.630.
Geology, aerial photos, bibliography
async with IdroGEO() as idrogeo:
units = await idrogeo.lookup("formazioni", "argille", limit=2)
# [{'sigla': 'AAC', 'nome_unita': 'ARGILLE E ARENARIE GLAUCONITICHE DI CATENANUOVA', …}, …]
flights = await idrogeo.lookup("voli", "1954") # [{'id': '0001', 'nome': 'Volo GAI', …}]
The reference lists behind the landslide records, searched by text:
geological units (formazioni), aerial survey flights (voli),
bibliography (bibliografie), institutions (istituzioni), professional
bodies (ordini) and funding sources (finanziamenti). Ten answers by
default; limit asks for more. Also idrogeo lookup formazioni argille.
For AI agents: the MCP server
pip install "ispra-idrogeo[mcp]"
idrogeo-mcp is an MCP server, so Claude
Desktop, an IDE or your own agent can answer questions like "how many people
live in high landslide hazard areas in Sarno, and is anyone monitoring the
landslides there?" or "is this address in a flood zone?". For Claude Desktop, add it to
claude_desktop_config.json:
{
"mcpServers": {
"idrogeo": { "command": "idrogeo-mcp" }
}
}
As an always-on service, for an agent that talks HTTP (for example
mcp-geo-server's
chat and map), serve it over streamable HTTP at /mcp:
idrogeo-mcp --transport streamable-http --host 0.0.0.0 --port 8767
Every option also reads an environment variable, so a compose file needs no
command line, and GET /health answers {"status": "ok"}:
services:
idrogeo-mcp:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
command: uvx --from "ispra-idrogeo[mcp]>=0.6" idrogeo-mcp
environment:
IDROGEO_MCP_TRANSPORT: streamable-http
IDROGEO_MCP_HOST: 0.0.0.0
IDROGEO_MCP_PORT: "8767"
IDROGEO_DATA_DIR: /data/idrogeo # for download_dataset
volumes:
- idrogeo-data:/data/idrogeo
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8767/health')"]
On 127.0.0.1 the SDK refuses requests with a foreign Host header (DNS
rebinding protection); on 0.0.0.0 it does not, unless you list the names
clients use: IDROGEO_MCP_ALLOWED_HOSTS=idrogeo-mcp:*,localhost:*.
| Tool | What the agent gets |
|---|---|
hazard_at |
Landslide and flood hazard, municipality and slope of a point |
landslides_near |
The landslides around a point, nearest first |
find_area |
The ISTAT code of a region, province or municipality from its name |
area_risk |
Hazard and exposure: residents, buildings, heritage per hazard class |
area_landslides |
IFFI counts by type of movement, events, monitoring |
landslide |
One landslide in full, codes decoded |
search_landslides / search_events |
Landslides by type and activity; events by area and date |
monitoring_sites / webcams |
Who is watching, with which instruments |
code_list |
Any code list, in four languages |
download_dataset |
A layer downloaded (only if changed) to the local folder |
Every tool but download_dataset only reads. Searches stop at limit rows
(100 by default, 1,000 at most), so an agent never receives 700,000
landslides at once, and IdroGEO's refusals come back as readable errors.
Bring your own HTTP client
import httpx
from ispra_idrogeo import IdroGEO
http = httpx.AsyncClient(timeout=httpx.Timeout(30, read=600), proxy="http://proxy:3128")
async with IdroGEO(http, retries=0) as idrogeo: # your client, your retry policy
...
await http.aclose() # the client is yours: IdroGEO does not close it
Handle errors
import httpx
from ispra_idrogeo import IdrogeoError
try:
folder = await idrogeo.fetch(opendata("frane_piff", "puglia"))
except IdrogeoError as exc:
# IdroGEO answered, but not with what was asked: 404, API refusal, not JSON.
print("IdroGEO said no:", exc)
except httpx.HTTPError as exc:
# Network problems still failing after the retries.
print("network:", exc)
An unknown layer, area or mosaic name raises ValueError before any request.
Example notebooks
examples/ has three notebooks run on the live service, with
their outputs: a town and its landslides (Sarno, from exposure figures
to a map of its debris flows), Italy in numbers (who lives in hazard
areas, municipality by municipality) and what changed (following the
inventory day by day).
The traps we already fell into
IdroGEO works well, but its older documentation and many scripts on the web point to paths that no longer exist. This library handles these for you:
| You try | What actually happens | What ispra-idrogeo does |
|---|---|---|
/opendata/iffi/<region>/iffi_<region>_<layer>.zip, as in older guides |
HTTP 404 in 2026: the files moved | uses /opendata/frane/<layer>_<area>_opendata.zip |
the dictionaries in /opendata/iffi/dizionari/*.json |
HTTP 404: they moved to the API | reads /api/dizionari |
the WFS at geoserver/idrogeo/frane/ows |
HTTP 401: the WFS needs a login | reads features through the WMS, which is public: GetFeatureInfo on 101 queryable layers |
| asking the WMS about a point with a 1×1 image or the point as the box | nothing, or the wrong pixel | a ~2 m box around the point and its middle pixel (i=50, j=50), lat,lon order as WMS 1.3.0 wants |
| the lithology layer next to the hazard layers | ~16 s for the whole answer, against under one second without it | hazard_at(..., lithology=False) by default |
| the slope at sea | -9999.0 |
slope_deg = None |
a file for trentino-alto-adige |
there is none: it publishes per province | offers bolzano and trento in PROVINCES |
| downloading the inventory again every day "to be safe" | ~870 MB a day, whether anything changed or not | conditional GET: one 304 when nothing changed |
/api/evento/{id}, which the OpenAPI lists with no login |
HTTP 401 without a login (/api/frana/{id} does answer 200) |
reads events through search_events, which is public |
/api/frana/{id} with an id that does not exist |
HTTP 401 "Missing authorization header", not 404 | raises IdrogeoError: unknown or not public |
/api/sito/{id} with the site's id (a UUID) |
HTTP 404: the path wants the uid (SIPIE001); /listing of an unknown site answers null with HTTP 200 |
site(uid) and stations(uid), which raise IdrogeoError for both |
webcam geom as longitude and latitude |
it is EPSG:3857 metres, while sites use lon/lat | says so in webcams() |
a webcam's timelapse_url |
HTTP 404 or a redirect to the web app: the videos are not published | leaves the field as IdroGEO sends it |
{"tipo_movimento": "eq.6"} on landslides, like every other field |
invalid input syntax for type integer: this one field wants a plain 6, and no list |
landslide_filter(movement=6) |
landslides of a region, {"regione": "eq.16"} |
not an embedded resource; regione_2019, which does filter, is empty for 18% of the landslides, and regione_orig is worse (Campania: 19,710 and 6,224 of 55,254) |
iter_landslides_in(region=15) checks the record's own regione, which is always set: 55,254 |
| paging the whole inventory 100,000 at a time | each page slower than the last (2.5 s at offset 0, 6.7 s at 600,000), and id_frana takes no gt. for keyset paging |
one page of a million rows for area scans: ~20 s for Italy |
several landslides by id, {"id_frana": "in.(…)"} |
an empty list, no error | landslide(id) for each one |
| reading the shapefile while a new version is unpacking | half a shapefile, or a crash | unpacks beside it and swaps the folder in whole |
All checked against the live service on 3 November 2026.
API at a glance
| Name | What it does |
|---|---|
IdroGEO(client=None, *, retries=3, backoff_s=2.0) |
The async client; opens (and closes) its own HTTP client if not given one. |
.fetch(dataset, directory=None, *, unpack=True) |
The dataset ready to read: the unpacked folder, or the file. |
.download(dataset, directory=None) |
Download(path, changed): the file, and whether this call changed it. |
.summary(summary, level, code=None) |
"iffi" counts or "pir" indicators; without code, the list of that level. |
.dictionaries() / .dictionary(name) |
The names of the code lists / one list. |
.landslide(id_frana) |
The full record of one validated landslide. |
.iter_landslides(filters, modified_since=, page_size=1000, select=, order="id_frana") |
Every matching IFFI landslide, page after page; only the changed ones with modified_since. |
.iter_events(filters, modified_since=, page_size=1000, select=, order="uid") |
Every matching landslide event, page after page. |
.sites(filters, modified_since=, select=) |
The monitoring sites (1,063). |
.site(uid) / .stations(uid) |
One site / its stations, instruments and sensors. |
.webcams(filters) |
The webcams pointed at landslides. |
.iter_landslides_in(region=, province=, municipality=, filters=, select=) |
Every landslide of an area, checked on the record's own area fields. |
.search_landslides(filters, limit=, offset=, select=, order=) |
One page of IFFI landslides. |
.search_events(filters, limit=, offset=, select=, order=) |
One page of validated landslide events. |
.municipalities(summary="pir", directory=None, *, concurrency=8, max_age=None) |
The summary of all 7,899 municipalities, saved one JSON per municipality. |
.summaries(summary, level, directory=None, *, concurrency=8, max_age=None) |
The same for every region, province or municipality. |
opendata(layer, area="ispra", fmt="shapefile") |
An open-data Dataset (URL + file name). |
mosaic("pai_frane" | "idraulica") |
A national hazard mosaic. |
sensor_data() |
The monitoring network readings. |
REGIONS / PROVINCES / region_area(cod_reg) |
Area file names by ISTAT code. |
LANDSLIDE_LAYERS / EVENT_LAYERS / MOSAICS |
What there is. |
default_directory() |
Where downloads go when no directory is given. |
.hazard_at(lat, lon, lithology=False) |
PointHazard: landslide class, flood scenarios, municipality, slope. |
.landslides_near(lat, lon, radius_m=500, limit=200) |
IFFI landslides around a point, with distance_m. |
.lookup(kind, text, limit=10) |
Search geological units, flights, bibliography, institutions, funding. |
IdrogeoError |
IdroGEO answered, but not with what was asked for. |
.labels(name, lang) / .code_labels(fields, lang) / decode(record, labels) |
Code lists, and records with <field>_label added. |
ispra_idrogeo.geo.read(path) |
A downloaded file or folder as a GeoDataFrame (extra [geo]). |
ispra_idrogeo.geo.frame(rows, geometry="point") |
API rows as a GeoDataFrame, in their own CRS. |
ispra_idrogeo.geo.add_labels(frame, labels) |
<column>_label columns for coded columns. |
.download / .fetch(..., progress=callback) |
callback(done, total) after each chunk, for your own progress bar. |
ispra_idrogeo.watch.changes(idrogeo, kind, mark) |
One round of idrogeo watch: what changed since a Mark. |
idrogeo-mcp |
An MCP server with the same data as 12 tools (extra [mcp]). |
idrogeo <command> |
The same from the shell: fetch, summary, municipalities, landslides, events, sites, webcams, landslide, labels, dictionaries, lookup, hazard, near, watch. |
Lower level, for your own HTTP layer: summary_path, search_params,
level_codes, since_filter, parse / parse_list, read_validators / write_validators,
read_json / write_json, extract.
Contributing
Contributions of every size are welcome, from a typo to a new feature. You don't need to be a geologist: most of the code is plain Python, HTTP and files.
git clone https://github.com/agent-engineering-studio/ispra-idrogeo
cd ispra-idrogeo
uv sync # install everything, dev tools included
make check # ruff + mypy --strict + pytest, the same gate as CI
The tests need no network: they run against a mocked transport
(httpx.MockTransport), so they are fast and never hit ISPRA's servers.
ispra-idrogeo/
├── src/ispra_idrogeo/
│ ├── sources.py # where the open data lives: pure URL builders
│ ├── api.py # REST API requests and parsers
│ ├── cache.py # the persistent folder, ETag/Last-Modified, safe unzip
│ ├── client.py # IdroGEO: async client, retries, streaming
│ └── errors.py # IdrogeoError
├── docs/
│ ├── make_figures.py # redraws docs/images/ from the live API
│ └── images/ # the figures of this README
└── tests/
How to propose a change:
flowchart LR
A["Open an issue<br/>or pick one"] --> B["Fork + branch"]
B --> C["Code + tests"]
C --> D["make check ✅"]
D --> E["Pull request<br/>Conventional Commits"]
E --> F["Review & merge 🎉"]
Rules are short: see CONTRIBUTING.md. Commits follow Conventional Commits, coverage stays above 90%, and contributions are accepted under Apache-2.0.
Ideas to work on
Not promises, just directions where help would make a difference. Open an issue to discuss one before starting.
| Idea | Why it matters | Good for |
|---|---|---|
| 🧠 More MCP tools (hazard at a point, nearest landslides) | Agents that answer "is my house at risk?" | intermediate, AI users |
| 🧩 A GeoServer bootstrap that publishes the downloads, for mcp-geo-server | From open data to a live map server in one command | advanced |
| 📚 Documentation site | Search, API reference, tutorials | anyone |
Found something odd in IdroGEO? That is a contribution too: open an issue with what you sent and what came back.
Data licence and credits
Unless stated otherwise, ISPRA publishes its data under CC BY 4.0 (note legali). Anything you publish from it must credit the source, for example:
Source: ISPRA — IdroGEO, Italian landslide inventory (IFFI) and national hazard mosaics.
This library only downloads the data. The figures in this README are drawn
from IdroGEO API answers of 3 November 2026 (redraw them with
uv run python docs/make_figures.py); the region map is a schematic tile
map, not a geographic one.
Code: Apache-2.0. Extracted from Limen.
If this project is useful to you, a ⭐ on GitHub helps others find it.
Metadata
Release files for ispra-idrogeo 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ispra_idrogeo-0.6.0.tar.gz | 66.6 kB | With provenance | Details for ispra_idrogeo-0.6.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ispra_idrogeo-0.6.0-py3-none-any.whl | Python 3 | none | any | Details for ispra_idrogeo-0.6.0-py3-none-any.whl |
Total release size: 124.1 kB
Release files / ispra_idrogeo-0.6.0.tar.gz
| Download URL | ispra_idrogeo-0.6.0.tar.gz |
|---|---|
| Size | 66.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e583c2bc140485c7d919174e0ef779c1da456703cb02830ab5e84fd68f4fc9c3
|
|
BLAKE2b-256 checksum How to use checksums |
004158c0937a5d5ab94478f8753b1210691630347314314e74be00ed8dc3e25f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logRelease files / ispra_idrogeo-0.6.0-py3-none-any.whl
| Download URL | ispra_idrogeo-0.6.0-py3-none-any.whl |
|---|---|
| Size | 57.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0435923af5e6e10e6cc637deb5bd07e7b9736d6c39090a08b4589f9ede32e12f
|
|
BLAKE2b-256 checksum How to use checksums |
cc1cb49996154c0175ef7ae6d2d18b7c8e167c6d8f043f573a9bf35327873ed2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logSecurity for ispra-idrogeo 0.6.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for ispra_idrogeo-0.6.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.