WMS vs WFS vs WMTS vs OGC API Features: A Decision Guide

Four OGC service protocols can publish the same spatial dataset, yet they hand the client four completely different things: a rendered picture, the raw geometry, a pre-baked tile, or a JSON resource. Choosing the wrong one is one of the most expensive early mistakes in a spatial platform — a base map served through on-the-fly rendering melts under load, and an editing client wired to an image service can never write a single vertex back. This guide compares WMS, WFS, WMTS and OGC API - Features across payload type, caching model, editing capability, statelessness and best-fit use cases, gives you a decision tree and a Python probe to detect what a server actually offers, and closes with the error, testing and scaling considerations that decide whether the choice holds up in production.

Prerequisites & Architecture Context

This is an architectural comparison rather than a single-protocol implementation, so it assumes you have at least skimmed the individual protocol references. You should be comfortable with:

  • HTTP semantics — request methods, status codes, content negotiation via Accept, and cache directives (Cache-Control, ETag, Vary)
  • The shared OGC key-value request contract (SERVICE, VERSION, REQUEST parameters) versus REST resource paths
  • Basic spatial concepts: bounding boxes, tiles, features, and coordinate reference systems — axis-order pitfalls are covered in the SRS and Coordinate Reference System Handling guide
  • Python packages: requests (HTTP), lxml (parsing capabilities XML), and the standard library (json, urllib.parse)

Each protocol has its own deep-dive in this collection. The rendering contract is defined in Understanding OGC Web Map Service Specifications; vector editing and geometry CRUD live in the WFS Transactional Operations Deep Dive; the tiling model is explained in WMTS Tile Matrix Sets Explained; and the modern REST/JSON approach is covered in OGC API Features and the REST Transition. All four sit inside the broader OGC Standards Architecture & Service Fundamentals family. This page exists so you can decide which of those to reach for before you read any of them end to end.

The single most useful mental model is the pixels-versus-data split. Two protocols return rendered images the client can only display (WMS, WMTS); two return the actual geometry the client can measure, reproject and edit (WFS, OGC API - Features). Within each pair, the second axis is dynamic-versus-static: WMS renders per request while WMTS serves cached tiles; WFS speaks XML/GML transactions while OGC API - Features speaks REST/JSON.

The Two Axes That Separate the Four Protocols A quadrant chart. The horizontal axis runs from pixels on the left to data on the right. The vertical axis runs from cached and static at the top to dynamic per-request at the bottom. WMTS occupies the pixels-static quadrant, WMS the pixels-dynamic quadrant, OGC API Features the data-cacheable quadrant, and WFS the data-transactional quadrant. PIXELS DATA CACHED / STATIC DYNAMIC / PER-REQUEST WMTS pre-rendered tiles WMS rendered on the fly OGC API - Features REST / JSON reads WFS GML transactions

Specification Deep-Dive: What Each Protocol Actually Returns

The protocols look interchangeable in a service catalogue — they can all list the same layer name — but the response contract is the thing that determines your entire client architecture.

WMS (Web Map Service) renders the requested layers server-side and returns a georeferenced raster image (PNG, JPEG, GIF). The GetMap operation takes a BBOX, WIDTH, HEIGHT, LAYERS, STYLES and CRS, and every response is computed on demand, which is what lets styling and layer composition vary per request. The client receives only pixels; it cannot query a coordinate without a separate GetFeatureInfo round trip.

WFS (Web Feature Service) returns the vector features themselves — geometry plus attributes — usually as GML, with GeoJSON available in WFS 2.0 output formats. Crucially, WFS is the only one of the four with a write path: the Transaction operation performs Insert, Update and Delete against the feature store, wrapped in an XML envelope. That transactional capability is the reason WFS is chosen for editing workflows.

WMTS (Web Map Tile Service) serves pre-rendered image tiles addressed by a discrete TileMatrixSet, TileMatrix (zoom level), TileRow and TileCol. Because the tile grid is fixed and finite, every tile is a static, immutable resource that can be generated ahead of time and cached indefinitely. There is no per-request rendering cost on a cache hit — the trade is that you cannot vary styling or bounding box freely.

OGC API - Features is the REST/JSON reimagining of feature access. Instead of a SERVICE/REQUEST key-value contract it exposes resources: a landing page at /, a /collections list, and /collections/{id}/items returning GeoJSON. It is described by an OpenAPI document, uses HTTP content negotiation, and supports the same read and (via the Part 4 extension) write operations as WFS, but in a form that any HTTP client library consumes natively.

The Comparison Matrix

Concern WMS WFS WMTS OGC API - Features
What the client receives Rendered raster image (pixels) Raw vector features (geometry + attributes) Pre-rendered image tile (pixels) Vector features as GeoJSON resources (data)
Primary payload format PNG / JPEG / GIF GML (XML); GeoJSON in 2.0 PNG / JPEG tiles GeoJSON / JSON
Interface style KVP GetMap over HTTP GET KVP + XML POST KVP or RESTful tile URLs Resource-oriented REST
Caching model Per-request; cacheable by canonical query string, huge key space Poor — dynamic queries, transactions uncacheable Excellent — fixed finite tile grid, immutable per revision Good for reads via HTTP cache headers; writes uncacheable
Transactions / editing None Yes — Insert / Update / Delete None Read-only in core; write via Part 4 extension
Typical latency profile Higher — renders on demand Moderate to high — depends on query + payload size Lowest — static file served, often from CDN Low to moderate — indexed reads, paginated
Statelessness / CDN-friendliness Cacheable but low hit rate on varied requests Low — request bodies and transactions Highest — stable immutable tile URLs High for GET; clean cacheable resource paths
CRS / axis-order handling Version-sensitive (1.1.1 vs 1.3.0 axis swap) Version-sensitive; explicit srsName Fixed per tile matrix set CRS CRS via query param; defaults to CRS84 (lon,lat)
Client ecosystem support Universal — every GIS client, Leaflet, OpenLayers Broad in desktop GIS; heavier in browsers Universal for slippy-map clients Growing fast; native fit for web/JS and REST tooling
Best-fit use case Dynamic thematic rendering, per-user styling Feature editing, download, server-side analysis High-volume static base maps Greenfield REST/JSON integrations, web apps

How to Choose

The matrix collapses into a short set of rules once you separate the two decisions — pixels versus data, then the read/write pattern within each.

Base maps and reference layers go to WMTS. If the content is stable — terrain, imagery, cadastral backdrops, administrative boundaries — and many clients pan and zoom across it, pre-rendering into a tile cache is almost always correct. Every tile becomes an immutable file a CDN serves without touching your rendering engine. The WMTS Tile Matrix Sets Explained guide covers aligning your matrix set to the well-known Google/Web Mercator grid so standard slippy-map clients work unmodified.

Dynamic thematic rendering goes to WMS. When the map must be styled per request — a choropleth whose class breaks depend on a user-selected attribute, a time slider, a filtered subset, or a customer-specific palette — you need on-the-fly rendering. WMS GetMap is designed for exactly this, and its STYLES/SLD support lets the styling vary without pre-generating an unbounded tile set.

Feature editing, download and analysis go to WFS or OGC API - Features. If the client must obtain the geometry — to snap and edit vertices, run a spatial query locally, reproject, or export to a file — a rendered image is useless and you need a data protocol. Choose WFS when you must write back and your ecosystem is XML/GML-centric or already speaks WFS transactions; its Transaction operation is the mature, widely-implemented editing path, detailed in the WFS Transactional Operations Deep Dive.

New greenfield REST integrations go to OGC API - Features. For anything built fresh — a JSON-native web app, a mobile client, a data pipeline that already speaks REST — OGC API - Features is the path of least resistance. It returns GeoJSON that browsers and requests consume without an XML parser, is self-describing via OpenAPI, and paginates cleanly. If you are migrating away from WFS, exposing both during the transition is normal.

Choosing an OGC Service Protocol: Decision Tree A top-down decision tree. The root question asks whether the client needs pixels or data. The pixels branch then asks whether the workload is high read-volume and static: yes routes to WMTS, no routes to WMS. The data branch asks whether the client needs to edit geometry: yes routes to WFS or OGC API Features Part 4; no leads to a further question of whether the client is JSON-native, which routes to OGC API Features if yes or WFS if no. Need pixels or data? what does the client consume? PIXELS DATA High volume & static? base map vs per-request style YES WMTS cached tiles NO WMS render on the fly Need to edit geometry? write features back? YES WFS or OGC API Features Part 4 NO JSON-native client? web / REST tooling? YES OGC API - Features REST / GeoJSON reads NO WFS GML download Serving both a base map and editable data? Combine branches: WMTS backdrop + a data protocol on top.

Python Implementation

A decision guide is only actionable if you can point it at a real URL and learn what that server offers. The snippet below probes a base endpoint two ways — the classic GetCapabilities request for SERVICE=WMS|WFS|WMTS, and the OGC API landing page with JSON content negotiation — then recommends a protocol from what it detects. It uses only requests and the standard library.

"""
probe_ogc.py — detect which OGC service protocols an endpoint offers
and recommend one for a stated need.

Dependencies (pip install):
    requests>=2.31
"""
from __future__ import annotations

import urllib.parse
from dataclasses import dataclass, field
from typing import Literal

import requests

Need = Literal["basemap", "dynamic_render", "edit", "download", "rest"]

# Classic OGC services answer a shared KVP contract; we probe each in turn.
_KVP_SERVICES = ("WMS", "WFS", "WMTS")
_TIMEOUT = 10


@dataclass
class ProbeResult:
    base_url: str
    protocols: set[str] = field(default_factory=set)
    recommendation: str = ""
    notes: list[str] = field(default_factory=list)


def _kvp_url(base: str, service: str) -> str:
    """Append a GetCapabilities query while preserving any existing path."""
    sep = "&" if urllib.parse.urlparse(base).query else "?"
    return f"{base}{sep}SERVICE={service}&REQUEST=GetCapabilities"


def _detect_kvp(base: str, session: requests.Session, result: ProbeResult) -> None:
    """Send REQUEST=GetCapabilities for each classic service and sniff the body."""
    for service in _KVP_SERVICES:
        try:
            resp = session.get(_kvp_url(base, service), timeout=_TIMEOUT)
        except requests.RequestException as exc:
            result.notes.append(f"{service} probe failed: {exc.__class__.__name__}")
            continue
        body = resp.text[:2000].lower()
        # A real capabilities doc echoes the service name in its root element,
        # e.g. <WMS_Capabilities>, <wfs:WFS_Capabilities>, <Capabilities ...wmts>.
        marker = "wmts" if service == "WMTS" else f"{service.lower()}_capabilities"
        if resp.ok and ("<?xml" in body or "capabilities" in body) and marker in body:
            result.protocols.add(service)


def _detect_ogc_api(base: str, session: requests.Session, result: ProbeResult) -> None:
    """Fetch the OGC API landing page and confirm the conformance/collections links."""
    try:
        resp = session.get(base, headers={"Accept": "application/json"}, timeout=_TIMEOUT)
    except requests.RequestException as exc:
        result.notes.append(f"OGC API probe failed: {exc.__class__.__name__}")
        return
    ctype = resp.headers.get("Content-Type", "")
    if not (resp.ok and "json" in ctype):
        return
    try:
        landing = resp.json()
    except ValueError:
        return
    # A conformant landing page advertises typed links; look for the
    # 'conformance' and 'data' relations that mark an OGC API - Features service.
    rels = {link.get("rel") for link in landing.get("links", []) if isinstance(link, dict)}
    if {"conformance", "data"} & rels or "conformsTo" in landing:
        result.protocols.add("OGC API - Features")


def _recommend(need: Need, result: ProbeResult) -> None:
    """Map a stated need onto the best available detected protocol."""
    preference: dict[Need, list[str]] = {
        "basemap": ["WMTS", "WMS"],
        "dynamic_render": ["WMS", "WMTS"],
        "edit": ["WFS", "OGC API - Features"],
        "download": ["OGC API - Features", "WFS"],
        "rest": ["OGC API - Features", "WFS"],
    }
    for candidate in preference[need]:
        if candidate in result.protocols:
            result.recommendation = candidate
            return
    result.recommendation = "none available for this need"


def probe(base_url: str, need: Need = "rest") -> ProbeResult:
    """Probe an endpoint, populate detected protocols, and recommend one."""
    result = ProbeResult(base_url=base_url)
    with requests.Session() as session:
        session.headers["User-Agent"] = "ogc-protocol-probe/1.0"
        _detect_kvp(base_url, session, result)
        _detect_ogc_api(base_url, session, result)
    _recommend(need, result)
    return result


if __name__ == "__main__":
    outcome = probe("https://example.org/geoserver/ows", need="basemap")
    print("Detected:", sorted(outcome.protocols) or "none")
    print("Recommended:", outcome.recommendation)
    for note in outcome.notes:
        print("Note:", note)

Annotated Walkthrough

KVP probing (_detect_kvp) — The three classic services share the SERVICE/REQUEST contract, so a single loop issues ?SERVICE=WMS&REQUEST=GetCapabilities, then the same for WFS and WMTS. _kvp_url inspects the existing query string with urllib.parse.urlparse so it appends with & when the base already carries parameters and ? otherwise — appending blindly produces malformed URLs on endpoints like .../ows?service=.... The body sniff is deliberately shallow: it reads only the first 2 KB and looks for a service-specific root marker, because capabilities documents can be hundreds of kilobytes and parsing the whole thing to answer a yes/no question is wasteful.

Marker selection — WMS and WFS capabilities documents name themselves in the root element (WMS_Capabilities, WFS_Capabilities), so the marker is derived directly from the service name. WMTS is the exception: its root element is a generic Capabilities in the WMTS namespace, so the code matches on the wmts namespace token instead. This asymmetry is a common trip-up when writing a generic detector.

OGC API detection (_detect_ogc_api) — Rather than a KVP request, this fetches the base URL with Accept: application/json and parses the landing page. A conformant OGC API - Features landing page carries a links array with typed rel relations; the presence of the conformance and data relations (or a conformsTo array) distinguishes it from an arbitrary JSON endpoint. Content-Type is checked first so an HTML homepage does not reach resp.json().

Recommendation mapping (_recommend) — The stated need maps to an ordered preference list, and the first detected protocol in that list wins. A basemap need prefers WMTS but falls back to WMS; an edit need prefers WFS but accepts OGC API - Features. The ordering encodes the “how to choose” narrative above as data, so the recommendation is explainable and easy to tune.

Session reuse — All probes run inside one requests.Session, so connection pooling and a shared User-Agent apply across the four requests to the same host — cheap, and polite to the server being fingerprinted.

Error Handling & Edge Cases

Mixing protocols behind one gateway — GeoServer and MapServer commonly expose WMS, WFS, WMTS and OGC API - Features from a single deployment. The gateway or reverse proxy must route on protocol shape, not just path: OGC API uses clean REST paths (/collections/...) and JSON content negotiation, while the classic three share the ?SERVICE=...&REQUEST=... key-value contract on a common endpoint like /ows. If your cache rules are global rather than per-protocol you will eventually cache a WFS Transaction response or an OGC API write, serving stale or wrong data. Scope caching to WMTS tiles and read-only GETs explicitly.

A layer that exists in one protocol but not another — Publishing a layer for WMS does not automatically expose it via WFS or OGC API - Features; server configuration gates each service independently. A probe that finds WMS capabilities is no guarantee the same layer is downloadable. Always confirm the specific collection or feature type in the relevant capabilities document or /collections list before wiring a client.

Content negotiation collisions — Requesting the base URL with Accept: application/json can return an HTML landing page from a server that ignores the header, or a KVP endpoint may answer a bare GET with a default GetCapabilities. Guard every JSON parse with a Content-Type check and a try/except ValueError, as the probe does, so a non-JSON body never raises deep in your logic.

Version and axis-order drift across protocols — WMS 1.1.1 versus 1.3.0 and WFS 1.1.0 versus 2.0.0 differ in axis ordering, while OGC API - Features defaults to CRS84 (longitude, latitude). A client that pulls a bounding box from a WMS request and reuses it verbatim against WFS can silently invert coordinates. Normalise CRS handling centrally; the SRS and Coordinate Reference System Handling guide covers the axis-swap rules per protocol version.

Timeouts and huge capabilities documents — Enterprise servers return multi-hundred-kilobyte capabilities XML. Always set an explicit request timeout (the probe uses ten seconds) and read only the prefix you need for detection. Streaming or truncating avoids blocking a worker on a slow or oversized response.

Testing & Compliance Verification

Protocol selection deserves the same test discipline as any routing logic. The skeleton below verifies the recommendation mapping and URL construction without hitting the network by injecting a fake session.

"""
test_probe_ogc.py — unit tests for the protocol probe and recommender
"""
import pytest
from probe_ogc import ProbeResult, _kvp_url, _recommend, _KVP_SERVICES


class TestKvpUrl:
    def test_appends_with_question_mark_when_no_query(self):
        url = _kvp_url("https://example.org/ows", "WMS")
        assert url == "https://example.org/ows?SERVICE=WMS&REQUEST=GetCapabilities"

    def test_appends_with_ampersand_when_query_present(self):
        url = _kvp_url("https://example.org/ows?map=roads", "WFS")
        assert url.startswith("https://example.org/ows?map=roads&SERVICE=WFS")


class TestRecommend:
    def test_basemap_prefers_wmts_over_wms(self):
        result = ProbeResult(base_url="x", protocols={"WMS", "WMTS"})
        _recommend("basemap", result)
        assert result.recommendation == "WMTS"

    def test_edit_falls_back_to_ogc_api_when_no_wfs(self):
        result = ProbeResult(base_url="x", protocols={"OGC API - Features"})
        _recommend("edit", result)
        assert result.recommendation == "OGC API - Features"

    def test_no_match_reports_none(self):
        result = ProbeResult(base_url="x", protocols={"WMTS"})
        _recommend("edit", result)
        assert result.recommendation == "none available for this need"


def test_kvp_service_set_is_the_classic_three():
    assert set(_KVP_SERVICES) == {"WMS", "WFS", "WMTS"}

For formal validation of each detected service, run the relevant OGC CITE (Compliance + Interoperability Testing + Evaluation) suite — there are separate engines for WMS 1.3.0, WFS 2.0, WMTS 1.0 and OGC API - Features. Compliance testing for spatial services in general, including wiring CITE into a pipeline, is covered under the Python automation collection. At minimum, schema-validate each capabilities document against its official OGC XSD and assert that the OGC API landing page passes the Features Core conformance class check before you trust a recommendation in production.

Performance & Scaling Notes

Tile caching versus on-the-fly rendering is the central scaling lever. A WMTS tile is generated once and served forever from cache or CDN, so throughput is bounded by static file delivery, not by your rendering engine. WMS renders every GetMap on demand: composite, symbolise, rasterise, encode. For a popular base map the difference is orders of magnitude — this is why high-traffic deployments pre-seed a WMTS cache for stable layers and reserve WMS for the genuinely dynamic ones. The WMTS Tile Matrix Sets Explained guide details seeding and matrix alignment.

Cache key space determines hit rate. WMTS has a small, finite key space (matrix set × zoom × row × col), so hit rates approach 100% for popular areas. WMS is cacheable only by canonical query string, and free-form BBOX, WIDTH and STYLES explode the key space — two clients rarely request identical parameters, so the hit rate is low unless you snap requests to a fixed grid (at which point you have effectively reinvented WMTS).

Payload size dominates data-protocol latency. WFS and OGC API - Features return geometry, and an unbounded feature request can serialise megabytes of coordinates. Always paginate: OGC API - Features uses limit and cursor-style next links, and WFS 2.0 offers count/startIndex. Push filtering (bounding box, attribute predicates) to the server so the client downloads only what it renders.

CDN placement follows statelessness. WMTS tiles and read-only OGC API - Features GETs are ideal CDN candidates because their URLs are stable and idempotent; set long Cache-Control max-age on tiles and shorter, revalidated caching on feature reads. Never place a CDN in front of WFS transactions or OGC API writes — mutating operations must reach the origin every time.

Connection reuse for chained services. When a gateway proxies to upstream GeoServer or MapServer, use a pooled requests.Session with a sized HTTPAdapter so concurrent renders do not exhaust connections. The probe above reuses a single session for exactly this reason, scaled up.

Gotchas / Frequently Asked Questions

What is the fundamental difference between WMS and WFS?

WMS returns server-rendered raster images — the client receives pixels and cannot inspect the underlying geometry without a separate GetFeatureInfo query. WFS returns the raw vector features themselves as GML or GeoJSON, so the client holds the actual coordinates and attributes and can reproject, measure, edit, or analyse them locally. If your client only needs to show a map, WMS is lighter; if it needs the data, WFS (or OGC API - Features) is the only option.

When should I use WMTS instead of WMS?

Use WMTS when the map content is static or slowly-changing and read volume is high — base maps, imagery, reference layers. WMTS serves pre-rendered tiles from a fixed tile matrix set, so every request is a cache hit a CDN can serve without touching your rendering engine. WMS renders on the fly, which is necessary for dynamic or per-user styling but far more expensive per request. Many deployments pre-seed a WMTS cache for stable layers and keep WMS only for the layers that genuinely vary.

Is OGC API - Features a replacement for WFS?

It is the modern successor. OGC API - Features exposes the same feature-access capability as WFS but over a resource-oriented REST/JSON interface described by OpenAPI, instead of an XML key-value or POST contract. Reads return GeoJSON that any HTTP client consumes natively, and the Part 4 extension adds create/update/delete. WFS remains widespread in existing infrastructure, so servers commonly offer both during the transition — pick OGC API - Features for greenfield work and WFS when you must interoperate with established WFS clients.

Which of these protocols are CDN-friendly?

WMTS is the most CDN-friendly: its tile URLs are stable, stateless and immutable per revision, so a CDN caches them almost indefinitely. Read-only OGC API - Features GETs cache well with standard HTTP headers because their resource paths are clean and idempotent. WMS GetMap is cacheable by canonical query string but has a huge key space, giving low hit rates unless requests are snapped to a grid. WFS transactions and OGC API writes must never be cached — they mutate state and have to reach the origin.

Can I run WMS, WFS, WMTS and OGC API - Features behind a single gateway?

Yes, and it is common — GeoServer and MapServer expose all four from one deployment. The gateway must route on protocol shape: OGC API - Features uses clean REST paths (/collections/...) and JSON content negotiation, while WMS, WFS and WMTS share the ?SERVICE=...&REQUEST=... key-value contract on a common endpoint. The critical rule is that caching must be configured per protocol — cache WMTS tiles and read-only GETs aggressively, but never cache WFS or OGC API write operations, or you will serve stale or incorrect data.


Back to OGC Standards Architecture & Service Fundamentals

Related: