Export an Attribute Table to CSV with PyQGIS
Handing data to someone who does not use QGIS almost always means a CSV. It is tempting to write one by hand — open a file, join the values with commas — and that works right up until a field contains a comma, a quote, an accented character or a line break. QgsVectorFileWriter uses the GDAL CSV driver, which handles all four correctly and additionally gives you column selection, encoding control and optional geometry in one call.
This page is a focused recipe within Attribute Tables and Field Management in PyQGIS. It covers a basic export, choosing columns, encoding and separators, including geometry as WKT, and exporting only the rows that matter.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A loaded vector layer, or a path you can open with
QgsVectorLayer. - Write permission to the output directory.
The basic export
writeAsVectorFormatV3() is the current entry point and takes its settings through an options object.
from qgis.core import (
QgsCoordinateTransformContext,
QgsProject,
QgsVectorFileWriter,
)
layer = QgsProject.instance().mapLayersByName("parcels")[0]
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "CSV"
options.fileEncoding = "UTF-8"
error, message, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
layer,
"/data/output/parcels.csv",
QgsCoordinateTransformContext(),
options,
)
if error != QgsVectorFileWriter.NoError:
raise RuntimeError(f"export failed: {message}")
print("written")
Breakdown: driverName = "CSV" selects the GDAL driver; the file extension alone is not enough. fileEncoding = "UTF-8" should be set explicitly rather than relying on the system default, which on Windows is often a legacy code page that mangles accented characters. The function returns a four-tuple and does not raise on failure — checking error != NoError is the only way to notice a permission problem or a locked file.
Choose which columns to export
An export of every column is rarely what the recipient wants, and it leaks internal working fields.
WANTED = ["parcel_id", "owner", "zoning", "assessed_value"]
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "CSV"
options.fileEncoding = "UTF-8"
options.attributes = [layer.fields().indexOf(n) for n in WANTED]
missing = [n for n, i in zip(WANTED, options.attributes) if i == -1]
if missing:
raise KeyError(f"missing field(s): {', '.join(missing)}")
Breakdown: attributes takes field indexes, in the order you want the columns to appear — so the list doubles as a column ordering. Checking for -1 before writing turns a silently missing column into an explicit failure; without it the export succeeds with fewer columns than expected, which is easy to miss in a file nobody opens until next week.
Encoding, separators and geometry
The CSV driver's behaviour is tuned through layerOptions, a list of KEY=VALUE strings passed straight to GDAL.
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "CSV"
options.fileEncoding = "UTF-8"
options.layerOptions = [
"GEOMETRY=AS_WKT", # add a WKT geometry column
"SEPARATOR=SEMICOLON", # for locales where comma is the decimal mark
"STRING_QUOTING=IF_AMBIGUOUS",
]
Breakdown: GEOMETRY=AS_WKT prepends a WKT column holding each feature's geometry as text — omit it entirely for a pure attribute table, and use GEOMETRY=AS_XY for a point layer where separate X and Y columns are friendlier. SEPARATOR=SEMICOLON matters in locales where the comma is the decimal separator and Excel expects semicolons. STRING_QUOTING=IF_AMBIGUOUS quotes only the values that need it, which keeps the file readable while staying correct.
If the recipient will reopen the file in QGIS, GEOMETRY=AS_WKT round-trips faithfully — but remember WKT carries no CRS, so send the EPSG code alongside it or the coordinates are ambiguous. See Coordinate Reference Systems in PyQGIS.
Export only the rows you need
Writing the whole table and filtering afterwards wastes time and hands over data the recipient did not ask for. Filter at the source.
from qgis.core import (
QgsCoordinateTransformContext,
QgsFeatureRequest,
QgsProject,
QgsVectorFileWriter,
)
layer = QgsProject.instance().mapLayersByName("parcels")[0]
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "CSV"
options.fileEncoding = "UTF-8"
options.attributes = [layer.fields().indexOf(n) for n in ("parcel_id", "assessed_value")]
request = QgsFeatureRequest()
request.setFilterExpression('"zoning" = \'commercial\' AND "assessed_value" > 500000')
request.setFlags(QgsFeatureRequest.NoGeometry)
options.filterExtent = layer.extent()
options.symbologyExport = QgsVectorFileWriter.NoSymbology
writer_layer = layer.materialize(request)
error, message, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
writer_layer, "/data/output/commercial.csv",
QgsCoordinateTransformContext(), options,
)
if error != QgsVectorFileWriter.NoError:
raise RuntimeError(message)
print(writer_layer.featureCount(), "row(s) exported")
Breakdown: layer.materialize(request) produces an in-memory layer containing only the matching features, which the writer then exports in full — this is the cleanest way to combine a filter with an export, because writeAsVectorFormatV3() has no filter parameter of its own. NoGeometry on the request avoids decoding geometry that the CSV will not contain anyway. NoSymbology suppresses style export, which is meaningless for CSV and merely slows things down.
The alternative — exporting only the current selection — is a one-line change, and pairs naturally with Select Features by Expression in PyQGIS:
options.onlySelectedFeatures = True
Breakdown: With this flag the writer exports only what is currently selected on the layer, which is exactly what a plugin's "Export selection" button should do. It is ignored when nothing is selected, producing a full export rather than an empty file — worth guarding with a selectedFeatureCount() check if an empty selection should be an error.
Make the file reopenable
A CSV that QGIS or a colleague can load back without guesswork needs two companion files. Writing them alongside the export takes a few lines and removes an entire category of support questions.
"ETRS89 …"
from pathlib import Path
from qgis.PyQt.QtCore import QVariant
CSVT = {
QVariant.Int: "Integer",
QVariant.LongLong: "Integer64",
QVariant.Double: "Real",
QVariant.Date: "Date",
QVariant.DateTime: "DateTime",
}
out = Path("/data/output/parcels.csv")
exported = [layer.fields()[i] for i in options.attributes]
types = ['"WKT"'] if "GEOMETRY=AS_WKT" in options.layerOptions else []
types += [f'"{CSVT.get(f.type(), "String")}"' for f in exported]
out.with_suffix(".csvt").write_text(",".join(types), encoding="utf-8")
out.with_suffix(".prj").write_text(layer.crs().toWkt(), encoding="utf-8")
Breakdown: A .csvt file is a single line of quoted type names, positionally matched to the CSV's columns — get the order wrong and every column is mistyped, which is why deriving it from the same options.attributes list used for the export is important. The WKT entry has to lead when a geometry column was requested, because the driver writes it first. The .prj file carries the CRS as WKT, which is what makes a GEOMETRY=AS_WKT column unambiguous. Both are read automatically by QGIS, GDAL and most other tools when the CSV is opened.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | writeAsVectorFormatV3 available; the older V2 form is deprecated but still present. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | SaveVectorOptions gained layerMetadata; CSV driver options unchanged. |
The SaveVectorOptions fields and GDAL CSV layer options have been stable across 3.x.
Troubleshooting
- The function returned no error but the file is missing. The return value was not checked.
writeAsVectorFormatV3()returns an error code rather than raising. - Accented characters are mangled.
fileEncodingwas left at the default. Set it to"UTF-8"explicitly, and tell the recipient — Excel needs a byte-order mark or an import step to read UTF-8 CSV correctly. - Excel puts everything in one column. The locale expects semicolons. Add
SEPARATOR=SEMICOLONtolayerOptions. - Numbers appear as text in the spreadsheet. The field is a string in the source layer, so the CSV is faithful. Fix the type at the layer level rather than in the export.
- The geometry column is missing. No
GEOMETRY=layer option was set. AddAS_WKT, orAS_XYfor points. - Fewer columns than expected. One of the names in the
attributeslist resolved to-1. Validate before writing.
Conclusion
Let QgsVectorFileWriter handle the quoting, escaping and encoding — the failure modes it prevents are exactly the ones that survive testing and break on real data. Set the encoding explicitly, choose the columns deliberately, check the returned error code, and use materialize() when only a subset should leave the building.
Frequently Asked Questions
Why is my exported CSV empty or missing?writeAsVectorFormatV3() returns an error code instead of raising, so a permissions problem or a locked file passes silently. Always compare the first element of the returned tuple against QgsVectorFileWriter.NoError.
How do I include the geometry in the CSV?
Add "GEOMETRY=AS_WKT" to options.layerOptions for a WKT column, or "GEOMETRY=AS_XY" for separate coordinate columns on a point layer. WKT carries no CRS, so send the EPSG code with the file.
Can I export only the selected features?
Yes — set options.onlySelectedFeatures = True. Note that with nothing selected it exports everything rather than producing an empty file, so guard it with a selectedFeatureCount() check.
Why does Excel show my accented characters incorrectly? The file is UTF-8 but Excel is reading it as a legacy code page. Import the CSV through Excel's text-import wizard specifying UTF-8, or export with an encoding your recipient's tooling expects.
Can I append to an existing CSV rather than replacing it?
Set options.actionOnExistingFile to the append value. It is worth confirming the field order matches, because the writer appends positionally and will happily produce a file whose later rows are misaligned with its header.