portalkit
One Python API for Socrata, CKAN, ArcGIS and OpenDataSoft open data portals.
Nearly every government open data portal runs on one of four platforms. Each has its own client, its own pagination bugs, and its own way of losing your data quietly. portalkit detects which platform a URL is running and gives you the same API either way, returning pandas or geopandas.
Installation
pip install portalkit # add [geo] for geometry support
pip install 'portalkit[geo]'
Requires Python 3.10+.
Quick start
import portalkit as pk
df = pk.load("chicago/311", limit=5000) # a known dataset, by name
portal = pk.open("https://data.sfgov.org") # or any portal URL
portal.search("evictions")
gdf = portal.dataset("6z8x-wfk4").to_geopandas(where="file_date > '2026-01-01'")
pk.open works out which software the portal runs by asking it. You do not have to know, and you
do not need a different library per city.
Why this exists
sodapy, the client most people reach for, has been unmaintained since 2022 and only speaks
Socrata. ckanapi is a faithful wrapper around CKAN's Action API with no opinion about
DataFrames. ArcGIS means either Esri's SDK or writing the pagination yourself. Most projects end
up with three half-written clients carrying the same three bugs.
Silently truncated downloads
Point geopandas.read_file() at an ArcGIS FeatureServer layer and you get 1000 rows back with no
error and no warning, because that is the server's maxRecordCount. Socrata's default is the
same. Analyses get published on top of that number.
Paging past it is not just a loop. Without an explicit sort, resultOffset and $offset are free
to return the same row twice and never show you another, so both need a stable order: :id on
Socrata, the object id field on ArcGIS. Some ArcGIS layers cannot page at all and have to be
fetched through returnIdsOnly and batched where clauses. OpenDataSoft caps offset at 10,000
and errors past it, so anything larger goes through its export endpoint.
portalkit does all of that and checks the result against the row count the portal itself reports.
Adaptive ArcGIS page sizes
A layer's advertised maxRecordCount describes what it will return for cheap rows. Ask for the
same number of full-resolution polygons and the server returns a 500 wrapped inside a 200 body,
which a naive client reads as "no more rows" and stops. There is no way to know the real ceiling
except to find it, so portalkit halves the page size and retries until the server copes, then
keeps the smaller size. It also requests six decimal places of coordinate precision, which is
about ten centimetres and roughly halves a polygon response.
Column types from the schema, not from guessing
Socrata returns every value as a string, dates included. Letting pandas infer afterwards turns ZIP
code 02134 into 2134.0. The portal already published a schema saying which column is an
identifier, so portalkit uses it. When the schema turns out to be wrong, which happens, the column
comes back untouched rather than as a column of nulls, so you can see the mess and decide.
Geometry
Socrata sends GeoJSON, ArcGIS sends rings and paths, OpenDataSoft wraps a GeoJSON Feature in
one column and its centroid in another. All of it converts to shapely with the CRS set.
Esri packs every ring of a shape into one flat list and distinguishes exteriors from holes by winding direction. Treating everything after the first ring as a hole silently deletes islands: on the Census state boundaries layer, 11 of 56 states are multi-part. portalkit classifies rings by orientation and assigns holes to the shell that contains them.
When a dataset carries both a shape and its centroid, the shape wins.
Portals
| Platform | Detected by | Query language | Notes |
|---|---|---|---|
| Socrata | /api/catalog/v1 |
SoQL | Pass token= for an app token to lift the anonymous rate limit |
| CKAN | /api/3/action/site_read |
DataStore filters, SQL when where is given |
Only DataStore-backed resources are queryable |
| ArcGIS | /rest/services + f=json |
SQL-ish where |
FeatureServer and MapServer, by service or by layer URL |
| OpenDataSoft | /api/explore/v2.1/catalog/datasets |
ODSQL | Requests past 10,000 rows use the export endpoint |
API
| Call | Returns |
|---|---|
pk.open(url, kind=None, token=None, cache=True) |
A Portal |
pk.dataset("nyc/311") |
A Dataset from the catalog |
pk.load("nyc/311", **kwargs) |
A DataFrame, straight from the catalog |
pk.search(url, query, limit=20) |
A list of DatasetInfo |
portal.search(query, limit=20) |
A list of DatasetInfo |
portal.dataset(id) |
A Dataset |
dataset.info |
DatasetInfo, including columns and row count |
dataset.to_pandas(where=, select=, order=, limit=, cache=, progress=) |
A DataFrame |
dataset.to_geopandas(geometry=None, crs=4326, **kwargs) |
A GeoDataFrame |
where, select and order go to the portal in its own query language. Filtering server-side is
usually the difference between two seconds and five minutes.
Caching
Every tabular fetch is stored as Parquet, keyed by portal, dataset and the exact query, so re-running a notebook does not re-run the download.
| Control | Effect |
|---|---|
PORTALKIT_CACHE |
Cache directory, defaults to the platform cache dir |
cache=False |
Skip the cache for one call |
max_age=<seconds> |
Treat anything older as a miss |
portalkit clear-cache |
Empty it |
to_geopandas() does not cache. Shapely geometry does not survive a Parquet round trip, so it
would be a cache that never hits.
Command line
$ portalkit catalog chicago
reference dataset portal
------------------------ ----------------------- ------------------------------
chicago/311 311 Service Requests https://data.cityofchicago.org
chicago/building-permits Building permits https://data.cityofchicago.org
chicago/crimes Crimes, 2001 to present https://data.cityofchicago.org
$ portalkit info chicago/311
311 Service Requests
id v6vf-nfxy
portal https://data.cityofchicago.org (socrata)
rows 14,517,544
$ portalkit get chicago/311 --where "sr_type = 'Pothole in Street Complaint'" -o potholes.parquet
wrote 422,462 rows to potholes.parquet
| Command | What it does |
|---|---|
portalkit catalog [filter] |
List the built-in dataset references |
portalkit search <portal> <query> |
Search one portal |
portalkit info <ref> |
Show a dataset's columns and row count |
portalkit get <ref> -o <file> |
Download to .parquet, .csv, .json, .geojson or .gpkg |
portalkit clear-cache |
Delete everything cached |
The catalog
pk.load("nyc/311") works because a small list of well-known datasets ships with the package. It
is not meant to be exhaustive, it is meant to save the five minute detour of discovering that
Chicago's 311 dataset is v6vf-nfxy. portalkit catalog lists what is there.
Adding your city is a pull request against src/portalkit/catalog.toml with a portal URL, a
dataset id and a title. For entries you would rather not publish, point PORTALKIT_CATALOG at
your own TOML file and it is merged over the built-in one.
Notes
CKAN is a catalogue first. Only resources pushed into its DataStore can be queried; everything
else is a file on disk. portalkit resolves a package to its first DataStore-backed resource, or
says plainly that there is not one and where the files are. Pass package:resource-id to pick a
specific one.
ArcGIS addressing follows the REST URLs. Give it a service and name a layer with
portal.dataset("0"), or give it the layer URL directly.
Large geometry downloads from municipal servers are genuinely slow. All 3,235 US counties at full
resolution is a few hundred megabytes. The CLI shows a running row count and to_pandas takes a
progress= callback. Filter server-side first where you can.
Rate limits belong to the portal, not to portalkit. One connection at a time, retries with jitter,
and Retry-After respected. Most of these portals are one box run by a team of two.
Contributing
Bug reports and pull requests are welcome, especially catalog entries for new cities. uv sync
then uv run pytest to get started. uv run pytest -m live runs the tests that hit real portals.
License
MIT.
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 portalkit-0.1.1.tar.gz.
File metadata
- Download URL: portalkit-0.1.1.tar.gz
- Upload date:
- Size: 142.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
402c0c9912e2b2d438ba667641b34a2e3e65e82377d1b0810cc271d8478d3200
|
|
| MD5 |
6caacb60ecf04800709f91f29b72f0eb
|
|
| BLAKE2b-256 |
75cba26f454c0309405d292ea6801c248cf7d98f947043236157b322876a716e
|
Provenance
The following attestation bundles were made for portalkit-0.1.1.tar.gz:
Publisher:
release.yml on aviseth/portalkit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
portalkit-0.1.1.tar.gz -
Subject digest:
402c0c9912e2b2d438ba667641b34a2e3e65e82377d1b0810cc271d8478d3200 - Sigstore transparency entry: 2580020336
- Sigstore integration time:
-
Permalink:
aviseth/portalkit@a3dfc52b452a374509d547083b4740dec71677ad -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/aviseth
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a3dfc52b452a374509d547083b4740dec71677ad -
Trigger Event:
push
-
Statement type:
File details
Details for the file portalkit-0.1.1-py3-none-any.whl.
File metadata
- Download URL: portalkit-0.1.1-py3-none-any.whl
- Upload date:
- Size: 37.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
35a36f198907db6d909d066a9bd71d863e672152dd08333b68c5e0c844c5ad8f
|
|
| MD5 |
581736018251274e050b7f199ec03263
|
|
| BLAKE2b-256 |
16f3b2930860612c1244a44fe5bd633294adf68b2a2512dd0056acac4538424b
|
Provenance
The following attestation bundles were made for portalkit-0.1.1-py3-none-any.whl:
Publisher:
release.yml on aviseth/portalkit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
portalkit-0.1.1-py3-none-any.whl -
Subject digest:
35a36f198907db6d909d066a9bd71d863e672152dd08333b68c5e0c844c5ad8f - Sigstore transparency entry: 2580020345
- Sigstore integration time:
-
Permalink:
aviseth/portalkit@a3dfc52b452a374509d547083b4740dec71677ad -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/aviseth
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a3dfc52b452a374509d547083b4740dec71677ad -
Trigger Event:
push
-
Statement type: