Lesson 5: Join a spreadsheet
Complexity: intermediate
Outcome: a checked join between the observations layer and a CSV table, plus a permanent observations_with_status layer in the course GeoPackage.
Before you begin
Complete Lesson 4 first.
You need:
03_project/qgis-course.qgz02_working/course.gpkg- the
observationslayer containingOBS-001,OBS-002andOBS-003 - a plain-text editor such as Notepad or TextEdit in plain-text mode
1. Create the CSV exactly as shown
- Open your plain-text editor.
- Create a new blank file.
- Copy these four lines exactly:
obs_id,status,checked_date
OBS-001,checked,2026-08-01
OBS-002,follow_up,2026-08-02
OBS-999,unmatched_test,2026-08-03
- Choose File → Save As….
- Browse to
qgis-course/01_source. - Save the file as:
observation-status.csv
- If the editor offers an encoding choice, choose UTF-8.
- If using Windows Notepad, choose All files rather than Text Documents if needed so the file is not saved as
observation-status.csv.txt. - Close the text editor.
- In File Explorer or Finder, confirm the final filename ends in
.csv.
You should now have
01_source/observation-status.csv
2. Open the course project
- Start QGIS.
- Choose Project → Open….
- Open
03_project/qgis-course.qgz. - Confirm
observationsis in the Layers panel. - Right-click
observationsand choose Open Attribute Table. - Confirm
OBS-001,OBS-002andOBS-003each appear exactly once inobs_id. - Close the attribute table.
If those IDs are missing or duplicated, fix Lesson 4 before continuing. A join is only as reliable as the key used to connect the records.
3. Add the CSV as a non-spatial table
- Choose Layer → Add Layer → Add Delimited Text Layer….
- Beside File name, click
…. - Browse to
qgis-course/01_source. - Select
observation-status.csv. - Click Open.
- Check the data preview. You should see three columns:
obs_id,status,checked_date. - Under Geometry definition, choose No geometry (attribute only table).
- Click Add.
- Click Close.
- In the Layers panel, find the newly added table.
- Right-click it and choose Rename Layer.
- Rename it:
observation_status
- Save the QGIS project.
A non-spatial table appears in the Layers panel but has nothing to draw on the map. That is expected.
4. Inspect the table and key field
- Right-click
observation_status. - Choose Open Attribute Table.
- Confirm there are exactly three rows.
- Confirm the
obs_idvalues are:
OBS-001
OBS-002
OBS-999
- Confirm
OBS-999is present. It is deliberately unmatched. - Close the table.
5. Create a live join
- Double-click
observationsin the Layers panel. - Choose Joins in the left-hand list.
- Click the green + button to add a join.
- In Join layer, choose
observation_status. - In Join field, choose
obs_id. - In Target field, choose
obs_id. - If a custom field-name prefix is offered, set it to:
status_
- Leave the remaining options at their defaults for this exercise.
- Click OK to close the Add Vector Join dialog.
- Click Apply in Layer Properties.
- Click OK.
- Save the QGIS project.
The join is now part of the QGIS project configuration. It has not rewritten the original observations table.
6. Inspect the joined result
- Right-click
observations. - Choose Open Attribute Table.
- Scroll to the right until you find the joined fields. With the prefix above, they should begin with
status_. - Find the row
OBS-001. - Confirm the joined status is
checked. - Find
OBS-002. - Confirm the joined status is
follow_up. - Find
OBS-003. - Confirm the joined status fields are null/empty because the CSV contains no
OBS-003row. - Close the table.
You should now understand two different unmatched cases
OBS-003has geometry but no matching CSV rowOBS-999exists in the CSV but has no matching geometry
Neither should be silently mistaken for a successful match.
7. Check the unmatched CSV row explicitly
- Right-click
observation_status. - Open its attribute table.
- Find
OBS-999. - Confirm no
OBS-999feature exists in theobservationstable. - Close the CSV table.
Record in 03_project/README.md:
Lesson 5 join check
Target key: observations.obs_id
Join key: observation_status.obs_id
Matched: OBS-001, OBS-002
Target without table match: OBS-003
Table row without geometry match: OBS-999
Save the README.
8. Test why duplicate join keys are a problem
Do this in a separate test file. Do not alter the original source CSV.
- In File Explorer or Finder copy
01_source/observation-status.csv. - Rename the copy:
observation-status-duplicate-test.csv
- Open the copy in a text editor.
- Add this line at the end:
OBS-001,second_status,2026-08-04
- Save and close the file.
- Add the duplicate-test CSV to QGIS as No geometry (attribute only table) using the same method as step 3.
- Open its attribute table.
- Confirm
OBS-001now appears twice. - Close the table.
A simple one-to-one layer join cannot faithfully represent two status records for one spatial feature. In a real project, repeated visits or observations belong in a related child table rather than being flattened into a one-to-one join.
- Right-click the duplicate-test table in the Layers panel.
- Choose Remove Layer….
- Confirm removal. This removes it from the QGIS project only; it does not delete the CSV from disk.
- Save the project.
9. Open the Processing Toolbox
You will now create a permanent output rather than relying only on the live project join.
- Choose Processing → Toolbox.
- If the Toolbox opens as a panel, leave it open.
- Click its search box.
- Type:
Join attributes by field value
- Double-click Join attributes by field value from the results.
10. Configure the durable join
In the Processing dialog:
- Set the first/input layer to
observations. - Set its join/key field to
obs_id. - Set the second/input table to
observation_status. - Set its join/key field to
obs_id. - If there is an option for which table fields to copy, leave all fields selected for this exercise.
- If a field prefix is offered, use:
status_
- If an option says to discard non-matching input records, leave it off. You want
OBS-003retained so the missing match remains visible. - Find the main joined output destination.
- Click its destination control and choose Save to GeoPackage….
- Browse to
02_working/course.gpkg. - Set the output layer/table name to:
observations_with_status
- If the algorithm offers a separate output for non-matching input features, save it to the same GeoPackage as:
observations_without_status_match
- Click Run.
- Wait until the log reports completion.
- Click Close.
- Save the QGIS project.
11. Verify the permanent output
- Find
observations_with_statusin the Layers panel. - Right-click it and choose Open Attribute Table.
- Confirm there are still three spatial rows.
- Confirm
obs_idstill containsOBS-001,OBS-002andOBS-003. - Confirm
OBS-001has statuschecked. - Confirm
OBS-002has statusfollow_up. - Confirm
OBS-003has no joined status. - Close the table.
The permanent output is now a GeoPackage layer. It no longer depends on the CSV join being active to retain the copied joined values.
12. Compare live join and durable output
- Open the attribute table for original
observations. - Observe the joined
status_fields supplied by the live join. - Close it.
- Open
observations_with_status. - Observe that the joined values are now physical fields in the saved output.
- Close it.
The original layer remains the source feature layer. The new layer is a derived result. Do not quietly replace one with the other without recording the method.
13. Record the method
Add to 03_project/README.md:
Lesson 5 durable join
Input spatial layer: course.gpkg → observations
Input table: 01_source/observation-status.csv
Spatial key: obs_id
Table key: obs_id
Output: course.gpkg → observations_with_status
Unmatched spatial record retained: OBS-003
Unmatched table test row: OBS-999
Save the README and QGIS project.
14. Reopen and check
- Close the QGIS project.
- Reopen
03_project/qgis-course.qgz. - Confirm
observations_with_statusis present. - Open its attribute table.
- Confirm the joined values remain after reopening.
- Close the table.
Verify before moving on
You should be able to explain and show:
- where
observation-status.csvis stored - why it was loaded as No geometry
- the two fields used as join keys
OBS-003as a spatial feature with no table matchOBS-999as a table row with no spatial match- why duplicate
OBS-001records are a one-to-many design issue - the difference between a live QGIS join and
observations_with_status obs_idpreserved in the durable result
Common problems
Every joined field is null
Open both attribute tables and compare the key values character by character. Check field type, spelling, spaces and case. OBS-001, OBS-001 and obs-001 are not safe to assume are equivalent.
The CSV appears as points or asks for a CRS
Re-add it and choose No geometry (attribute only table). This file contains no coordinates.
observation-status.csv.txt will not import as expected
Rename the actual file so its extension is .csv. On Windows you may need to enable View → Show → File name extensions in File Explorer to see the real extension.
OBS-003 disappeared from the permanent output
Rerun Join attributes by field value and make sure you are not discarding non-matching input features.
Several table rows genuinely belong to one feature
Do not duplicate the geometry to make the join look successful. Use a one-to-many relation or a separate visits/observations table linked by the stable key.
Next: Lesson 6: Spatial analysis.
Official reference: QGIS joins and relations.
Test basis: QGIS 3.44 LTR. Last reviewed: 4 September 2026