Storing and Querying Matched Trajectories

Matching produces more data than it consumes. A raw fix is a timestamp and two floats; a matched fix carries an edge identifier, an offset along that edge, a confidence, a graph version and usually a snapped coordinate as well. Three to five times the input volume is normal, and it arrives every day.

That would be a manageable storage problem if there were one query. There are at least three, and they want different things. “Where was vehicle 14 on Tuesday afternoon?” wants a vehicle-time lookup. “Which vehicles entered this zone last week?” wants a spatial index. “How much of our driving is on roads with a weight limit?” wants to join against the road network by edge. A single table with a single index serves one of them well and the other two badly.

This page sets out a storage design that handles all three, the partitioning that keeps it fast as the archive grows, and the hot/cold split that keeps it affordable. It follows on from the matching work in the trajectory analysis section and assumes output carrying the version stamp described in pinning map versions.


Prerequisites

  • PostgreSQL 16+ with PostGIS 3.4+ for the hot store, or an equivalent with declarative partitioning and a spatial index.
  • GeoParquet 1.0+ and an object store for the cold tier. polars or pyarrow for writing.
  • A stable edge identity — OSM way id plus direction — so that stored rows remain joinable when the graph is rebuilt.
  • An operating-date column derived in the vehicle’s local timezone, per calculating accurate dwell times across timezone shifts.
  • A projected CRS decided up front, because changing it later means rewriting the archive.

Choosing the Grain

Three grains are possible, and most fleets end up materialising two of them.

Row per matched fix. One row for every input observation, carrying its snapped position and the edge it landed on. Largest by far, and the only grain that preserves the vehicle’s own timeline at full resolution. Stop detection, speed profiling and anything replaying a journey need it.

Row per matched segment. One row per contiguous run of fixes on the same directed edge, with entry and exit times. Typically five to ten times smaller, because a vehicle spends many fixes on one road. This is the grain that answers road-network questions directly, and it joins to the road graph without an aggregation step.

Row per trip. One row per journey with a summary geometry and aggregate metrics. Tiny, and the grain most reporting actually consumes.

The mistake is picking one. The segment grain cannot answer “what was the speed profile through this junction”; the fix grain cannot answer “how many vehicle-kilometres on residential roads” without scanning everything. Materialising fix and segment grains, and deriving trip summaries from segments, covers the realistic query set at a storage cost dominated by the fix table.

One journey, three grains A single forty-minute journey stored three ways. At fix grain it is 2 400 rows preserving the full timeline. At segment grain it is 214 rows, one per contiguous run on a directed edge. At trip grain it is one row with a summary geometry and aggregate metrics. One 40-minute journey at 1 Hz, three storage grains fix grain 2 400 rows segment grain 214 rows trip grain 1 row Fix grain answers "what happened at 14:32". Segment grain answers "how much driving on this road". Trip grain answers everything a weekly report asks and nothing an investigation needs. Materialise the first two; derive the third, so it can be recomputed when a definition changes.

The Schema

CREATE TABLE matched_segments (
    segment_id      bigint GENERATED ALWAYS AS IDENTITY,
    vehicle_id      text        NOT NULL,
    operating_date  date        NOT NULL,     -- vehicle-local, not UTC
    trip_id         uuid        NOT NULL,
    osm_way_id      bigint      NOT NULL,
    direction       smallint    NOT NULL,     -- +1 forward, -1 reverse
    enter_utc       timestamptz NOT NULL,
    exit_utc        timestamptz NOT NULL,
    length_m        double precision NOT NULL,
    mean_speed_kmh  real,
    confidence      real,
    graph_version   char(12)    NOT NULL,
    geom            geometry(LineString, 32631) NOT NULL,
    PRIMARY KEY (operating_date, segment_id)
) PARTITION BY RANGE (operating_date);

CREATE INDEX ON matched_segments USING gist (geom);
CREATE INDEX ON matched_segments (vehicle_id, enter_utc);
CREATE INDEX ON matched_segments (osm_way_id, direction);

Three indexes because there are three query shapes, and none of them substitutes for another. The GiST index answers “what passed through this polygon”; the composite index answers “what did this vehicle do between these times”; the edge index answers “what happened on this road”. Dropping any one of them turns its query into a partition scan.

PARTITION BY RANGE (operating_date) is the single most consequential line. Fleet queries are almost always bounded in time, and partition pruning turns a billion-row table into a few million rows before any index is consulted. Monthly partitions suit most fleets; daily partitions are worth it past roughly half a billion rows a year.

Note that operating_date is vehicle-local. Partitioning on a UTC date splits a night shift across two partitions for half the fleet, which quietly doubles the work for every query about a shift.


Query Shapes and What They Cost

Question Index used Typical cost
Vehicle 14, Tuesday afternoon partition + (vehicle_id, enter_utc) single-digit milliseconds
Everything in this depot polygon last week partition + GiST tens of milliseconds
All driving on this way, last quarter partition + (osm_way_id, direction) tens of milliseconds
Vehicle-kilometres by road class, last year full scan of pruned partitions seconds to minutes

The last row is the one that should not run against the hot store. Analytical scans over a year of data are exactly what columnar formats are built for, and running them against an indexed row store competes with the operational queries for buffer cache.

What partitioning and indexing are each worth Four query shapes measured against three configurations: no partitioning and no indexes, indexes only, and partitioned with indexes. Partition pruning dominates for time-bounded queries; the spatial index dominates for area queries; the analytical scan is slow in every configuration, which is the argument for a separate cold tier. 1.2 billion rows, three configurations vehicle + time 18 s / 240 ms / 4 ms area, one week 21 s / 160 ms / 31 ms one way, one quarter 19 s / 190 ms / 44 ms yearly aggregate slow everywhere faint: none · mid: indexes · dark: partitioned + indexed

Hot and Cold Tiers

The access pattern for fleet trajectory data is strongly age-dependent. Operational queries touch the last few weeks; analytical queries scan months or years and touch each row once. Serving both from one system means paying database prices for archival data and letting analytical scans evict the operational working set from cache.

The split that works is simple. Keep a rolling window — sixty to ninety days is typical — in the partitioned database. Age older partitions out to GeoParquet on object storage, partitioned by operating month, and query them with a columnar engine. Detach rather than delete, so the aged partition can be reattached if something needs it back.

-- Age a partition out: detach, export, then drop.
ALTER TABLE matched_segments DETACH PARTITION matched_segments_2026_02;
-- COPY ... TO PROGRAM, or read with a client and write GeoParquet
DROP TABLE matched_segments_2026_02;

The saving is substantial in both directions. Columnar storage with dictionary encoding on the identifier columns typically lands at a quarter to a fifth of the database’s on-disk size, and analytical scans over it run several times faster because they read only the columns they need.


Modelling the Edge Reference

The column that most often causes trouble later is the one holding the road identity. Three schemes are common, and only one of them survives a graph rebuild.

The engine’s internal edge id. Compact and fast to join within a single graph build, and meaningless across builds. An archive keyed on it becomes unjoinable the first time the graph is rebuilt, which for a fleet on a weekly refresh is within days.

The OSM way id alone. Stable across rebuilds and ambiguous about direction, so a one-way violation report cannot be produced from it and a dual carriageway collapses to a single road.

The OSM way id plus a direction flag. Stable, unambiguous, and two columns. This is the scheme worth standardising on, and the small cost is that engines split a single way at junctions, so a matched segment sometimes spans several engine edges belonging to one way.

-- The edge reference, and an index that serves road-network joins
osm_way_id  bigint   NOT NULL,
direction   smallint NOT NULL CHECK (direction IN (-1, 1)),

CREATE INDEX ON matched_segments (osm_way_id, direction);

Where the split point genuinely matters — a long way carrying different speed limits along its length — carry the entry offset as well and treat (way, direction, offset_bucket) as the key. That is a refinement worth adding when a question demands it rather than by default, because it makes every join more expensive for a distinction most queries do not need.

Joining to the road network

The join to road attributes should happen at query time against a versioned copy of the network, not by denormalising road class and speed limit onto every matched row. Denormalising looks attractive — it removes a join from every query — and it freezes the road attributes at match time, so a correction to the map never reaches the archive.

Keep a road_edges table per graph version, joined on (osm_way_id, direction). It is small relative to the trajectory data, it makes attribute corrections retroactive, and it keeps the trajectory rows narrow, which matters more than the join once the archive is measured in hundreds of gigabytes.

Frequently Asked Questions

Do I need a purpose-built trajectory database?

Almost certainly not. Specialised trajectory stores earn their keep on similarity search — “find journeys shaped like this one” — which fleet analytics rarely asks. The queries fleets actually run are partition-pruned range scans and a handful of index lookups, and a partitioned PostGIS table plus a columnar archive handles both at a fraction of the operational cost of another system to run.

How do I handle the road network changing under stored rows?

Store the graph version on the row and re-resolve rather than migrate. When a way is split upstream, the stored identifier stops resolving, and that is a state you want to detect rather than paper over. A nightly job that counts unresolvable edge references per partition turns a silent decay into a number, and the count is a useful proxy for how stale the archive’s road references have become.

Should the snapped coordinate be stored, or recomputed from the edge and offset?

Store it. Recomputing means loading the road geometry for every row, which turns a cheap scan into a join against a large table. The snapped position is sixteen bytes and it makes the archive self-contained, which matters more than the storage once the graph that produced it is several versions old.

What retention is reasonable for each grain?

Fix grain is the expensive one and the least often needed at age; two years is generous for most fleets. Segment grain is a fifth of the size and answers most historical questions, so five to seven years is affordable. Trip grain is small enough that keeping it indefinitely costs nothing and saves an argument later.

How do I keep the two grains consistent?

Derive one from the other in a single job rather than producing them independently. Segments computed from the same matched output that produced the fixes cannot disagree; segments computed by a second pipeline reading the same inputs eventually will, and the disagreement will be found by a customer.

Is it worth storing the unmatched fixes as well?

Yes, flagged. A fix the matcher rejected is evidence about coverage, device health and map quality, and discarding it makes the archive look cleaner than the data was. Keep them in the fix-grain table with a null edge reference and a reason code.

Different grains, different retention Annual storage for a fleet producing 70 million fixes a month: fix grain 340 gigabytes, segment grain 62 gigabytes, trip grain under one gigabyte. Recommended retention runs the other way — two years, seven years and indefinitely. Annual volume against sensible retention, 70 M fixes/month fix grain 340 GB/yr · keep 2 yr segment grain 62 GB/yr · keep 7 yr trip grain 0.8 GB/yr · keep indefinitely One retention policy across all three either throws away cheap history or keeps expensive history nobody reads. Set the fix-grain window from what an investigation realistically reaches back to, which is rarely years. Compressing the deep archive changes these numbers by roughly a factor of three in your favour.

Operational Troubleshooting

Spatial queries slow down over months. The GiST index has bloated, usually after bulk deletes. Reindex the affected partitions; with partitioning this is a per-partition operation rather than a maintenance window.

A vehicle-time query scans everything. The query is not bounded on operating_date, so no partition is pruned. Adding the date predicate — even a generous one — is usually a hundredfold improvement.

Edge joins return nothing after a graph rebuild. Stored rows reference way identifiers that were split upstream. Re-resolve as described in adding truck restrictions, and keep the graph version on the row so the mismatch is detectable rather than silent.

Storage grows faster than fix volume. Usually a wide fix-grain table carrying columns nobody queries — every CAN signal, both geometries, several timestamp variants. Audit column usage before adding hardware.

Analytical queries stall operational ones. The tiers are not separated. This is the symptom the hot/cold split exists to prevent.


Deployment Checklist