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
Four ways an API returns an incomplete result, usually with HTTP 200, and the check for each. four incomplete results, most of them HTTP 200SymptomThe check that names itexactly 10 / 100 / 1000 featuresnumberMatched, or resultType=hits0 features, JSON bodyprint the bbox, its CRS and axisorder0 features, body starts with "<"strip tags β€” it is an exceptionreporta plausible but short countlook for "remark" in the payloadThe count alone distinguishes none of them spatialworkflow.io
Three arrived as HTTP 200 on this page, and the exception report as a 400 that other servers send as 200. Only the check in the right-hand column tells them apart.

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']}")
Three requests asking for different limits, each returning the same server cap. the cap is substituted silentlylimit=1010 returnedlimit=100010 returnedlimit=10000010 returnednumberMatched25 exist0100Only the last row was ever in the response envelope spatialworkflow.io
Asking for more does not produce an error. It produces the cap, again.

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.

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:

  1. The bbox is in the wrong units or CRS.
  2. The axis order is swapped, putting your box somewhere else entirely β€” the cause of this page's empty download.
  3. The layer name is misspelled β€” and the server said so, in XML, with a 400 or even a 200.
  4. The attribute filter matches nothing because of case or an unexpected value.
  5. 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.

The same Utrecht bounding box in EPSG:4326 sent latitude-first and longitude-first to a WFS. the same four numbers, two axis orderslat, lon β€” as EPSG::4326 defines it52.05, 5.05, 52.15, 5.205 features returnedlon, lat β€” the habitread as 5Β°N 52Β°E β†’0 features, HTTP 200Swapped, the box is in the Indian Ocean β€” and the server correctly finds nothing there spatialworkflow.io
A box read in the wrong axis order lands nowhere near the data. The server finds nothing there, correctly, and says so with HTTP 200.

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

  • numberMatched may be absent even when the response is complete. Some servers omit it because computing the total is expensive. Fall back to a resultType=hits request rather than assuming completeness.
  • A next link 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 remark arrives 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.url includes the encoded parameters, and it is usually enough to spot the problem without reproducing it.

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.