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.

Shapefile to GeoJSON conversion pipeline in PyQGISA source shapefile with its sidecar files is loaded as a QgsVectorLayer through the ogr provider, reprojected to EPSG:4326 with a QgsCoordinateTransform, and written as RFC7946 UTF-8 GeoJSON by QgsVectorFileWriter, producing a web-ready WGS84 output. A guardrail branch validates geometry before writing and spot-checks encoding and coordinates after.Shapefile to GeoJSON: load, reproject, writeSourceshapefile.shp .shx .dbf.prj .cpgQgsVectorLayerprovider: ogrisValid()QgsCoordinateTransformreprojectto EPSG:4326QgsVectorFileWriterV3 · RFC7946UTF-8 encodingGeoJSON.geojson outputWGS84, web-readyGuardrails around the writeBefore: isGeosValid() · fixgeometries repairAfter: matching .cpg codepage · spot-check output coords

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 .prj and .cpg). A missing .prj means 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.

What changes when a shapefile becomes GeoJSONFive rows compare the two formats. Shapefile truncates field names to ten characters while GeoJSON has no limit. Shapefile encoding depends on a companion file whereas GeoJSON is always UTF-8. Shapefile stores the CRS in a projection file whereas GeoJSON assumes WGS84. Shapefile fixes one geometry type per file whereas GeoJSON allows mixed types. Shapefile is several files whereas GeoJSON is one.Five differences the conversion resolves for youaspectshapefileGeoJSONfield name length10 charactersunrestrictedencodinga .cpg file, if presentalways UTF-8CRSa .prj fileassumed WGS84geometry typesone per filemixed allowedfiles on disk4 or moreexactly 1

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.

Two levers on GeoJSON file sizeThree bars show file size. Full precision with fifteen decimal places is the largest. Reducing to six decimal places, which is about eleven centimetres on the ground, roughly halves it. Adding simplification at a tolerance invisible at the target scale halves it again. Each bar is annotated with the resulting ground accuracy.Six decimals is 11 cm — more than any web map can showfull precision15 decimals · accurate to a nanometreCOORDINATE_PRECISION=6≈ 11 cm on the ground+ simplifyinvisible at the target scalesmallerlarger

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:

  1. 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
    
  2. 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 to GeoJSON, 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 versionPythonNotes
3.10–3.143.7–3.8writeAsVectorFormatV3 is unavailable — use writeAsVectorFormatV2 with the same SaveVectorOptions.
3.28 LTR3.9writeAsVectorFormatV3 available; opts.ct reprojection identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12writeAsVectorFormatV3 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 install python3-gdal alongside 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 .cpg file 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:
    import processing
    fixed = processing.run("native:fixgeometries", {
        "INPUT": layer, "OUTPUT": "TEMPORARY_OUTPUT"
    })["OUTPUT"]
    
    Comparing 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. Set layer.setCrs(...) explicitly if the .prj is absent, then attach opts.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 10000 to 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.