Part 4 – Spatial Join, Density & Statistics

Spatial Analysis
Data Visualization
Join bus stop locations to neighbourhoods, calculate density, compute descriptive statistics, and chart the results in Excel.
Published

August 6, 2026

Classification: Beginner GIS Analysis | Spatial Data Basics | Spatial Join, Density & Statistics

Learning Level: Beginner

Time Estimate: 90 minutes

Software Required: QGIS LTR 3.44, Excel (or Google Sheets)

Author: Daniele Cannatella


Overview

This exercise is the fourth hands-on block of the Week 1 GIS workshop, following on from Part 1 – Vector Import & Basemap. You will import a point layer of bus stops in Vlissingen, count how many fall within each neighbourhood using a spatial join, calculate bus stop density, compute descriptive statistics across all neighbourhoods, visualize the result as a choropleth, and build a bar chart from the same data in Excel.

Learning Outcomes

By completing this exercise, you will be able to:

✅ Import a point layer into QGIS and check it against an existing polygon layer
✅ Use a spatial join to count points within polygons (a zonal statistic — one count per neighbourhood “zone”)
✅ Calculate a density field (points per area)
✅ Compute and interpret basic descriptive statistics (mean, median, min/max) across neighbourhoods
✅ Style a quick choropleth map of counts or density
✅ Export attribute data as CSV and build a bar chart in Excel


Data & Software

You will need:

Resource Type Description
vlissingen.qgz QGIS project Continues from Part 1 — includes [cbs-neighbourhoods]
vlissingen_neighbourhoods.gpkg Geopackage CBS neighbourhood boundaries for Vlissingen (same layer as Part 1)
bus_stops.gpkg Vector (points) Bus stop locations from OpenStreetMap, pre-filtered to Vlissingen

Background: OpenStreetMap (OSM) is a crowdsourced map of the world — anyone can add or edit features. It’s a fast, free source of point-of-interest data like shops and amenities, but coverage depends on how actively an area has been mapped, so a bus stop count from OSM is an estimate, not a guaranteed complete inventory.


Step 1: Import the Bus Stops Layer

  1. Launch QGIS LTR 3.44 and open the project vlissingen.qgz
  2. From the Browser Panel, import the point layer bus_stops.gpkg
  3. Check that it draws correctly over Vlissingen, alongside vlissingen_neighbourhoods

Result: Bus stops locations appear as points scattered across the neighbourhood boundaries.

❓ Question: How many bus stops does the layer contain in total, and what CRS is it in?

✅ Solution (click to reveal):

Open the Attribute Table for the feature count (bottom-left of the table window), and check Properties → Information for the CRS. If it doesn’t match the project CRS (EPSG:28992), QGIS reprojects it on the fly for display, but it’s worth confirming before the join in Step 2.


Step 2: Count Bus stops per Neighbourhood

2.1 Join Attributes by Location (Summary)

  1. Go to Processing → Toolbox → Vector general → Join Attributes by Location (Summary)
  2. Configure the join:
    • Join to features in: vlissingen_neighbourhoods
    • By comparing to: bus_stops
    • Where the features: intersect
    • Fields to summarise: check only bus
    • Summaries to calculate: check only count
  3. Run the tool

Tip: For point-in-polygon counts, intersect and within give the same result for almost every bus stop — the difference only matters for a point sitting exactly on a boundary line. Either predicate works here.

Result: A new layer is created (e.g. Joined layer) with one row per neighbourhood and a new field holding the bus stop count for that neighbourhood.

  1. Rename the output layer stops_per_neighbourhood

2.2 Replace NULL values

  1. Open the Attribute Table for stops_per_neighbourhood, observe the bus_count column

NULL values: In databases, NULL is used to represent unknown or missing values. In this exercise, NULL was attributed to the neighborhoods that do not contain any bus stop; thus this value may be replaced by zero without changing the meaning of the data. However, note that NULL and zero are not interchangeable, and the replacement shown in this step should not be done indiscriminately.

  1. Toggle editing mode
  2. Open Field Calculator
  3. Update existing field: bus_count
  4. Expression:
if ("bus_count" IS NULL, 0, "bus_count")
  • if ("bus_count" IS NULL – sets a condition
  • 0 – specifies what to do if the condition is true: which value to use as a replacement
  • "bus_count"- specifies what to do if the condition is false (i.e. value is not NULL): keep the current bus_count value

Result: The bus_count column is updated to replace NULL values by numberic values (zero).

2.3 Calculate Density

Raw counts favour large neighbourhoods, so add a density field to make the comparison fair.

  1. Open the Attribute Table for stops_per_neighbourhood, toggle editing mode
  2. Open Field Calculator
  3. Create a new field: bus_stop_dens (type: Decimal number)
  4. Expression:
"bus_count" / ($area / 1000000)
  • "bus_count" – the bus stop count field created by the join (rename in the expression if your join used a different field name)
  • $area / 1000000 – converts the neighbourhood’s area from m² to km²
  • Dividing gives bus stops per km²
  1. Click OK, save your edits, and toggle editing off

Result: Each neighbourhood now has both a raw bus stop count and a density value.


Step 3: Descriptive Statistics

Before mapping or charting the data, get a feel for the numbers themselves — this is what turns a table of values into something you can interpret.

3.1 Open the Statistics Panel

  1. Select stops_per_neighbourhood in the Layers Panel
  2. Go to View → Panels → Statistics Panel (enable it if not already visible)
  3. Set Field/Expression to bus_stop_dens
  4. Make sure Statistics on is set to the whole layer, not just a selection

Result: The panel lists summary statistics for bus_stop_dens across every neighbourhood — at minimum, note Count, Minimum, Maximum, Mean, and Median.

3.2 Interpret the Numbers

Reading mean vs. median: If the mean is noticeably higher than the median, a small number of neighbourhoods with very high density are pulling the average up — the median gives a better sense of what a “typical” neighbourhood looks like in that case.

Write down the citywide mean bus stop density — you’ll use it in Step 6 to add a reference line to your bar chart.

❓ Question: How many neighbourhoods sit above the citywide mean density? Does that match your impression from the raw numbers?

✅ Solution (click to reveal):

Sort the attribute table by bus_stop_dens (click the column header) and count the rows above the mean value from Step 3.1. Because the mean is sensitive to outliers, it’s common for a majority of neighbourhoods to sit below the mean, with just one or two very dense neighbourhoods pulling it up — worth checking against the median from Step 3.1.


Step 4: Visualize in QGIS

  1. Select stops_per_neighbourhood, open Layer Styling (F7)
  2. Change the classification type to Graduated
  3. Value: bus_stop_dens
  4. Classification Mode: Natural Breaks (Jenks), 4 classes
  5. Choose a sequential colour ramp and click Classify, then Apply
  6. Make sure the bus_stops point layer is drawn on top, with small, visible markegit chers, so individual bus stops stay visible over the choropleth

Result: A choropleth of bus stop density per neighbourhood, with the underlying bus stop points still visible for reference.

❓ Question: Does the neighbourhood with the most bus stops (highest count) also have the highest density?

✅ Solution (click to reveal):

Not necessarily — a large neighbourhood can have a high raw count simply because it covers more area, while a small, dense neighbourhood can have fewer bus stops in total but a higher density. Comparing the count map and the density map side by side (or just the two fields in the attribute table) usually makes this visible.


Step 5: Export the Attribute Table as CSV

  1. Right-click stops_per_neighbourhood → Export → Save Features As…
  2. Format: Comma Separated Value [CSV]
  3. File name: stops_per_neighbourhood``.csv
  4. Under Layer Options, set GEOMETRY to AS_XY or No geometry, so the CSV stays a plain table rather than embedding geometry text in every row
  5. Click OK

Result: A CSV file with one row per neighbourhood, including its name, bus stop count, and density.


Step 6: Build a Bar Chart in Excel or Google Sheets

  1. Open stops_per_neighbourhood.csv in Excel (or import it into Google Sheets)
  2. Select the neighbourhood name column and the bus_stop_dens (or count) column
  3. Insert a bar chart (or column chart) from the selection
  4. Sort the data by value first (largest to smallest) so the chart is easy to read
  5. Give the chart a title and label the axes (e.g. “Bus stop density by neighbourhood (bus stops/km²)”)
  6. Add a horizontal reference line at the citywide mean you noted in Step 3.2 (e.g. add a constant “Mean” column and plot it as a second series, or use a chart error-bar/trendline set to that value), so the chart shows at a glance which neighbourhoods sit above or below average

Result: A bar chart ranking Vlissingen’s neighbourhoods by bus stop density, with the citywide mean visible as a reference.


Step 7: Export and Share

  1. Take a screenshot of the QGIS choropleth map (with legend)
  2. Take a screenshot (or export) of the Excel/Google Sheets bar chart
  3. Upload both to the course Miro board
  4. File → Save Project, and save the spreadsheet

Reflection

  • What does the raw bus stop-count map miss that the density map shows?
  • Why might the mean and median density differ, and which one better describes a “typical” neighbourhood?
  • How would the presence of NULL values affect these statistics and their interpretation?
  • How do the choropleth map and the bar chart complement each other — what’s easier to read in each?
  • What are the limitations of using crowdsourced OSM data to count amenities like bus stops?

Back to top