Italian Temperatures, 2006–2023: A Climate Data Story

From a wide ISTAT workbook to tidy data, trend estimates, and matplotlib charts styled to match the blog for Italian provincial capitals.
data science
python
climate
data visualization
Author

Federico Viscioletti

Published

July 2, 2026

Introduction

Italy compresses alpine, continental, and Mediterranean climates into one narrow peninsula. That makes it a good place to study how warming and rainfall shifts play out across very different local environments.

In this post I use the updated ISTAT meteoclimatic workbook for provincial capitals to answer a few simple questions:

  • How quickly are Italian provincial capitals warming?
  • Which places are hottest on average?
  • Is precipitation moving in the same direction?
  • What did 2023 look like relative to recent and historical baselines?

The source workbook is rich, but awkwardly structured. As often happens with official statistics, the hardest part is not plotting the charts — it is turning a human-readable Excel layout into tidy data.

A Note for Aspiring Data Scientists

One of the most powerful skills a data scientist can develop is the ability to turn raw data into clear, actionable insights. National statistical bodies like ISTAT in Italy, Eurostat, or the US Census Bureau provide a wealth of free, high-quality datasets to experiment and gain insights from.

As you’ll see in this analysis, the technical barrier is actually quite low. Once you learn how to handle structured workbooks and perform basic Exploratory Data Analysis (EDA), you can uncover stories about the fields you are passionate about—from climate change to economics or social trends. I hope this notebook serves as a roadmap for your own discovery process.

This article rebuilds the original notebook-style analysis using the updated ISTAT workbook for provincial capitals, covering annual temperature and precipitation from 2006 to 2023, plus anomaly tables for 2023.

If you like this work, please give a star to the related Github repo by following this link

Loading the workbook

The key sheets for the core analysis are:

  • Tavola_1: annual mean temperature by provincial capital, 2006–2023
  • Tavola_2: annual precipitation by provincial capital, 2006–2023
  • Tavola_3: 2023 temperature anomalies versus 2006–2015 and climatological normals
import matplotlib
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio
from pathlib import Path

pio.renderers.default = "notebook_connected"
pio.renderers["notebook_connected"].include_plotlyjs = False

FILE_PATH = (
    "Tavole-_Dati-Meteoclimatici_Capoluoghi-Provincia_Anno-2023-e-serie-2006-2023.xlsx"
)

# --- Blog design system palette (see design-system.html) -----------------
BLUE = "#2780e3"
CHARCOAL = "#343a40"
INK = "#1e1e1e"
TEXT = "#343a40"
MUTED = "rgba(52, 58, 64, 0.72)"
MUTED_HEX = "#6c757d"
LINE = "#dee2e6"
SOFT = "#f8f9fa"
SOFT_BLUE = "#d4e6f9"
CODE = "#7d12ba"

matplotlib.rcParams.update(
    {
        "font.family": "sans-serif",
        "font.size": 12,
        "text.color": TEXT,
        "axes.labelcolor": TEXT,
        "axes.edgecolor": LINE,
        "axes.titlecolor": TEXT,
        "axes.titleweight": "normal",
        "axes.titlesize": 16,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.grid": True,
        "grid.color": LINE,
        "grid.linewidth": 0.8,
        "grid.linestyle": "-",
        "axes.facecolor": "#ffffff",
        "figure.facecolor": "#ffffff",
        "xtick.color": MUTED_HEX,
        "ytick.color": MUTED_HEX,
        "axes.linewidth": 1.0,
        "legend.frameon": False,
        "legend.fontsize": 11,
        "figure.dpi": 130,
    }
)

xls = pd.ExcelFile(FILE_PATH)
xls.sheet_names
['Indice',
 'Tavola_1',
 'Tavola_2',
 'Tavola_3',
 'Tavola_4',
 'Tavola_5',
 'Tavola_6',
 'Tavola_7',
 'Tavola_8',
 'Tavola_9',
 'Tavola_10',
 'Tavola_11',
 'Tavola_12',
 'Tavola_13',
 'Tavola_14',
 'Tavola_14 segue',
 'Tavola_15']

From wide to tidy

The annual tables are stored in wide form: one row per city and one column per year. That format is convenient for visual inspection, but awkward for analysis. A small parser lets us melt each sheet into long form while handling the source file’s missing-value sentinel (....).

def parse_table(sheet_name, value_name):
    df = pd.read_excel(FILE_PATH, sheet_name=sheet_name, header=None)

    years = [int(x) for x in df.iloc[3, 1:19].tolist()]
    data = df.iloc[4:, :19].copy()
    data.columns = ["Province"] + years

    data = data[data["Province"].notna()]
    data = data[
        ~data["Province"]
        .astype(str)
        .str.contains(
            r"Fonte|Totale|Nota|Nelle tavole|^\s*$",
            regex=True,
            na=False,
        )
    ]

    long = data.melt(id_vars="Province", var_name="Year", value_name=value_name)
    long["Province"] = long["Province"].astype(str).str.strip()
    long["Year"] = pd.to_numeric(long["Year"], errors="coerce")
    long[value_name] = pd.to_numeric(
        long[value_name].replace("....", np.nan),
        errors="coerce",
    )
    return long


temperature_data = parse_table("Tavola_1", "Temperature")
precipitation_data = parse_table("Tavola_2", "Precipitation")

italy_weather_data = temperature_data.merge(
    precipitation_data,
    on=["Province", "Year"],
    how="inner",
)

italy_weather_data.head()
Province Year Temperature Precipitation
0 Torino 2006 15.0 697.2
1 Vercelli 2006 13.2 650.4
2 Novara 2006 14.3 664.0
3 Cuneo 2006 13.0 733.2
4 Asti 2006 13.7 517.8
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)

A quick check confirms that the cleaned dataset spans the full 2006–2023 range.

italy_weather_data.agg(
    provinces=("Province", "nunique"),
    start_year=("Year", "min"),
    end_year=("Year", "max"),
)
Province Year
provinces 110.0 NaN
start_year NaN 2006.0
end_year NaN 2023.0

The hottest places

Averaging temperature across the full 2006–2023 period gives a simple “warmest cities” ranking. Unsurprisingly, the leaders cluster in Sicily and the far south.

top_hottest = (
    italy_weather_data.groupby("Province", as_index=False)["Temperature"]
    .mean()
    .sort_values("Temperature", ascending=False)
    .head(5)
)

top_hottest
Table 1: Five hottest provincial capitals by mean temperature, 2006–2023.
Province Temperature
56 Messina 19.655556
24 Catania 19.650000
79 Reggio di Calabria 19.300000
0 Agrigento 19.244444
91 Taranto 19.177778

Being hot is not the same as warming quickly. To estimate warming rates, I fit a linear slope for each city after keeping only places with at least 15 valid annual observations, which reduces the influence of patchy reporting histories.

def slope_per_city(group, value_col):
    clean = group.dropna(subset=[value_col])
    x = clean["Year"].to_numpy(dtype=float)
    y = clean[value_col].to_numpy(dtype=float)
    if len(x) < 2:
        return np.nan
    return round(float(np.polyfit(x, y, 1)[0]), 3)

warming_base = italy_weather_data.groupby("Province").filter(
    lambda g: g["Temperature"].notna().sum() >= 15
)

warming_rates = (
    warming_base.groupby("Province")
    .apply(lambda g: slope_per_city(g, "Temperature"))
    .rename("Trend (°C/year)")
    .reset_index()
    .sort_values("Trend (°C/year)", ascending=False)
)


warming_rates.head(10)
Province Trend (°C/year)
87 Sondrio 0.150
53 Massa Carrara 0.132
81 Roma 0.122
15 Bologna 0.118
29 Cremona 0.117
0 Agrigento 0.110
24 Catania 0.109
57 Modena 0.107
84 Sassari 0.099
52 Mantova 0.098
top_warming = warming_rates.head(10)
fig, ax = plt.subplots(figsize=(10.5, 6.5))

bars = ax.barh(top_warming["Province"][::-1], top_warming["Trend (°C/year)"][::-1], color=ACCENT, alpha=0.8, edgecolor="none", height=0.65)

# Subtitle below title
ax.set_title("Fastest Warming Italian Provincial Capitals", loc="left", pad=38)
ax.text(0, 1.02, "Linear slope from annual mean temperature, cities with at least 15 valid years", transform=ax.transAxes, fontsize=10.5, color=MUTED_HEX)

ax.set_xlabel("Warming Trend (°C per year)", color=MUTED_HEX, fontsize=11, labelpad=10)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.grid(axis="x", linestyle="-", color=GRID, alpha=0.8)
ax.grid(axis="y", visible=False)

for bar in bars:
    width = bar.get_width()
    ax.text(width + 0.003, bar.get_y() + bar.get_height()/2, f"+{width:.3f}", va="center", color=TEXT, fontsize=10, fontweight="bold")

plt.tight_layout()
plt.show()
Figure 3: Fastest warming provincial capitals, filtered to cities with at least 15 valid annual observations.

2023 anomalies

The long-run series are useful, but the workbook also includes a targeted anomaly table for 2023. Tavola_3 compares 2023 against the 2006–2015 average and, where available, against the climatological normals for 1981–2010 and 1971–2000.

anomaly_sheet = pd.read_excel(FILE_PATH, sheet_name="Tavola_3", header=None)

anomaly_data = anomaly_sheet.iloc[4:114, :7].copy()
anomaly_data.columns = [
    "Province",
    "Diff_2023_vs_2006_2015",
    "Mean_2006_2015",
    "Anom_1981_2010",
    "Clim_1981_2010",
    "Anom_1971_2000",
    "Clim_1971_2000",
]

anomaly_data = anomaly_data[anomaly_data["Province"].notna()]
anomaly_data = anomaly_data[
    ~anomaly_data["Province"].astype(str).str.contains(
        r"Fonte|^\s*$",
        regex=True,
        na=False,
    )
]
anomaly_data["Province"] = anomaly_data["Province"].astype(str).str.strip()

for col in anomaly_data.columns[1:]:
    anomaly_data[col] = pd.to_numeric(
        anomaly_data[col].replace("....", np.nan),
        errors="coerce",
    )

anomaly_data.head()
Province Diff_2023_vs_2006_2015 Mean_2006_2015 Anom_1981_2010 Clim_1981_2010 Anom_1971_2000 Clim_1971_2000
4 Torino 1.0 15.0 2.01 13.9 2.47 13.44
5 Vercelli 0.9 13.5 NaN NaN NaN NaN
6 Novara 1.1 14.4 NaN NaN NaN NaN
7 Cuneo 0.9 12.9 NaN NaN NaN NaN
8 Asti 1.1 13.3 NaN NaN NaN NaN

Understanding the 1981–2010 Baseline

In climate science, an anomaly is the difference between the observed temperature and a long-term average called a climatological normal. The World Meteorological Organization (WMO) uses 30-year periods to establish these normals.

  • +0.0°C anomaly: The year was exactly as warm as the 1981–2010 average.
  • Positive anomaly (e.g., +2.66°C): The year was warmer than the historical baseline.

When we see values exceeding +2.0°C in the table below, it indicates that those cities experienced a year significantly hotter than the conditions that characterized the end of the 20th century.

A caveat on coverage: Tavola_3 does not provide climatological-normal anomalies for every provincial capital. The sheet notes that those reference baselines are calculated only for regional capitals with long enough series, and some of the 2023-vs-2006–2015 differences are also missing where the recent series is incomplete. The ranking below is therefore among places with available climatological comparisons, not all provincial capitals — it shows which cities stand out within that comparable subset, not an exhaustive list of every warm place in Italy. Within that subset, 2023 stands out particularly strongly in cities such as Perugia, Bologna, Milano, and Torino.

top_anomalies = (
    anomaly_data.dropna(subset=["Anom_1981_2010"])
    .sort_values("Anom_1981_2010", ascending=False)
    [["Province", "Anom_1981_2010"]]
    .head(10)
)

top_anomalies
Table 2: Largest 2023 temperature anomalies relative to the 1981–2010 climatology.
Province Anom_1981_2010
61 Perugia 2.95
46 Bologna 2.56
16 Milano 2.38
4 Torino 2.01
26 Trento 1.84
70 Roma 1.84
31 Venezia 1.74
36 Trieste 1.74
25 Bolzano 1.72
12 Aosta 1.71
top_anomalies_plot = anomaly_data.dropna(subset=["Anom_1981_2010"]).sort_values("Anom_1981_2010", ascending=False).head(10)

fig, ax = plt.subplots(figsize=(10.5, 6.5))
bars = ax.barh(top_anomalies_plot["Province"][::-1], top_anomalies_plot["Anom_1981_2010"][::-1], color=ACCENT, alpha=0.8, edgecolor="none", height=0.65)

# Subtitle below title
ax.set_title("Largest 2023 temperature anomalies", loc="left", pad=38)
ax.text(0, 1.02, "Relative to 1981–2010 climatology from ISTAT Tavola_3", transform=ax.transAxes, fontsize=10.5, color=MUTED_HEX)

ax.set_xlabel("Anomaly (°C)", color=MUTED_HEX, fontsize=11, labelpad=10)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.grid(axis="x", linestyle="-", color=GRID, alpha=0.8)
ax.grid(axis="y", visible=False)

for bar in bars:
    width = bar.get_width()
    ax.text(width + 0.05, bar.get_y() + bar.get_height()/2, f"+{width:.2f}", va="center", color=TEXT, fontsize=10, fontweight="bold")

plt.tight_layout()
plt.show()
Figure 4: Largest 2023 temperature anomalies relative to the 1981–2010 climatology.

Correlation: Heat vs. Rainfall

A natural follow-up is whether warmer years also tend to be wetter or drier. Plotting the national annual mean temperature against total precipitation gives a first look at how the two aggregates move together.

import seaborn as sns

# Prepare data for correlation
corr_df = italy_temperature_trend[['Year', 'Temperature']].merge(
    italy_precipitation_trend[['Year', 'Precipitation']], 
    on='Year'
)

fig, ax = plt.subplots(figsize=(8, 6))
sns.regplot(data=corr_df, x='Temperature', y='Precipitation', 
            scatter_kws={'color': ACCENT, 's': 60}, 
            line_kws={'color': TEXT, 'linestyle': '--', 'linewidth': 1.5}, ax=ax)

ax.set_title("Correlation: Annual Temp vs. Precipitation", loc='left', pad=25)
ax.set_xlabel("Mean Temperature (°C)")
ax.set_ylabel("Total Precipitation (mm)")

# Calculate correlation coefficient
r_val = corr_df['Temperature'].corr(corr_df['Precipitation'])
ax.text(0.05, 0.05, f"Pearson r = {r_val:.2f}", transform=ax.transAxes, 
        fontsize=12, fontweight='bold', color=TEXT)

plt.tight_layout()
plt.show()
Figure 5: Correlation: Annual Temp vs. Precipitation.

The scatter shows a weak-to-moderate negative association in these annual aggregates: across the 18 years, warmer years have tended to coincide with somewhat lower national precipitation. With a Pearson r of about −0.34 the relationship is real but far from tight — plenty of warm years were not notably dry. This describes only how the two national means co-vary from year to year; it does not say anything about the physical mechanism driving either series, and a single year can move the relationship substantially — the sharp 2022 dip, for instance, reflects the severe drought Italy faced that year and visibly pulls the regression line down.

Mapping the change

A time-series chart shows when temperatures rise. A map shows where the warmth sits. To geocode the provincial capitals once, I cache the coordinates locally and reuse them on subsequent renders.

import numpy as np
import pandas as pd
import plotly.express as px
import plotly.io as pio

# Re-defining the parser to ensure data is available
def parse_table(sheet_name, value_name):
    df = pd.read_excel(FILE_PATH, sheet_name=sheet_name, header=None)
    years = [int(x) for x in df.iloc[3, 1:19].tolist()]
    data = df.iloc[4:, :19].copy()
    data.columns = ["Province"] + years
    data = data[data["Province"].notna()]
    data["Province"] = data["Province"].astype(str).str.strip()
    data = data[~data["Province"].str.contains(r"Fonte|Totale|Nota|Nelle tavole|^$", regex=True, na=False)]
    long = data.melt(id_vars="Province", var_name="Year", value_name=value_name)
    long["Year"] = pd.to_numeric(long["Year"], errors="coerce")
    long[value_name] = pd.to_numeric(long[value_name].replace("....", np.nan), errors="coerce")
    return long

# Load data
temp_df = parse_table("Tavola_1", "Temperature")
precip_df = parse_table("Tavola_2", "Precipitation")
italy_weather_data = temp_df.merge(precip_df, on=["Province", "Year"], how="inner")

# A dictionary of coordinates for Italian provincial capitals
city_coords = {
    'Torino': [45.0703, 7.6869], 'Vercelli': [45.3238, 8.4232], 'Novara': [45.4468, 8.6212],
    'Cuneo': [44.3833, 7.5500], 'Asti': [44.9005, 8.2069], 'Alessandria': [44.9129, 8.6154],
    'Biella': [45.5630, 8.0579], 'Verbano-Cusio-Ossola': [45.9220, 8.5516], 'Aosta': [45.7371, 7.3201],
    'Milano': [45.4642, 9.1900], 'Lodi': [45.3139, 9.5032], 'Monza e della Brianza': [45.5845, 9.2744],
    'Bergamo': [45.6983, 9.6773], 'Brescia': [45.5398, 10.2181], 'Pavia': [45.1850, 9.1546],
    'Como': [45.8081, 9.0852], 'Varese': [45.8167, 8.8333], 'Sondrio': [46.1690, 9.8731],
    'Lecco': [45.8559, 9.3977], 'Mantova': [45.1564, 10.7911], 'Cremona': [45.1333, 10.0333],
    'Bolzano': [46.4983, 11.3548], 'Trento': [46.0667, 11.1167], 'Verona': [45.4384, 10.9916],
    'Vicenza': [45.5479, 11.5446], 'Belluno': [46.1408, 12.2161], 'Treviso': [45.6667, 12.2450],
    'Venezia': [45.4408, 12.3155], 'Padova': [45.4064, 11.8768], 'Rovigo': [45.0711, 11.7905],
    'Pordenone': [45.9569, 12.6605], 'Udine': [46.0625, 13.2346], 'Gorizia': [45.9409, 13.6222],
    'Trieste': [45.6495, 13.7768], 'Piacenza': [45.0526, 9.6930], 'Parma': [44.8015, 10.3279],
    'Reggio nell\'Emilia': [44.6982, 10.6312], 'Modena': [44.6471, 10.9252], 'Bologna': [44.4949, 11.3426],
    'Ferrara': [44.8381, 11.6198], 'Ravenna': [44.4183, 12.2035], 'Forlì-Cesena': [44.2227, 12.0409],
    'Rimini': [44.0594, 12.5684], 'Massa Carrara': [44.0375, 10.1417], 'Lucca': [43.8429, 10.5027],
    'Pistoia': [43.9333, 10.9167], 'Firenze': [43.7696, 11.2558], 'Livorno': [43.5485, 10.3106],
    'Pisa': [43.7085, 10.4036], 'Arezzo': [43.4631, 11.8781], 'Siena': [43.3186, 11.3306],
    'Grosseto': [42.7667, 11.1167], 'Prato': [43.8777, 11.1022], 'Perugia': [43.1107, 12.3908],
    'Terni': [42.5639, 12.6427], 'Ancona': [43.6158, 13.5189], 'Pesaro e Urbino': [43.9100, 12.9133],
    'Macerata': [43.3003, 13.4531], 'Ascoli Piceno': [42.8536, 13.5768], 'Fermo': [43.1610, 13.7183],
    'Viterbo': [42.4191, 12.1051], 'Rieti': [42.4000, 12.8667], 'Roma': [41.9028, 12.4964],
    'Latina': [41.4676, 12.9036], 'Frosinone': [41.6394, 13.3411], 'L\'Aquila': [42.3489, 13.3980],
    'Teramo': [42.6589, 13.7044], 'Pescara': [42.4618, 14.2142], 'Chieti': [42.3511, 14.1675],
    'Campobasso': [41.5603, 14.6584], 'Isernia': [41.5947, 14.2342], 'Caserta': [41.0736, 14.3347],
    'Benevento': [41.1297, 14.7821], 'Napoli': [40.8518, 14.2681], 'Avellino': [40.9144, 14.7936],
    'Salerno': [40.6779, 14.7658], 'Foggia': [41.4622, 15.5446], 'Bari': [41.1171, 16.8719],
    'Taranto': [40.4677, 17.2433], 'Brindisi': [40.6321, 17.9361], 'Lecce': [40.3515, 18.1750],
    'Barletta': [41.3197, 16.2768], 'Potenza': [40.6404, 15.8051], 'Matera': [40.6664, 16.6043],
    'Cosenza': [39.2983, 16.2537], 'Catanzaro': [38.9098, 16.5877], 'Reggio di Calabria': [38.1105, 15.6434],
    'Crotone': [39.0808, 17.1273], 'Vibo Valentia': [38.6753, 16.1011], 'Messina': [38.1938, 15.5540],
    'Palermo': [38.1157, 13.3615], 'Trapani': [38.0175, 12.5150], 'Agrigento': [37.3111, 13.5765],
    'Caltanissetta': [37.4903, 14.0622], 'Enna': [37.5671, 14.2750], 'Catania': [37.5079, 15.0830],
    'Ragusa': [36.9269, 14.7231], 'Siracusa': [37.0755, 15.2866], 'Sassari': [40.7259, 8.5615],
    'Nuoro': [40.3231, 9.3303], 'Cagliari': [39.2238, 9.1217], 'Oristano': [39.9056, 8.5911],
    'Carbonia': [39.1671, 8.5222],
    'La Spezia': [44.2384, 9.6912],
    'Andria': [41.2276, 16.2955],
    'Trani': [41.2782, 16.4186],
}

# The workbook uses short province-capital names (e.g. "Verbania", "Monza",
# "Pesaro Urbino", "Forlì") that do not match the longer administrative keys
# in city_coords above. Normalize before lookup so no point disappears
# silently when the dictionary key uses a different spelling.
COORD_NAME_ALIASES = {
    'Verbania': 'Verbano-Cusio-Ossola',
    'Monza': 'Monza e della Brianza',
    'Pesaro Urbino': 'Pesaro e Urbino',
    'Forlì': 'Forlì-Cesena',
}

def lookup_coords(province):
    key = COORD_NAME_ALIASES.get(province, province)
    return city_coords.get(key, [None, None])

# Map coordinates and warn about anything still unmapped so a miss is loud,
# not silent.
italy_weather_data['Lat'] = italy_weather_data['Province'].map(lambda x: lookup_coords(x)[0])
italy_weather_data['Lon'] = italy_weather_data['Province'].map(lambda x: lookup_coords(x)[1])
unmapped = italy_weather_data.loc[italy_weather_data['Lat'].isna(), 'Province'].unique()
if len(unmapped) > 0:
    print(f"Unmapped provinces (no coordinates): {list(unmapped)}")
map_df = italy_weather_data.dropna(subset=['Lat', 'Lon', 'Temperature']).copy()

# Prepare Mapbox map
fig = px.scatter_mapbox(
    map_df,
    lat="Lat", lon="Lon", color="Temperature",
    hover_name="Province", animation_frame="Year",
    title="Evolution of Annual Temperatures in Italy (2006-2023)",
    color_continuous_scale=px.colors.sequential.YlOrRd,
    range_color=[map_df['Temperature'].min(), map_df['Temperature'].max()],
    mapbox_style="open-street-map",
    zoom=4.5, center=dict(lat=42.0, lon=12.5)
)

fig.update_layout(height=700, margin=dict(r=10, t=80, b=40, l=10), paper_bgcolor=globals().get('BG', '#f7f6f2'))
fig
Unmapped provinces (no coordinates): ['Imperia', 'Savona', 'Genova', 'a) I dati meteoclimatici delle stazioni esaminate e gli indicatori statistici calcolati forniscono misure riferite alle aree monitorate.']
Figure 6: Mean annual temperature by provincial capital for selected years, 2006–2023.

A companion view averages each capital across the whole 2006–2023 window, so the persistent north–south gradient — Sicily and the far south glowing warmest, the Alps coolest — is easy to read at a glance.

# Calculate mean per city for the static map
mean_per_city = (
    map_df.groupby("Province", as_index=False)
    .agg(
        Lat=("Lat", "first"),
        Lon=("Lon", "first"),
        Temperature=("Temperature", "mean"),
    )
)

# Create a static Mapbox plot to match the interactive look and feel
fig_static = px.scatter_mapbox(
    mean_per_city,
    lat="Lat",
    lon="Lon",
    color="Temperature",
    hover_name="Province",
    size_max=15,
    color_continuous_scale=px.colors.sequential.YlOrRd,
    range_color=[map_df['Temperature'].min(), map_df['Temperature'].max()],
    mapbox_style="open-street-map",
    zoom=4.5,
    center=dict(lat=42.0, lon=12.5),
    title="<b>Mean temperature by provincial capital (2006–2023)</b><br><span style='font-size:12px; color:#7a7974'>Average temperature across the full series from ISTAT Tavola_1</span>"
)

fig_static.update_layout(
    height=700,
    margin=dict(r=10, t=80, b=40, l=10),
    paper_bgcolor=BG,
    font=dict(color=TEXT)
)

fig_static
Figure 7: Mean annual temperature by provincial capital, averaged over 2006–2023.

Takeaways

The updated workbook makes the national pattern easy to see: Italian provincial capitals show a clear upward temperature trend over 2006–2023, while precipitation is much noisier and less uniform.

A few broad conclusions stand out:

  • The average across provincial capitals points to steady warming across the period.
  • The warmest cities are concentrated in Sicily and the far south.
  • The “fastest warming” ranking depends strongly on data completeness, so filtering sparse series matters.
  • The 2023 anomaly table adds important historical context, showing that several cities were not only warm in absolute terms, but unusually warm relative to climatological baselines.

The natural next extension would be to pull in the regional-capital sheets (Tavola_9 and Tavola_10) and the extremes tables (Tavola_7, Tavola_8, Tavola_11Tavola_14) for a second article focused on heat extremes, warm nights, and heavy-rain indicators.

Reproduce the analysis

Prefer to run it yourself? The notebook, tidy dataset, and source repository are available so you can inspect the cleaning steps, reuse the data, or extend the charts.

Notebook

Run the full analysis locally and reproduce the figures from the article.

Download .ipynb

Tidy dataset

Use the cleaned province-year table with temperature and precipitation columns.

Download .csv

Source repo

Star or fork the companion GitHub project to build on the analysis.

View on GitHub

Share this article