Build a Cycling Trail Finder App

Discover bike-permitted paths and off-road trails in any area using OpenStreetMap data. This example demonstrates OR-combined tag filtering - the same expressive query surface as Overpass - to surface trails where cycling is explicitly permitted, whether as informal paths through nature or unpaved forestry tracks.

or_tagshighway=pathbicycle=yesline geometryPython

What you'll build

A map layer showing all cycling-permitted trails in a given area - ideal for MTB apps, gravel cycling route planners, or any outdoor recreation feature. The result is a GeoJSON FeatureCollection of LineString features you can render directly as a styled overlay in Leaflet, Mapbox, or any GeoJSON-capable map library.

The key technique here is the or_tags parameter: you specify that the feature must have bicycle=yesorbicycle=designated - so both informally permitted and formally designated cycling ways are captured in one request.

Prerequisites

Run these commands on a Bash terminal.

Python
pip install osmfeatures

Script

View full source code: test_cycling_trails.py

Python
"""
App idea: adventure cycling layer showing unpaved trails where cycling is
explicitly permitted — ideal for MTB or gravel bike route planning.

OSM tags used:
  - ``highway=path`` + ``bicycle=yes`` or ``bicycle=designated``
  - ``highway=track`` + ``bicycle=yes`` or ``bicycle=designated``

API calls use ``or_tags`` to match either bicycle permission value.
"""

from osmfeatures import OSMFeature, OSMFeatureCollection, OSMFeaturesClient

DJURGARDEN_BBOX = "18.090,59.320,18.170,59.345"


def test_cycling_trails(client: OSMFeaturesClient):
    # Paths with cycling explicitly permitted (bicycle=yes or bicycle=designated)
    paths_cycling = client.query(
        bbox=DJURGARDEN_BBOX,
        type="way",
        shape="line",
        tags="highway=path",
        or_tags=["bicycle=yes", "bicycle=designated"],
        limit=50,
        disable_budget_warning=True,
    )
    # Dedicated cycling tracks (unpaved forestry-style)
    tracks = client.query(
        bbox=DJURGARDEN_BBOX,
        type="way",
        shape="line",
        tags="highway=track",
        or_tags=["bicycle=yes", "bicycle=designated"],
        limit=50,
        disable_budget_warning=True,
    )

    assert isinstance(paths_cycling, OSMFeatureCollection)
    assert isinstance(tracks, OSMFeatureCollection)

    all_trails = paths_cycling["features"] + tracks["features"]
    assert len(all_trails) > 0, "Expected cycling trail features in Djurgården"

    for f in all_trails:
        assert isinstance(f, OSMFeature)
        assert f.osm_type == "way"
        assert f["geometry"]["type"] == "LineString"
        tags = f.tags
        hw = tags.get("highway")
        assert hw in ("path", "track", "cycleway"), f"Unexpected highway tag: {hw}"
        # Every returned trail must carry an explicit cycling-permitted tag
        assert tags.get("bicycle") in ("yes", "designated"), (
            f"Trail {f['id']} (highway={hw}) lacks explicit bicycle access: "
            f"bicycle={tags.get('bicycle')!r}"
        )

    print(f"\n[cycling trails] {len(all_trails)} trail segments in Djurgården")

How it works

The query uses three different filter parameters with distinct semantics. Understanding the difference between tags and or_tags is the key to building expressive queries:

ParameterValueSemantics
tagshighway=pathAND filter - the feature must have this tag. Multiple tags values are all required.
or_tagsbicycle=yesOR filter - the feature must match at least one or_tags value. Repeat the parameter for each alternative. Requires a spatial anchor.
or_tagsbicycle=designated
typelineReturns only LineString geometries - the correct type for routable ways.
Why two separate requests? OSM uses highway=path for informal unpaved paths and highway=track for wider forestry / agricultural tracks. These are distinct highway classes - a single tags=highway=path call won't return tracks. Making two requests and merging the results mirrors the Overpass approach of querying multiple way types in a union.
Passing repeated parameters in Python: Python's requests library accepts a list of (key, value) tuples for the params argument. This is the correct way to send repeated query parameters like or_tags=bicycle=yes&or_tags=bicycle=designated.

Adapt it

Change the tags and or_tags combination to find other types of route infrastructure:

Use casetagsor_tags
Dedicated cyclewayshighway=cycleway(none needed)
Hiking trailshighway=pathfoot=yes, foot=designated
Horse riding trailshighway=bridleway(none needed)
Ski pistespiste:type=downhill(none needed)

Try it live

Explore the full parameter reference and test OR-tag queries interactively.