django-ninja-spatial
GeoJSON and Mapbox Vector Tile serialization for django-ninja with GeoDjango.
It serializes GeoDjango geometry fields to RFC 7946
GeoJSON and validates GeoJSON back into GEOSGeometry, both in hand-written Schema
classes and in ModelSchema. Point a ModelSchema at a model with geometry fields and
they round-trip as GeoJSON:
from ninja import NinjaAPI, ModelSchema
from ninja_spatial import register_geometry_fields
from places.models import Place # 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] }
}
Features
- Covers all seven GeoJSON geometry types (Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection) plus a generic geometry type that accepts any of them.
- Works in both directions: GeoJSON requests are validated into
GEOSGeometry, and geometry fields are serialized to GeoJSON in responses. - Validates per RFC 7946 (position length,
LineStringminimum points, closed linear rings), so you get useful pydantic errors and an accurate OpenAPI schema. - Encodes and decodes Mapbox Vector Tiles, with helpers for XYZ tiling.
- Converts between GEOS and GeoJSON without going through GDAL, which keeps Z coordinates and empty geometries intact.
- The GeoJSON schemas depend only on pydantic. GeoDjango and MVT support are optional.
- Built on pydantic v2 and ships type hints (
py.typed).
Table of contents
- Installation
- Quick start
- Field types
- Usage
- Example project
- Compatibility
- How it works
- Notes and limitations
- Releasing
- Contributing
- License
Installation
pip install django-ninja-spatial # core (pydantic only)
pip install "django-ninja-spatial[ninja]" # + Django and django-ninja (GeoDjango bridge)
pip install "django-ninja-spatial[mvt]" # + Mapbox Vector Tile support (shapely)
You also need GeoDjango itself (GEOS, plus GDAL/PROJ for a spatial database backend). See the GeoDjango install guide.
Quick start
Register the geometry field types once, for example 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)
Geometry columns now serialize to GeoJSON, and the OpenAPI schema describes them.
Field types
These annotated types (importable from ninja_spatial) go in any Schema. They validate
input GeoJSON or 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 one accepts a GeoJSON object (dict), a GeoJSON / WKT / EWKT / HEXEWKB string, an
existing GEOSGeometry, or a ninja_spatial.geojson schema instance. A typed field
returns a 422 when given the wrong geometry type.
Usage
ModelSchema
After calling register_geometry_fields(), ModelSchema introspects GeoDjango geometry
columns on its own (see Quick start). There is nothing else to wire up.
Explicit Schema fields
Write schemas by hand when you want full control. On input, fields validate GeoJSON and
produce a GEOSGeometry; on output, 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, so wrap in Optional
anything: Optional[GeometryField] = None # any of the seven 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}
Declare nullable geometry fields as Optional[...] (for example
Optional[PointField] = None). A bare PointField = None is not optional at the type
level and will reject null. For a non-default SRID, use the factory:
geometry_field(geom_types=("Point",), srid=3857).
Pure-Pydantic GeoJSON schemas (without GeoDjango)
The ninja_spatial.geojson module is plain pydantic v2 and needs no GeoDjango:
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 (the generic geometry type):
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 provide 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]".
encode_tile reprojects your geometries (SRID 4326 by default) to Web Mercator and
quantizes them to the requested 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")
For geometries that are already in tile or local coordinates you can encode directly, with no GeoDjango involved:
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 elsewhere. MVT covers Point, LineString, Polygon and
their Multi variants; GeometryCollection is not an MVT geometry.
Example project
There is a complete GeoDjango and django-ninja app in example/: GeoJSON CRUD,
bounding-box and nearest-neighbour queries, point-in-polygon, and a vector-tile endpoint,
all running on SpatiaLite so it needs no PostGIS server.
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 to 3.12 |
| Django | 4.2 to 5.x |
| django-ninja | 1.0+ |
| pydantic | 2.x |
| Databases | any GeoDjango spatial backend (PostGIS, SpatiaLite, and so on) |
The pure GeoJSON schemas (ninja_spatial.geojson) require only pydantic; GeoDjango and the
MVT extra are optional.
How it works
The ninja_spatial.geojson module holds a pydantic v2 model for each geometry type and a
type-discriminated Geometry union, validated against RFC 7946. The ninja_spatial.fields
module 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 documentation accurate. register_geometry_fields() registers each GeoDjango
field's get_internal_type() with django-ninja's ORM type registry so ModelSchema
introspection picks them up. The GEOS-to-GeoJSON conversion is written against GEOS
constructors and .tuple rather than GDAL, which is what preserves Z and empty geometries.
Notes and limitations
- GeoJSON coordinates are WGS 84 (lon, lat, optional altitude). If a column uses another
SRID, set it with
geometry_field(srid=...)or reproject before assigning. - GEOS does not support nested
GeometryCollections (RFC 7946 discourages them too), so converting one to GEOS raises an error. RasterFieldis out of scope, since rasters are not GeoJSON geometries.FeatureandFeatureCollectionwrappers are out of scope; this library handles geometry objects, which is what model fields store.
Releasing
Releases go to PyPI through .github/workflows/publish.yml
using PyPI Trusted Publishing, so no API tokens
are stored in the repo.
One-time setup:
- On pypi.org (and
test.pypi.org) add a Trusted
Publisher for owner
sirmmo, repodjango-ninja-spatial, workflowpublish.yml, environmentpypi(andtestpypi). - Create the matching GitHub Environments named
pypiandtestpypi.
To cut a release, bump version in pyproject.toml, push, and publish a GitHub Release
tagged vX.Y.Z (the workflow checks the tag against the version). To rehearse against
TestPyPI, run the workflow manually with the target set to testpypi.
Contributing
Issues and pull requests are welcome.
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
The test suite configures Django in tests/conftest.py and runs in memory, with no spatial
database required.
License
MIT © Marco Montanari
Metadata
Release files for django-ninja-spatial 0.9.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| django_ninja_spatial-0.9.1.tar.gz | 23.5 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_ninja_spatial-0.9.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.7 kB
Release files / django_ninja_spatial-0.9.1.tar.gz
| Download URL | django_ninja_spatial-0.9.1.tar.gz |
|---|---|
| Size | 23.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
19f87e4d678fe3ae5bac9e5e1994994ea83bbe0d854f4fce054285acca823081
|
|
BLAKE2b-256 checksum How to use checksums |
7ab4a279d143fcc81bf062dd6c28042645b6520cef128a375d97a4bbc1807d23
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 25, 2026.
Transparency logRelease files / django_ninja_spatial-0.9.1-py3-none-any.whl
| Download URL | django_ninja_spatial-0.9.1-py3-none-any.whl |
|---|---|
| Size | 20.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9f9cfecd522cc81f72e0eef93284d24f79f93850908b5f0ec47c5480e58d3950
|
|
BLAKE2b-256 checksum How to use checksums |
4613c03c280fb547e0867a079c5567d0f1af216e8478bca9e3308f1f0431ee71
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 25, 2026.
Transparency logSecurity for django-ninja-spatial 0.9.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for django_ninja_spatial-0.9.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 25, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.