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())
2.0 release highlights and migration summary (click to expand)
Release highlights
restgdf 2.0.0 includes the following major additions alongside the core 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; setAuthConfig.transport="body"to restore the old behavior.
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. Wrap any AsyncHTTPSession with
restgdf.resilience.ResilientSession and configure via
restgdf.resilience.ResilienceConfig or RESTGDF_RESILIENCE_ENABLED=1.
See MIGRATION.md 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
Treat the split above as the stable dependency boundary: geo-enabled
environments should depend on restgdf[geo] explicitly. See
MIGRATION.md
for the full 1.x → 2.0 rewrite table and upgrade recipes.
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.
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": "..."}.
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 full 1.x → 2.0 rewrite table.
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 from 1.x to 2.0.
Uses
Metadata
Release files for restgdf 3.1.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.1.0.tar.gz | 237.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| restgdf-3.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 355.3 kB
Release files / restgdf-3.1.0.tar.gz
| Download URL | restgdf-3.1.0.tar.gz |
|---|---|
| Size | 237.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
dd64cf362d8f6aa2bedbbd6e87a4e7bebcd921e5203db26af526ebc3af45ed54
|
|
BLAKE2b-256 checksum How to use checksums |
6e097a85914319dbe922a4c6e5a59278881bd39ac8260ed85915e4f7a63387eb
|
| 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.1.0-py3-none-any.whl
| Download URL | restgdf-3.1.0-py3-none-any.whl |
|---|---|
| Size | 117.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
77bbe3fd77ae7977c172ba02f0313ddc1718ab8efb08fc1af3619e12b8568844
|
|
BLAKE2b-256 checksum How to use checksums |
7bbc3036b52c314997561eebf9bb11833bd5df8f11f0233bd5d82532c79be8f7
|
| 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