Density of population and building ages in Barcelona

Libraries

import numpy as np
import geopandas as gpd
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
import mapclassify

plt.style.use('../map.mplstyle')

Data

Data is obtained from the Open Data Portal of the Ajuntament de Barcelona, and from a curated GitHub repository containing the geographical information on the partitioning of the city in “Districtes”, “Barris”, and “Seccions Censals”.

# from https://opendata-ajuntament.barcelona.cat/data/ca/dataset/est-cadastre-edificacions-edat-mitjana/resource/4b0e0ac1-eade-4055-bea3-f07e2a79a7b4
edat_edificacions_fname = "../data/2026_edificacions_edat_mitjana.csv"

# from https://opendata-ajuntament.barcelona.cat/data/ca/dataset/pad_mdbas/resource/eb82adf2-a7b0-40e6-9624-b4b9eff23018
population_fname = "../data/2025_pad_mdbas.csv"

# from https://github.com/martgnz/bcn-geodata/blob/master/seccio-censal/seccio-censal.geojson
seccio_censal_geojson_fname = "../data/seccio-censal.geojson"

# from https://github.com/martgnz/bcn-geodata/blob/master/barris/barris.geojson
barris_geojson_fname = "../data/barris.geojson"

# from https://github.com/martgnz/bcn-geodata/blob/master/districtes/districtes.geojson
districtes_geojson_fname = "../data/districtes.geojson"
# load seccio_censal geojson file
seccio_censal = gpd.read_file(seccio_censal_geojson_fname)

# fix data type of columns
seccio_censal["SEC_CENS"] = seccio_censal["SEC_CENS"].astype(int)
seccio_censal["BARRI"] = seccio_censal["BARRI"].astype(int)
seccio_censal["DISTRICTE"] = seccio_censal["DISTRICTE"].astype(int)

# select columns we need
seccio_censal = seccio_censal[['SEC_CENS', 'BARRI', 'DISTRICTE', 'AREA', 'geometry']]
seccio_censal.head()
SEC_CENS BARRI DISTRICTE AREA geometry
0 1 1 1 226854.317 POLYGON ((2.17575 41.37827, 2.17577 41.37823, ...
1 2 1 1 37756.212 POLYGON ((2.1751 41.37905, 2.17543 41.37877, 2...
2 3 1 1 40992.088 POLYGON ((2.1722 41.37692, 2.17265 41.37681, 2...
3 4 1 1 96760.068 POLYGON ((2.16962 41.37847, 2.17043 41.37779, ...
4 5 1 1 60542.195 POLYGON ((2.17366 41.38071, 2.17407 41.38024, ...
# load Barris geojson file
barris = gpd.read_file(barris_geojson_fname)

barris["BARRI"] = barris["BARRI"].astype(int)
barris["DISTRICTE"] = barris["DISTRICTE"].astype(int)
barris = barris[['BARRI', 'DISTRICTE', 'NOM', 'geometry']].rename(columns={'NOM':'NOM_BARRI'})
barris.head()
BARRI DISTRICTE NOM_BARRI geometry
0 1 1 el Raval POLYGON ((2.16471 41.38593, 2.16936 41.38554, ...
1 2 1 el Barri Gòtic POLYGON ((2.17701 41.38525, 2.17873 41.38396, ...
2 3 1 la Barceloneta POLYGON ((2.19623 41.38745, 2.19631 41.38745, ...
3 7 2 la Dreta de l'Eixample POLYGON ((2.17091 41.40182, 2.17221 41.40083, ...
4 8 2 l'Antiga Esquerra de l'Eixample POLYGON ((2.15736 41.39331, 2.15847 41.39245, ...
# load Districtes geojson file
districtes = gpd.read_file(districtes_geojson_fname)
districtes['DISTRICTE'] = districtes['DISTRICTE'].astype(int)
districtes = districtes[['DISTRICTE', 'NOM', 'geometry']].rename(columns={'NOM':'NOM_DISTRICTE'})
districtes.head()
DISTRICTE NOM_DISTRICTE geometry
0 1 Ciutat Vella POLYGON ((2.18345 41.39061, 2.18459 41.38976, ...
1 2 Eixample POLYGON ((2.1869 41.40165, 2.18689 41.40087, 2...
2 3 Sants-Montjuïc MULTIPOLYGON (((2.14824 41.37623, 2.14896 41.3...
3 4 Les Corts POLYGON ((2.10342 41.4011, 2.10352 41.40109, 2...
4 5 Sarrià-Sant Gervasi MULTIPOLYGON (((2.07313 41.43522, 2.07319 41.4...
# load data on the mean age of the construction, keeping only the code of the seccio_censal
# and the data point relative to the mean construction age
edat = pd.read_csv(edat_edificacions_fname)
edat = edat.rename(
    columns={'Seccio_censal':'SEC_CENS', 'Codi_barri':'BARRI', 'Codi_districte':'DISTRICTE'})\
        [['SEC_CENS', 'BARRI', 'DISTRICTE','Edat_mitjana']]
edat.head()
SEC_CENS BARRI DISTRICTE Edat_mitjana
0 1 1 1 100.4
1 2 1 1 108.7
2 3 1 1 110.8
3 4 1 1 95.4
4 5 1 1 119.6
# load data on the number of people living in each "Secció Censal"
population = pd.read_csv(population_fname)
population['SEC_CENS'] = population['Seccio_Censal'].apply(lambda x:int(str(x)[-3:]))
population = population.rename(columns={'Codi_Districte':'DISTRICTE', 'Valor':'population'})\
    [['DISTRICTE', 'SEC_CENS', 'population']]
population.head()
DISTRICTE SEC_CENS population
0 1 1 1419
1 1 2 1345
2 1 3 3710
3 1 4 3092
4 1 5 2443
# this is the GeoDataFrame that will be used for plotting the maps
gdf = seccio_censal.merge(edat, how='left').merge(population, how='left')
gdf['density'] = gdf['population']/gdf['AREA']
gdf.info()
<class 'geopandas.geodataframe.GeoDataFrame'>
RangeIndex: 1068 entries, 0 to 1067
Data columns (total 8 columns):
 #   Column        Non-Null Count  Dtype   
---  ------        --------------  -----   
 0   SEC_CENS      1068 non-null   int64   
 1   BARRI         1068 non-null   int64   
 2   DISTRICTE     1068 non-null   int64   
 3   AREA          1068 non-null   float64 
 4   geometry      1068 non-null   geometry
 5   Edat_mitjana  1055 non-null   float64 
 6   population    1068 non-null   int64   
 7   density       1068 non-null   float64 
dtypes: float64(3), geometry(1), int64(4)
memory usage: 66.9 KB

Plots

Bivariate map

gdf['x_group'] = gdf[['Edat_mitjana']].apply(mapclassify.Quantiles.make(rolling=True, k = 3))
gdf['y_group'] = gdf[['density']].apply(mapclassify.Quantiles.make(rolling=True, k = 3))
gdf['xy_group'] = gdf['x_group'].astype(str) + "-" + gdf['y_group'].astype(str)
gdf.head()
SEC_CENS BARRI DISTRICTE AREA geometry Edat_mitjana population density x_group y_group xy_group
0 1 1 1 226854.317 POLYGON ((2.17575 41.37827, 2.17577 41.37823, ... 100.4 1419 0.006255 2 0 2-0
1 2 1 1 37756.212 POLYGON ((2.1751 41.37905, 2.17543 41.37877, 2... 108.7 1345 0.035623 2 1 2-1
2 3 1 1 40992.088 POLYGON ((2.1722 41.37692, 2.17265 41.37681, 2... 110.8 3710 0.090505 2 2 2-2
3 4 1 1 96760.068 POLYGON ((2.16962 41.37847, 2.17043 41.37779, ... 95.4 3092 0.031955 2 0 2-0
4 5 1 1 60542.195 POLYGON ((2.17366 41.38071, 2.17407 41.38024, ... 119.6 2443 0.040352 2 1 2-1
color_mapping = {
    "0-0": "#f0f0f0",
    "1-0": "#b3cde3",
    "2-0": "#6497b1",

    "0-1": "#fdbb84",
    "1-1": "#9e9e9e",
    "2-1": "#4f81a3",

    "0-2": "#e34a33",
    "1-2": "#b3588a",
    "2-2": "#2c3e75"
}
# calculate the max and min of the age of the buildings
import contextily as ctx

# gdf = gdf.to_crs(epsg=3857)
# districtes = districtes.to_crs(epsg=3857)

scale = 0.7
fig, ax = plt.subplots(figsize=(scale*10, scale*12))
gdf.plot(
    categorical = True,
    edgecolor = "xkcd:light grey",
    linewidth = 0.2,
    linestyle='--',
    ax = ax,
    color=gdf["xy_group"].map(color_mapping),
).set_axis_off();

districtes.plot(
    ax=ax,
    facecolor='none',
    edgecolor='k',
    linewidth=0.15)

# ctx.add_basemap(
#     ax,
#     source=ctx.providers.CartoDB.Positron,
#     alpha=0.5  # key: keep it subtle
# )

# --- Create inset axis for legend ---
legend_ax = fig.add_axes([0.18, 0.18, 0.1, 0.1])  # [left, bottom, width, height]
legend_ax.set_aspect('equal')

# --- Build 3x3 color grid ---
legend_grid = np.array([
    ["0-0", "1-0", "2-0"],
    ["0-1", "1-1", "2-1"],
    ["0-2", "1-2", "2-2"]
])

color_grid = [[color_mapping[cell] for cell in row] for row in legend_grid]

# --- Plot as image ---
for i in range(3):
    for j in range(3):
        legend_ax.add_patch(plt.Rectangle(
            (j, i), 1, 1,
            facecolor=color_grid[i][j],
            edgecolor='gray'
        ))

legend_ax.set_xlim(0, 3)
legend_ax.set_ylim(0, 3)

# Remove ticks
legend_ax.set_xticks([])
legend_ax.set_yticks([])

# --- Axis labels ---
legend_ax.set_xlabel("Older Buidlings", fontsize=9)
legend_ax.set_ylabel("Denser Areas", fontsize=9)

legend_ax.set_xticks([0.5, 1.5, 2.5])
legend_ax.set_xticklabels(["Low", "Med", "High"], fontsize=8)
legend_ax.tick_params('both', length=0)

legend_ax.set_yticks([0.5, 1.5, 2.5])
legend_ax.set_yticklabels(["Low", "Med", "High"], fontsize=8)

# Clean frame
for spine in legend_ax.spines.values():
    spine.set_visible(False)

ax.text(
    2.05, 41.48, "Where Barcelona is Older and Denser",
    fontsize=16, weight='bold'
)

ax.text(
    2.05, 41.474, "Mean Age of Buildings and Density of Population",
    fontsize=14
)

ax.text(
    0.71, 0.01,
    "Source: Barcelona Open Data",
    transform=ax.transAxes,
    fontsize=8,
    color="gray"
)

fig.savefig('map.png', bbox_inches='tight', dpi=600)
ax.axis('off')
plt.show()

plot_df = population_gdf.merge(df).merge(districtes[['DISTRICTE', 'NOM_DISTRICTE']], on='DISTRICTE')

fig, ax = plt.subplots(figsize=(8,6))
for districte in plot_df['DISTRICTE'].unique():
    tmp = plot_df[plot_df['DISTRICTE']==districte]
    nom_districte = districtes.set_index('DISTRICTE')['NOM_DISTRICTE'][districte]
    ax.scatter(tmp['Edat_mitjana'], tmp['density'], label=nom_districte, alpha=0.7)

ax.legend(loc='upper right', frameon=True)

ax.set_xlabel("Mean age of constructions")
ax.set_ylabel("Mean density of population (hab/m²)")
Text(0, 0.5, 'Mean density of population (hab/m²)')

plot_df = population_gdf.merge(df).merge(districtes[['DISTRICTE', 'NOM_DISTRICTE']], on='DISTRICTE')

fig, ax = plt.subplots(figsize=(8,6))

ax.scatter(plot_df['Edat_mitjana'], plot_df['density'], alpha=0.7)

ax.set_xlabel("Mean age of constructions")
ax.set_ylabel("Mean density of population (hab/m²)")
Text(0, 0.5, 'Mean density of population (hab/m²)')

barris_df = pd.DataFrame({
    'area':seccio_censal.groupby('BARRI').AREA.sum(),
    'Edat_mitjana':df.groupby('BARRI').Edat_mitjana.mean(),
    'population':population.merge(seccio_censal).groupby('BARRI')['population'].sum()
}).reset_index()
barris_df['density'] = barris_df['population']/barris_df['area']
barris_df = barris_df.merge(barris[['BARRI', 'DISTRICTE', 'NOM_BARRI']])\
    .merge(districtes[['DISTRICTE', 'NOM_DISTRICTE']])
barris_df
BARRI area Edat_mitjana population density DISTRICTE NOM_BARRI NOM_DISTRICTE
0 1 1100286.139 119.895238 49917 0.045367 1 el Raval Ciutat Vella
1 2 815593.939 131.933333 27878 0.034181 1 el Barri Gòtic Ciutat Vella
2 3 1179381.956 92.836364 14749 0.012506 1 la Barceloneta Ciutat Vella
3 4 1109668.776 118.576923 22767 0.020517 1 Sant Pere, Santa Caterina i la Ribera Ciutat Vella
4 5 929355.787 70.425000 36621 0.039405 2 el Fort Pienc Eixample
... ... ... ... ... ... ... ... ...
68 69 1226952.564 33.271429 13691 0.011159 10 Diagonal Mar i el Front Marítim del Poblenou Sant Martí
69 70 1208701.464 54.375000 30724 0.025419 10 el Besòs i el Maresme Sant Martí
70 71 1085739.627 53.480000 21902 0.020172 10 Provençals del Poblenou Sant Martí
71 72 733989.017 61.487500 26976 0.036753 10 Sant Martí de Provençals Sant Martí
72 73 1129978.396 54.875000 31173 0.027587 10 la Verneda i la Pau Sant Martí

73 rows × 8 columns

fig, ax = plt.subplots(figsize=(8,6))
for districte in barris_df['DISTRICTE'].unique():
    tmp = barris_df[barris_df['DISTRICTE']==districte]
    nom_districte = districtes.set_index('DISTRICTE')['NOM_DISTRICTE'][districte]
    ax.scatter(tmp['Edat_mitjana'], tmp['density'], label=nom_districte)

ax.legend(loc='upper right', frameon=True)

ax.set_xlabel("Mean age of constructions")
ax.set_ylabel("Mean density of population (hab/m²)")
Text(0, 0.5, 'Mean density of population (hab/m²)')

fig, ax = plt.subplots(figsize=(3.5,3.5))
plot_df.Edat_mitjana.hist(grid=False, bins=np.arange(20)*10)
ax.set_xlabel("Mean age of building")
ax.set_ylabel("Number of sections")
m = plot_df.Edat_mitjana.mean()
ax.axvline(x=m, color='k', linestyle='--', linewidth=0.75)

y = 250
ax.annotate(
    f"Mean = {m:.1f}",
    xy=(72, y),
    xytext=(120, y),
    arrowprops=dict(arrowstyle="->", color="black"),
    fontsize=10,
    weight='bold'
)
Text(120, 250, 'Mean = 69.4')

def barrio_plot(barrio):
    fig, ax = plt.subplots()
    barrio.plot(ax=ax, column='Edat_mitjana', cmap='YlOrRd')
    ax.axis(False)
    ax.set_title(barrio['NOM_BARRI'].unique()[0])

    # color bar
    cax = fig.add_axes([0.18, 0.82, 0.12, 0.01])
    m, M = df.Edat_mitjana.min(), df.Edat_mitjana.max()
    sm = plt.cm.ScalarMappable(cmap='YlOrRd', norm=plt.Normalize(vmin=m, vmax=M))
    sm._A = []
    cbar = plt.colorbar(sm, cax=cax, drawedges=False, orientation="horizontal")
    cbar.outline.set_edgecolor('darkgray')
    cbar.set_label("Mean age of constructions", fontsize=10)


gdf = seccio_censal.merge(df).merge(barris[['BARRI', 'NOM_BARRI']])

barrio_id = 20
barrio = gpd.GeoDataFrame(gdf[gdf.BARRI == barrio_id])
barrio_plot(barrio)
# for barrio_id in gdf['BARRI'].unique():
#     barrio = gpd.GeoDataFrame(gdf[gdf.BARRI == barrio_id])
#     barrio_plot(barrio)

BARRI NOM_BARRI DISTRICTE geometry
0 1 el Raval 1 POLYGON ((2.16471 41.38593, 2.16936 41.38554, ...
1 2 el Barri Gòtic 1 POLYGON ((2.17701 41.38525, 2.17873 41.38396, ...
2 3 la Barceloneta 1 POLYGON ((2.19623 41.38745, 2.19631 41.38745, ...
3 7 la Dreta de l'Eixample 2 POLYGON ((2.17091 41.40182, 2.17221 41.40083, ...
4 8 l'Antiga Esquerra de l'Eixample 2 POLYGON ((2.15736 41.39331, 2.15847 41.39245, ...
... ... ... ... ...
68 50 les Roquetes 8 POLYGON ((2.18315 41.45279, 2.18312 41.45271, ...
69 53 la Trinitat Nova 8 POLYGON ((2.18872 41.45504, 2.18878 41.45484, ...
70 54 Torre Baró 8 POLYGON ((2.18137 41.46144, 2.18173 41.46103, ...
71 55 Ciutat Meridiana 8 POLYGON ((2.17959 41.46406, 2.17998 41.46357, ...
72 56 Vallbona 8 POLYGON ((2.18407 41.4683, 2.18417 41.46828, 2...

73 rows × 4 columns

gdf.merge(barris, on=["BARRI", "DISTRICTE"])
id SEC_CENS BARRI DISTRICTE AREA geometry_x Edat_mitjana NOM_BARRI geometry_y
0 0 1 1 1 226854.317 POLYGON ((2.17575 41.37827, 2.17577 41.37823, ... 100.4 el Raval POLYGON ((2.16471 41.38593, 2.16936 41.38554, ...
1 1 2 1 1 37756.212 POLYGON ((2.1751 41.37905, 2.17543 41.37877, 2... 108.7 el Raval POLYGON ((2.16471 41.38593, 2.16936 41.38554, ...
2 2 3 1 1 40992.088 POLYGON ((2.1722 41.37692, 2.17265 41.37681, 2... 110.8 el Raval POLYGON ((2.16471 41.38593, 2.16936 41.38554, ...
3 3 4 1 1 96760.068 POLYGON ((2.16962 41.37847, 2.17043 41.37779, ... 95.4 el Raval POLYGON ((2.16471 41.38593, 2.16936 41.38554, ...
4 4 5 1 1 60542.195 POLYGON ((2.17366 41.38071, 2.17407 41.38024, ... 119.6 el Raval POLYGON ((2.16471 41.38593, 2.16936 41.38554, ...
... ... ... ... ... ... ... ... ... ...
1050 1050 16 19 4 26533.586 POLYGON ((2.14156 41.38653, 2.14264 41.38562, ... 55.9 les Corts POLYGON ((2.13747 41.39144, 2.13877 41.39124, ...
1051 1051 90 27 5 52964.068 POLYGON ((2.14645 41.40951, 2.14719 41.40859, ... 68.9 el Putxet i el Farró POLYGON ((2.14838 41.40699, 2.14848 41.4069, 2...
1052 1052 91 27 5 40719.053 POLYGON ((2.14315 41.41149, 2.14447 41.41138, ... 66.5 el Putxet i el Farró POLYGON ((2.14838 41.40699, 2.14848 41.4069, 2...
1053 1053 92 27 5 162177.819 POLYGON ((2.14264 41.41083, 2.1427 41.41082, 2... 63.2 el Putxet i el Farró POLYGON ((2.14838 41.40699, 2.14848 41.4069, 2...
1054 1054 93 27 5 28166.117 POLYGON ((2.13981 41.40682, 2.14038 41.40684, ... 58.0 el Putxet i el Farró POLYGON ((2.14838 41.40699, 2.14848 41.4069, 2...

1055 rows × 9 columns

df.groupby('BARRI').Edat_mitjana.max()
BARRI
1     146.9
2     173.5
3     110.6
4     134.1
5      86.9
      ...  
69     49.3
70     78.0
71     70.4
72    101.1
73     86.9
Name: Edat_mitjana, Length: 73, dtype: float64