Deduplicating Repeated GPS Fixes from Buffered Trackers

A tracker that loses cellular coverage does not stop recording. It buffers, and when the connection returns it flushes everything it has been holding — sometimes twice, if the acknowledgement was the thing that got lost. The ingest layer is almost always at-least-once, so the same observation can arrive two or three times, minutes or hours after it was made.

The naive fix is a DISTINCT on the coordinate columns, and it is a disaster: a vehicle parked at a depot for six hours emits thousands of identical coordinates, all of them real, and deduplicating on position deletes the entire stop. This page sets out the identity a fix actually has, how to deduplicate on it, and how to keep the operation from being reintroduced incorrectly by the next person who sees a lot of repeated coordinates in the table. It supports the sort-dependent operations in trace resampling, which will not run correctly until duplicates are gone.


Compatibility and Configuration Requirements

Requirement Value Why
Observation timestamp Present and distinct from arrival time Deduplicating on arrival time removes nothing, because retransmissions arrive later
Timestamp resolution Milliseconds or better At whole-second resolution a 10 Hz device produces legitimate collisions
Ingest metadata received_at, ingest_offset or similar Needed to decide which copy is the later one
Polars ≥ 1.0, or pandas ≥ 2.1 unique(keep="last") semantics used below
Ordering Deduplicate before any join_asof or resample Both break on duplicate keys

If the feed genuinely has only one timestamp and it is the arrival time, deduplication is not possible and the upstream contract needs fixing. That is worth escalating rather than working around: a feed without observation times cannot support dwell, speed or matching correctly either.


The Deduplicator

from __future__ import annotations

import polars as pl


def deduplicate_fixes(
    fixes: pl.LazyFrame,
    key: tuple[str, ...] = ("vehicle_id", "ts_utc"),
    arrival_col: str = "received_at",
) -> pl.LazyFrame:
    """Remove retransmitted fixes, keeping the most recently received copy.

    Identity is (vehicle, observation time). Coordinates are deliberately not
    part of the key: a stationary vehicle repeats its position legitimately and
    must keep every one of those rows.
    """
    return (
        fixes.sort([*key, arrival_col])
        .unique(subset=list(key), keep="last", maintain_order=True)
    )


def duplicate_report(fixes: pl.LazyFrame, key: tuple[str, ...] = ("vehicle_id", "ts_utc")) -> pl.LazyFrame:
    """Per-device duplicate rate, for monitoring rather than for cleaning."""
    return (
        fixes.group_by(list(key))
        .agg(pl.len().alias("copies"))
        .group_by("vehicle_id")
        .agg(
            pl.len().alias("observations"),
            (pl.col("copies") - 1).sum().alias("duplicate_rows"),
        )
        .with_columns(
            (pl.col("duplicate_rows") / pl.col("observations")).alias("duplicate_rate")
        )
    )

The whole implementation is four lines because the difficulty is not in the code — it is in choosing the key. ("vehicle_id", "ts_utc") is the observation’s identity. Adding coordinates to the subset makes the operation wrong; removing the vehicle makes it catastrophic, because two vehicles can and do report at the same instant.

Two things that look like duplicates, one of which is not The upper block shows three rows sharing one observation timestamp, arriving at three different times — a retransmission, of which two copies must go. The lower block shows six rows with identical coordinates at six consecutive timestamps — a parked vehicle, of which none may be removed. Same table, two very different patterns retransmission — remove two ts_utc 09:14:03.000 lat 52.3701 lon 4.8952 received 09:14:04 ts_utc 09:14:03.000 lat 52.3701 lon 4.8952 received 09:47:11 ts_utc 09:14:03.000 lat 52.3702 lon 4.8952 received 09:47:12 — keep parked vehicle — remove none six distinct timestamps, one coordinate — this is the stop A DISTINCT on (lat, lon) removes five of the six lower rows and the depot break disappears from every report.

Execution and Tuning Guidelines

Run it at ingest, not in the analytics job. Duplicates break sorting, and sorting is a precondition for join_asof, windowed variance, and every rolling filter in outlier removal. Removing them once at the boundary is much cheaper than defending every downstream stage against them.

Keep the later arrival. Devices re-send corrected fixes: a position recomputed with more satellites, a speed field that was null in the first attempt, an accuracy estimate that arrived after the fix. Keeping the first copy systematically prefers the worse one. The sort on received_at before unique(keep="last") is what implements this, and it is easy to lose in a refactor.

Alert on the duplicate rate, per device family. A rate that jumps from 0.2 % to 9 % is a device family that has started retrying, which usually means a coverage or backend problem worth knowing about before it becomes a data problem.

Do not deduplicate across the operating-day boundary in a way that drops the earlier day’s copy. A fix observed at 23:59 and re-sent at 00:04 belongs to the earlier day. If deduplication runs per partition, the two copies never meet and both survive; run it on the observation key across a window wide enough to cover the longest buffering the devices do.

Duplicate rate is a device-health signal Weekly duplicate rate for three device families. Two stay near half a percent. The third jumps from 0.4 percent to 9 percent in week five, immediately after a firmware rollout that changed the acknowledgement timeout, and stays there. Weekly duplicate rate by device family 10 % 5 % 0 family C families A and B firmware 4.2 w1 w8 The deduplicator absorbed this silently — the data stayed correct and the fleet gained nine percent of wasted ingest. A cleaning step that hides a device fault is doing half its job; publish the rate so the other half gets done.

Common Pitfalls Specific to This Technique

Deduplicating on a truncated timestamp. A pipeline that casts observation time to whole seconds before deduplicating will merge genuinely distinct fixes from any device faster than 1 Hz. Keep the native resolution in the key and truncate only for display.

Using DISTINCT * and calling it deduplication. Two copies of the same observation frequently differ in one field — received_at, an ingest offset, a corrected accuracy — so DISTINCT * removes nothing while appearing to address the problem. It is worse than doing nothing, because it looks like the problem was handled.

Removing duplicates after computing derived columns. Speed, heading and displacement computed across a duplicated timestamp produce a zero time delta and therefore an infinite speed. Those rows then get flagged as outliers and removed by a later stage, which quietly deletes real observations that happened to sit next to a retransmission.


Sizing the Deduplication Window

Deduplication is only correct if both copies of a fix are visible to the same operation. A tracker that buffers for six hours will deliver its retransmission into a partition that the original left long ago, and a deduplicator scoped to one partition will keep both.

The window has to be at least as long as the longest buffering the fleet’s devices actually do, and finding that number is a measurement rather than a guess:

lag = (
    fixes.select(
        (pl.col("received_at") - pl.col("ts_utc")).dt.total_seconds().alias("lag_s"),
        "device_family",
    )
    .group_by("device_family")
    .agg(
        pl.col("lag_s").quantile(0.99).alias("p99_lag_s"),
        pl.col("lag_s").max().alias("max_lag_s"),
    )
)

A device family whose 99th-percentile lag is four minutes and whose maximum is eleven hours has one vehicle that spends its nights in an underground car park. Size the window on the maximum, not the percentile, because the percentile is exactly the number that lets rare duplicates through.

There are three practical shapes for the window, and the right one depends on how the data arrives.

Whole-batch. In a nightly job that reads a full day plus a margin, deduplicate across the entire read. Simplest, and correct as long as the margin exceeds the maximum lag.

Rolling upsert. In a warehouse that receives fixes continuously, make the observation key the primary key and let the store resolve collisions. MERGE or INSERT ... ON CONFLICT DO UPDATE gives exactly the last-writer-wins semantics wanted here, without any window at all.

Streaming with state. In a Kafka consumer, keep a bounded set of recently seen keys per vehicle and drop repeats. The set has to be bounded, which means the window is explicit and short — so this shape only works when late duplicates are also handled downstream, typically by the same upsert.

Reprocessing safely

A backfill that re-reads raw ingest will re-introduce every duplicate the first run removed. That is fine when the deduplicator runs inside the same job, and it is a data corruption when the deduplicated table is appended to rather than rewritten. Make the deduplicated dataset a function of its input partition, rewritten wholesale on each run, so replaying a day is idempotent by construction rather than by discipline.

Sizing the window from the observed lag Lag between observation time and arrival time for three device families. Two families deliver within seconds. The third has a long tail reaching eleven hours, caused by vehicles that park underground overnight, and it is that tail that sets the deduplication window. Observation-to-arrival lag, log axis families A, B family C — long tail max 11 h — size the window here 1 s 1 min 1 h 12 h A window sized on the 99th percentile covers families A and B and lets family C's overnight flush through as duplicates. Where an eleven-hour window is impractical, use an upsert keyed on the observation instead of a window at all. Re-measure after any change to buffering or retry configuration; this tail is a firmware property, not a physical one.