Automating Shapefile to GeoJSON Conversion in QGIS
Web maps, JavaScript libraries, and REST APIs almost all speak GeoJSON, while most desktop and agency data still ships as ESRI shapefiles. Converting one to the other by hand in the Save Layer As dialog is fine for a single file, but it does not scale to the folders of shapefiles that real delivery pipelines produce. PyQGIS closes that gap: the QgsVectorFileWriter API writes GeoJSON headlessly from the Python Console, a standalone script, or a plugin, so the same conversion runs identically on your laptop and on a server. This is a routine Vector Data Manipulation task and it pairs naturally with format work once other layers have been merged or clipped.
This page shows how to convert a single shapefile with writeAsVectorFormatV3(), reproject to WGS84 correctly on the way out (GeoJSON's RFC 7946 mandates it), and then loop over an entire directory. It also covers the encoding, geometry-validity, and environment traps that cause silent failures, plus reliable fallbacks when the QGIS Python environment is unavailable.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) with GDAL/OGR available — GDAL ships with every standard QGIS install and provides the GeoJSON driver.
- A source shapefile with its sidecar files present (
.shp,.shx,.dbf, and ideally.prjand.cpg). A missing.prjmeans QGIS cannot know the source CRS. - Read access to the source and write access to the output folder.
- The QGIS Python Console (
Plugins > Python Console) for interactive runs, or a configured standalone environment for headless use.
If you run outside QGIS, the interpreter must be QGIS's bundled Python with QGIS_PREFIX_PATH and PYTHONPATH set and QgsApplication.initQgis() called first; otherwise the vector drivers will not load.
Convert a Single Shapefile
The core recipe loads the .shp as a QgsVectorLayer, configures a SaveVectorOptions object for the GeoJSON driver, and writes it with writeAsVectorFormatV3(). On QGIS 3.16+ this is the current, non-deprecated writer:
from pathlib import Path
from qgis.core import (
QgsVectorLayer,
QgsVectorFileWriter,
QgsProject,
)
def convert_shp_to_geojson(input_path: str, output_path: str) -> None:
layer = QgsVectorLayer(input_path, Path(input_path).stem, "ogr")
if not layer.isValid():
raise RuntimeError(f"Failed to load shapefile: {input_path}")
opts = QgsVectorFileWriter.SaveVectorOptions()
opts.driverName = "GeoJSON"
opts.fileEncoding = "UTF-8"
# RFC7446-compliant output: right-hand-rule winding + trimmed coordinates
opts.layerOptions = ["RFC7946=YES"]
err_code, err_msg, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
layer,
output_path,
QgsProject.instance().transformContext(),
opts,
)
if err_code != QgsVectorFileWriter.NoError:
raise RuntimeError(f"Export error ({err_code}): {err_msg}")
print(f"Success: {Path(input_path).name} -> {Path(output_path).name}")
convert_shp_to_geojson("/data/parcels.shp", "/data/out/parcels.geojson")
Breakdown: QgsVectorLayer(..., "ogr") opens the shapefile through the OGR provider; the validity check stops a bad path or missing sidecar from producing a confusing driver error later. SaveVectorOptions is the single place to configure the export — driverName selects GeoJSON and fileEncoding forces UTF-8 so accented attribute values survive. The RFC7946=YES layer option tells the GDAL GeoJSON driver to emit spec-compliant output. writeAsVectorFormatV3 returns a four-tuple (error_code, error_message, new_filename, new_layer_name) — always unpack all four and compare error_code against QgsVectorFileWriter.NoError rather than assuming success.
Reproject to WGS84 During Export
RFC 7946 requires GeoJSON coordinates in WGS84 (EPSG:4326). If your shapefile is in a projected CRS — a UTM zone, a national grid, Web Mercator — writing it unchanged produces coordinates in metres that web maps will place in the ocean off West Africa. Attach a QgsCoordinateTransform to the save options so the writer reprojects each feature on the way out. Getting the source and destination CRS right is the same discipline covered in Coordinate Reference Systems:
from qgis.core import (
QgsVectorLayer,
QgsVectorFileWriter,
QgsCoordinateReferenceSystem,
QgsCoordinateTransform,
QgsProject,
)
layer = QgsVectorLayer("/data/parcels_utm.shp", "parcels", "ogr")
dest_crs = QgsCoordinateReferenceSystem("EPSG:4326")
context = QgsProject.instance().transformContext()
opts = QgsVectorFileWriter.SaveVectorOptions()
opts.driverName = "GeoJSON"
opts.fileEncoding = "UTF-8"
opts.layerOptions = ["RFC7946=YES"]
if layer.crs().authid() != "EPSG:4326":
opts.ct = QgsCoordinateTransform(layer.crs(), dest_crs, context)
err_code, err_msg, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
layer, "/data/out/parcels.geojson", context, opts
)
print("OK" if err_code == QgsVectorFileWriter.NoError else err_msg)
Breakdown: opts.ct is the coordinate transform the writer applies per feature. Building it only when the source is not already EPSG:4326 avoids a needless identity transform. The transform pulls its datum-shift parameters from the project's transformContext, which is why the same context object is passed both to the options (implicitly, via the transform) and to writeAsVectorFormatV3. Always spot-check the first few output coordinates: values that still look like hundreds of thousands mean the source .prj was missing and QGIS never knew the true source CRS.
Convert an Entire Folder
The scalable payoff is batching. Glob the source directory with pathlib, derive each output name from the input stem, and reuse the single-file function so one failure does not abort the run. This is the same looping pattern used across batch processing with PyQGIS:
from pathlib import Path
src_dir = Path("/data/shapefiles")
out_dir = Path("/data/geojson")
out_dir.mkdir(parents=True, exist_ok=True)
shapefiles = sorted(src_dir.glob("*.shp"))
if not shapefiles:
raise FileNotFoundError(f"No shapefiles in {src_dir}")
failures = []
for shp in shapefiles:
out_path = out_dir / f"{shp.stem}.geojson"
try:
convert_shp_to_geojson(str(shp), str(out_path))
except RuntimeError as exc:
failures.append((shp.name, str(exc)))
print(f"Converted {len(shapefiles) - len(failures)} of {len(shapefiles)} files")
for name, err in failures:
print(f" FAILED {name}: {err}")
Breakdown: sorted(src_dir.glob("*.shp")) gathers inputs deterministically; swap in rglob to recurse into subfolders. Each output path is built from the input stem so parcels.shp becomes parcels.geojson. Wrapping each conversion in try/except collects failures into a report instead of stopping at the first bad file — essential when one corrupt shapefile sits in a batch of hundreds. The early FileNotFoundError prevents a silent no-op when the glob pattern or path is wrong.
What the format change costs you
Shapefile and GeoJSON disagree about several things, and the conversion resolves each disagreement silently. Knowing which resolutions happen prevents the surprises that surface downstream.
The CRS row is the one that bites. GeoJSON as published assumes WGS84, and every web consumer will read it that way regardless of what your .prj said — which is exactly why the reprojection step above is not optional dressing but the part that makes the output correct.
Keep the output small
A GeoJSON of survey-precision coordinates is often ten times larger than it needs to be for a web map, and the two levers that matter are coordinate precision and vertex count.
Both levers are lossy in a way that matters only if the GeoJSON is the archive copy. Keep the GeoPackage as the source of truth and treat the GeoJSON as a derived, disposable publication artefact — then trimming it aggressively costs nothing. Simplify Geometry in PyQGIS covers choosing the tolerance from the target scale.
Fallback Methods
When the QGIS Python environment is unavailable or throws driver conflicts, two proven alternatives produce identical output:
- GDAL/OGR CLI. Bypass QGIS entirely with
ogr2ogr. It is the fastest option on a headless server and carries no Python startup cost:ogr2ogr -f GeoJSON -t_srs EPSG:4326 -lco RFC7946=YES -overwrite output.geojson input.shp - Processing Toolbox batch (no code). Open the Processing Toolbox (
Ctrl+Alt+T), search Convert format, right-click the tool and choose Execute as Batch Process. Add every shapefile, set the output format toGeoJSON, and run. It is the friendliest route for non-programmers but offers no programmatic error handling.
QGIS Version Compatibility
The code targets QGIS 3.34 LTR (Python 3.12). The writer API differs across releases:
| QGIS version | Python | Notes |
|---|---|---|
| 3.10–3.14 | 3.7–3.8 | writeAsVectorFormatV3 is unavailable — use writeAsVectorFormatV2 with the same SaveVectorOptions. |
| 3.28 LTR | 3.9 | writeAsVectorFormatV3 available; opts.ct reprojection identical. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | writeAsVectorFormatV3 unchanged; GDAL's GeoJSON driver still honours RFC7946=YES. |
The bare QgsVectorFileWriter.writeAsVectorFormat (no version suffix) is deprecated and will emit warnings — prefer the V3 method on any 3.16+ install.
Troubleshooting
Driver not found. GDAL is not exposing the GeoJSON driver. Confirm GDAL is bundled with your QGIS install; on Windows reinstall via the OSGeo4W installer, on Linux installpython3-gdalalongside QGIS.- Field names truncated or characters garbled. Shapefiles cap field names at 10 characters and use legacy codepages, so corruption originates on the read, not the write. Keep
opts.fileEncoding = "UTF-8"and ensure a matching.cpgfile sits beside the.shp. If it persists, convert to GeoPackage first, then export that to GeoJSON. - Export succeeds but features are missing. Invalid geometries — self-intersections, unclosed rings — are dropped silently. Repair them before writing:
Comparingimport processing fixed = processing.run("native:fixgeometries", { "INPUT": layer, "OUTPUT": "TEMPORARY_OUTPUT" })["OUTPUT"]featureCount()before and after export catches silent drops quickly. - Coordinates land in the wrong place. The source CRS was wrong or unknown (missing
.prj), or you skipped the reprojection step. Setlayer.setCrs(...)explicitly if the.prjis absent, then attachopts.ct. - Out-of-memory on large exports. GeoJSON is read entirely into RAM by most consumers, so files above ~50–100 MB strain browsers. Split by attribute, or stream with
ogr2ogr -gt 10000to batch features.
Conclusion
Automating shapefile-to-GeoJSON conversion in PyQGIS comes down to three reliable moves: load and validate the layer, configure SaveVectorOptions with the GeoJSON driver, UTF-8 encoding, and RFC7946=YES, and write it with writeAsVectorFormatV3() while attaching a QgsCoordinateTransform whenever the source is not already WGS84. Wrap that in a pathlib loop with per-file error collection and the same recipe scales from one file to an entire delivery folder. Handled this way inside broader Spatial Data Processing & Automation pipelines, the conversion becomes a reproducible, version-controlled step rather than a manual chore — and validating output through a lightweight GeoJSON linter before deployment keeps web-map consumers happy.
Frequently Asked Questions
Does my GeoJSON output need to be in WGS84 (EPSG:4326)?
Yes. RFC 7946 mandates WGS84 and most web-mapping libraries assume it. If the source shapefile is in a projected CRS, attach a QgsCoordinateTransform to opts.ct so PyQGIS reprojects during export, or use ogr2ogr -t_srs EPSG:4326. Skip it and coordinates stay in projected units that web maps will misplace.
Which writer method should I use on QGIS 3.34 LTR?
Use writeAsVectorFormatV3, available from QGIS 3.16 and the current standard on 3.34 LTR. It returns a four-tuple (error_code, error_message, new_filename, new_layer_name) — unpack all four and check error_code against QgsVectorFileWriter.NoError. The V2 method is only needed on releases between 3.10 and 3.14.
Why are my field names truncated or my characters garbled after conversion?
Shapefiles cap field names at 10 characters and use legacy codepages, so the issue surfaces on the read, not the write. Keep opts.fileEncoding = "UTF-8" and make sure a matching .cpg file sits beside the shapefile. If corruption persists, convert the shapefile to GeoPackage first, then export that to GeoJSON.
How do I convert an entire folder of shapefiles in one run?
Glob the directory with pathlib.Path.glob("*.shp"), derive each output path from the input stem, and call the conversion function in a loop wrapped in try/except so one bad file does not abort the batch. For headless servers you can loop ogr2ogr instead, or use the Processing Toolbox "Convert format" tool's Execute as Batch Process option for a no-code approach.
My export runs but the GeoJSON is missing features — what happened?
Invalid geometries such as self-intersections or unclosed rings are dropped silently during export. Run native:fixgeometries on the layer before writing so the geometries are repaired first, and compare feature counts before and after to confirm nothing was lost.