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
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
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_idhere) and one that says where on the line it falls (time) - the order field has a real type —
timehas 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:
sequencetimestampstationpoint_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.
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
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.
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.
Internal links
For a broader overview, see Working with vector geometries in GeoPandas.
Related tasks:
- How to Read a Shapefile in Python with GeoPandas
- How to Reproject Spatial Data in Python (GeoPandas)
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.