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 with | inside tags - to surface trails where cycling is explicitly permitted, whether as informal paths through nature or unpaved forestry tracks.

tags=a|bhighway=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 | operator inside a tags value: 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 one ``tags`` value with ``|`` for either bicycle permission.
"""

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",
        way_shape="line",
        tags=["highway=path", "bicycle=yes|bicycle=designated"],
        limit=50,
    )
    # Dedicated cycling tracks (unpaved forestry-style)
    tracks = client.query(
        bbox=DJURGARDEN_BBOX,
        type="way",
        way_shape="line",
        tags=["highway=track", "bicycle=yes|bicycle=designated"],
        limit=50,
    )

    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 combines repeated tags values (AND) with | inside one value (OR). Understanding that difference is the key to building expressive queries:

ParameterValueSemantics
tagshighway=pathAND filter - the feature must have this tag. Multiple tags values are all required.
tagsbicycle=yes|bicycle=designatedOR filter - join alternatives with | inside one value. The feature must match at least one. A separate tags value is ANDed with the others. Requires a spatial anchor.
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 tags=highway=path&tags=bicycle=yes|bicycle=designated.

Adapt it

Change the tags values to find other types of route infrastructure:

Use casetags (repeat for AND)
Dedicated cyclewayshighway=cycleway
Hiking trailshighway=path and foot=yes|foot=designated
Horse riding trailshighway=bridleway
Ski pistespiste:type=downhill

Try it live

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