How to Build an ArcGIS Data Analysis Web Tool with Python and Django (Step-by-Step)

A while back, we had a real problem at work. Our field teams needed to run buffer analysis on uploaded shapefiles — but they had no access to ArcGIS Pro, and the GIS team was always swamped. The fix was clear: build a web tool they could use from a browser, no desktop software needed.

That one problem pushed us to build a full stack that combined Django on the backend, the ArcGIS API for Python for the analysis work, and a lightweight Leaflet.js map on the front end. The result was a tool that non-GIS users could actually use.

This tutorial walks you through building exactly that — from an empty folder to a working web app. We will cover Django project setup, connecting to ArcGIS Online, running real spatial analysis, storing results in PostGIS, and displaying them on a web map.

What this tutorial covers:

  • Django + ArcGIS API for Python + GeoDjango + PostGIS
  • Secure credential handling
  • Spatial models and migrations
  • Buffer analysis and overlay analysis using the ArcGIS Python library
  • Rendering results on a Leaflet.js map
  • Publishing layers back to ArcGIS Online

Prerequisites:

  • Python 3.10 or newer
  • An ArcGIS Online account or Enterprise portal (free Developer tier works)
  • PostgreSQL with the PostGIS extension installed
  • Basic comfort with Django

The working code referenced in this tutorial is available onGitHub. Real code exists — this is not a theoretical walkthrough.

1. Understanding the Architecture Before You Write a Single Line

Before jumping into code, let us be clear about what each piece does and how they connect.

Browser (Leaflet.js / ArcGIS JS SDK)

        ↕  HTTP / GeoJSON

Django Views & REST API (Django REST Framework)

        ↕  Python calls

ArcGIS API for Python

        ↕  HTTPS

ArcGIS Online / Enterprise Portal

        ↕

PostGIS (PostgreSQL) ← Stores raw data + analysis results

Django handles the request/response cycle, authentication, file uploads, and API routing. The ArcGIS API for Python talks to your Esri portal to run the analysis. PostGIS stores both your input data and the results. The front end reads GeoJSON from your Django API and renders it on a map.

ArcGIS API for Python vs. ArcGIS API for JavaScript — What Is the Difference?

This matters a lot, and most tutorials skip it entirely.

  • ArcGIS API for Python runs on theserver side. Use it to connect to portals, search feature layers, run geoprocessing tools, publish data, and automate GIS workflows. This is thearcgis Python package.
  • ArcGIS API for JavaScript runs in thebrowser. Use it to render interactive maps, display feature layers, handle map clicks, and build rich Esri-powered front ends.

In this tutorial we use both: Python handles all analysis work on the server, and the JavaScript SDK (or Leaflet.js as an alternative) handles map display.

ArcGIS Python API vs. Direct REST API Calls

The Python API is a wrapper around Esri’s REST services. For most analysis tasks, the Python API is cleaner and faster to write. But for simple read operations — like fetching a single feature layer’s metadata — rawrequests calls against the REST endpoint can be faster and lighter, since you skip the overhead of loading the full Python API library.

Cost and Account Notes

The free Esri Developer tier gives you access to ArcGIS Online and enough credits for development and testing. For production, you will need to understand the difference betweennamed users (licensed Esri users with their own login) andapp logins (OAuth 2.0 client credentials used in unattended scripts). App logins avoid per-user licensing costs for back-end analysis workflows. API key authentication is the simplest option for server-to-server calls, which is what we use here.

2. Setting Up Your Python Environment

Why You Need an Isolated Virtual Environment

Thearcgis package has heavy dependencies — GDAL, Shapely, numpy, and others. If you install it into your system Python, it will almost certainly conflict with something else. Always use an isolated environment.

Using venv (recommended for most setups):

# Linux / macOS

python3.10 -m venv arcgis_env

source arcgis_env/bin/activate

# Windows

python -m venv arcgis_env

arcgis_env\Scripts\activate

Using conda (if you already use it for data science work):

conda create -n arcgis_env python=3.10

conda activate arcgis_env

Known issue: On Windows,pip install arcgis sometimes fails because of GDAL wheel mismatches. If it does, install GDAL via conda first (conda install -c conda-forge gdal), then install thearcgis package on top. On Linux (Ubuntu), GDAL is usually available viaapt:

sudo apt-get install gdal-bin libgdal-dev

Installing All Dependencies

Create arequirements.txt in your project root. Pin your versions — the ArcGIS API has made breaking changes between minor releases, and you do not want your analysis code to break after an upgrade.

Django==4.2.13

arcgis==2.3.0

psycopg2-binary==2.9.9

djangorestframework==3.15.1

django-cors-headers==4.3.1

djangorestframework-gis==1.0

python-decouple==3.8

celery==5.3.6

redis==5.0.4

Install everything:

pip install -r requirements.txt

Setting Up PostGIS

PostGIS must be enabled as a PostgreSQL extension. Log in to your PostgreSQL instance and run:

CREATE DATABASE arcgis_tool;

\c arcgis_tool

CREATE EXTENSION postgis;

— Verify it worked

SELECT PostGIS_version();

If theCREATE EXTENSION command fails, it usually means the PostGIS package is not installed at the OS level, or the version of PostGIS does not match your PostgreSQL version. On Ubuntu:

sudo apt-get install postgresql-15-postgis-3

Replace15 with your actual PostgreSQL version. After installing, restart PostgreSQL and try again.

3. Starting the Django Project and Configuring GeoDjango

Create the Project

django-admin startproject arcgis_tool .

python manage.py startapp analysis

Your folder structure should look like this:

arcgis_tool/

├── arcgis_tool/

│   ├── __init__.py

│   ├── settings.py

│   ├── urls.py

│   └── wsgi.py

├── analysis/

│   ├── models.py

│   ├── views.py

│   ├── services.py       ← we will create this

│   └── urls.py

├── requirements.txt

├── .env

└── manage.py

Configure settings.py

This is where most people get stuck. Here is the full block you need:

# settings.py

INSTALLED_APPS = [

    ‘django.contrib.admin’,

    ‘django.contrib.auth’,

    ‘django.contrib.contenttypes’,

    ‘django.contrib.sessions’,

    ‘django.contrib.messages’,

    ‘django.contrib.staticfiles’,

    ‘django.contrib.gis’,          # ← Required for GeoDjango

    ‘rest_framework’,

    ‘rest_framework_gis’,

    ‘corsheaders’,

    ‘analysis’,

]

MIDDLEWARE = [

    ‘corsheaders.middleware.CorsMiddleware’,  # ← Must be first

    ‘django.middleware.security.SecurityMiddleware’,

    # … rest of middleware

]

DATABASES = {

    ‘default’: {

        ‘ENGINE’: ‘django.contrib.gis.db.backends.postgis’,

        ‘NAME’: config(‘DB_NAME’, default=’arcgis_tool’),

        ‘USER’: config(‘DB_USER’, default=’postgres’),

        ‘PASSWORD’: config(‘DB_PASSWORD’),

        ‘HOST’: config(‘DB_HOST’, default=’localhost’),

        ‘PORT’: config(‘DB_PORT’, default=’5432′),

    }

}

CORS_ALLOWED_ORIGINS = [

    “http://localhost:3000”,

    “http://127.0.0.1:8000”,

]

If you forgetdjango.contrib.gis: Your spatial models will import fine butmakemigrations will silently create non-spatial fields. You will only notice when you try to run spatial queries and they fail. Always check yourINSTALLED_APPS first when spatial queries break.

GDAL and GEOS Paths (Windows and Docker)

On Windows and inside Docker containers, Django’s GeoDjango often cannot find GDAL and GEOS automatically. Add these lines tosettings.py when running on Windows:

import os

if os.name == ‘nt’:  # Windows only

    GDAL_LIBRARY_PATH = r’C:\OSGeo4W\bin\gdal308.dll’

    GEOS_LIBRARY_PATH = r’C:\OSGeo4W\bin\geos_c.dll’

On Docker with a Debian base image, GDAL is usually found automatically. But Alpine images are a problem — see the deployment section for details.

Storing ArcGIS Credentials Securely

Never hardcode your ArcGIS credentials insettings.py or any source file. We usepython-decouple to load them from a.env file.

Create a.env file in your project root:

DB_NAME=arcgis_tool

DB_USER=postgres

DB_PASSWORD=your_postgres_password

DB_HOST=localhost

DB_PORT=5432

ARCGIS_URL=https://www.arcgis.com

ARCGIS_API_KEY=your_api_key_here

Add.env to your.gitignore immediately.

Insettings.py, load these values:

from decouple import config

ARCGIS_URL = config(‘ARCGIS_URL’, default=’https://www.arcgis.com’)

ARCGIS_API_KEY = config(‘ARCGIS_API_KEY’)

API key vs. username/password: For unattended server-to-server calls, API keys are the better choice. They do not expire mid-session the way token-based username/password auth can, and they do not require storing a user password on the server. You generate them in ArcGIS Online under your developer settings.

Test Your ArcGIS Connection Early

Before you build anything else, confirm the connection works. Open the Django shell:

python manage.py shell

Then run:

from arcgis.gis import GIS

from django.conf import settings

try:

    gis = GIS(settings.ARCGIS_URL, api_key=settings.ARCGIS_API_KEY)

    print(f”Connected as: {gis.properties.user}”)

except Exception as e:

    print(f”Connection failed: {e}”)

If you see aTokenExpiredError, your API key has not been configured correctly. If you seeAGOLERROR_OBJECT_DOES_NOT_EXIST, double-check your portal URL — the trailing slash matters.

4. Building Spatial Models with GeoDjango

Defining the Model

# analysis/models.py

from django.contrib.gis.db import models

class AnalysisLayer(models.Model):

    name = models.CharField(max_length=255)

    description = models.TextField(blank=True)

    # Spatial fields

    point_location = models.PointField(srid=4326, null=True, blank=True)

    boundary = models.PolygonField(srid=4326, null=True, blank=True)

    geometry = models.GeometryField(srid=4326, null=True, blank=True)

    # Metadata

    source_layer_id = models.CharField(max_length=255, blank=True)

    analysis_type = models.CharField(max_length=100, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    updated_at = models.DateTimeField(auto_now=True)

    class Meta:

        indexes = [

            models.Index(fields=[‘analysis_type’]),

        ]

    def __str__(self):

        return self.name

class BufferResult(models.Model):

    source_layer = models.ForeignKey(AnalysisLayer, on_delete=models.CASCADE)

    buffer_distance_meters = models.FloatField()

    result_geometry = models.MultiPolygonField(srid=4326)

    arcgis_job_id = models.CharField(max_length=255, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

Field type notes:

  • UsePointField for single location data (GPS coordinates, facility locations)
  • UsePolygonField for area data (parcels, districts, zones)
  • UseGeometryField when you do not know the geometry type in advance — it accepts any geometry but is harder to query efficiently
  • srid=4326 is WGS 84 — the standard for GPS data and most web services. Usesrid=3857 (Web Mercator) only if you need precise distance calculations at local scale, since 4326 distorts distances at mid-latitudes

Running and Validating Migrations

python manage.py makemigrations

python manage.py migrate

PostGIS-backed migrations are different from regular Django migrations. They create geometry columns in PostgreSQL with proper GIST spatial indexes. Verify in PostgreSQL:

SELECT f_table_name, f_geometry_column, coord_dimension, srid, type

FROM geometry_columns

WHERE f_table_name LIKE ‘analysis_%’;

You should see your tables listed with their geometry types and SRIDs.

Common Migration Errors

  1. “django.core.exceptions.ImproperlyConfigured: Could not find the GDAL library” — Your GDAL install is missing or the path is wrong. On Ubuntu:sudo apt-get install gdal-bin. On Windows: checkGDAL_LIBRARY_PATH in settings.
  2. “ProgrammingError: type ‘geometry’ does not exist” — PostGIS extension is not installed. RunCREATE EXTENSION postgis; in psql.
  3. “ERROR: column ‘id’ does not exist” — You ranmigrate withoutmakemigrations first. Always runmakemigrations first, thenmigrate.

5. Connecting to ArcGIS and Running Spatial Analysis

The ArcGIS Service Layer

Never put ArcGIS connection code directly in views. Put it in aservices.py file. This keeps your business logic separate and makes testing much easier.

# analysis/services.py

from arcgis.gis import GIS

from arcgis.features import FeatureLayerCollection

from arcgis.features.use_proximity import create_buffers

import json

from django.conf import settings

from django.core.cache import cache

def get_gis_connection():

    “””

    Returns a cached GIS connection to avoid re-authenticating on every request.

    Cache expires after 50 minutes (tokens last 60 min by default).

    “””

    gis = cache.get(‘arcgis_gis_connection’)

    if gis is None:

        try:

            gis = GIS(

                url=settings.ARCGIS_URL,

                api_key=settings.ARCGIS_API_KEY

            )

            cache.set(‘arcgis_gis_connection’, gis, timeout=3000)

        except Exception as e:

            raise ConnectionError(f”Failed to connect to ArcGIS: {str(e)}”)

    return gis

def search_feature_layers(search_query: str, owner: str = None, tag: str = None):

    “””

    Search the portal for feature layers matching a query.

    “””

    gis = get_gis_connection()

    query = search_query

    if owner:

        query += f” owner:{owner}”

    if tag:

        query += f” tags:{tag}”

    results = gis.content.search(

        query=query,

        item_type=”Feature Layer”,

        max_items=20

    )

    return results

def run_buffer_analysis(feature_layer_item, distance_meters: float):

    “””

    Run buffer analysis on a feature layer using the ArcGIS Python library.

    Returns a FeatureSet with the buffered geometries.

    “””

    gis = get_gis_connection()

    try:

        result = create_buffers(

            input_layer=feature_layer_item,

            distances=[distance_meters],

            distance_unit=”Meters”,

            output_name=f”buffer_{int(distance_meters)}m”,

            context={“outSR”: {“wkid”: 4326}},

            gis=gis

        )

        return result

    except Exception as e:

        raise RuntimeError(f”Buffer analysis failed: {str(e)}”)

def featureset_to_geojson(feature_set):

    “””

    Convert an ArcGIS FeatureSet to a GeoJSON dict for use in Django.

    “””

    return feature_set.to_geojson

def save_analysis_result_to_db(feature_set, source_layer, buffer_distance):

    “””

    Convert ArcGIS FeatureSet → GeoJSON → GeoDjango model.

    Full round-trip example.

    “””

    from django.contrib.gis.geos import GEOSGeometry

    from .models import BufferResult

    geojson_str = featureset_to_geojson(feature_set)

    geojson = json.loads(geojson_str)

    for feature in geojson[‘features’]:

        geom_str = json.dumps(feature[‘geometry’])

        geom = GEOSGeometry(geom_str, srid=4326)

        BufferResult.objects.create(

            source_layer=source_layer,

            buffer_distance_meters=buffer_distance,

            result_geometry=geom

        )

Handling Async Analysis Jobs

Some ArcGIS geoprocessing tools are asynchronous — they return a job ID and you poll for the result. Here is the polling pattern:

import time

def poll_geoprocessing_job(gp_job, max_wait_seconds=120):

    “””

    Poll an async ArcGIS geoprocessing job until complete.

    “””

    start = time.time()

    while True:

        status = gp_job.status

        if status == “esriJobSucceeded”:

            return gp_job.outputs

        elif status in (“esriJobFailed”, “esriJobCancelled”):

            raise RuntimeError(f”Geoprocessing job failed: {gp_job.messages}”)

        elif time.time() – start > max_wait_seconds:

            raise TimeoutError(“Geoprocessing job timed out”)

        time.sleep(3)

For long-running jobs (anything over 10–15 seconds), do not run them in a synchronous Django view. Use Celery instead — see the performance section below.

6. Building Django Views and URL Routing

Class-Based Views vs. Function-Based Views

For this use case, we recommend class-based views (CBVs). The main reason is that analysis endpoints typically handle multiple HTTP methods differently — GET returns results, POST triggers analysis, DELETE removes a layer. CBVs make that separation clean and explicit.

# analysis/views.py

from rest_framework.views import APIView

from rest_framework.response import Response

from rest_framework import status

from rest_framework.parsers import MultiPartParser, JSONParser

from django.contrib.gis.geos import GEOSGeometry

import json

from .models import AnalysisLayer, BufferResult

from .services import run_buffer_analysis, save_analysis_result_to_db, search_feature_layers

from .serializers import AnalysisLayerSerializer, BufferResultGeoSerializer

class BufferAnalysisView(APIView):

    parser_classes = [MultiPartParser, JSONParser]

    def post(self, request):

        “””

        Accept a feature layer item ID and buffer distance.

        Trigger buffer analysis and return GeoJSON results.

        “””

        layer_id = request.data.get(‘layer_id’)

        distance = float(request.data.get(‘distance_meters’, 500))

        if not layer_id:

            return Response(

                {‘error’: ‘layer_id is required’},

                status=status.HTTP_400_BAD_REQUEST

            )

        try:

            from arcgis.gis import GIS

            from django.conf import settings

            gis = GIS(settings.ARCGIS_URL, api_key=settings.ARCGIS_API_KEY)

            layer_item = gis.content.get(layer_id)

            result_feature_set = run_buffer_analysis(layer_item, distance)

            geojson = result_feature_set.to_geojson

            return Response(json.loads(geojson), status=status.HTTP_200_OK)

        except RuntimeError as e:

            return Response({‘error’: str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

    def get(self, request):

        “””Return stored buffer results as GeoJSON.”””

        results = BufferResult.objects.all().order_by(‘-created_at’)[:20]

 

        serializer = BufferResultGeoSerializer(results, many=True)

 

        return Response(serializer.data)

 

class LayerSearchView(APIView):

 

    def get(self, request):

 

        query = request.query_params.get(‘q’, ”)

 

        owner = request.query_params.get(‘owner’)

 

        tag = request.query_params.get(‘tag’)

 

        

 

        layers = search_feature_layers(query, owner=owner, tag=tag)

 

        data = [

 

            {‘id’: item.id, ‘title’: item.title, ‘type’: item.type}

 

            for item in layers

 

        ]

 

        return Response(data)

Serializers for Spatial Data

# analysis/serializers.py

 

from rest_framework_gis.serializers import GeoFeatureModelSerializer

 

from rest_framework import serializers

 

from .models import AnalysisLayer, BufferResult

 

class AnalysisLayerSerializer(GeoFeatureModelSerializer):

 

    class Meta:

 

        model = AnalysisLayer

 

        geo_field = ‘geometry’

 

        fields = [‘id’, ‘name’, ‘description’, ‘analysis_type’, ‘created_at’]

 

class BufferResultGeoSerializer(GeoFeatureModelSerializer):

 

    class Meta:

 

        model = BufferResult

 

        geo_field = ‘result_geometry’

 

        fields = [‘id’, ‘buffer_distance_meters’, ‘created_at’]

URL Routing

# analysis/urls.py

 

from django.urls import path

 

from . import views

 

urlpatterns = [

 

    path(‘buffer/’, views.BufferAnalysisView.as_view(), name=’buffer-analysis’),

 

    path(‘layers/search/’, views.LayerSearchView.as_view(), name=’layer-search’),

 

]

 

# arcgis_tool/urls.py

 

from django.urls import path, include

 

urlpatterns = [

 

    path(‘api/analysis/’, include(‘analysis.urls’)),

 

]

7. Rendering Results on a Web Map

Using Leaflet.js

Leaflet.js is our first choice for displaying analysis results. It is lightweight, does not require an Esri account for rendering, and works perfectly with GeoJSON from our Django API. For most field tool use cases, it is all you need.

 

Here is a complete Django template that loads analysis results and displays them on a Leaflet map:

 

<!– templates/analysis/map.html –>

 

<!DOCTYPE html>

 

<html>

 

<head>

 

    <title>ArcGIS Analysis Results</title>

 

    <link rel=”stylesheet” href=”https://unpkg.com/leaflet@1.9.4/dist/leaflet.css” />

 

    <style>

 

        #map { height: 600px; width: 100%; }

 

        .controls { padding: 10px; background: #f5f5f5; }

 

    </style>

 

</head>

 

<body>

 

    <div class=”controls”>

 

        <button onclick=”toggleLayer(‘original’)”>Toggle Original Layer</button>

 

        <button onclick=”toggleLayer(‘buffer’)”>Toggle Buffer Results</button>

 

    </div>

 

    <div id=”map”></div>

 

    <script src=”https://unpkg.com/leaflet@1.9.4/dist/leaflet.js”></script>

 

    <script>

 

        const map = L.map(‘map’).setView([35.0, -106.0], 10);

 

        

 

        L.tileLayer(‘https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png’, {

 

            attribution: ‘© OpenStreetMap contributors’

 

        }).addTo(map);

 

        let bufferLayer = null;

 

        const layers = {};

 

        // Load buffer results from our Django API

 

        fetch(‘/api/analysis/buffer/’)

 

            .then(response => response.json())

 

            .then(geojson => {

 

                bufferLayer = L.geoJSON(geojson, {

 

                    style: {

 

                        color: ‘#ff7800’,

 

                        weight: 2,

 

                        fillOpacity: 0.3

 

                    },

 

                    onEachFeature: function(feature, layer) {

 

                        layer.bindPopup(

 

                            `Buffer Distance: ${feature.properties.buffer_distance_meters}m`

 

                        );

 

                    }

 

                }).addTo(map);

 

                layers[‘buffer’] = bufferLayer;

 

                

 

                if (bufferLayer.getBounds().isValid()) {

 

                    map.fitBounds(bufferLayer.getBounds());

 

                }

 

            })

 

            .catch(err => console.error(‘Failed to load results:’, err));

 

        function toggleLayer(name) {

 

            const layer = layers[name];

 

            if (!layer) return;

 

            if (map.hasLayer(layer)) {

 

                map.removeLayer(layer);

 

            } else {

 

                map.addLayer(layer);

 

            }

 

        }

 

    </script>

 

</body>

 

</html>

When to Use the ArcGIS Maps SDK for JavaScript Instead

Leaflet.js is enough for most analysis result display. But switch to the ArcGIS Maps SDK for JavaScript when you need:

 

  • Esri basemaps (streets, imagery, topographic)
  • Feature layer popups with Esri styling
  • Direct access to portal layers without exporting to GeoJSON
  • Editing capabilities — the JS SDK has native feature editing

 

Basic ArcGIS JS SDK integration in a Django template:

 

<link rel=”stylesheet” href=”https://js.arcgis.com/4.29/esri/themes/light/main.css”>

 

<script src=”https://js.arcgis.com/4.29/”></script>

 

<div id=”viewDiv” style=”height:600px;”></div>

 

<script>

 

    require([

 

        “esri/Map”,

 

        “esri/views/MapView”,

 

        “esri/layers/GeoJSONLayer”

 

    ], function(Map, MapView, GeoJSONLayer) {

 

        const map = new Map({ basemap: “streets-navigation-vector” });

 

        

 

        const view = new MapView({

 

            container: “viewDiv”,

 

            map: map,

 

            center: [-106.0, 35.0],

 

            zoom: 10

 

        });

 

        const bufferLayer = new GeoJSONLayer({

 

            url: “/api/analysis/buffer/”

 

        });

 

        map.add(bufferLayer);

 

    });

 

</script>

8. Publishing Results Back to ArcGIS Online and Calling Geoprocessing Tools

Publishing a Feature Layer from Django

When you want to push your analysis results back to ArcGIS Online — for example, to share them with team members who use ArcGIS Pro or Survey123 — useFeatureLayerCollection.manager.overwrite().

 

# In services.py

 

import geopandas as gpd

 

import json

 

from arcgis.features import FeatureLayerCollection

 

from .models import BufferResult

 

def publish_results_to_portal(layer_item_id: str):

 

    “””

 

    Convert stored QuerySet results to GeoDataFrame and overwrite an existing portal layer.

 

    “””

 

    gis = get_gis_connection()

 

    

 

    # Step 1: Get results from PostGIS

 

    results = BufferResult.objects.all()

 

    

 

    # Step 2: Convert to GeoDataFrame

 

    data = []

 

    for r in results:

 

        data.append({

 

            ‘geometry’: r.result_geometry.geojson,

 

            ‘buffer_distance’: r.buffer_distance_meters,

 

            ‘created_at’: str(r.created_at)

 

        })

 

    

 

    gdf = gpd.GeoDataFrame(

 

        data,

 

        geometry=gpd.GeoSeries.from_wkt([r.result_geometry.wkt for r in results]),

 

        crs=”EPSG:4326″

 

    )

 

    

 

    # Step 3: Export to GeoJSON

 

    geojson_path = ‘/tmp/buffer_results.geojson’

 

    gdf.to_file(geojson_path, driver=’GeoJSON’)

 

    

 

    # Step 4: Overwrite existing layer

 

    layer_item = gis.content.get(layer_item_id)

 

    flc = FeatureLayerCollection.fromitem(layer_item)

 

    result = flc.manager.overwrite(geojson_path)

 

    

 

    return result

 

Rate limits: ArcGIS Online enforces rate limits on publishing and analysis operations. If you hit them, you will see a429 response or aServiceUnavailableError. For batch publishing, add exponential backoff and retry logic.

Calling ArcGIS Geoprocessing Tools via the REST API

Sometimes the Python API wrapper is slower than calling the REST endpoint directly. For simple, synchronous geoprocessing operations, rawrequests calls can be 30–50% faster.

 

import requests

 

from django.conf import settings

 

def call_gp_tool_direct(service_url: str, params: dict):

 

    “””

 

    Call a geoprocessing tool directly via the REST API.

 

    Use this when the Python API overhead is not worth it.

 

    “””

 

    token_response = requests.post(

 

        “https://www.arcgis.com/sharing/rest/generateToken”,

 

        data={

 

            ‘f’: ‘json’,

 

            ‘client’: ‘requestip’,

 

            ‘expiration’: 60

 

        },

 

        headers={‘Authorization’: f’Bearer {settings.ARCGIS_API_KEY}’}

 

    )

 

    

 

    params[‘f’] = ‘json’

 

    params[‘token’] = settings.ARCGIS_API_KEY

 

    

 

    response = requests.post(f”{service_url}/execute”, data=params)

 

    result = response.json()

 

    

 

    if ‘error’ in result:

 

        raise RuntimeError(f”GP tool error: {result[‘error’][‘message’]}”)

 

    

 

    return result

 

def submit_async_gp_job(service_url: str, params: dict, max_polls=40):

 

    “””

 

    Submit an async geoprocessing job and poll for results.

 

    Pattern: submit → poll → retrieve

 

    “””

 

    params[‘f’] = ‘json’

 

    params[‘token’] = settings.ARCGIS_API_KEY

 

    

 

    # Submit

 

    submit_response = requests.post(

 

        f”{service_url}/submitJob”,

 

        data=params

 

    ).json()

 

    

 

    job_id = submit_response.get(‘jobId’)

 

    if not job_id:

 

        raise RuntimeError(“Job submission failed”)

 

    

 

    # Poll

 

    import time

 

    for _ in range(max_polls):

 

        status_response = requests.get(

 

            f”{service_url}/jobs/{job_id}”,

 

            params={‘f’: ‘json’, ‘token’: settings.ARCGIS_API_KEY}

 

        ).json()

 

        

 

        job_status = status_response.get(‘jobStatus’)

 

        if job_status == ‘esriJobSucceeded’:

 

            # Retrieve results

 

            result_response = requests.get(

 

                f”{service_url}/jobs/{job_id}/results/Output_Features”,

 

                params={‘f’: ‘json’, ‘token’: settings.ARCGIS_API_KEY}

 

            ).json()

 

            return result_response

 

        elif job_status in (‘esriJobFailed’, ‘esriJobCancelled’):

 

            raise RuntimeError(f”Job failed: {status_response.get(‘messages’)}”)

 

        

 

        time.sleep(3)

 

    

 

    raise TimeoutError(“Geoprocessing job timed out after polling”)

Common Errors and How to Fix Them

“GDAL not found”

  • Ubuntu: sudo apt-get install gdal-bin libgdal-dev python3-gdal
  • Windows: Install OSGeo4W and setGDAL_LIBRARY_PATH in settings.py
  • Docker (Alpine): Alpine does not have GDAL packages that work well with the Python GDAL bindings. Usepython:3.10-slim-bookworm (Debian-based) instead. Add to your Dockerfile:

 

FROM python:3.10-slim-bookworm

 

RUN apt-get update && apt-get install -y \

 

    gdal-bin \

 

    libgdal-dev \

 

    python3-gdal \

 

    postgresql-client

TokenExpiredError Mid-Analysis

This happens when a long-running analysis outlasts your token lifetime. The fix is to catch it and re-authenticate:

 

from arcgis.gis import GIS

 

from arcgis._impl.common._mixins import PropertyMap

 

def get_gis_with_retry():

 

    try:

 

        gis = get_gis_connection()

 

        # Force a lightweight API call to check if token is still valid

 

        _ = gis.properties

 

        return gis

 

    except Exception:

 

        # Clear the cache and reconnect

 

        cache.delete(‘arcgis_gis_connection’)

 

        return get_gis_connection()

GeoDjango Migration Errors with PostGIS

  1. “postgis extension not found” — RunCREATE EXTENSION postgis; as a superuser in your database
  2. “django.db.utils.ProgrammingError: relation does not exist” — You are running spatial queries before runningmigrate. Runpython manage.py migrate first
  3. “SRID mismatch” — Your geometry data is in a different SRID than your model field. Use.transform(4326) on the geometry before saving

Performance Considerations

Spatial Indexing

PostGIS automatically creates a GIST spatial index when you create a spatial column through GeoDjango. You can verify it:

 

SELECT indexname, indexdef

 

FROM pg_indexes

 

WHERE tablename = ‘analysis_analysislayer’;

 

For large datasets, make sure you are using spatial filters (__intersects,__within,__distance_lte) in your QuerySets rather than filtering in Python after fetching all rows.

Celery for Long-Running Analysis

Never run ArcGIS analysis inside a synchronous Django view for jobs that take more than 5–10 seconds. Use Celery:

 

# analysis/tasks.py

 

from celery import shared_task

 

from .services import run_buffer_analysis, save_analysis_result_to_db

 

@shared_task

 

def run_buffer_task(layer_item_id: str, distance_meters: float, analysis_layer_id: int):

 

    from arcgis.gis import GIS

 

    from django.conf import settings

 

    from .models import AnalysisLayer

 

    

 

    gis = GIS(settings.ARCGIS_URL, api_key=settings.ARCGIS_API_KEY)

 

    layer_item = gis.content.get(layer_item_id)

 

    source_layer = AnalysisLayer.objects.get(id=analysis_layer_id)

 

    

 

    result = run_buffer_analysis(layer_item, distance_meters)

 

    save_analysis_result_to_db(result, source_layer, distance_meters)

 

    

 

    return {‘status’: ‘complete’, ‘layer_id’: analysis_layer_id}

Caching GeoJSON Results

If the same GeoJSON gets requested repeatedly, cache it:

 

from django.core.cache import cache

 

import json

 

def get_cached_geojson(cache_key, queryset_func, timeout=300):

 

    cached = cache.get(cache_key)

 

    if cached:

 

        return cached

 

    

 

    result = queryset_func()

 

    cache.set(cache_key, result, timeout=timeout)

 

    return result

Deploying to Production

Checklist

  • All ArcGIS credentials must be in environment variables, never in source code
  • UseDEBUG=False in production settings
  • SetALLOWED_HOSTS explicitly
  • Runpython manage.py collectstatic before deploying
  • Use Gunicorn as the WSGI server behind Nginx

 

Example Gunicorn command:

 

gunicorn arcgis_tool.wsgi:application \

 

    –workers 4 \

 

    –bind 0.0.0.0:8000 \

 

    –timeout 120  # Increase for long analysis jobs

Docker Considerations

Use Debian-based images for GeoDjango, not Alpine. Alpine does not have reliable GDAL packages. A workingDockerfile:

 

FROM python:3.10-slim-bookworm

 

RUN apt-get update && apt-get install -y \

 

    gdal-bin libgdal-dev libgeos-dev \

 

    postgresql-client \

 

    && rm -rf /var/lib/apt/lists/*

 

WORKDIR /app

 

COPY requirements.txt .

 

RUN pip install –no-cache-dir -r requirements.txt

 

COPY . .

 

CMD [“gunicorn”, “arcgis_tool.wsgi:application”, “–bind”, “0.0.0.0:8000”]

What You Built and What to Build Next

At this point you have a working stack: Django accepts requests, the ArcGIS API for Python runs analysis against an Esri portal, PostGIS stores the results, and Leaflet.js renders them on a map. Real field teams can use this from any browser.

 

Natural next steps:

 

  • Add user authentication so field teams have separate accounts and analysis history
  • Add support for multi-layer overlay analysis (intersect, union, difference)
  • Build a job status endpoint so the front end can poll Celery task progress
  • Export analysis results as PDF reports using ReportLab or WeasyPrint
  • Add support for uploading shapefiles and converting them to PostGIS layers with GDAL

Authoritative Resources

Leave a Reply

Your email address will not be published. Required fields are marked *