restgdf
lightweight async Esri REST client with optional GeoPandas extras
Python ≥ 3.11 | async-first | zero mandatory geo dependencies | pydantic-validated responses
import asyncio
from aiohttp import ClientSession
from restgdf import FeatureLayer
async def main():
async with ClientSession() as session:
layer = await FeatureLayer.from_url(
"https://maps1.vcgov.org/arcgis/rest/services/Beaches/MapServer/6",
session=session,
)
print(layer.name, layer.count)
async for row in layer.stream_rows():
print(row)
break
asyncio.run(main())
3.0 release highlights and migration summary (click to expand)
Release highlights
restgdf 3.0 added the following major capabilities alongside the 2.0 typed-model migration described below.
- Streaming APIs.
FeatureLayer.stream_features,stream_feature_batches, andstream_rowsexpose ArcGIS pagination as async generators withon_truncation="raise" | "ignore" | "split",order="request" | "completion", andmax_concurrent_pagesknobs, plus an R-61feature_layer.streamparent span when telemetry is enabled.stream_gdf_chunksis the legacyGeoDataFrame-per-page shape (requiresrestgdf[geo], completion-order only, no shared knobs).stream_rowsworks on the base install. - Pandas-first output.
FeatureLayer.get_df()returns apandas.DataFramewithout requiring the geo extra, sibling toget_gdf(). - Output adapters.
restgdf.adapters.{dict,stream,pandas,geopandas}compose the streaming primitives into tabular shapes. - Nested config.
restgdf.Config/restgdf.get_config()replace the flatSettingsobject with eight frozen sub-configs andRESTGDF_<CATEGORY>_<FIELD>env vars. The old flat variables keep working with aDeprecationWarning. - Error taxonomy.
restgdf.errorsexposesRestgdfError,ConfigurationError,OptionalDependencyError,TransportError,RestgdfTimeoutError,RateLimitError,ArcGISServiceError,PaginationError,FieldDoesNotExistError,SchemaValidationError,AuthenticationError, andOutputConversionError— all with URL, status-code, and retry-after context populated where applicable. - Optional telemetry.
pip install restgdf[telemetry]unlocksRestgdfInstrumentorand trace/span log correlation; see the new tracing recipe and streaming recipe. - Header-token default. Tokens now ride the
X-Esri-Authorizationheader by default; construct the session withTokenSessionConfig(transport="body")to restore the old body transport (a bareAuthConfig(transport="body")is a config holder that is not auto-applied to a session).
See CHANGELOG.md
and MIGRATION.md
for the full release notes and upgrade guidance.
2.0 migration changes
restgdf 2.0 is a major release built on pydantic 2.13.
See MIGRATION.md for the full breaking-changes table and
code-rewrite recipes.
- Typed responses.
FeatureLayer.metadata,Directory.metadata/.services/.report, and helpers likeget_metadata,safe_crawlnow return pydantic models instead of raw dicts. - Validated envelopes.
get_feature_count,get_object_ids, and token refresh surface malformed ArcGIS payloads as a typedRestgdfResponseError(withmodel_name,context,raw). - Schema-drift observability. Vendor variance in permissive payloads
(metadata, crawl) is logged through the opt-in
restgdf.schema_driftlogger instead of silentlyKeyError-ing. - Redacted credentials.
AGOLUserPass.passwordis apydantic.SecretStrso passwords are never inrepr()or logs. - Centralized settings.
Settings/get_settings()readsRESTGDF_*environment variables (chunk size, timeout, user agent, token URL, refresh threshold, etc.). - Migration helpers.
restgdf.compat.as_dictandas_json_dictconvert any returned model back to a plain dict during a transitional upgrade window. - Deprecated shim.
restgdf._types.*still imports the legacyTypedDictnames, but they now re-export the pydantic classes and emitDeprecationWarning. The shim will be removed in a future major release. - Dependency bump.
pydantic>=2.13.3,<3is a new required dependency.
Resilience extra
For production workloads that need automatic retry with jitter and per-service-root rate limiting, install the optional resilience extra:
pip install restgdf[resilience]
This adds stamina and aiolimiter. Installing it applies nothing on its
own: wrap any AsyncHTTPSession with restgdf.resilience.ResilientSession,
configured via a restgdf.ResilienceConfig with enabled=True
(RESTGDF_RESILIENCE_ENABLED=1 sets that flag on the process-global config,
but only a session you explicitly wrap with ResilientSession reads it —
setting the env var alone does not add resilience to a FeatureLayer or
Directory call). See MIGRATION.md and the
polite bulk crawl recipe
for details.
gpd.read_file(url, driver="ESRIJSON") does not account for max record count
limitations, so large services get truncated at the server's
maxRecordCount.
restgdf uses asyncio to read all features from a service, not just the first page, while letting you choose between a light-core install and an optional GeoPandas extra.
Installation
Requires Python ≥ 3.11 (3.9 reached EOL 2025-10-31; 3.10 reaches EOL 2026-10-31).
Install the lightweight core package when you want typed metadata, query
helpers, crawl/auth utilities, or raw feature rows without pulling in
pandas, geopandas, or pyogrio:
pip install restgdf
Base-install capabilities include:
- typed pydantic response models like
LayerMetadataandCrawlReport FeatureLayer.from_url,.metadata,.count, and.get_oids()- single-field
get_unique_values()queries - raw feature dictionaries via
FeatureLayer.stream_features()/stream_rows()(deprecatedrow_dict_generator()still works) Directorycrawling andArcGISTokenSessionauthentication helpers
Install the geo extra for GeoDataFrame and pandas-backed workflows:
pip install "restgdf[geo]"
restgdf[geo] adds:
FeatureLayer.get_gdf()/ deprecatedgetgdf()FeatureLayer.sample_gdf()andhead_gdf()FeatureLayer.fieldtypes- pandas-backed helpers like
get_value_counts()andget_nested_count() - low-level
restgdf.utils.getgdfhelpers
Install the resilience extra for automatic retry with exponential back-off and configurable (per-service-root or per-host) rate limiting — useful for bulk/multi-server crawls:
pip install "restgdf[resilience]"
restgdf[resilience] adds stamina and aiolimiter. It changes no request
behavior by itself — wrap your own aiohttp.ClientSession with
restgdf.resilience.ResilientSession (configured via
restgdf.ResilienceConfig) and pass the wrapped session as
session= to FeatureLayer/Directory. See the
polite bulk crawl recipe
for a full worked example.
Treat the split above as the stable dependency boundary: geo-enabled
environments should depend on restgdf[geo] explicitly. See
MIGRATION.md
for the 1.x → 2.0 and 2.x → 3.0 upgrade guides.
Light-core usage
import asyncio
from aiohttp import ClientSession
from restgdf import FeatureLayer
beaches_url = r"https://maps1.vcgov.org/arcgis/rest/services/Beaches/MapServer/6"
async def main():
async with ClientSession() as session:
beaches = await FeatureLayer.from_url(beaches_url, session=session)
cities = await beaches.get_unique_values("CITY")
first_rows = []
async for row in beaches.stream_rows(data={"outFields": "CITY,STATE"}):
first_rows.append(row)
if len(first_rows) == 2:
break
return beaches.count, beaches.metadata.max_record_count, cities[:3], first_rows
count, max_record_count, cities, first_rows = asyncio.run(main())
print(count, max_record_count)
print(cities)
print(first_rows[0])
Streaming
FeatureLayer exposes ArcGIS pagination as three async generators so
you can process millions of rows without buffering them in memory. The
on_truncation knob controls what happens when the server caps a page
at maxRecordCount: "raise" (default), "ignore" (log + continue),
or "split" (bisect by object-id and retry, up to depth 32).
import asyncio
from aiohttp import ClientSession
from restgdf import FeatureLayer
zipcodes_url = "https://services.arcgis.com/P3ePLMYs2RVChkJx/ArcGIS/rest/services/USA_ZIP_Codes_2016/FeatureServer/0"
async def main():
async with ClientSession() as session:
oh = await FeatureLayer.from_url(
zipcodes_url,
where="STATE = 'OH'",
session=session,
)
# 1. Feature dicts, one per row (base install)
first_feature = None
async for feature in oh.stream_features(on_truncation="split"):
first_feature = feature
break
# 2. Per-page batches, preserving ArcGIS page boundaries
page_sizes = []
async for batch in oh.stream_feature_batches(order="request"):
page_sizes.append(len(batch))
if len(page_sizes) == 3:
break
# 3. GeoDataFrame chunks (requires `restgdf[geo]`; note that
# stream_gdf_chunks does *not* accept on_truncation / order /
# max_concurrent_pages — it yields in completion order).
chunk_shapes = []
async for chunk in oh.stream_gdf_chunks():
chunk_shapes.append(chunk.shape)
if len(chunk_shapes) == 2:
break
return first_feature, page_sizes, chunk_shapes
first_feature, page_sizes, chunk_shapes = asyncio.run(main())
See the streaming recipe
for the full matrix of on_truncation, order, and
max_concurrent_pages combinations on the iter_pages-based shapes
(stream_features, stream_feature_batches, stream_rows).
GeoDataFrame workflows (restgdf[geo])
import asyncio
from aiohttp import ClientSession
from restgdf import FeatureLayer
beaches_url = r"https://maps1.vcgov.org/arcgis/rest/services/Beaches/MapServer/6"
zipcodes_url = "https://services.arcgis.com/P3ePLMYs2RVChkJx/ArcGIS/rest/services/USA_ZIP_Codes_2016/FeatureServer/0"
async def main():
async with ClientSession() as session:
beaches = await FeatureLayer.from_url(beaches_url, session=session)
beaches_gdf = await beaches.get_gdf()
daytona = await beaches.where("LOWER(City) LIKE 'daytona%'")
daytona_gdf = await daytona.get_gdf()
oh_zipcodes = await FeatureLayer.from_url(
zipcodes_url,
where="STATE = 'OH'",
session=session,
)
oh_zipcodes_gdf = await oh_zipcodes.get_gdf()
return beaches_gdf, daytona_gdf, oh_zipcodes_gdf
beaches_gdf, daytona_gdf, oh_zipcodes_gdf = asyncio.run(main())
print(beaches_gdf.shape)
# (243, 10)
print(daytona_gdf.shape)
# (83, 10)
print(oh_zipcodes_gdf.shape)
# (1026, 8)
Keyword arguments to FeatureLayer.get_gdf() are passed on to
aiohttp.ClientSession.post; include query parameters like where and token
in the data dict when needed.
Truncation now raises (PAGINATION-01). get_gdf() and
stream_gdf_chunks() — the GeoDataFrame path — now inspect each page for
exceededTransferLimit=true and raise restgdf.errors.PaginationError,
mirroring the raw-feature streaming engine, instead of silently returning
a GeoDataFrame that is missing rows. If a very large layer trips this,
read it through the iter_pages-based stream_features / stream_rows
shapes, which expose on_truncation="ignore" | "split" (the legacy geo
chunk path does not take that knob).
Token authentication
Token helpers are available in the base install. The GeoDataFrame example below
requires restgdf[geo] because it calls get_gdf().
import asyncio
from aiohttp import ClientSession
from restgdf import AGOLUserPass, ArcGISTokenSession, FeatureLayer
secured_url = "https://example.com/arcgis/rest/services/Secured/FeatureServer/0"
async def main():
async with ClientSession() as base_session:
token_session = ArcGISTokenSession(
session=base_session,
credentials=AGOLUserPass(
username="my-username",
password="my-password",
),
)
layer = await FeatureLayer.from_url(secured_url, session=token_session)
return await layer.get_gdf()
secured_gdf = asyncio.run(main())
If you already have a token, you can pass it with token="..." or
data={"token": "..."}. restgdf sends a token-bearing request as a POST
with the token in the request body (never serialized into the URL query
string), so the token does not leak into server or proxy access logs —
this holds even for short requests that would otherwise use GET (AUTH-01).
For fully header-based auth, use an ArcGISTokenSession, which sends the
token via the X-Esri-Authorization header instead.
Typed responses
Typed responses are part of the base pip install restgdf surface.
Every response is a pydantic model. Attribute access replaces dict
indexing, and model_dump(by_alias=True) round-trips back to ArcGIS
camelCase:
import asyncio
from aiohttp import ClientSession
from restgdf import FeatureLayer
async def main():
async with ClientSession() as session:
fl = await FeatureLayer.from_url(beaches_url, session=session)
md = fl.metadata # restgdf.LayerMetadata
return md.name, md.max_record_count, md.model_dump(by_alias=True)
name, max_record_count, arcgis_dict = asyncio.run(main())
Need a plain dict during a transitional migration? Use
restgdf.compat.as_dict(md). See MIGRATION.md for
the 1.x → 2.0 and 2.x → 3.0 upgrade guides.
ArcGIS drift guarantees and limitations
- Strict envelopes (
CountResponse,ObjectIdsResponse,TokenResponse) fail fast withRestgdfResponseErrorwhen required keys are missing or malformed. - Permissive envelopes (
LayerMetadata,FeaturesResponse, crawl/service payloads) still tolerate unknown extras and missing optional fields, but a top-level ArcGIS JSON error envelope ({"error": {...}}) now raisesRestgdfResponseErrorinstead of being mistaken for partial metadata or an empty feature page. - Query helpers intentionally call
aiohttpJSON decoding withcontent_type=None, so mislabeled JSON bodies such astext/plainstill parse. - Non-JSON/HTML bodies are not normalized: malformed query bodies still bubble
the underlying JSON decoder error, and
ArcGISTokenSession.update_token()still preserves aiohttp's nativeContentTypeErrorbehavior for HTML token pages.
Documentation
Full docs live at https://restgdf.readthedocs.io/ (hosted by Read the Docs).
Docs for humans and LLMs
Every page is published in three formats so you can feed it to a teammate or to a language model without any preprocessing:
| Format | URL |
|---|---|
| Rendered HTML | https://restgdf.readthedocs.io/en/latest/ |
| Plain Markdown (per page) | append .md to any page — e.g. https://restgdf.readthedocs.io/en/latest/quickstart.html.md |
| llms.txt index | https://restgdf.readthedocs.io/en/latest/llms.txt |
| llms-full.txt (all pages) | https://restgdf.readthedocs.io/en/latest/llms-full.txt |
| Ask DeepWiki | https://deepwiki.com/joshuasundance-swca/restgdf |
Point your coding agent or RAG pipeline at llms-full.txt for the entire
reference in a single file, or at llms.txt for a concise table of
contents.
For contributors
- CONTRIBUTING.md — local setup, PR checklist, commit conventions, gate suite.
- ARCHITECTURE.md — module layout, exception taxonomy, logger hierarchy, config precedence, session ownership, streaming shapes, extras matrix.
- CHANGELOG.md — every user-visible change.
- MIGRATION.md — upgrading across 1.x → 2.0, 2.x → 3.0, and 3.0.x → 3.1.
Uses
- restgdf_api
- govgis_nov2023 — a bulk, multi-host crawl; see the polite bulk crawl recipe for the resilience + concurrency + User-Agent composition this kind of workload needs.
Metadata
Release files for restgdf 3.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| restgdf-3.3.0.tar.gz | 299.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| restgdf-3.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 438.0 kB
Release files / restgdf-3.3.0.tar.gz
| Download URL | restgdf-3.3.0.tar.gz |
|---|---|
| Size | 299.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4044dae3f823746194a469eeef8a2dc1a498f4106163f94adb0c19f6539c83aa
|
|
BLAKE2b-256 checksum How to use checksums |
0dc91b0cbfffe826cf2467e9e4635871e841801cdde9c9bb13e6f0df214dfe02
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
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 Jul 24, 2026.
Transparency logRelease files / restgdf-3.3.0-py3-none-any.whl
| Download URL | restgdf-3.3.0-py3-none-any.whl |
|---|---|
| Size | 138.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
face3eba586be7fd65cb73a9ba67508b774adba84706a901c2791c040c0f4ffc
|
|
BLAKE2b-256 checksum How to use checksums |
6577140a60b7716ddeb30291909dac4a3cc05f3ecab678f94b1ec4c341076f24
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
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 Jul 24, 2026.
Transparency log