Téléchargement, nettoyage, calcul du prix médian au m² et visualisation. Tout le pipeline en moins de 50 lignes.
DVF — Demandes de Valeurs Foncières — est le registre officiel des transactions immobilières françaises, publié par la DGFiP sous licence Étalab 2.0. Il recense plus de 6,5 millions de mutations depuis 2014, avec le prix réel acté, la surface, le type de bien et les coordonnées géographiques. C'est la seule source publique donnant accès aux prix de vente réels — et non aux prix d'annonce — sur l'ensemble du territoire.
pandas est la bibliothèque Python de référence pour manipuler des données tabulaires. Chargement de CSV volumineux, filtrage, agrégation, calcul de statistiques : toutes les opérations utiles sur DVF s'expriment en quelques lignes. Ce tutoriel couvre le pipeline complet, de l'URL de téléchargement jusqu'au prix médian par commune, avec chaque étape expliquée et le code commenté.
Python 3.9 minimum est requis. Les versions 3.10, 3.11 et 3.12 fonctionnent parfaitement. La seule dépendance obligatoire est pandas dans sa version 2.0 ou supérieure — l'API de gestion des types a changé entre la 1.x et la 2.x, et ce tutoriel utilise les conventions actuelles. requests sert au téléchargement du fichier. matplotlib est optionnel, uniquement pour la section visualisation.
# Installation dans un environnement virtuel (recommandé)
python -m venv .venv
source .venv/bin/activate # Linux / macOS
# .venv\Scripts\activate # Windows PowerShell
pip install "pandas>=2.0" requests matplotlib tqdm
Les fichiers DVF géolocalisés (version Étalab, format CSV gzip) sont hébergés sur files.data.gouv.fr. Il existe deux niveaux de granularité : le fichier national complet et les fichiers par département. Pour une analyse locale, le fichier départemental est largement suffisant et beaucoup plus rapide à charger.
import requests
from pathlib import Path
from tqdm import tqdm
def download_dvf(year: int = 2024, departement: str = None, dest_dir: str = ".") -> Path:
"""
Télécharge un fichier DVF depuis files.data.gouv.fr.
Args:
year: Millésime DVF (2019 à 2024).
departement: Code département sur 2 ou 3 chiffres (ex: '75', '69', '974').
Si None, télécharge le fichier national complet.
dest_dir: Répertoire de destination.
Returns:
Chemin local du fichier téléchargé.
"""
base = "https://files.data.gouv.fr/geo-dvf/latest/csv"
if departement:
url = f"{base}/{year}/departements/{departement}.csv.gz"
filename = f"dvf_{year}_{departement}.csv.gz"
else:
url = f"{base}/{year}/full.csv.gz"
filename = f"dvf_{year}_full.csv.gz"
dest = Path(dest_dir) / filename
if dest.exists():
print(f"Fichier déjà présent : {dest}")
return dest
print(f"Téléchargement : {url}")
response = requests.get(url, stream=True)
response.raise_for_status()
total = int(response.headers.get("content-length", 0))
with open(dest, "wb") as f, tqdm(
total=total, unit="B", unit_scale=True, desc=filename
) as bar:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
bar.update(len(chunk))
print(f"Téléchargé : {dest} ({dest.stat().st_size / 1e6:.1f} Mo)")
return dest
# Exemple : département du Rhône (69)
chemin = download_dvf(year=2024, departement="69")
full.csv.gz pèse environ 1,2 Go compressé (6 Go décompressé). Les fichiers par département varient de 5 Mo (territoires peu peuplés) à 200 Mo (Île-de-France). Pour la plupart des analyses locales, travaillez toujours par département.
pandas lit nativement les CSV gzippés sans décompression préalable. Les colonnes DVF ont des types hétérogènes — la version géolocalisée Étalab utilise le point comme séparateur décimal, ce qui simplifie le chargement.
import pandas as pd
# Spécifier les types à la lecture accélère le chargement
# et évite les inférences incorrectes sur les gros fichiers
dtype_map = {
"code_commune": str,
"code_postal": str,
"type_local": "category",
"nature_mutation": "category",
"nombre_pieces_principales": "Int64", # nullable pour gérer les NaN
}
df = pd.read_csv(
chemin,
sep=",",
dtype=dtype_map,
parse_dates=["date_mutation"],
low_memory=False,
)
print(f"Lignes chargées : {len(df):,}")
print(f"Colonnes : {df.shape[1]}")
df.info(memory_usage="deep")
Les colonnes clés pour une analyse de prix immobilier résidentiel :
| Colonne | Type | Description |
|---|---|---|
date_mutation | date | Date d'acte notarié |
valeur_fonciere | float | Prix de vente total en euros |
surface_reelle_bati | float | Surface habitable déclarée en m² |
type_local | category | Maison / Appartement / Local industriel… |
nature_mutation | category | Vente / VEFA / Échange… |
code_commune | str | Code INSEE 5 caractères |
longitude | float | Coordonnée WGS84 |
latitude | float | Coordonnée WGS84 |
# Distribution par type de local
print(df["type_local"].value_counts())
# Vérifier les valeurs manquantes sur les colonnes critiques
cols_critiques = ["valeur_fonciere", "surface_reelle_bati", "type_local", "code_commune"]
print(df[cols_critiques].isna().sum())
DVF brut contient des lignes inutilisables pour une analyse de prix résidentiel : locaux commerciaux, terrains, ventes groupées, surfaces aberrantes, prix nuls. Chaque filtre ci-dessous est justifié — ne pas les appliquer tous conduit à des prix médians faussés.
def nettoyer_dvf(df: pd.DataFrame) -> pd.DataFrame:
"""Filtre et nettoie un DataFrame DVF pour l'analyse de prix résidentiel."""
# 1. Ventes simples uniquement (pas VEFA, pas échanges)
mask = df["nature_mutation"] == "Vente"
# 2. Résidentiel uniquement
mask &= df["type_local"].isin(["Appartement", "Maison"])
# 3. Surface cohérente (9 m² minimum, 500 m² max)
mask &= df["surface_reelle_bati"].between(9, 500)
# 4. Prix positif et non nul
mask &= df["valeur_fonciere"] > 0
# 5. Code commune présent
mask &= df["code_commune"].notna()
df_clean = df[mask].copy()
# Calcul du prix au m²
df_clean["prix_m2"] = df_clean["valeur_fonciere"] / df_clean["surface_reelle_bati"]
# 6. Éliminer les outliers par commune (règle des 3 sigma)
stats = df_clean.groupby("code_commune")["prix_m2"].agg(["mean", "std"])
df_clean = df_clean.join(stats, on="code_commune", rsuffix="_comm")
df_clean = df_clean[
(df_clean["prix_m2"] >= df_clean["mean"] - 3 * df_clean["std"]) &
(df_clean["prix_m2"] <= df_clean["mean"] + 3 * df_clean["std"])
].drop(columns=["mean", "std"])
print(f"Lignes après nettoyage : {len(df_clean):,} "
f"({len(df_clean)/len(df)*100:.1f}% retenues)")
return df_clean
df_clean = nettoyer_dvf(df)
nature_mutation == "Vente en l'état futur d'achèvement".
La médiane est la statistique adaptée aux prix immobiliers. Elle est robuste aux valeurs extrêmes — un bien de prestige ne tire pas la médiane vers le haut comme il le ferait avec une moyenne. 50 % des transactions sont en dessous, 50 % au-dessus.
def prix_median_par_commune(df_clean: pd.DataFrame, nb_min: int = 5) -> pd.DataFrame:
"""
Calcule le prix médian au m² par commune.
Args:
df_clean: DataFrame nettoyé avec colonne prix_m2.
nb_min: Seuil minimum de transactions (médianes non significatives sous ce seuil).
"""
result = (
df_clean
.groupby("code_commune")
.agg(
prix_m2_median=("prix_m2", "median"),
nb_transactions=("valeur_fonciere", "count"),
prix_m2_p25=("prix_m2", lambda x: x.quantile(0.25)),
prix_m2_p75=("prix_m2", lambda x: x.quantile(0.75)),
)
.reset_index()
)
result = result[result["nb_transactions"] >= nb_min]
for col in ["prix_m2_median", "prix_m2_p25", "prix_m2_p75"]:
result[col] = result[col].round(0).astype(int)
return result.sort_values("prix_m2_median", ascending=False)
medians = prix_median_par_commune(df_clean, nb_min=5)
print(medians.head(20).to_string(index=False))
# Exporter en CSV
medians.to_csv("dvf_prix_median_communes.csv", index=False)
Deux approches selon l'usage : matplotlib pour une visualisation rapide en script, folium pour une carte interactive exportable en HTML.
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
def plot_distribution_prix(df_clean: pd.DataFrame, commune: str, label: str = "") -> None:
"""Histogramme de distribution des prix/m² pour une commune donnée."""
data = df_clean[df_clean["code_commune"] == commune]["prix_m2"]
fig, ax = plt.subplots(figsize=(9, 4))
ax.hist(data, bins=40, color="#1a1a1a", alpha=0.8, edgecolor="white", linewidth=0.4)
ax.axvline(data.median(), color="#f5a623", linewidth=2,
label=f"Médiane : {data.median():,.0f} €/m²")
ax.axvline(data.mean(), color="#e74c3c", linewidth=1.5, linestyle="--",
label=f"Moyenne : {data.mean():,.0f} €/m²")
ax.set_xlabel("Prix au m² (€)", fontsize=11)
ax.set_ylabel("Nombre de transactions", fontsize=11)
ax.set_title(f"Distribution des prix/m² — {label or commune}", fontsize=13, fontweight="bold")
ax.xaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x:,.0f} €"))
ax.legend()
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout()
plt.savefig(f"distribution_{commune}.png", dpi=150)
plt.show()
plot_distribution_prix(df_clean, commune="69123", label="Lyon")
# Carte choroplèthe avec folium (pip install folium)
import folium
m = folium.Map(location=[46.5, 2.5], zoom_start=6, tiles="CartoDB positron")
folium.Choropleth(
geo_data="communes.geojson", # GeoJSON communes France (data.gouv.fr)
data=medians,
columns=["code_commune", "prix_m2_median"],
key_on="feature.properties.code",
fill_color="YlOrRd",
fill_opacity=0.75,
line_opacity=0.3,
legend_name="Prix médian au m² (€)",
nan_fill_color="lightgray",
).add_to(m)
m.save("carte_prix_dvf.html")
DVF contient les colonnes longitude et latitude (WGS84) mais pas de code IRIS. Pour descendre au niveau infra-communal, il faut une jointure spatiale avec le découpage IRIS de l'INSEE.
# pip install geopandas
import geopandas as gpd
# Charger le contour IRIS (data.gouv.fr / IGN — fichier GeoPackage CONTOURS-IRIS)
iris_gdf = gpd.read_file("CONTOURS-IRIS.gpkg", layer="IRIS_GE").to_crs(epsg=4326)
# Convertir DVF en GeoDataFrame
dvf_gdf = gpd.GeoDataFrame(
df_clean,
geometry=gpd.points_from_xy(df_clean["longitude"], df_clean["latitude"]),
crs="EPSG:4326"
).dropna(subset=["longitude", "latitude"])
# Jointure spatiale : chaque transaction hérite du code IRIS qui la contient
dvf_iris = gpd.sjoin(dvf_gdf, iris_gdf[["CODE_IRIS", "geometry"]], how="left", predicate="within")
medians_iris = (
dvf_iris.groupby("CODE_IRIS")
.agg(prix_m2_median=("prix_m2", "median"), nb_transactions=("prix_m2", "count"))
.reset_index()
)
print(medians_iris.head())
Cette jointure est coûteuse en mémoire sur le fichier national. Privilégiez le traitement par département, ou utilisez directement l'API Intent Analytics qui retourne le code_iris et le prix médian IRIS précalculé.
Ce tutoriel couvre l'essentiel. Pour exploiter DVF sans écrire de code :
Python 3.9 ou supérieur. Les bibliothèques nécessaires sont pandas (≥ 2.0), requests et optionnellement matplotlib. Installez-les via pip install pandas requests matplotlib.
Le fichier DVF national complet (full.csv.gz, environ 1,2 Go compressé) prend 5 à 15 minutes selon la connexion. Il est recommandé de travailler par département (fichiers de 5 à 200 Mo) pour les analyses locales.
Filtrez sur la colonne type_local == 'Appartement' et sur la colonne nature_mutation == 'Vente'. Ajoutez valeur_fonciere > 0 et surface_reelle_bati entre 9 et 500 pour éliminer les valeurs aberrantes.
Directement, non : DVF contient des coordonnées géographiques mais pas de code IRIS. Il faut croiser les coordonnées avec le découpage IRIS via une jointure spatiale (GeoPandas + fichier IRIS INSEE), ou utiliser l'API Intent Analytics qui retourne directement le code_iris.