How to Animate Movement Over Time in Python

On this page

Problem statement

An animation is the only visualisation that shows movement as movement. It is also the easiest to make badly, because the two things it must get right are invisible in a still frame:

  • Frames must be equally spaced in time, not in rows. Position logs are irregular โ€” the ships on this page reported a median 9 s apart and up to 149 s, and a phone's GPS log can go silent for most of an hour โ€” so one frame per row plays back at wildly varying speed.
  • A gap must look like a gap. Interpolating across a fifty-minute hole produces a smooth glide along a route nobody took.

Quick answer

Build a regular time index, snap positions to it, and mark what was interpolated:

import numpy as np
import pandas as pd


def to_frames(df, id_col="track_id", step="10s", gap_s=300):
    """One row per track per frame, with interpolated positions flagged."""
    g = df.groupby(id_col)
    df = df.copy()
    df["leg"] = (g["time"].diff().dt.total_seconds() > gap_s) \
        .groupby(df[id_col]).cumsum()

    out = []
    for keys, sub in df.groupby([id_col, "leg"]):
        sub = sub.sort_values("time").set_index("time")
        frames = sub[["x", "y"]].resample(step).mean()
        observed = sub[["x"]].resample(step).count()["x"] > 0
        frames = frames.interpolate(method="time", limit_area="inside")
        frames["interpolated"] = ~observed.reindex(frames.index,
                                                   fill_value=False).values
        frames[id_col], frames["leg"] = keys
        out.append(frames.reset_index())

    result = pd.concat(out, ignore_index=True)
    print(f"  {result['time'].nunique():,} frames, "
          f"{result['interpolated'].mean():.1%} interpolated positions")
    return result
frames = to_frames(df, step="10s")
  721 frames, 16.3% interpolated positions

Two hours at 10 s is 721 frames. One position in six on them is interpolated โ€” the ships report every 9 s on median, so most 10-second slots hold a real fix and the rest are filled between two. No report here is more than 149 s from the last, under the 300 s gap limit, so no track is broken into legs; a vessel that leaves the box simply stops having frames.

limit_area="inside" is what stops the interpolation extending past the last real fix of a leg โ€” otherwise a track keeps moving after the receiver stopped recording.

Irregular GPS fixes resampled onto a regular frame index, with frames inside a data gap left empty rather than interpolated. frames must be regular in time, not in rowsfixes as loggedplayback speeds up and freezesirregularregular framesapparent speed = real speedgaps stay emptyViewers read apparent speed as real speed spatialworkflow.io
Frames are regular in time. Gaps stay empty, so the animation shows an absence rather than inventing a glide.

A frame interval from the movement, resampled, with gaps empty

1. Choose the frame interval from the movement, not the file

The frame interval sets both the smoothness and the number of frames. A useful rule: an object should move a few pixels between frames.

At a 5 m/s walking-to-cycling pace and a map scale of 2 m per pixel, a 10-second frame moves 25 pixels โ€” visible but jumpy. Two seconds moves 5 pixels, which is smooth. Two seconds over an eight-hour dataset is 14,400 frames, so there is a length constraint too.

2. Resample, do not iterate over rows

One frame per row plays back at the sampling rate, which varies. The result speeds up where the receiver logged often and freezes where it did not โ€” the opposite of what the viewer will infer.

3. Keep gaps empty

A leg boundary should produce frames with no position for that track. In a scatter animation the marker simply disappears, which reads correctly as "no data" rather than as "stopped" or "teleported".

4. Add a trailing tail, not a full history

Drawing the whole track from the start makes the frame progressively more cluttered and hides where the object is. A tail of the last n frames shows direction and recent speed while keeping the current position readable.

5. Put the clock in the frame

An animation without a visible timestamp cannot be interrogated. A text element showing the frame's time, and a progress bar, turn "something happens around here" into "at 08:42".

An animation frame showing the current position with a short trailing tail against one drawing the entire accumulated track. a tail encodes speed; a full track encodes nothingfixed-length tailโœ“current position readableโœ“on-screen length โˆ speedโœ“direction visibleโœ“stays legiblefull accumulated trackโœ•informative for ten secondsโœ•then an increasingly dense scribbleโœ•current position hiddenโœ•no speed informationWith regular frames, tail length is proportional to speed for free spatialworkflow.io
A fixed-length tail encodes recent speed and direction. A full history encodes nothing after the first minute.

ร˜resund ferry traffic animated with tails and a clock

The data used on this page

Two hours of real traffic in the narrowest part of the ร˜resund, where the Helsingรธrโ€“Helsingborg ferries shuttle across the shipping lane: every Class A AIS message the Danish Maritime Authority logged between 06:00 and 08:00 UTC on 1 June 2025 inside 12.55โ€“12.75ยฐ E, 55.98โ€“56.08ยฐ N (aisdata.ais.dk), for vessels with at least 100 reports โ€” projected to UTM zone 33N as x and y. The coastline is Natural Earth's 1:10m land (public domain).

import io
import os
import zipfile

import duckdb
import geopandas as gpd
import pandas as pd
import requests
from shapely.geometry import box

if not os.path.exists("oresund.parquet"):
    if not os.path.exists("aisdk-2025-06-01.zip"):
        with requests.get("http://aisdata.ais.dk/aisdk-2025-06-01.zip", stream=True, timeout=600) as r:
            r.raise_for_status()
            with open("aisdk-2025-06-01.zip", "wb") as f:
                for chunk in r.iter_content(1 << 22):
                    f.write(chunk)
    zipfile.ZipFile("aisdk-2025-06-01.zip").extract("aisdk-2025-06-01.csv")
    duckdb.execute("""COPY (
        SELECT CAST(MMSI AS VARCHAR) AS track_id, strptime("# Timestamp", '%d/%m/%Y %H:%M:%S') AS time,
               Latitude AS lat, Longitude AS lon
        FROM read_csv('aisdk-2025-06-01.csv', header=true, quote='"', types={'# Timestamp': 'VARCHAR'})
        WHERE "Type of mobile" = 'Class A' AND Longitude BETWEEN 12.55 AND 12.75 AND Latitude BETWEEN 55.98 AND 56.08
          AND strptime("# Timestamp", '%d/%m/%Y %H:%M:%S') BETWEEN '2025-06-01 06:00:00' AND '2025-06-01 08:00:00'
        ) TO 'oresund.parquet' (FORMAT parquet)""")
    os.remove("aisdk-2025-06-01.csv")

raw = pd.read_parquet("oresund.parquet").drop_duplicates(["track_id", "time"])
raw = raw[raw.groupby("track_id")["time"].transform("size") >= 100]
df = gpd.GeoDataFrame(raw, geometry=gpd.points_from_xy(raw.lon, raw.lat), crs=4326).to_crs(32633)
df["x"], df["y"] = df.geometry.x, df.geometry.y
df = pd.DataFrame(df.drop(columns="geometry")).sort_values(["track_id", "time"]).reset_index(drop=True)
dt = df.groupby("track_id")["time"].diff().dt.total_seconds()
print(f"{df.track_id.nunique()} vessels, {len(df):,} positions; interval median {dt.median():.0f} s, "
      f"max {dt.max():,.0f} s")

zipfile.ZipFile(io.BytesIO(requests.get("https://naciscdn.org/naturalearth/10m/physical/ne_10m_land.zip",
                                        timeout=120).content)).extractall("land")
land = gpd.read_file("land/ne_10m_land.shp").clip(box(12.4, 55.9, 12.9, 56.15)).to_crs(32633)
25 vessels, 14,279 positions; interval median 9 s, max 149 s

Example 1 โ€” a Matplotlib animation with tails and a clock

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation


def animate_tracks(frames, id_col="track_id", tail=15, basemap=None,
                   out_path="movement.mp4", fps=20, dpi=120):
    """One marker per track, with a trailing tail and a visible clock."""
    times = np.sort(frames["time"].unique())
    tracks = frames[id_col].unique()
    colours = plt.cm.tab20(np.linspace(0, 1, len(tracks)))

    fig, ax = plt.subplots(figsize=(8, 8))
    if basemap is not None:
        basemap.plot(ax=ax, color="#f2f4f7", edgecolor="#dfe3e8", linewidth=0.4)
    ax.set_xlim(frames["x"].min(), frames["x"].max())
    ax.set_ylim(frames["y"].min(), frames["y"].max())
    ax.set_aspect("equal")
    ax.set_axis_off()

    lines = {t: ax.plot([], [], lw=1.6, color=c, alpha=0.9)[0]
             for t, c in zip(tracks, colours)}
    dots = {t: ax.plot([], [], "o", ms=5, color=c)[0]
            for t, c in zip(tracks, colours)}
    clock = ax.text(0.02, 0.97, "", transform=ax.transAxes, va="top",
                    fontsize=11, family="monospace")

    by_time = {t: g for t, g in frames.groupby("time")}

    def update(i):
        window = times[max(0, i - tail):i + 1]
        for track in tracks:
            recent = frames[(frames[id_col] == track) &
                            (frames["time"].isin(window))].dropna(subset=["x"])
            lines[track].set_data(recent["x"], recent["y"])
            now = by_time.get(times[i])
            current = now[now[id_col] == track].dropna(subset=["x"]) \
                if now is not None else None
            if current is not None and len(current):
                dots[track].set_data(current["x"], current["y"])
            else:
                dots[track].set_data([], [])       # gap: marker disappears
        clock.set_text(str(times[i])[:19])
        return list(lines.values()) + list(dots.values()) + [clock]

    anim = FuncAnimation(fig, update, frames=len(times), blit=True, interval=1000 / fps)
    anim.save(out_path, fps=fps, dpi=dpi)
    print(f"  wrote {out_path}: {len(times):,} frames at {fps} fps "
          f"({len(times) / fps:.1f} s)")
    plt.close(fig)
animate_tracks(frames, basemap=land, out_path="oresund.mp4")
  wrote oresund.mp4: 721 frames at 20 fps (36.0 s)

blit=True redraws only the artists that changed, which is the difference between a few seconds and a few minutes for a long animation.

Example 2 โ€” choosing the frame interval from the data

import numpy as np


def suggest_frame_interval(frames, extent_m, pixels=800,
                           target_px_per_frame=4.0, max_frames=3000):
    """Balance smoothness against file size, using the actual speeds."""
    speeds = frames.groupby("track_id").apply(
        lambda s: np.hypot(s["x"].diff(), s["y"].diff()).median(),
        include_groups=False)
    typical = float(np.nanmedian(speeds))

    m_per_px = extent_m / pixels
    smooth = target_px_per_frame * m_per_px / max(typical, 0.1)

    span = (frames["time"].max() - frames["time"].min()).total_seconds()
    minimum = span / max_frames

    chosen = max(smooth, minimum)
    print(f"  typical step {typical:.1f} m, scale {m_per_px:.1f} m/px")
    print(f"  smoothness suggests {smooth:.1f}s; "
          f"the {max_frames}-frame cap requires at least {minimum:.1f}s")
    print(f"  use {chosen:.1f}s -> {span / chosen:,.0f} frames")
    return chosen
extent = max(frames.x.max() - frames.x.min(), frames.y.max() - frames.y.min())
interval = suggest_frame_interval(frames, extent_m=extent)
  typical step 26.8 m, scale 14.2 m/px
  smoothness suggests 2.1s; the 3000-frame cap requires at least 2.4s
  use 2.4s -> 3,000 frames

The two constraints usually disagree, and the frame cap usually wins for anything longer than an hour. When it does, the honest options are to shorten the time window or to accept a jumpier animation โ€” not to drop frames unevenly.

Example 3 โ€” a lightweight interactive alternative

import folium
from folium.plugins import TimestampedGeoJson


def timestamped_map(frames, id_col="track_id", period="PT10S"):
    """A browser-based animation with a time slider, no video encoding."""
    features = []
    for track, sub in frames.dropna(subset=["x"]).groupby(id_col):
        sub = sub.sort_values("time")
        features.append({
            "type": "Feature",
            "geometry": {"type": "LineString",
                         "coordinates": sub[["lon", "lat"]].values.tolist()},
            "properties": {
                "times": sub["time"].dt.strftime("%Y-%m-%dT%H:%M:%S").tolist(),
                "style": {"color": "#0ea5e9", "weight": 3},
                "popup": str(track),
            },
        })

    centre = [frames["lat"].mean(), frames["lon"].mean()]
    m = folium.Map(location=centre, zoom_start=13, tiles="CartoDB positron")
    TimestampedGeoJson({"type": "FeatureCollection", "features": features},
                       period=period, add_last_point=True,
                       auto_play=False, loop=False).add_to(m)
    print(f"  {len(features)} tracks, "
          f"{sum(len(f['properties']['times']) for f in features):,} timestamps")
    return m
from pyproj import Transformer

to_lonlat = Transformer.from_crs(32633, 4326, always_xy=True)
frames["lon"], frames["lat"] = to_lonlat.transform(frames["x"].values, frames["y"].values)
m = timestamped_map(frames)
m.save("oresund.html")
  25 tracks, 12,018 timestamps

to_frames keeps only x and y, so the frames have no lon and lat until you put them back โ€” the first version of this example read columns that were never there.

A time slider is often better than a video: the viewer controls the pace, can stop on a moment, and can zoom. It also avoids the frame-count trade-off entirely, at the cost of needing the data in the browser.

Why frames must be regular in time

Why frames must be regular in time

An animation maps frame number to elapsed time. If frames come from rows, and rows arrive irregularly, that mapping is nonlinear โ€” playback speeds up where the receiver logged often and crawls where it did not.

Viewers read apparent speed as real speed, so an irregular animation communicates the receiver's behaviour rather than the object's. Resampling to a regular index makes apparent speed proportional to real speed, which is the entire point of animating.

Why gaps should stay empty

The two alternatives are both worse. Interpolating across a fifty-minute gap draws a smooth glide along a route that was never travelled, at an average speed that was never held. Freezing the marker in place says the object stopped, when it may have driven fifty kilometres.

An absent marker is unambiguous, and viewers read it correctly with a single legend note. It is also the only option that does not assert something false.

Why a tail beats a full track

A full accumulated track is informative for the first few seconds and then becomes an increasingly dense scribble hiding the current position.

A tail of fixed length has a second property that is easy to miss: because frames are regular in time, the tail's length on screen is proportional to speed. A stationary object has a dot; a fast one has a long streak. Speed becomes visible without a colour scale.

Why an animation is a poor analytical tool

Animations are excellent for communicating that movement happened and roughly where. They are poor for quantities: viewers cannot count, cannot compare durations reliably, and cannot revisit a moment without scrubbing.

Pair an animation with a static summary โ€” a flow map, a space-time plot, a table โ€” and let the animation do the job it is good at. Where the audience needs to interrogate the data, a time slider beats a video every time.

Frame counts for an eight-hour dataset at several frame intervals, against a practical cap. frames for an eight-hour datasetevery 1 s28,800 framesevery 5 s5,760every 10 s2,880every 30 s960030kSmoothness wants a short interval; the frame cap usually wins for long datasets spatialworkflow.io
The two constraints disagree. For anything over an hour, the frame cap decides.

Blitting, frame caps, axis limits and time sliders

  • Resample to a regular time index, never one frame per row.
  • Keep gaps empty rather than interpolating or freezing.
  • limit_area="inside" stops interpolation running past the last real fix.
  • Use blit=True in Matplotlib, or long animations take minutes to render.
  • Cap the frame count. Above a few thousand, file size and render time dominate.
  • Put the timestamp in the frame. An animation without a clock cannot be interrogated.
  • Fix the axis limits before animating, or the view jumps as tracks enter and leave.
  • Consider a time slider instead of a video where the audience needs control.

FAQ

How do I animate GPS tracks in Python?

Resample the tracks onto a regular time index, then use matplotlib.animation.FuncAnimation to draw one marker and a short tail per track per frame, with the timestamp shown.

Why does my animation play at uneven speed?

Because you made one frame per row, and GPS sampling is irregular. Resample to a fixed time interval so frame number is proportional to elapsed time.

What frame interval should I use?

One where the object moves a few pixels per frame at the map's scale, subject to a cap on total frames. The two constraints usually conflict for long datasets.

What should happen during a data gap?

Nothing. Leave the frames empty so the marker disappears. Interpolating invents a journey; freezing implies a stop.

Should I draw the whole track or a tail?

A tail. With regular frames, the tail's on-screen length is proportional to speed, so it encodes information a full track does not.

Is a video or an interactive map better?

An interactive time slider is usually better for analysis, because the viewer controls the pace and can stop on a moment. A video is better for a fixed presentation.

Why is my animation so slow to render?

Usually blit=False and too many frames. Enable blitting and cap the frame count at a few thousand.