GeoJSON & QGIS Export Workflows for Sensor Data

A calibrated, quality-controlled sensor archive is only useful to field ecologists and regulators once they can see it on a map, and QGIS is the tool most of them already have open. The friction is almost never the science — it is the handoff. Coordinates written in the projected CRS a hydraulic model used land in the Gulf of Guinea when QGIS assumes degrees; pandas Timestamp columns arrive as opaque epoch integers no analyst can read; a 400k-feature export locks the desktop for a minute because the whole FeatureCollection was assembled in RAM before the first byte hit disk. Getting environmental point data out of Python and into a GeoJSON layer that opens cleanly, styles predictably, and preserves the quality flags that make the data trustworthy is a specific engineering discipline. It sits at the delivery edge of the Geospatial Data Storage, Interpolation & GIS Export pipeline, translating stored and interpolated fields into an interoperable artifact anyone with QGIS can inspect.

This guide covers the parts that actually break: the WGS84 requirement that RFC 7946 imposes on GeoJSON, a feature property schema that survives the round trip into a QGIS attribute table, and chunked writing for layers too large to hold in memory at once.

Sensor data GeoJSON export flow A four-stage left-to-right pipeline: source features from PostGIS or a GeoDataFrame, reprojection to WGS84 EPSG:4326, a written GeoJSON file conforming to RFC 7946, and finally a styled QGIS layer. Source features PostGIS / GeoDataFrame Reproject to WGS84 (EPSG:4326) Write GeoJSON RFC 7946, chunked QGIS layer styled by flag / value

Prerequisites

Export is the last mile, so the assumptions it makes about upstream state are strict. Missing any of these produces a file that opens but is subtly wrong — the most expensive kind of failure, because it looks fine until an analyst trusts it.

  • Python 3.11 with geopandas==0.14.3, shapely==2.0.4, pyproj==3.6.1, and fiona==1.9.6. GeoPandas drives geometry handling and CRS transforms, Shapely backs the geometry objects, pyproj performs the reprojection, and Fiona is the OGR binding that actually writes the GeoJSON driver. Use pandas==2.2.2 for the tabular frame you build geometry from.
  • A declared source CRS. Every geometry needs a known CRS before you can reproject it. Readings stored as raw longitude/latitude are almost always WGS84 already, but coordinates arriving from a survey-grade instrument or a projected model may be in UTM, a national grid, or Web Mercator. If you are still normalizing coordinate systems at ingest, resolve that first — GeoJSON export cannot infer a CRS that was never recorded.
  • Point observations with a stable schema. Each row needs longitude, latitude, a measured value, its unit, an event timestamp, and a qc_flag. These become the source of every feature. Reading them out of a spatial store is covered in PostGIS storage and spatial indexing for sensor networks; that cluster is the canonical origin of the features you export here.
  • Quality flags already assigned. The QC flag is what makes a map trustworthy — it tells the viewer which points to believe. Flags come from the Automated Calibration, Validation & Anomaly Detection stage and must be present before export, not invented during it. Exporting unflagged data hides missing and suspect readings behind identical markers.

Step-by-Step Workflow

Step 1 — Assemble Source Features

Whether the data lives in PostGIS or a flat file, the target is the same: a GeoDataFrame with a real geometry column and a declared CRS. Building geometry from separate longitude/latitude columns is the most common path for sensor data, because readings almost always arrive as two numeric fields rather than a serialized geometry.

# python 3.11 · geopandas==0.14.3 · shapely==2.0.4 · pyproj==3.6.1 · fiona==1.9.6 · pandas==2.2.2
from __future__ import annotations
import geopandas as gpd
import pandas as pd

def build_feature_frame(
    df: pd.DataFrame,
    lon_col: str = "longitude",
    lat_col: str = "latitude",
    source_crs: str = "EPSG:4326",
) -> gpd.GeoDataFrame:
    """
    Turn a tabular sensor frame into a GeoDataFrame of point features.

    Time complexity:  O(n) — one geometry constructed per row.
    Space complexity: O(n) — geometry column added alongside attributes.
    """
    geometry = gpd.points_from_xy(df[lon_col], df[lat_col], crs=source_crs)
    gdf = gpd.GeoDataFrame(df.copy(), geometry=geometry)
    # Fail loudly if any coordinate is absent — a null geometry silently
    # becomes an empty feature QGIS renders nowhere.
    if gdf.geometry.is_empty.any() or gdf.geometry.isna().any():
        raise ValueError("Null or empty geometry present; check lon/lat columns")
    return gdf

Note the explicit crs=source_crs. A GeoDataFrame with no CRS cannot be safely reprojected — GeoPandas will refuse — and worse, it may be written as-is and mislabelled downstream. Time: O(n). Space: O(n).

Step 2 — Reproject to WGS84

RFC 7946, the current GeoJSON specification, mandates that all coordinates be WGS84 longitude/latitude in decimal degrees. There is no CRS field to override it — a compliant reader assumes EPSG:4326 unconditionally. This is the single most common reason a sensor layer lands in the ocean when opened in QGIS: the coordinates were metres in a projected system but the file claimed, by omission, to be degrees.

def to_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """
    Reproject any GeoDataFrame to EPSG:4326 for RFC 7946 compliance.
    Idempotent: a frame already in 4326 is returned unchanged.
    """
    if gdf.crs is None:
        raise ValueError("Source CRS is undefined; set it before reprojecting")
    if gdf.crs.to_epsg() == 4326:
        return gdf
    return gdf.to_crs(epsg=4326)

pyproj caches compiled transformer objects, so the per-frame cost is dominated by the point count, not transformer setup. Time: O(n). Space: O(n). After reprojection, assert that longitudes fall within -180..180 and latitudes within -90..90; values outside those bounds are an unmistakable sign the source CRS was mislabelled.

Step 3 — Design the Property Schema

Every non-geometry column becomes a GeoJSON feature property and a column in the QGIS attribute table. GeoJSON’s type system is thin — string, number, boolean, null — so anything richer must be flattened deliberately rather than left to the driver’s defaults.

def normalize_properties(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """
    Coerce property columns into GeoJSON-safe types:
      - timestamps -> ISO 8601 strings (GeoJSON has no datetime type)
      - qc_flag    -> int (styling key; keep CF Convention codes)
      - value      -> float
    """
    gdf = gdf.copy()
    if "timestamp" in gdf.columns:
        gdf["timestamp"] = pd.to_datetime(gdf["timestamp"], utc=True).dt.strftime(
            "%Y-%m-%dT%H:%M:%SZ"
        )
    if "qc_flag" in gdf.columns:
        gdf["qc_flag"] = gdf["qc_flag"].astype("int64")
    if "value" in gdf.columns:
        gdf["value"] = gdf["value"].astype("float64")
    return gdf

The QC flag stays an integer on purpose: the Automated Calibration, Validation & Anomaly Detection stage assigns CF Convention codes, and QGIS builds a categorized renderer from exact integer matches far more reliably than from free-text labels. Time: O(n). Space: O(n).

Step 4 — Write GeoJSON in Chunks

For layers that fit comfortably in memory, gdf.to_file(path, driver="GeoJSON") is all you need. The problem starts at scale: to_file builds the entire feature collection before flushing, so a multi-hundred-thousand-point export can spike memory well past the size of the data itself. Writing in row batches through Fiona bounds that footprint.

import fiona
from fiona.crs import from_epsg

def write_geojson_chunked(
    gdf: gpd.GeoDataFrame,
    path: str,
    chunk_size: int = 50_000,
) -> int:
    """
    Stream a large GeoDataFrame to a GeoJSON file in row batches.

    Returns the number of features written.
    Time complexity:  O(n).
    Space complexity: O(chunk_size) — only one batch is held at a time.
    """
    if gdf.crs is None or gdf.crs.to_epsg() != 4326:
        raise ValueError("GeoDataFrame must be in EPSG:4326 before writing GeoJSON")

    schema = gpd.io.file.infer_schema(gdf)
    written = 0
    with fiona.open(
        path, "w", driver="GeoJSON", crs=from_epsg(4326), schema=schema
    ) as sink:
        for start in range(0, len(gdf), chunk_size):
            batch = gdf.iloc[start : start + chunk_size]
            sink.writerecords(batch.iterfeatures())
            written += len(batch)
    return written

iterfeatures() yields one GeoJSON-shaped record per row without materializing them all, and Fiona flushes each batch to disk. Time: O(n). Space: O(chunk_size) rather than O(n) — the memory ceiling is set by chunk_size, not the layer size.

Step 5 — Load and Verify in QGIS

Open the file with Layer → Add Layer → Add Vector Layer. QGIS infers field types from the JSON values, so confirm in the attribute table that qc_flag is an integer and value is a real, not text. Then style by the flag, which is the whole point of preserving it — the two focused guides below carry this through end to end.


Configuration and Tuning

Property schemas are not one-size-fits-all: a PM2.5 network and a water-quality buoy array need different fields on each feature. Keep the geometry and QC flag universal, and vary the measurement-specific properties.

Sensor type Core properties QGIS styling key Notes
PM2.5 (µg/m³) value, qc_flag, timestamp, aqi_band graduated on value Pre-bin to AQI bands so categorized color needs no expression
Air temperature (°C) value, qc_flag, timestamp graduated on value Diverging ramp around a seasonal midpoint reads best
Dissolved oxygen (mg/L) value, qc_flag, timestamp, depth_m graduated on value Include depth; DO is strongly stratified
Conductivity / EC (µS/cm) value, qc_flag, timestamp graduated on value Log-scale bins; EC spans orders of magnitude
Rain gauge (mm) value, qc_flag, timestamp, interval_min graduated on value Carry accumulation interval so totals are comparable

Chunk sizing: chunk_size=50_000 balances write throughput against memory on a typical worker. Drop it to 10_000 on memory-constrained edge boxes; raise it to 100_000 where RAM is plentiful and the OGR flush overhead per batch starts to dominate.

Coordinate precision: RFC 7946 recommends about six decimal places (~0.1 m) for geographic coordinates. Trimming precision on export can cut file size by 20–40% for dense point layers with no meaningful loss for environmental point positioning.


Validation

A GeoJSON that parses is not necessarily a GeoJSON that is correct. Gate every export on structural and geographic checks before it leaves the pipeline.

import json

def validate_geojson_export(path: str, expected_count: int) -> dict:
    """Structural and geographic sanity checks on a written GeoJSON file."""
    with open(path, "r", encoding="utf-8") as fh:
        fc = json.load(fh)

    issues: list[str] = []
    feats = fc.get("features", [])
    if len(feats) != expected_count:
        issues.append(f"feature count {len(feats)} != expected {expected_count}")

    for f in feats:
        lon, lat = f["geometry"]["coordinates"][:2]
        if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
            issues.append(f"coordinate out of WGS84 bounds: {lon},{lat}")
            break
        if "qc_flag" in f["properties"] and not isinstance(
            f["properties"]["qc_flag"], int
        ):
            issues.append("qc_flag is not an integer")
            break

    return {"features": len(feats), "issues": issues, "ok": not issues}

Expected output shape: a FeatureCollection whose features array length equals your input row count, every geometry a Point with two-element WGS84 coordinates, and qc_flag present as an integer on every feature. Quality-flag distribution: on a healthy network the overwhelming majority of features carry qc_flag == 1 (good); a sudden surge of 9 (hardware failure) between exports signals a field outage, not a formatting bug.


Failure Modes and Edge Cases

Projected coordinates written as degrees. The classic null-island or ocean-drift symptom. Always reproject with to_crs(4326) and assert coordinate bounds before writing. A GeoDataFrame with crs=None is the trap — it writes without complaint and lies about its position.

Datetime columns serialized as epoch integers. Left to the driver, a pandas Timestamp may land as a large integer no analyst recognizes. Convert to ISO 8601 strings explicitly in the property-normalization step so the attribute table is human-readable and text-sortable.

Memory blowup on large layers. to_file assembles the whole collection first. For anything past ~100k features, use the chunked writer so the footprint is bounded by chunk_size. Symptoms are a long stall and a memory spike right before the file appears.

Mixed geometry types in one file. Some readers and styling workflows expect a single geometry type per layer. If a frame mixes points and the occasional buffer polygon, split them into separate files — a heterogeneous FeatureCollection is legal but frustrates QGIS’s single-symbol renderers.

Null property values changing field type. A column that is all integers except for a few Nones can be inferred as a float or string field. Fill or explicitly type QC and value columns before export so the QGIS attribute table types stay stable across runs.


Integration

GeoJSON export is where stored and validated sensor data becomes a shareable map artifact, and it connects directly to the stages on either side of it. Upstream, the features originate in PostGIS storage and spatial indexing for sensor networks — a spatial query there returns exactly the point set this workflow serializes. The quality flags that make the export trustworthy come from the Automated Calibration, Validation & Anomaly Detection pipeline, and they travel through export untouched so the map reflects the same quality assessment the numeric archive holds.

Downstream, the two focused workflows below take the file to the analyst’s screen. Exporting QC-flagged sensor data to GeoJSON for QGIS is the end-to-end function for producing a flag-preserving export, and styling GeoJSON sensor layers with QGIS-ready properties augments each feature with the color, size, and label attributes that turn a bare point layer into a legible thematic map.


FAQ

Why does my GeoJSON open in the wrong place in QGIS?

The coordinates were written in a projected CRS (such as UTM metres or Web Mercator) but the GeoJSON was tagged, or assumed, as EPSG:4326. RFC 7946 requires GeoJSON coordinates to be WGS84 longitude/latitude in decimal degrees. Reproject to EPSG:4326 with geopandas to_crs(4326) before writing, and confirm coordinate values fall within -180..180 and -90..90.

How do I keep timestamps readable in the QGIS attribute table?

Serialize timestamps to ISO 8601 strings (for example 2026-07-10T14:30:00Z) before export. GeoJSON has no native datetime type, so a pandas Timestamp is written either as an epoch integer or a driver-specific string. An explicit ISO string is unambiguous, sorts correctly as text, and QGIS can parse it with the to_datetime expression when you need temporal filtering.

What is the row limit before GeoJSON becomes impractical for QGIS?

GeoJSON is a text format with no spatial index, so QGIS reads the whole file into memory. Point layers up to roughly 100k–200k features stay responsive. Beyond that, write the file in chunks to control the producer’s memory, and consider GeoPackage or a live PostGIS connection for interactive editing. GeoJSON remains ideal for sharing snapshots and web handoff.

Should QC flags be exported as integers or strings?

Export QC flags as integers matching your CF Convention codes (for example 1 good, 4 missing, 9 hardware failure). Integer fields let QGIS build a categorized renderer with exact value matches and keep the file compact. Add a separate human-readable qc_label string only if analysts need it in the attribute table; the integer stays the styling key.


Articles in This Section

Styling GeoJSON Sensor Layers with QGIS-Ready Properties

Add QGIS-ready categorical and graduated styling attributes to GeoJSON sensor exports — flag color codes, symbol sizes, and label fields generated from Python.

Read guide

Exporting QC-Flagged Sensor Data to GeoJSON for QGIS

Export quality-flagged environmental sensor readings to WGS84 GeoJSON for QGIS with geopandas — preserving QC flags, timestamps, and units as styleable feature properties.

Read guide