Load a CSV as a Point Layer in PyQGIS
Sooner or later somebody hands you a spreadsheet export with a latitude column and a longitude column and asks for a map. QGIS reads that file directly through the delimitedtext provider, and the whole job is a single QgsVectorLayer call — provided the URI says exactly what the file contains. Leave out the CRS and the points land off the coast of Africa; leave out the type detection and every numeric column arrives as text; forget which character separates the decimals and half the rows silently lose their geometry.
This recipe belongs to Layer Data Sources & Formats in PyQGIS. It builds the URI piece by piece, covers WKT columns and awkward regional formats, and shows how to count the rows that did not become points.
Prerequisites
- QGIS 3.40 LTR or newer; everything here also runs unchanged on the QGIS 4 series.
- A CSV with a header row and either two coordinate columns or one WKT column.
- Knowing what coordinate system the numbers are in. If nobody can tell you, look at the value ranges: longitudes between −180 and 180 with several decimal places are almost certainly WGS 84; six- or seven-digit integers are projected metres.
Build the URI and load the layer
The provider reads the file lazily, so constructing the layer is cheap and the first real work happens on the first iteration or render. The URI is a file:// URL followed by query parameters, and assembling it from a dictionary keeps it readable and correctly escaped.
from pathlib import Path
from urllib.parse import urlencode
from qgis.core import QgsVectorLayer, QgsProject
csv_path = Path("/data/inbox/sensor_sites.csv")
params = {
"type": "csv",
"delimiter": ",",
"detectTypes": "yes",
"xField": "longitude",
"yField": "latitude",
"crs": "EPSG:4326",
"spatialIndex": "yes",
"subsetIndex": "no",
"watchFile": "no",
}
uri = f"{csv_path.as_uri()}?{urlencode(params)}"
sites = QgsVectorLayer(uri, "sensor_sites", "delimitedtext")
if not sites.isValid():
raise RuntimeError(f"could not open {csv_path}: {sites.error().summary()}")
QgsProject.instance().addMapLayer(sites)
print(sites.featureCount(), "rows,", sites.fields().names())
Breakdown: Path.as_uri() produces a correctly formed file:/// URL on every platform, including the drive letter and forward slashes Windows needs, which is the part people usually get wrong by hand. detectTypes=yes makes the provider scan the column contents and assign integer, double, date or string types; without it every field is a string and a later "population" > 1000 expression compares text. spatialIndex=yes builds an in-memory index when the layer opens, which is what keeps panning and spatial filters fast on a file with tens of thousands of rows. watchFile=no stops QGIS reloading the layer whenever another program touches the file — useful in an editor, a nuisance in a script.
A delimitedtext layer is read-only. You can style it, filter it and feed it to Processing, but you cannot add a field or edit a value; the file itself is the source of truth, and edits belong either in the spreadsheet or in a copy.
Count the rows that did not become points
The provider does not fail on a bad row. It creates the feature, fills in the attributes, and leaves the geometry empty. The feature count matches the row count, the layer is valid, and the missing points only show up when somebody asks why a site is absent from the map. Check for them immediately after loading.
from qgis.core import QgsFeatureRequest
request = QgsFeatureRequest().setFilterExpression("$geometry IS NULL")
missing = [f["site_id"] for f in sites.getFeatures(request)]
print(f"{len(missing)} of {sites.featureCount()} rows have no geometry")
for site_id in missing[:20]:
print(" ", site_id)
Breakdown: Filtering on $geometry IS NULL asks the expression engine rather than Python to find the empty rows, so the loop only touches the ones you want. Do not add the NoGeometry flag to this particular request, tempting as it is for speed — without geometry fetched, every feature looks empty and the filter matches all of them. Printing the business identifier rather than the feature id matters because delimited text feature ids are row numbers, and row numbers shift the moment somebody sorts the spreadsheet.
Two causes account for almost every empty geometry: a blank coordinate cell, and a number the provider could not parse. The second one has a fix in the URI.
Regional formats, WKT and awkward files
European exports often use a semicolon as the delimiter and a comma as the decimal separator. The provider handles both, but only if told.
params.update({
"delimiter": ";",
"decimalPoint": ",",
"encoding": "Windows-1252",
"skipLines": "2",
"trimFields": "yes",
})
uri = f"{csv_path.as_uri()}?{urlencode(params)}"
sites = QgsVectorLayer(uri, "sensor_sites", "delimitedtext")
Breakdown: decimalPoint=, applies only to the coordinate columns and to type detection, so 53,8011 becomes a number instead of a failed parse. encoding matters for place names: a file written by Excel on a Western European Windows machine is usually Windows-1252, and reading it as UTF-8 turns every accented character into a replacement glyph. skipLines drops title rows some exports put above the real header, and trimFields removes the padding spaces that otherwise make " S01" and "S01" different values in a join.
When the geometry is not a point — a boundary exported from a database, or a line drawn in another tool — the file usually carries it as Well-Known Text in a single column. Swap the coordinate parameters for a WKT field and tell the provider what shape to expect.
wkt_params = {
"type": "csv",
"delimiter": ",",
"detectTypes": "yes",
"wktField": "geom_wkt",
"geomType": "polygon",
"crs": "EPSG:27700",
}
wkt_uri = f"{Path('/data/inbox/parcels.csv').as_uri()}?{urlencode(wkt_params)}"
parcels = QgsVectorLayer(wkt_uri, "parcels", "delimitedtext")
print(parcels.wkbType(), parcels.featureCount())
Breakdown: Without geomType, the provider inspects the first rows to decide the layer type, and a file whose first record happens to be a POINT produces a point layer on which every polygon row has no geometry. Stating the type removes the guess. For a lookup table with no location at all — a list of sensor calibrations, say — geomType=none gives a geometryless layer that can sit on the right-hand side of a join by field value.
Filter rows and pick up a re-exported file
A CSV often holds more than you want on the map — decommissioned sensors, test rows, a year of readings when you need a week. A subset string filters the layer without touching the file, and on this provider it behaves like a WHERE clause written in the QGIS expression language.
sites.setSubsetString("\"status\" = 'active' AND \"installed\" >= '2024-01-01'")
print(sites.featureCount(), "active sites")
# later, after someone overwrites the CSV with a fresh export
sites.dataProvider().reloadData()
sites.triggerRepaint()
print(sites.featureCount(), "after reload")
Breakdown: On a large file the first filtered count is slow because the provider has to scan every row; subsetIndex=yes in the URI makes it build an index of matching rows once so that subsequent requests only read those lines, which is worth turning on when a script applies one filter and then iterates many times. Date comparisons only work if detectTypes recognised the column as a date — otherwise the comparison is between strings, which happens to sort correctly for ISO YYYY-MM-DD values and silently fails for 17/09/2026.
reloadData() is the explicit form of what watchFile=yes does automatically: it drops the provider's cached rows, re-reads the file and re-runs detection. Call it when a script knows the file has changed — at the start of each iteration of a scheduled job, for instance — and check the feature count afterwards, because a re-export with a changed header row gives a valid layer with every geometry empty. Field names in the subset string must match the new header exactly; if a column was renamed the layer becomes invalid rather than unfiltered, which at least fails loudly.
Copy it into a real format
Keep the CSV layer for a quick look; copy it before doing anything else. A GeoPackage copy is editable, carries real field types that do not depend on re-detection, keeps a persistent spatial index, and does not change underneath you when the spreadsheet is re-exported.
import processing
result = processing.run("native:savefeatures", {
"INPUT": sites,
"OUTPUT": "/data/work/sensor_sites.gpkg",
"LAYER_NAME": "sensor_sites",
})
copied = QgsVectorLayer(result["OUTPUT"], "sensor_sites", "ogr")
print(copied.isValid(), copied.featureCount())
Breakdown: native:savefeatures is the Processing wrapper around the vector file writer and accepts any layer, so it avoids re-specifying fields and CRS. Rows with null geometry are copied too, which is what you want for an audit trail and not what you want for analysis — follow it with native:removenullgeometries if downstream tools choke on empty shapes. For more control over the output container, writing to a GeoPackage covers the writer options directly.
QGIS version compatibility
The delimitedtext URI parameters used here have been stable since QGIS 3.0. decimalPoint, skipLines and trimFields date back to the 2.x provider. Enum values such as QgsFeatureRequest.NoGeometry have scoped equivalents under Qgis (here Qgis.FeatureRequestFlag.NoGeometry); on the QGIS 4 series the scoped forms are the ones to use. native:savefeatures arrived in 3.24 — on older releases use QgsVectorFileWriter.writeAsVectorFormatV3 instead.
Troubleshooting
- Points appear near 0°, 0°. The coordinates are projected metres but the URI says
EPSG:4326, or the x and y fields are swapped. Latitude isyField. - Everything is a string.
detectTypes=yesis missing, or the column contains one non-numeric value such asn/a, which demotes the whole column to text. isValid()is False. The URL is malformed — usually a Windows path pasted asfile://C:\data. Build it withPath.as_uri().- Accented names show as
�. Wrongencoding; tryWindows-1252orISO-8859-1. - The layer reloads while a script runs. Set
watchFile=no. - A column called
xis used as geometry when you did not ask. Without explicit field names the provider auto-detects common coordinate headers; always passxFieldandyField.
Conclusion
Build the URI from a dictionary, state the delimiter, the coordinate fields and the CRS explicitly, and turn on type detection. Then count the null geometries straight away, because the provider will never tell you about them. Once the layer looks right, copy it to GeoPackage and do your real work there.
Frequently Asked Questions
Can I load a CSV that has no header row?
Yes — add useHeader=no. Fields are then named field_1, field_2 and so on, and xField and yField must use those names.
Why can I not edit the layer? The delimited text provider is read-only by design. Copy it to a GeoPackage or a memory layer to edit.
How do I load a tab-separated file?
Use type=csv with the two-character delimiter \t — a backslash then a t, written "\\t" in Python — which the provider reads as a tab, or use type=regexp with a delimiter pattern for anything irregular.
Does the layer update when the CSV changes?
Only if watchFile=yes, and even then only while QGIS has it open. A copy in GeoPackage never changes on its own.