How to Convert Points to Lines in GeoPandas

On this page

Problem statement

Turning points into lines — GPS fixes into tracks, survey stations into a route — is LineString(points) per group, and Shapely builds the line in exactly the order it is handed the points. That makes two things the whole job: the order of the rows and the grouping into separate lines. Get either wrong and the result is still a valid geometry. It is just the wrong path, and nothing says so.

The examples run on four public GPS traces from OpenStreetMap that pass through Oakham in Rutland — a five-minute trip round the town, the outward and return journeys of a day between Nottingham and Bedford Purlieus nature reserve, and a four-hour journey from 2024, 24,199 points in all — and measure what unsorted rows, missing groups and text timestamps do to them.

Quick answer

Connecting points in data order can zigzag; sorting them first produces a clean line. 1 2 3 4 5 Unsorted → zigzag 1 2 3 4 5 Sorted → clean path spatialworkflow.io
Connecting points in their stored order can zigzag; sorting them first yields a clean line.

To convert points to lines in GeoPandas, read your point layer into a GeoDataFrame, sort the points by a sequence field or timestamp, group them by a route or track ID if needed, and build Shapely LineString geometries from each ordered point set.

import geopandas as gpd
from shapely.geometry import LineString

gdf = gpd.read_file("data/gps_points.gpkg")

gdf = gdf[gdf.geometry.notnull() & (gdf.geom_type == "Point")]
gdf = gdf.sort_values(["trace_id", "time"])

lines = (
    gdf.groupby("trace_id")["geometry"]
    .apply(lambda points: LineString(points.tolist()) if len(points) >= 2 else None)
    .dropna()
)

lines_gdf = gpd.GeoDataFrame({"trace_id": lines.index}, geometry=list(lines), crs=gdf.crs)
print(lines_gdf.assign(km=lines_gdf.to_crs("EPSG:27700").length / 1000).round(1))
   trace_id                                           geometry     km
0   2417266  LINESTRING (-1.16403 52.92335, -1.1639 52.9231...   71.0
1   2417267  LINESTRING (-0.46688 52.59228, -0.46688 52.592...   66.8
2   2417363  LINESTRING (-0.73544 52.68245, -0.73554 52.682...    1.8
3  11301415  LINESTRING (-0.43744 52.50128, -0.43773 52.501...  212.5

This creates one line per trace_id, using the point order defined by time. The lengths are measured in the British National Grid (EPSG:27700), which is in metres; measured in the file's own EPSG:4326 they would be in degrees.

Load, check the CRS, sort, group

The same points connected in file order and in sorted order. the order of the rows is the shape of the lineFile orderzig-zagSorted by sequencethe intended pathLineString joins points in the order given — nothing infers the route for you spatialworkflow.io
Sorting before building the geometry is the whole job.

Load the point dataset

Start by reading your point file. GeoPandas reads shapefiles, GeoJSON, GeoPackage and GPX the same way.

import geopandas as gpd

gdf = gpd.read_file("data/gps_points.gpkg")
print(gdf.head(3))
print(gdf.geom_type.unique().tolist())
print(gdf.dtypes)
                       time  ...                   geometry
0 2016-08-19 16:54:42+00:00  ...  POINT (-0.73544 52.68245)
1 2016-08-19 16:54:43+00:00  ...  POINT (-0.73554 52.68246)
2 2016-08-19 16:54:44+00:00  ...  POINT (-0.73561 52.68247)

[3 rows x 5 columns]
['Point']
time           datetime64[ms, UTC]
ele                        float64
trace_id                     int64
description                    str
geometry                  geometry
dtype: object

Check that the geometry type is Point

  • the layer has a field that says which line a point belongs to (trace_id here) and one that says where on the line it falls (time)
  • the order field has a real type — time has come back as a timezone-aware datetime, not text

If the geometry is not Point, stop and inspect the source data. The examples here assume Point features, not MultiPoint.

Check the coordinate reference system

The CRS does not change how a line is built, but it decides what its length means.

print(gdf.crs)
EPSG:4326

The GPS points are longitude and latitude, so a length in this CRS is in degrees. Reproject before measuring — and pick a CRS that keeps distances true in your area. Web Mercator (EPSG:3857) is in metres too, but it stretches distances by roughly 1 / cos(latitude), which at Oakham's 52.7° is about 65%:

trip = lines_gdf[lines_gdf["trace_id"] == 2417363]
for crs in ["EPSG:4326", "EPSG:3857", "EPSG:27700"]:
    print(crs, round(trip.to_crs(crs).length.iloc[0], 5))
EPSG:4326 0.02362
EPSG:3857 3025.61221
EPSG:27700 1835.98746

Sort points into the correct order

This is the most important step. A line is created from the point sequence you provide. If the points are unordered, the line zigzags between them.

Common sort fields:

  • sequence
  • timestamp
  • station
  • point_order

If you have multiple lines in the same dataset, sort by both the group field and the order field:

gdf = gdf.sort_values(["trace_id", "time"])

If your timestamp field is stored as text, parse it before sorting. Text sorts character by character, which only matches time order when every value has the same zero-padded layout. ISO 8601 strings such as 2017-05-21T08:40:51Z do; a spreadsheet export such as 21/05/2017 9:40:51 does not:

import pandas as pd

ride = gdf[gdf["trace_id"] == 2417266].copy()
ride["time_text"] = ride["time"].map(lambda t: f"{t.day}/{t.month:02d}/{t.year} {t.hour}:{t:%M:%S}")

by_text = ride.sort_values("time_text")
print(by_text["time_text"].iloc[[0, 1, -1]].tolist())

ride["time_parsed"] = pd.to_datetime(ride["time_text"], format="%d/%m/%Y %H:%M:%S")
by_time = ride.sort_values("time_parsed")
print(by_time["time_text"].iloc[[0, 1, -1]].tolist())

iso = ride["time"].dt.strftime("%Y-%m-%dT%H:%M:%SZ")
print("ISO text sorts like time:", (ride.sort_values("time").index == ride.assign(iso=iso).sort_values("iso").index).all())
['21/05/2017 10:00:00', '21/05/2017 10:00:01', '21/05/2017 9:59:59']
['21/05/2017 8:40:51', '21/05/2017 8:40:52', '21/05/2017 10:04:25']
ISO text sorts like time: True

Sorted as text, the trip starts at 10:00 and ends at 9:59, because "1" sorts before "8" and "9".

Group points that belong to the same line

If all points belong to one line, no grouping is needed. If the dataset contains multiple routes or tracks, group by the relevant ID field.

Examples:

  • one route per route_id
  • one GPS track per trace_id
  • one asset path per asset_id

Leaving the group out does not raise an error — it joins every trace into one line, with a straight jump from the end of one trip to the start of the next:

one_line = LineString(gdf.sort_values("time").geometry.tolist())
print(round(gpd.GeoSeries([one_line], crs=gdf.crs).to_crs("EPSG:27700").length.iloc[0] / 1000, 1), "km as one line")
print(round(lines_gdf.to_crs("EPSG:27700").length.sum() / 1000, 1), "km as separate traces")
457.2 km as one line
352.1 km as separate traces

OpenStreetMap GPS traces turned into lines

The data used on this page

The setup downloads four public GPS traces from OpenStreetMap's trace archive (they are published under the contributors' own names; GPX data from OpenStreetMap), stacks their track points into one point layer with a trace_id and a time, and writes a second copy of the same points sorted along a Hilbert curve — the ordering recommended for GeoParquet, because it lets a reader skip row groups outside the area it wants.

import urllib.request
from pathlib import Path

import pandas as pd
import geopandas as gpd

TRACES = {
    2417363: "New housing & pub Oakham",
    2417266: "Nottingham to Bedford Purlieus NNR",
    2417267: "Nottingham from Bedford Purlieus NNR",
    11301415: "Uk",
}

Path("data").mkdir(exist_ok=True)
frames = []
for trace_id, description in TRACES.items():
    gpx = Path(f"data/trace_{trace_id}.gpx")
    if not gpx.exists():
        request = urllib.request.Request(
            f"https://www.openstreetmap.org/trace/{trace_id}/data",
            headers={"User-Agent": "spatialworkflow-docs"},
        )
        gpx.write_bytes(urllib.request.urlopen(request, timeout=120).read())
    track = gpd.read_file(gpx, layer="track_points")
    frames.append(track[["time", "ele", "geometry"]].assign(trace_id=trace_id, description=description))

points = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), crs="EPSG:4326")
points.to_file("data/gps_points.gpkg", layer="points")

# the same rows, stored the way a spatially sorted GeoParquet file stores them
points.iloc[points.hilbert_distance().argsort()].to_parquet("data/gps_points_hilbert.parquet")

summary = points.groupby("trace_id").agg(points=("time", "size"), start=("time", "min"), end=("time", "max"))
print(summary)
          points                     start                       end
trace_id                                                            
2417266     5015 2017-05-21 08:40:51+00:00 2017-05-21 10:04:25+00:00
2417267     4552 2017-05-21 15:42:22+00:00 2017-05-21 16:58:15+00:00
2417363      299 2016-08-19 16:54:42+00:00 2016-08-19 16:59:44+00:00
11301415   14333 2024-03-07 15:14:59+00:00 2024-03-07 19:13:55+00:00

Example 1: Convert a single ordered set of points into one line

import geopandas as gpd
from shapely.geometry import LineString

gdf = gpd.read_file("data/gps_points.gpkg")
trip = gdf[gdf["trace_id"] == 2417363].copy()

# Keep valid Point rows only
trip = trip[trip.geometry.notnull() & (trip.geom_type == "Point")]

# Sort by time
trip = trip.sort_values("time")

if len(trip) < 2:
    raise ValueError("At least two points are required to create a line.")

line = LineString(trip.geometry.tolist())
line_gdf = gpd.GeoDataFrame({"name": ["oakham_trip"]}, geometry=[line], crs=trip.crs)

print(len(trip), "points,", len(line.coords), "vertices")
print(round(line_gdf.to_crs("EPSG:27700").length.iloc[0]), "m")

# A receiver that is not moving logs the same position again and again
repeats = trip.geometry.eq(trip.geometry.shift())
print(repeats.sum(), "points repeat the one before")
299 points, 299 vertices
1836 m
85 points repeat the one before

Use this pattern when all records belong to one feature. Every point becomes a vertex, including the ones logged while the receiver was not moving; they add nothing to the shape or the length.

Example 2: Convert grouped points into multiple lines

This is the more common real-world case: one layer, many tracks.

import pandas as pd
import geopandas as gpd
from shapely.geometry import LineString

gdf = gpd.read_file("data/gps_points.gpkg")
gdf = gdf[gdf.geometry.notnull() & (gdf.geom_type == "Point")]

line_records = []
for trace_id, group in gdf.groupby("trace_id"):
    group = group.sort_values("time")
    if len(group) < 2:
        continue
    line_records.append(
        {
            "trace_id": trace_id,
            "description": group["description"].iloc[0],
            "point_count": len(group),
            "start": group["time"].iloc[0],
            "end": group["time"].iloc[-1],
            "geometry": LineString(group.geometry.tolist()),
        }
    )

lines_gdf = gpd.GeoDataFrame(line_records, crs=gdf.crs)
lines_gdf["km"] = (lines_gdf.to_crs("EPSG:27700").length / 1000).round(1)
lines_gdf["minutes"] = ((lines_gdf["end"] - lines_gdf["start"]).dt.total_seconds() / 60).round()

print(lines_gdf[["trace_id", "point_count", "km", "minutes"]])
print(lines_gdf.geom_type.unique().tolist())
   trace_id  point_count     km  minutes
0   2417266         5015   71.0     84.0
1   2417267         4552   66.8     76.0
2   2417363          299    1.8      5.0
3  11301415        14333  212.5    239.0
['LineString']

This creates one line per trace_id and uses timestamp order within each group. Group attributes — the description, the start and end times — are carried across while the records are built.

Length of four GPS traces built from time-sorted rows and from the same rows in Hilbert-curve storage order. the same rows, built sorted and unsorted2024, sorted212.5 km2024, unsorted814.0 kmOutward, sorted71.0 kmOutward, unsorted327.7 kmReturn, sorted66.8 kmReturn, unsorted332.1 km0300 km600 km900 kmEvery storage-order line is valid; none is simple spatialworkflow.io
Measured in Example 3 below: unsorted rows make each of these traces 3.8 to 5 times its real length, and every one of those lines still passes is_valid.

Example 3: What an unsorted layer does to the lines

The Hilbert-sorted copy holds exactly the same rows. Grouping it without sorting builds lines from rows in storage order:

hilbert = gpd.read_parquet("data/gps_points_hilbert.parquet")

def build(frame):
    return gpd.GeoSeries(
        frame.groupby("trace_id")["geometry"].apply(lambda pts: LineString(pts.tolist())),
        crs=frame.crs,
    )

unsorted_lines = build(hilbert)
sorted_lines = build(hilbert.sort_values(["trace_id", "time"]))

comparison = pd.DataFrame({
    "sorted_km": sorted_lines.to_crs("EPSG:27700").length / 1000,
    "unsorted_km": unsorted_lines.to_crs("EPSG:27700").length / 1000,
    "unsorted_valid": unsorted_lines.is_valid,
    "unsorted_simple": unsorted_lines.is_simple,
}).round(1)
print(comparison)
          sorted_km  unsorted_km  unsorted_valid  unsorted_simple
trace_id                                                         
2417266        71.0        327.7            True            False
2417267        66.8        332.1            True            False
2417363         1.8          3.8            True            False
11301415      212.5        814.0            True            False

Every scrambled line is valid, so a validity check will not catch this. The length gives it away, and so does is_simple: a GPS track can cross itself, but a zigzag through a whole route crosses itself constantly.

Example 4: Export the resulting line GeoDataFrame

Once the new line GeoDataFrame is ready, export it to a GIS file.

from pathlib import Path

Path("output").mkdir(exist_ok=True)
lines_gdf.to_file("output/routes.gpkg", driver="GPKG")
lines_gdf.to_file("output/routes.geojson", driver="GeoJSON")
lines_gdf.to_file("output/routes.shp")

for path in ["output/routes.gpkg", "output/routes.geojson", "output/routes.shp"]:
    back = gpd.read_file(path)
    print(path, back.geom_type.unique().tolist(), back["start"].dtype)
output/routes.gpkg ['LineString'] datetime64[ms, UTC]
output/routes.geojson ['LineString'] datetime64[ms, UTC]
output/routes.shp ['LineString'] str

You should see LineString from every format. The shapefile is the one that loses something: it has no datetime field type, so start and end come back as text.

Shapely joins points in the order given

The columns needed to turn a point table into one or many lines. what your point table needsabsentpresentgroup column!one line for everything✓one line per groupsequence column✕line follows row order✓line follows the sequenceresult✕usually wrong✓reproducibleWithout a sequence column the result depends on how the file happened to be written spatialworkflow.io
Two extra columns turn an ambiguous conversion into a deterministic one.

A LineString is an ordered list of vertices, and Shapely takes the order from the input; it has no notion of time or of which point should follow which. That is why the failures here are silent. Built from shuffled rows, the four traces measured 327.7, 332.1, 3.8 and 814.0 km instead of 71.0, 66.8, 1.8 and 212.5 — every line still valid, but none of them simple, because a track that zigzags between random fixes crosses itself constantly (Example 3). Built without grouping, all four traces became one line of 457.2 km where the separate traces total 352.1, the difference being the straight jumps from the end of one trip to the start of the next.

The order field itself can lie. Timestamps stored as text sort as text, so 21/05/2017 9:59:59 lands after 21/05/2017 10:00:01; only a zero-padded, largest-unit-first layout like ISO 8601 sorts the same as time. Parse first, then sort, then group, then build.

Unsorted rows, short groups and text timestamps

Points are not in the correct order

Row order in a file is not a promise: a spatially sorted GeoParquet file, a database query without ORDER BY, or a join that returns rows grouped by the other table all hand back points in some other order than the path. Sort on time or sequence every time, even when the file looks ordered.

Duplicate or repeated points

Repeated points are allowed, but they create redundant vertices; Example 1's trace repeats its previous position at 85 of its 299 points. Remove consecutive repeats if you need a lean line:

trip = trip[~trip.geometry.eq(trip.geometry.shift())]
print(len(trip), "points after dropping consecutive repeats")
214 points after dropping consecutive repeats

Drop only consecutive repeats. drop_duplicates on the geometry would also remove the second visit to a place the track passes twice, and the line would then skip a stretch of the route.

Symptoms of a wrong points-to-lines conversion and their causes, as measured on the Oakham traces. what the output tells youSymptomCauseLength 2-5x, is_simple Falserows not sorted by timeStraight jumps between tripsno group by trace_idTrip starts 10:00, ends 9:59time sorted as textLength about 1.65xmeasured in EPSG:3857IllegalArgumentExceptiongroup with one pointis_valid False, length 0every point in one placeOnly two of these raise or fail a validity check spatialworkflow.io
The failures seen on this page. Only the last two raise an error or fail is_valid; the rest produce valid lines that are wrong.

Too few points in a group

A line needs at least two points. With one, Shapely refuses:

from shapely.geometry import Point

try:
    LineString([Point(-0.73, 52.67)])
except Exception as error:
    print(type(error).__name__, error)

stood_still = LineString([Point(-0.73, 52.67), Point(-0.73, 52.67)])
print(stood_still.is_valid, stood_still.length)
GEOSException IllegalArgumentException: point array must contain 0 or >1 elements

False 0.0

Two points in the same place build a line, but an invalid one of zero length. A track logged by a receiver that never moved ends up like that, so check is_valid as well as the point count.

Timestamp field is stored as text

Text timestamps sort correctly only when every value has the same zero-padded, largest-unit-first layout, like ISO 8601. Parse anything else with pd.to_datetime and an explicit format before sorting.

Measure in a projected CRS

The 299-point trace measured 0.0236 in EPSG:4326 (degrees), 3,025.6 m in Web Mercator and 1,836.0 m in British National Grid. Only the last is its length.

MultiPoint input is not handled here

These examples assume one Point feature per row. If your source data uses MultiPoint, explode it first with gdf.explode(index_parts=False).

Multipart workflows

If one logical feature should become several segments — a GPX file with several <trkseg> elements, or a trip with a long pause you want to break at — group by both the track and a segment ID. GeoPandas reads a GPX file's segment number as track_seg_id in the track_points layer.

For a broader overview, see Working with vector geometries in GeoPandas.

Related tasks:

If your lines come out invalid after conversion, see How to Fix Invalid Geometries in Python (GeoPandas).

FAQ

How do I convert points to a line in GeoPandas?

Read the point layer with GeoPandas, sort the records by the correct order field, and pass the ordered point geometries to Shapely LineString.

How do I create multiple lines from grouped points in GeoPandas?

Group the GeoDataFrame by a field such as route_id or trip_id, sort each group by sequence or timestamp, and build one LineString per group.

Do the points need to be sorted before creating a line?

Yes. Line vertices are created in the exact order of the input points. Unsorted points usually produce incorrect lines.

Can I build lines from GPS points using timestamps?

Yes. This is a common workflow. Sort each GPS track by timestamp, then create one line per trip or track ID.

What happens if a group has only one point?

A valid line requires at least two points. Groups with one point should be skipped, stored separately, or reviewed as incomplete data.

How do I keep the line in the right order without a sequence or timestamp field?

If there is no explicit order field, you cannot reliably build a correct line, because row order in a file is not guaranteed to follow the path. Create an order field first, for example by sorting on a known direction or by an external reference, before calling LineString.

How do I calculate the length of each line after converting points?

Reproject the line GeoDataFrame to a projected CRS in metres, then read .length. For example lines_gdf.to_crs("EPSG:27700").length returns metres for the Oakham traces; computing length in EPSG:4326 would give degrees, which is not a usable distance, and EPSG:3857 would give metres inflated by about 65% at that latitude.

Can I connect points into a closed loop or polygon instead of an open line?

Yes. Append the first point to the end of the ordered list before building a LineString to close the ring, or pass the ordered points to shapely.geometry.Polygon directly when you want an area rather than a path.

How do I convert points to lines when each row holds a MultiPoint?

Explode the multipart geometries first with gdf = gdf.explode(index_parts=False), which gives one Point per row, then sort and group as usual. The LineString builder expects individual points, not MultiPoint objects.

Why is my LineString raising an error about needing at least two coordinates?

A group reduced to a single point, often after filtering nulls or dropping duplicates. Shapely raises IllegalArgumentException: point array must contain 0 or >1 elements. Skip groups where len(group) < 2 before calling LineString, and check is_valid afterwards: two points in the same place build a line, but an invalid one.