Guides · Tutoriel

Analyser DVF avec pandas : tutoriel Python complet.

Téléchargement, nettoyage, calcul du prix médian au m² et visualisation. Tout le pipeline en moins de 50 lignes.

< 50lignes de codepipeline complet
6,5Mtransactions DVFanalysables
LicenceÉtalab 2.0réutilisation libre
pandas+ requestsdépendances légères

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é.

1 Prérequis et installation

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
Note sur tqdm : tqdm n'est pas indispensable, mais il affiche une barre de progression lors du téléchargement des fichiers volumineux. Fortement conseillé pour le fichier national (~1.2 Go compressé).

2 Télécharger DVF

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")
Taille des fichiers : le fichier national 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.

3 Explorer les données

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 :

ColonneTypeDescription
date_mutationdateDate d'acte notarié
valeur_foncierefloatPrix de vente total en euros
surface_reelle_batifloatSurface habitable déclarée en m²
type_localcategoryMaison / Appartement / Local industriel…
nature_mutationcategoryVente / VEFA / Échange…
code_communestrCode INSEE 5 caractères
longitudefloatCoordonnée WGS84
latitudefloatCoordonné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())

4 Nettoyer et filtrer

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)
Pourquoi exclure les VEFA ? Les ventes en l'état futur d'achèvement (programmes neufs) affichent un prix au m² systématiquement supérieur à l'ancien — souvent de 20 à 40 %. Mélanger neuf et ancien fausse toute comparaison temporelle ou géographique. Si votre analyse porte sur le neuf, inversez le filtre : nature_mutation == "Vente en l'état futur d'achèvement".

5 Prix médian par commune

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)
Seuil minimum de transactions : une médiane calculée sur 2 ou 3 ventes n'est pas statistiquement significative. Pour une analyse sérieuse, montez à 20 pour les communes rurales et utilisez une fenêtre glissante de 24 mois plutôt qu'un millésime annuel unique.

6 Visualisation

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")

Vue d'ensemble du pipeline

URL geo-dvf (data.gouv.fr) | v requests.get() + stream [full.csv.gz / {dept}.csv.gz] | v pd.read_csv(dtype=..., parse_dates=...) [DataFrame brut — ~800k lignes/an national] | v nature_mutation == "Vente" type_local in [Appartement, Maison] surface entre 9 et 500 m² valeur_fonciere > 0 | outliers 3-sigma [DataFrame nettoyé — ~65–70% des lignes] | v df["prix_m2"] = valeur_fonciere / surface_reelle_bati [Colonne prix_m2 calculée] | v groupby("code_commune").agg(median, count, p25, p75) [DataFrame agrégé — une ligne par commune] | v matplotlib / folium / CSV QGIS [Visualisation ou export]

Jointure IRIS avec GeoPandas

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é.

Aller plus loin avec les données DVF

Ce tutoriel couvre l'essentiel. Pour exploiter DVF sans écrire de code :

Questions fréquentes

Quelle version de Python faut-il pour analyser DVF ?

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.

Combien de temps prend le téléchargement du fichier DVF national ?

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.

Comment filtrer DVF pour n'obtenir que les appartements ?

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.

DVF permet-il d'obtenir des prix à l'échelle IRIS ?

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.

Ressources liées

Lire les comparables DVF Comprendre les données DVF API DVF