Skip to main content

GeoJSON serialization/deserialization of geospatial geometries for django-ninja (GeoDjango / GEOS).

Project description

django-ninja-spatial

GeoJSON & Mapbox Vector Tile serialization for django-ninja + GeoDjango.
Turn GEOSGeometry model fields into clean GeoJSON APIs — and vector tiles — with almost no boilerplate.

Python Django django-ninja pydantic License: MIT


django-ninja-spatial makes your spatial models speak GeoJSON. Point your ModelSchema at a model with GeoDjango geometry fields and they serialize to — and validate from — RFC 7946 GeoJSON automatically:

from ninja import NinjaAPI, ModelSchema
from ninja_spatial import register_geometry_fields
from places.models import Place                 # has location = models.PointField()

register_geometry_fields()                       # once, at startup

class PlaceSchema(ModelSchema):
    class Meta:
        model = Place
        fields = "__all__"
// GET /api/places/1  ->
{
  "id": 1,
  "name": "Duomo di Milano",
  "location": { "type": "Point", "coordinates": [9.1916, 45.4641] }
}

No serializers to hand-write, no geom.json plumbing, no GDAL round-trips.

Features

  • 🗺️ All seven geometry types — Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection, plus a generic any-geometry type.
  • 🔄 Bidirectional — GeoJSON in (validated to GEOSGeometry) and GeoJSON out, in both Schema fields and ModelSchema.
  • Real validation — RFC 7946 rules (position arity, LineString ≥ 2 points, closed linear rings) with precise pydantic errors and accurate OpenAPI/Swagger docs.
  • 🧊 Mapbox Vector Tiles — encode/decode MVT and serve XYZ tiles with proper Web Mercator quantization.
  • 🪶 GDAL-free conversion — works straight off GEOS, preserves Z coordinates and empty geometries.
  • 🧩 Layered & optional — the pure-Pydantic GeoJSON schemas need only pydantic; the GeoDjango and MVT bits are opt-in extras.
  • 🔢 Pydantic v2 native, typed (py.typed), tested across the whole matrix.

Table of contents

Installation

pip install django-ninja-spatial            # core (pydantic only)
pip install "django-ninja-spatial[ninja]"   # + Django & django-ninja (GeoDjango bridge)
pip install "django-ninja-spatial[mvt]"     # + Mapbox Vector Tile support (shapely)

GeoDjango itself (GEOS, and GDAL/PROJ for a spatial DB backend) must be available in your environment as usual — see the GeoDjango install guide.

Quick start

Register the geometry field types once (e.g. in your app's AppConfig.ready):

# apps.py
from django.apps import AppConfig
from ninja_spatial import register_geometry_fields

class PlacesConfig(AppConfig):
    name = "places"
    def ready(self):
        register_geometry_fields()
# models.py
from django.contrib.gis.db import models

class Place(models.Model):
    name = models.CharField(max_length=100)
    location = models.PointField()
    area = models.PolygonField(null=True, blank=True)
# api.py
from ninja import NinjaAPI, ModelSchema
from places.models import Place

api = NinjaAPI()

class PlaceSchema(ModelSchema):
    class Meta:
        model = Place
        fields = "__all__"

@api.get("/places/{place_id}", response=PlaceSchema)
def get_place(request, place_id: int):
    return Place.objects.get(pk=place_id)

That's it — geometry columns serialize to GeoJSON and the OpenAPI schema describes them.

Field types

Use these annotated types (all importable from ninja_spatial) in any Schema. They validate input GeoJSON/WKT into a GEOSGeometry and serialize a GEOSGeometry back to a GeoJSON object.

Field Accepts / emits GeoDjango model field it maps to
GeometryField any of the seven types GeometryField
PointField Point PointField
LineStringField LineString LineStringField
PolygonField Polygon PolygonField
MultiPointField MultiPoint MultiPointField
MultiLineStringField MultiLineString MultiLineStringField
MultiPolygonField MultiPolygon MultiPolygonField
GeometryCollectionField GeometryCollection GeometryCollectionField

Each accepts a GeoJSON object (dict), a GeoJSON / WKT / EWKT / HEXEWKB string, an existing GEOSGeometry, or a ninja_spatial.geojson schema instance. Typed fields reject the wrong geometry type with a 422.

Usage

ModelSchema (the easy path)

After register_geometry_fields(), ModelSchema introspects GeoDjango geometry columns automatically (see Quick start). Nothing else to do.

Explicit Schema fields

Write schemas by hand when you want full control. On the way in, fields validate GeoJSON and produce a real GEOSGeometry; on the way out, they serialize a GEOSGeometry to a GeoJSON object.

from typing import Optional
from ninja import NinjaAPI, Schema
from ninja_spatial import PointField, PolygonField, GeometryField

api = NinjaAPI()

class PlaceIn(Schema):
    name: str
    location: PointField                      # must be a Point
    area: Optional[PolygonField] = None       # nullable -> wrap in Optional
    anything: Optional[GeometryField] = None  # any of the 7 geometry types

@api.post("/places")
def create_place(request, payload: PlaceIn):
    # payload.location is a django.contrib.gis.geos.Point (srid 4326)
    place = Place.objects.create(name=payload.name, location=payload.location)
    return {"id": place.id}

[!TIP] Declare nullable geometry fields as Optional[...] (e.g. Optional[PointField] = None). A bare PointField = None is not optional at the type level and will reject null.

Need a non-default SRID? Use the factory: geometry_field(geom_types=("Point",), srid=3857).

Pure-Pydantic GeoJSON schemas (no GeoDjango)

The ninja_spatial.geojson module is plain pydantic v2 — use it anywhere, no GeoDjango required:

from ninja_spatial.geojson import Point, Geometry, parse_geometry

Point.model_validate({"type": "Point", "coordinates": [1.0, 2.0]})

# Geometry is a discriminated union over the "type" member ("generic geometry"):
geom = parse_geometry({"type": "LineString", "coordinates": [[0, 0], [1, 1]]})
type(geom).__name__   # "LineString"
from ninja import Schema
from ninja_spatial.geojson import Geometry

class Shape(Schema):
    geometry: Geometry

Conversion helpers

from ninja_spatial import geos_to_geojson, geojson_to_geos

geom = geojson_to_geos({"type": "Point", "coordinates": [1, 2]})  # -> GEOSGeometry (srid 4326)
geos_to_geojson(geom)                                             # -> {"type": "Point", ...}
geos_to_geojson(geom, precision=6)                                # round coordinates

geojson_to_geos(value, srid=...) defaults to srid=4326 (the GeoJSON CRS); pass None to leave it unset. The schemas also expose schema.to_geos(srid=...) and Model.from_geos(geom, precision=...).

Mapbox Vector Tiles

Install the extra (it brings in shapely): pip install "django-ninja-spatial[mvt]".

Serve an XYZ tile — encode_tile reprojects your geometries (default SRID 4326) to Web Mercator and quantizes them to the tile:

from django.http import HttpResponse
from ninja_spatial import mvt
from places.models import Place

@api.get("/tiles/{int:z}/{int:x}/{int:y}")
def tile(request, z: int, x: int, y: int):
    minx, miny, maxx, maxy = mvt.tile_bounds(z, x, y)        # EPSG:3857
    rows = Place.objects.all()                               # filter to the bbox in prod
    data = mvt.encode_tile(
        [{"name": "places",
          "features": [{"geometry": p.location,
                        "properties": {"id": p.id, "name": p.name}} for p in rows]}],
        z, x, y,
    )
    return HttpResponse(data, content_type="application/vnd.mapbox-vector-tile")

Lower level (geometries already in tile/local coordinates, no GeoDjango needed):

tile = mvt.encode_layer(
    "places",
    [{"geometry": {"type": "Point", "coordinates": [50, 50]}, "properties": {"id": 1}}],
    quantize_bounds=(0, 0, 100, 100), extent=4096,
)
mvt.decode(tile)   # {"places": {"features": [...]}}

Geometries accept the same forms as everywhere else. MVT covers Point/LineString/Polygon and their Multi variants; GeometryCollection is not an MVT geometry.

Example project

A complete, runnable GeoDjango + django-ninja app lives in example/: GeoJSON CRUD, bounding-box and nearest-neighbour queries, point-in-polygon, and a vector-tile endpoint — all backed by SpatiaLite (no PostGIS server needed).

cd example
pip install -r requirements.txt
python manage.py migrate && python manage.py seed
python manage.py runserver        # interactive docs at http://127.0.0.1:8000/api/docs

Compatibility

Supported
Python 3.8 – 3.12
Django 4.2 – 5.x
django-ninja ≥ 1.0
pydantic 2.x
Databases any GeoDjango spatial backend (PostGIS, SpatiaLite, …)

The pure GeoJSON schemas (ninja_spatial.geojson) require only pydantic — GeoDjango and the MVT extra are optional.

How it works

  • Wire formatninja_spatial.geojson holds pydantic v2 models for each geometry type plus a type-discriminated Geometry union, validated per RFC 7946.
  • The GEOS bridgeninja_spatial.fields exposes annotated types whose __get_pydantic_core_schema__ validates input into a GEOSGeometry and serializes it back to GeoJSON, while __get_pydantic_json_schema__ keeps the OpenAPI docs accurate.
  • ModelSchemaregister_geometry_fields() registers each GeoDjango field's get_internal_type() with django-ninja's ORM type registry, so introspection just works.
  • Conversions are written directly against GEOS constructors and .tuple — no GDAL — which preserves Z and empty geometries.

Notes & limitations

  • GeoJSON's coordinate space is WGS 84 (lon, lat[, alt]). If your column uses another SRID, set it with geometry_field(srid=...) or reproject before assignment.
  • GEOS (and RFC 7946) discourage nested GeometryCollections; converting one to GEOS raises a clear error.
  • RasterField is out of scope (rasters are not GeoJSON geometries).
  • Feature / FeatureCollection wrappers are out of scope; this library covers geometry objects (which is what model fields hold).

Releasing

Releases are published to PyPI automatically by .github/workflows/publish.yml using PyPI Trusted Publishing (OIDC — no API tokens stored in the repo).

One-time setup

  1. On pypi.org (and test.pypi.org) add a pending Trusted Publisher: owner sirmmo, repo django-ninja-spatial, workflow publish.yml, environment pypi (and testpypi).
  2. Create the matching GitHub Environments named pypi and testpypi.

Cutting a release

  1. Bump version in pyproject.toml.
  2. Push, then publish a GitHub Release with tag vX.Y.Z (the workflow checks the tag matches the version) → it builds, twine checks, and uploads to PyPI.
  3. To rehearse, run the workflow manually (Actions → Publish to PyPI → Run workflow) with target testpypi.

Contributing

Issues and PRs are welcome! To set up locally:

git clone https://github.com/sirmmo/django-ninja-spatial
cd django-ninja-spatial
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest                       # ~145 tests, no spatial DB required

The suite configures Django in tests/conftest.py and runs entirely in memory.

License

MIT © Marco Montanari

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_ninja_spatial-0.9.0.tar.gz (24.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_ninja_spatial-0.9.0-py3-none-any.whl (20.6 kB view details)

Uploaded Python 3

File details

Details for the file django_ninja_spatial-0.9.0.tar.gz.

File metadata

  • Download URL: django_ninja_spatial-0.9.0.tar.gz
  • Upload date:
  • Size: 24.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for django_ninja_spatial-0.9.0.tar.gz
Algorithm Hash digest
SHA256 70b1b59c3f4582373642f6fd1692d50736fb3041f6d3def4a2a267229ccfcda3
MD5 4ea53660909603b0031de80fc88738e4
BLAKE2b-256 fae29176e88fb3a8ea7709c83a868cc1c9ed517e6f4a2cc69ff8fd00da36dfd2

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_ninja_spatial-0.9.0.tar.gz:

Publisher: publish.yml on sirmmo/django-ninja-spatial

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_ninja_spatial-0.9.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_ninja_spatial-0.9.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c2c5fb4ae57eaae7dec53e9eeea4ab323d657d2211aa26009541e813eb4d683b
MD5 1db93fe17326ff2fc599453b04dbb63a
BLAKE2b-256 fb7bbd6b158f279c72eb5b1db8820a23e7261e3963af2d5802b3a0f5b8759f2b

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_ninja_spatial-0.9.0-py3-none-any.whl:

Publisher: publish.yml on sirmmo/django-ninja-spatial

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page