Learn

GeoJSON with PostGIS and PostgreSQL

PostGIS, the PostgreSQL extension for geographic data, treats GeoJSON as a first-class interchange format. Two functions handle the conversion in both directions.

Set up a geometry column

Before storing geographic data, add a geometry column to your table and index it with a GiST index. The index makes spatial queries — intersections, distance searches, bounding box lookups — use the index instead of scanning every row.

CREATE TABLE locations (
  id   serial PRIMARY KEY,
  name text,
  geom geometry(Geometry, 4326)
);

CREATE INDEX locations_geom_idx ON locations USING GIST (geom);

geometry(Geometry, 4326) accepts any geometry type (Point, Polygon, LineString, etc.) and stores coordinates in EPSG:4326 — the same coordinate system GeoJSON requires. If you know all rows will be Points, use geometry(Point, 4326) to enforce it at the column level.

GeoJSON → geometry: ST_GeomFromGeoJSON

ST_GeomFromGeoJSON converts a GeoJSON geometry string to a PostGIS geometry value. Pass it a JSON string — not a full Feature, just the geometry object:

INSERT INTO locations (name, geom)
VALUES (
  'Eiffel Tower',
  ST_GeomFromGeoJSON('{"type":"Point","coordinates":[2.2945,48.8584]}')
);

Note: ST_GeomFromGeoJSON accepts the geometry part of a GeoJSON Feature, not the full Feature wrapper ({"type":"Feature",...}). Strip the wrapper before passing it in.

From a web application, you'll typically receive a full GeoJSON Feature from the client. Extract the geometry in your application layer before the INSERT, or use PostgreSQL's -> operator to pull it out of a jsonb column:

INSERT INTO locations (name, geom)
SELECT
  data->>'name',
  ST_GeomFromGeoJSON(data->'geometry')
FROM (VALUES ('{"type":"Feature","properties":{"name":"Eiffel Tower"},"geometry":{"type":"Point","coordinates":[2.2945,48.8584]}}'::jsonb)) AS t(data);

geometry → GeoJSON: ST_AsGeoJSON

ST_AsGeoJSON converts a PostGIS geometry value back to a GeoJSON geometry string:

SELECT name, ST_AsGeoJSON(geom) AS geojson
FROM locations
WHERE name = 'Eiffel Tower';
-- {"type":"Point","coordinates":[2.2945,48.8584]}

The return type is text. The output is the geometry object only — no Feature wrapper, no properties. You'll need to construct those in SQL or in your application layer.

Spatial query: find features within a polygon

ST_Intersects returns rows whose geometry overlaps the query shape. Pass the query shape as a GeoJSON geometry string using ST_GeomFromGeoJSON:

SELECT id, name
FROM locations
WHERE ST_Intersects(
  geom,
  ST_GeomFromGeoJSON('{
    "type": "Polygon",
    "coordinates": [[[2.28,48.85],[2.32,48.85],[2.32,48.87],[2.28,48.87],[2.28,48.85]]]
  }')
);

This query uses the GiST index on geom — PostGIS applies the index for the bounding box pre-filter, then ST_Intersects confirms the exact intersection. For large tables (millions of rows), the index is what keeps this under a millisecond.

Other spatial predicates follow the same pattern: ST_Within(geom, shape) returns rows entirely inside the shape; ST_DWithin(geom, shape, distance_metres) returns rows within a given distance.

Build a full FeatureCollection in SQL

To return a GeoJSON FeatureCollection directly from PostgreSQL — ready to send as an API response — combine ST_AsGeoJSON with json_build_object and json_agg:

SELECT json_build_object(
  'type', 'FeatureCollection',
  'features', json_agg(
    json_build_object(
      'type', 'Feature',
      'geometry', ST_AsGeoJSON(geom)::jsonb,
      'properties', json_build_object('name', name, 'id', id)
    )
  )
)
FROM locations;

The result is a single JSON object you can return directly from a SELECT in your API layer. Add a WHERE clause to filter by geography or property before aggregating.

Watch out: ST_AsGeoJSON returns text, not jsonb. When building a FeatureCollection with json_agg, cast to jsonb first (ST_AsGeoJSON(geom)::jsonb) or the geometry will be double-encoded as a JSON string inside your JSON.

Precision and rounding

ST_AsGeoJSON defaults to 9 decimal places, which is sub-millimetre precision — more than any web map needs and bloats your payload. Pass a second argument to cap it:

SELECT ST_AsGeoJSON(geom, 6) AS geojson FROM locations;
-- 6 decimal places ≈ 11 cm precision at the equator

Six decimal places is the right default for web mapping. Eight if you're storing survey data.


For GeoJSON format reference, see What is GeoJSON?. For polygon winding order (which PostGIS enforces), see GeoJSON polygon. For a Python-based approach to reading and writing GeoJSON, see GeoJSON with Python. For Elasticsearch geo_shape indexing, see GeoJSON with Elasticsearch.

If you're building a map interface on top of a PostGIS database, Element Editor is an open-source React library that handles the editing layer — draw, edit, and export back to GeoJSON for storage.

Continue reading

Mike Acler

Mike Acler

Design engineer and creator of Element Editor, an open-source map drawing and GeoJSON editing library for React. Writes about web cartography and GIS tooling.