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.
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 bothSchemafields andModelSchema. - ✅ 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
- Quick start
- Field types
- Usage
- Example project
- Compatibility
- How it works
- Notes & limitations
- Contributing
- License
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 barePointField = Noneis not optional at the type level and will rejectnull.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 format —
ninja_spatial.geojsonholds pydantic v2 models for each geometry type plus atype-discriminatedGeometryunion, validated per RFC 7946. - The GEOS bridge —
ninja_spatial.fieldsexposes annotated types whose__get_pydantic_core_schema__validates input into aGEOSGeometryand serializes it back to GeoJSON, while__get_pydantic_json_schema__keeps the OpenAPI docs accurate. - ModelSchema —
register_geometry_fields()registers each GeoDjango field'sget_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. RasterFieldis out of scope (rasters are not GeoJSON geometries).Feature/FeatureCollectionwrappers 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
- On pypi.org (and
test.pypi.org) add a pending
Trusted Publisher: owner
sirmmo, repodjango-ninja-spatial, workflowpublish.yml, environmentpypi(andtestpypi). - Create the matching GitHub
Environments
named
pypiandtestpypi.
Cutting a release
- Bump
versioninpyproject.toml. - 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. - 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
70b1b59c3f4582373642f6fd1692d50736fb3041f6d3def4a2a267229ccfcda3
|
|
| MD5 |
4ea53660909603b0031de80fc88738e4
|
|
| BLAKE2b-256 |
fae29176e88fb3a8ea7709c83a868cc1c9ed517e6f4a2cc69ff8fd00da36dfd2
|
Provenance
The following attestation bundles were made for django_ninja_spatial-0.9.0.tar.gz:
Publisher:
publish.yml on sirmmo/django-ninja-spatial
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_ninja_spatial-0.9.0.tar.gz -
Subject digest:
70b1b59c3f4582373642f6fd1692d50736fb3041f6d3def4a2a267229ccfcda3 - Sigstore transparency entry: 1628974230
- Sigstore integration time:
-
Permalink:
sirmmo/django-ninja-spatial@677f21787c2e2278184ae80f506516aff1518544 -
Branch / Tag:
refs/tags/v0.9.0 - Owner: https://github.com/sirmmo
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@677f21787c2e2278184ae80f506516aff1518544 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_ninja_spatial-0.9.0-py3-none-any.whl.
File metadata
- Download URL: django_ninja_spatial-0.9.0-py3-none-any.whl
- Upload date:
- Size: 20.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c2c5fb4ae57eaae7dec53e9eeea4ab323d657d2211aa26009541e813eb4d683b
|
|
| MD5 |
1db93fe17326ff2fc599453b04dbb63a
|
|
| BLAKE2b-256 |
fb7bbd6b158f279c72eb5b1db8820a23e7261e3963af2d5802b3a0f5b8759f2b
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_ninja_spatial-0.9.0-py3-none-any.whl -
Subject digest:
c2c5fb4ae57eaae7dec53e9eeea4ab323d657d2211aa26009541e813eb4d683b - Sigstore transparency entry: 1628974265
- Sigstore integration time:
-
Permalink:
sirmmo/django-ninja-spatial@677f21787c2e2278184ae80f506516aff1518544 -
Branch / Tag:
refs/tags/v0.9.0 - Owner: https://github.com/sirmmo
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@677f21787c2e2278184ae80f506516aff1518544 -
Trigger Event:
release
-
Statement type: