GeoJSON Downloaded from an API Is Empty or Truncated
On this page
Problem statement
You request a layer from a service and the number is wrong. Not obviously wrong β plausibly wrong:
import requests
HEADERS = {"User-Agent": "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"}
r = requests.get("https://demo.pygeoapi.io/master/collections/lakes/items",
params={"f": "json", "limit": 100000}, headers=HEADERS, timeout=60)
data = r.json()
print(r.status_code, len(data["features"]))
200 10
You asked for a hundred thousand. You got ten. The status is 200. The GeoJSON is valid. Nothing raised.
Or the opposite β you get nothing at all. Here is the Dutch national statistics office's neighbourhood layer on PDOK, asked for the neighbourhoods around Utrecht by a box in degrees:
import geopandas as gpd
BASE = "https://service.pdok.nl/cbs/wijkenbuurten/2023/wfs/v1_0"
wfs_url = (f"{BASE}?service=WFS&version=2.0.0&request=GetFeature&typeNames=wijkenbuurten:buurten"
"&outputFormat=application/json&bbox=5.05,52.05,5.20,52.15,urn:ogc:def:crs:EPSG::4326")
print(len(gpd.read_file(wfs_url)))
0
Both are the same underlying problem: a service applied a limit or a filter you did not know about, and reported success. The difference between a truncated download and a complete one is not visible in the file. It is only visible if you asked the server how many features there should have been.
Quick answer
Never trust a feature count you did not compare against the server's own:
r = requests.get("https://demo.pygeoapi.io/master/collections/lakes/items",
params={"f": "json", "limit": 100000}, headers=HEADERS, timeout=60)
payload = r.json()
matched = payload.get("numberMatched")
returned = payload.get("numberReturned", len(payload["features"]))
print(f"returned {returned} of {matched}")
try:
if matched is not None and returned < matched:
raise RuntimeError(f"truncated: {returned} of {matched} β page through the result")
except RuntimeError as err:
print(f"RuntimeError: {err}")
returned 10 of 25
RuntimeError: truncated: 10 of 25 β page through the result
The four causes, and how to tell them apart:
| Symptom | Cause | Check |
|---|---|---|
| exactly 10, 100, 1000, 5000 features | server-side cap | numberMatched or resultType=hits |
| 0 features, HTTP 200 | bbox in the wrong CRS, units or axis order | print the bbox and the layer's CRS |
| 0 features, XML body (HTTP 200 or 400) | an OGC exception report | check whether the body starts with < |
| a plausible but short count | mid-query timeout | look for remark in the JSON |
Print what the server said, ask for counts, page
1. Print what the server said, not just what you got
The first move is always the same: look at the response envelope rather than the features.
for key in ("numberMatched", "numberReturned", "remark", "type"):
if key in payload:
print(f"{key:16} {payload[key]}")
print(f"{'features':16} {len(payload['features'])}")
print(f"{'links':16} {[l['rel'] for l in payload.get('links', [])]}")
numberMatched 25
numberReturned 10
type FeatureCollection
features 10
links ['self', 'alternate', 'alternate', 'alternate', 'next', 'collection']
numberMatched 25, numberReturned 10, and a next link. The server told you plainly that there was more; the code just did not read it.
2. If there is no numberMatched, ask for the count separately
WFS servers frequently omit it from GeoJSON output. Use the dedicated hit-count request:
import re
r = requests.get(BASE, params={
"service": "WFS", "version": "2.0.0", "request": "GetFeature",
"typeNames": "wijkenbuurten:buurten", "resultType": "hits",
}, headers=HEADERS, timeout=120)
print(int(re.search(r'numberMatched="(\d+)"', r.text).group(1)))
14515
It returns XML even when you asked for JSON, because the count lives in an attribute of the WFS response element. Parse it with a regex. And you need it, because the GeoJSON output of this server says nothing about its own cap:
r = requests.get(BASE, params={
"service": "WFS", "version": "2.0.0", "request": "GetFeature",
"typeNames": "wijkenbuurten:buurten", "outputFormat": "application/json", "count": 100000,
}, headers=HEADERS, timeout=300)
payload = r.json()
print(f"asked for 100,000: got {len(payload['features']):,}; keys in the response: {sorted(payload)}")
asked for 100,000: got 1,000; keys in the response: ['bbox', 'crs', 'features', 'name', 'type']
A thousand neighbourhoods out of 14,515, a round number, and no numberMatched to warn you.
3. Recognise the round numbers
A count of exactly 10, 100, 500, 1000, 2000 or 5000 is almost never a coincidence. Those are default caps.
The trap is that asking for more does not raise:
URL = "https://demo.pygeoapi.io/master/collections/lakes/items"
for limit in (10, 1000, 100000):
n = len(requests.get(URL, params={"f": "json", "limit": limit}, headers=HEADERS, timeout=60).json()["features"])
print(f"asked {limit:>7} -> got {n}")
asked 10 -> got 10
asked 1000 -> got 10
asked 100000 -> got 10
Ten every time. The server's cap is ten and it silently substitutes it. No amount of asking harder gets past it β only paging does.
4. Diagnose an empty result: check the CRS β and the axis order β of your bbox
An empty result with HTTP 200 is nearly always a bounding box the server read differently from how you meant it. The problem statement's empty download is one. The numbers 5.05, 52.05 are longitude and latitude for Utrecht β but the bbox declares urn:ogc:def:crs:EPSG::4326, and in that CRS as the EPSG registry defines it latitude comes first. The server read a box at latitude 5Β° north, longitude 52Β° east β open sea in the Indian Ocean β and found no Dutch neighbourhoods there.
Try the same area every way:
CASES = {
"metres, no CRS": "132000,452000,140000,460000",
"metres, EPSG:28992": "132000,452000,140000,460000,urn:ogc:def:crs:EPSG::28992",
"degrees lon/lat, EPSG::4326": "5.05,52.05,5.20,52.15,urn:ogc:def:crs:EPSG::4326",
"degrees lat/lon, EPSG::4326": "52.05,5.05,52.15,5.20,urn:ogc:def:crs:EPSG::4326",
"degrees lon/lat, no CRS": "5.05,52.05,5.20,52.15",
}
for label, bbox in CASES.items():
r = requests.get(BASE, params={
"service": "WFS", "version": "2.0.0", "request": "GetFeature",
"typeNames": "wijkenbuurten:buurten", "outputFormat": "application/json",
"bbox": bbox, "count": 5,
}, headers=HEADERS, timeout=120)
print(f"{label:30} HTTP {r.status_code} -> {len(r.json()['features'])} features")
metres, no CRS HTTP 200 -> 5 features
metres, EPSG:28992 HTTP 200 -> 5 features
degrees lon/lat, EPSG::4326 HTTP 200 -> 0 features
degrees lat/lon, EPSG::4326 HTTP 200 -> 5 features
degrees lon/lat, no CRS HTTP 200 -> 0 features
Metres work with or without a declared CRS, because this server's default is the Dutch national grid, EPSG:28992. Degrees work only latitude-first. Every failure is HTTP 200 with an empty collection. For OGC API Features the rule is different again: bbox there is always longitude-first WGS84 unless you pass bbox-crs.
The check that catches this before you waste an afternoon is to ask for one feature inside the box you think you are sending β if it comes back empty, the box is not where you think.
5. Look for the exception report behind the status
Many OGC servers return errors as XML, some with a success status and some without. Check the body, not just the code:
r = requests.get(BASE, params={
"service": "WFS", "version": "2.0.0", "request": "GetFeature",
"typeNames": "wijkenbuurten:buurtn", "outputFormat": "application/json", "count": 5,
}, headers=HEADERS, timeout=120)
if r.text.lstrip().startswith("<"):
message = " ".join(re.sub("<[^>]+>", " ", r.text).split())
print(f"HTTP {r.status_code}, OGC exception: {message[:200]}")
HTTP 400, OGC exception: msWFSGetFeature(): WFS server error. TYPENAME 'buurtn' doesn't exist in this server. Please check the capabilities and reformulate your request.
That message names the actual problem β a misspelled layer name β which gpd.read_file() would have reported as an unhelpful GDAL failure. This server sends it with HTTP 400; others send the same kind of report with HTTP 200, which is why the check reads the body.
6. Look for remark
Overpass and some other services return HTTP 200, valid JSON, a partial feature list and a top-level remark explaining that the query timed out β see how to query the Overpass API, where one is captured:
if "remark" in payload:
raise RuntimeError(f"partial result: {payload['remark']}")
One function that catches all four failures
Example 1 β one function that catches all four failures
import re
import requests
class IncompleteResponse(RuntimeError):
pass
def fetch_geojson(url, params=None, *, timeout=180):
"""GET a GeoJSON collection and refuse to return a silently incomplete one."""
r = requests.get(url, params=params, headers=HEADERS, timeout=timeout)
body = r.text.lstrip()
if body.startswith("<"):
message = " ".join(re.sub("<[^>]+>", " ", r.text).split())
raise IncompleteResponse(f"HTTP {r.status_code}, XML body: {message[:200]}")
r.raise_for_status()
payload = r.json()
if "remark" in payload:
raise IncompleteResponse(f"server remark: {payload['remark']}")
features = payload.get("features", [])
matched = payload.get("numberMatched")
returned = payload.get("numberReturned", len(features))
if matched is not None and returned < matched:
raise IncompleteResponse(f"truncated: {returned} of {matched} features")
if not features:
raise IncompleteResponse(f"empty result β check the bbox CRS and the filter "
f"(request was {r.url})")
return payload
try:
fetch_geojson("https://demo.pygeoapi.io/master/collections/lakes/items",
{"f": "json", "limit": 100000})
except IncompleteResponse as exc:
print(exc)
truncated: 10 of 25 features
Every branch converts a plausible-looking success into a message naming the actual problem. Note that the empty-result message includes r.url β the fully encoded request β which is the single most useful thing to have when a filter silently matches nothing.
Example 2 β paging by following the next link
import geopandas as gpd
import pandas as pd
def fetch_all(url, params=None, *, max_pages=100):
"""Follow OGC API Features `next` links until the collection is complete."""
collected, matched, pages = [], None, 0
next_url, next_params = url, dict(params or {})
while next_url and pages < max_pages:
r = requests.get(next_url, params=next_params, headers=HEADERS, timeout=180)
r.raise_for_status()
payload = r.json()
matched = payload.get("numberMatched", matched)
collected.extend(payload.get("features", []))
pages += 1
links = {l["rel"]: l["href"] for l in payload.get("links", [])}
next_url, next_params = links.get("next"), None # the link carries its own params
print(f" page {pages}: {len(collected)} of {matched}")
if matched is not None and len(collected) != matched:
raise IncompleteResponse(f"stopped at {len(collected)} of {matched} after {pages} pages")
return gpd.GeoDataFrame.from_features(collected, crs="EPSG:4326")
lakes = fetch_all("https://demo.pygeoapi.io/master/collections/lakes/items", {"f": "json"})
print(len(lakes), "lakes")
page 1: 10 of 25
page 2: 20 of 25
page 3: 25 of 25
25 lakes
Following next beats computing offsets: the server builds the link, so it stays correct even when the server's paging rules are unusual. max_pages is a runaway guard β a misbehaving server that always returns a next link would otherwise loop forever.
Example 3 β the capped WFS, fetched completely
The PDOK layer had no next link and no numberMatched in its GeoJSON, so paging has to be driven by the hit count and startIndex:
def fetch_wfs_all(base, type_name, page_size=1000):
"""Page a WFS 2.0 layer with startIndex until the hit count is reached."""
common = {"service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": type_name}
hits = requests.get(base, params={**common, "resultType": "hits"}, headers=HEADERS, timeout=120)
expected = int(re.search(r'numberMatched="(\d+)"', hits.text).group(1))
frames, start = [], 0
while start < expected:
r = requests.get(base, params={**common, "outputFormat": "application/json", "count": page_size,
"startIndex": start, "sortBy": "buurtcode"},
headers=HEADERS, timeout=300)
page = r.json()["features"]
if not page:
break
frames.extend(page)
start += len(page)
if len(frames) != expected:
raise IncompleteResponse(f"{len(frames)} of {expected}")
return gpd.GeoDataFrame.from_features(frames, crs=28992), expected
buurten, expected = fetch_wfs_all(BASE, "wijkenbuurten:buurten")
print(f"{len(buurten):,} of {expected:,} neighbourhoods; buurtcode unique: {buurten['buurtcode'].is_unique}")
14,515 of 14,515 neighbourhoods; buurtcode unique: True
sortBy is not decoration. Paging by startIndex is only correct if the server returns rows in the same order on every request; without a sort key, a server is free to return an overlapping or gapped sequence, and the count check above would still pass on a result with duplicates. Sorting on the layer's key, and checking the key is unique at the end, closes that hole.
Why servers cap silently
Why servers cap silently rather than erroring
A public spatial service is defending itself against a request for ten million features. Two responses are possible: refuse, or return a page. Both standards chose to return a page, because paging is the intended access pattern and refusing would break every naive client.
The information you need is present β numberMatched in OGC API Features, numberMatched on the WFS response element β it is just not in the part of the payload most code reads. Reading the envelope before the features is the entire discipline.
Why an empty result is almost never "there is nothing there"
Genuinely empty areas exist, but they are rare compared to the alternatives. In rough order of likelihood:
- The bbox is in the wrong units or CRS.
- The axis order is swapped, putting your box somewhere else entirely β the cause of this page's empty download.
- The layer name is misspelled β and the server said so, in XML, with a 400 or even a 200.
- The attribute filter matches nothing because of case or an unexpected value.
- There is genuinely nothing there.
The way to rule out (5) is to request the same layer with no filter at all and confirm a non-zero count. If the unfiltered layer has 14,515 features and your filtered request has 0, the filter is the problem, not the data.
Why the CRS must be in the bbox parameter
A bounding box is four numbers. Nothing about 132000, 452000, 140000, 460000 says whether those are metres in a national grid, feet in a state plane, or a coordinate system nobody has heard of. The server has to assume something, and its assumption is the layer's declared default.
Both standards therefore let you say which CRS the numbers are in β a trailing URN in WFS, bbox-crs in OGC API Features. Passing it is one parameter and turns an ambiguous request into an unambiguous one.
Why to check both directions after any filtered download
Truncation removes features. A wrong filter removes different features. Both leave a plausible file, so check against something you know:
print(f"{len(buurten):,} features, bounds {buurten.total_bounds.round(0)}")
print(f"unique municipalities: {buurten['gemeentenaam'].nunique()}")
14,515 features, bounds [ 10425. 306846. 278026. 621876.]
unique municipalities: 343
Bounds that are much smaller than your study area, or a nunique() of 1 when you expected 30, are both visible in one line and invisible in the feature count alone.
Missing counts and next links
numberMatchedmay be absent even when the response is complete. Some servers omit it because computing the total is expensive. Fall back to aresultType=hitsrequest rather than assuming completeness.- A
nextlink may exist on the final page on some implementations, returning an empty collection. Stop when a page yields zero features as well as when the totals agree. gpd.read_file(url)hides all of this. It performs one request and gives you a GeoDataFrame with no envelope. Use it for exploration, never for a pipeline.- Rate limiting can also present as truncation β a 429 partway through a paged download leaves you with a partial set. Check the count after the loop, not just inside it.
- Some services cap by response size, not feature count, so the cap moves with geometry complexity: the same request can return fewer detailed polygons than simple points.
- Overpass
remarkarrives with valid JSON and HTTP 200. See OSMnx download fails, hangs or times out for how easily that one hides. - Log the full request URL on failure.
response.urlincludes the encoded parameters, and it is usually enough to spot the problem without reproducing it.
Internal links
- How to download data from a WFS service in Python β the paged, verified implementation
- Spatial web services explained β why the standards behave this way
- How to query the Overpass API from Python β
out countas the same defence - OSMnx download fails, hangs or times out β the silent-truncation case in OSM tooling
- GIS data sources explained β recording what you fetched and when
- Points plot in the ocean off Africa β the axis-order half of the CRS problem
- How to explore a spatial dataset you have never seen before β the sanity checks to run on any new download
- How to cache downloaded GIS data so you fetch it once β caching only responses that passed the checks
- How to work with fire perimeter data in Python β a paged feature service with duplicate incidents
FAQ
Why did I get exactly 1,000 features?
That is a server-side cap. Round numbers are almost never coincidences. Find the true total with numberMatched or a resultType=hits request, then page.
I asked for a bigger limit and still got the same number. Why?
Servers clamp limit to their maximum and return it without an error. Paging is the only way past it.
Why does my bbox return zero features?
Nearly always units or axis order. Declare the CRS of your bounding box β a trailing URN in WFS, bbox-crs in OGC API Features β and remember that urn:ogc:def:crs:EPSG::4326 means latitude first: on this page a longitude-first box in that CRS returned nothing, and the same box latitude-first returned the neighbourhoods.
The response is HTTP 200 but GeoPandas cannot read it. What is it?
An XML exception report. Check whether the body starts with < and strip the tags to read the server's actual message.
What is remark in a GeoJSON response?
A server-side timeout notice. The features present are a partial result. Treat any response containing it as a failure.
How do I know my download is complete?
Compare what you received against the count the server reports, and raise when they disagree. That comparison is the only reliable completeness check available.
Is gpd.read_file() on a service URL ever safe?
For exploring, yes. For anything whose count matters, no β it performs a single unpaged request and discards the envelope that would have told you the result was short.