Deprecating Spatial Fields with Sunset Headers

Retire a geometry field or a default projection without breaking clients: RFC 8594 Sunset and Deprecation headers, per-consumer usage tracking, and a removal date you can actually defend.

← Back to API Versioning for GIS Endpoints

This page shows how to retire a field, a property name or a default projection from a spatial API using machine-readable deprecation signals, with a removal date supported by evidence rather than optimism.

Context & When to Use

Spatial deprecations are heavier than ordinary ones. Removing a bbox property from a feature breaks any client that drew a rectangle from it. Changing the default output projection from 4326 to 3857 moves every coordinate by thousands of kilometres in a client that did not notice. Renaming geom to geometry looks trivial until you find a customer’s stored procedure parsing the old key. The blast radius is larger than the diff suggests, and the affected code is frequently outside your organisation.

A full version bump is the heavyweight answer and is often disproportionate — see Versioning Geospatial APIs Without Breaking Clients for when it is warranted. For a single field, the lighter path is to keep serving it while announcing its end date in the response itself, so any client with logging sees the warning without anybody reading a changelog.

Use this whenever the change is additive-then-subtractive: a new field replaces an old one, both are served for a window, and the old one goes away on a date announced in advance. Do not use it for changes that alter the meaning of an existing field — silently changing what distance is measured in deserves a version bump, not a header.

Runnable Implementation

from datetime import datetime, timezone
from email.utils import format_datetime
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Request, Response

router = APIRouter(prefix="/v1/features", tags=["features"])

# One place to declare every in-flight deprecation
DEPRECATIONS: dict[str, dict[str, Any]] = {
    "feature.bbox": {
        "deprecated_at": datetime(2026, 3, 1, tzinfo=timezone.utc),
        "sunset_at":     datetime(2026, 11, 1, tzinfo=timezone.utc),
        "replacement":   "Compute from geometry, or request ?include=envelope",
        "docs":          "https://www.geospatial-api.com/core-geospatial-api-architecture-with-fastapi-postgis/api-versioning-for-gis-endpoints/",
    },
}


def announce(response: Response, key: str) -> None:
    """Attach RFC 8594 Sunset plus a Deprecation header and a docs link."""
    spec = DEPRECATIONS[key]
    response.headers["Deprecation"] = format_datetime(spec["deprecated_at"], usegmt=True)
    response.headers["Sunset"] = format_datetime(spec["sunset_at"], usegmt=True)
    response.headers["Link"] = f'<{spec["docs"]}>; rel="deprecation"; type="text/html"'
    # A human-readable hint for anyone reading a raw response
    response.headers["Warning"] = (
        f'299 - "field {key} is deprecated; {spec["replacement"]}"'
    )


@router.get("")
async def list_features(
    request: Request,
    response: Response,
    include: Annotated[str | None, None] = None,
) -> dict[str, Any]:
    rows = await fetch_features(request)          # your existing query
    features = [_serialize(r) for r in rows]

    # Still serving the deprecated field — so still announcing it
    if any("bbox" in f for f in features):
        announce(response, "feature.bbox")
        await record_deprecated_use(request, "feature.bbox")

    return {"type": "FeatureCollection", "features": features}

The header set is deliberately redundant. Sunset is the machine-readable date, Deprecation marks when the clock started, Link points at the explanation, and Warning is the one a developer notices while poking at the API with curl.

Deprecation timeline for a spatial fieldAn eight-month timeline. At month zero the field is announced as deprecated and Deprecation, Sunset, Link and Warning headers begin appearing on every response. Months one to five are the migration window, during which per-consumer usage is tracked. At month six a one-hour brownout removes the field to surface any remaining consumers. At month seven a longer four-hour brownout runs. At month eight the field is removed permanently. A note records that the two brownouts are what convert silent dependence into a support ticket while there is still time.Eight months from announcement to removalannouncem0headers on every response · usage tracked per consumerbrownout 1 hm6brownout 4 hm7removedm8 · SunsetConsumers still reading the field:14 at m04 at m6 — brownout finds them0 at m8 — safe to remove

Key Parameters & Options

HeaderValuePurpose
DeprecationHTTP-dateWhen the field became discouraged
SunsetHTTP-dateWhen it stops working — the one clients automate against
Link; rel="deprecation"docs URLWhere the migration is explained
Warning: 299free textHuman-readable; visible in a raw curl
?include=opt-in field listLets clients prove they no longer need the field
Brownout1 h, then 4 hConverts silent dependence into a support ticket

Sunset is the only one of these with a formal specification behind it, and it is the one worth getting exactly right: an HTTP-date in GMT, not an ISO 8601 timestamp, because clients parsing it will use an HTTP-date parser.

Finding out who still depends on it

The hard part is not announcing the change — it is knowing when it is safe to make. For a plain JSON response you cannot see which keys a client reads, so the usage signal has to be constructed.

Four ways to find the remaining consumersFour techniques rated on how reliably they find dependent consumers. Reading the changelog and waiting for replies finds almost nobody and costs nothing. An opt-in sparse-fieldset parameter finds those who actively migrate, about half, at low cost. Contacting known integrators directly finds most of the large ones but misses small automated consumers. A scheduled brownout finds essentially everyone still depending on the field, at the cost of a controlled hour of breakage. The chart argues for combining the sparse-fieldset signal with brownouts.How much of the dependent population each technique surfaceschangelog + hope~7 %sparse-fieldset opt-in~50 % — those actively migratingcontact known integrators~70 % — misses small clientsscheduled brownout~98 %A brownout is a controlled outage of one field, announced in advance, during working hours, with someonewatching the support queue. That is a very different thing from finding out on the sunset date.

The brownout is worth the discomfort. Schedule it, announce it in the same headers, run it during business hours in your customers’ time zones, and treat every ticket it generates as a success rather than an incident.

Which changes a header can carry, and which need a version

Not every change is a candidate. The test is whether a client that ignores the announcement gets a smaller response or a wrong one. Smaller is survivable and belongs in a deprecation window; wrong is not, and belongs behind a version.

Deprecation header or version bump?Six changes classified. Removing a redundant bbox property, renaming a property with both served during the window, and dropping an unused legacy format are all safe for a deprecation header because a client that ignores the warning loses data it can recompute. Changing the default output projection, changing the unit of a distance field, and changing coordinate axis order all require a version bump, because a client that ignores the warning receives plausible numbers that are wrong.The test: does ignoring the warning give less data, or wrong data?✓ deprecation header is enoughremove a redundantbboxpropertyclient can recompute it from the geometryrename a property, serve both meanwhileold key present until the sunset datedrop an unused legacy output format406 is a clear, debuggable failureworst case: a client gets less and notices✕ needs a version bumpchange the default output projectioncoordinates move thousands of km, silentlychange a distance field from m to kmevery threshold downstream is now wrongchange coordinate axis ordervalid-looking points in the wrong hemisphereworst case: a client gets wrong data and does not notice

Gotchas & Failure Modes

  • Sunset in ISO 8601 format. RFC 8594 specifies an HTTP-date; a client using a strict parser silently ignores an ISO value and gets no warning at all.
  • Announcing on responses that do not contain the field. Deprecation headers on every response, including ones where the field is absent, train clients to ignore them. Announce only when actually serving the deprecated thing.
  • A sunset date in the removal PR. By then it is too late to be a warning. The date must be published at announcement time and must not move earlier.
  • Removing on the sunset date without a final check. Run the usage report the morning of the removal. If a large consumer appeared last week, a two-week extension is cheaper than an incident.
  • Caches masking the headers. A CDN that strips or does not vary on these headers hides the warning from everyone behind it. Verify the headers survive the edge — the caching behaviour in Caching Vector Tiles at the Edge with Cache-Control applies to headers as well as bodies.
  • Deprecating a geometry field without a replacement path. “Compute it yourself” is not a migration if the computation needs data the client does not have. Ship the replacement before starting the clock.

Verification Snippet

# The headers must be present, correctly formatted, and consistent
curl -sD - -o /dev/null "https://api.example.com/v1/features?bbox=-0.2,51.4,0,51.6" \
  | grep -iE 'deprecation|sunset|link|warning'
# Deprecation: Sun, 01 Mar 2026 00:00:00 GMT
# Sunset: Sun, 01 Nov 2026 00:00:00 GMT
# Link: <https://www.geospatial-api.com/…/api-versioning-for-gis-endpoints/>; rel="deprecation"
# Warning: 299 - "field feature.bbox is deprecated; Compute from geometry, or request ?include=envelope"
from email.utils import parsedate_to_datetime


def test_sunset_is_http_date_and_after_deprecation(client):
    r = client.get("/v1/features", params={"bbox": "-0.2,51.4,0,51.6"})
    dep = parsedate_to_datetime(r.headers["Deprecation"])
    sunset = parsedate_to_datetime(r.headers["Sunset"])
    assert sunset > dep
    # A meaningful window, not a formality
    assert (sunset - dep).days >= 90

← Back to API Versioning for GIS Endpoints