Bike-Share Trips With Negative Durations: The Daylight-Saving Night

On this page

Problem statement

You subtract started_at from ended_at in a bike-share trip file and a few trips come out negative: a ride that ended 54 minutes before it began. They are easy to drop, and with 35 of them in a year of Capital Bikeshare trips nobody would notice. But they are the visible part of a larger problem. The timestamps are local clock times with no time zone, and on the two nights a year when the clocks change, local time is not a measure of elapsed time. Some durations that night come out an hour too short, some an hour too long, and most of them look perfectly normal.

This page finds every trip in Washington, D.C. that the 2025 clock changes touched, repairs the ones that can be repaired, and marks the ones that cannot.

Quick answer

Attach the time zone with tz_localize before subtracting. In the repeated hour of the autumn night, tell pandas which reading to take: the one that adds no hour, except for a trip that went backwards, which must have started before the change and ended after it:

TZ = "America/New_York"
raw = (t.ended_at - t.started_at).dt.total_seconds() / 60
neg = (raw < 0).values
in_hour = ((t.started_at >= "2025-11-02 01:00") & (t.started_at < "2025-11-02 02:00")).values
# In the repeated hour, read each time so that no hour is added, unless the trip went backwards.
start = t.started_at.dt.tz_localize(TZ, ambiguous=neg, nonexistent="raise")             # True = daylight time
end = t.ended_at.dt.tz_localize(TZ, ambiguous=~in_hour, nonexistent="raise")
minutes = (end - start).dt.total_seconds() / 60
print(f"negative durations: {(raw < 0).sum()} as published, {(minutes < 0).sum()} after localizing")
print(f"durations changed by localizing: {(minutes != raw).sum()} of {len(t):,} trips in March and November")
negative durations: 35 as published, 0 after localizing
durations changed by localizing: 102 of 1,073,051 trips in March and November

Localizing removes all 35 negative durations and changes 102 durations in the two months. Every other trip keeps the duration it had. nonexistent="raise" would have stopped the script if any timestamp fell in the hour that does not exist in March; none did, which is itself a check that the published times are real local clock readings.

Scatter of published trip durations against published start time from midnight to 3 a.m. on 2 November 2025. Most trips sit between 0 and 30 minutes; 35 trips starting just before 02:00 are negative, down to about minus 56 minutes, and lines connect each to its localized duration between 3 and 33 minutes. 636 trips touching the repeated hour are marked as possibly an hour longer. Scatter of published trip durations against published start time from midnight to 3 a.m. on 2 November 2025. Most trips sit between 0 and 30 minutes; 35 trips starting just before 02:00 are negative, down to about minus 56 minutes, and lines connect each to its localized duration between 3 and 33 minutes. 636 trips touching the repeated hour are marked as possibly an hour longer.
The night daylight saving time ended. Red points are what the file says; teal circles are the same trips after localizing; amber points cannot be resolved from the file.

The night the clocks went back

1. Find where the negative trips are

print(t.loc[raw < 0, "started_at"].dt.strftime("%Y-%m-%d %H:00").value_counts().to_string())
print(t.loc[raw < 0, "rideable_type"].value_counts().to_dict())
started_at
2025-11-02 01:00    35
{'electric_bike': 19, 'classic_bike': 16}

Every negative trip starts between 01:00 and 02:00 on Sunday 2 November 2025, the night daylight saving time ended, and both bike types are affected. That is the signature of local time: the problem follows the calendar, not the equipment.

2. See the repeated hour

At 02:00 daylight time the clocks went back to 01:00 standard time, so every reading between 01:00 and 02:00 happened twice, an hour apart:

hour = pd.Series(pd.date_range("2025-11-02 00:30", "2025-11-02 02:30", freq="30min", tz=TZ))
print(pd.DataFrame({"local": hour.dt.strftime("%H:%M %Z"), "UTC": hour.dt.tz_convert("UTC").dt.strftime("%H:%M")}).to_string(index=False))
    local   UTC
00:30 EDT 04:30
01:00 EDT 05:00
01:30 EDT 05:30
01:00 EST 06:00
01:30 EST 06:30
02:00 EST 07:00
02:30 EST 07:30

A trip that starts at 01:55 the first time round and ends at 01:01 the second time is recorded as ending 54 minutes before it started; it actually lasted six. A file that stored UTC, or local time with its offset, could not produce it.

3. Repair the trips that went backwards

rep = pd.DataFrame({"published": raw[neg], "repaired": minutes[neg]})
print(rep.describe().round(1).loc[["count", "min", "50%", "max"]].to_string())
       published  repaired
count       35.0      35.0
min        -56.5       3.5
50%        -46.5      13.5
max        -27.1      32.9

Adding the hour back turns published durations of βˆ’56.5 to βˆ’27.1 minutes into trips of 3.5 to 32.9 minutes, with a median of 13.5 β€” ordinary bike-share trips.

4. Count the trips that cannot be repaired

win = lambda s: (s >= "2025-11-02 01:00") & (s < "2025-11-02 02:00")
both = win(t.started_at) & win(t.ended_at) & (raw >= 0)
into = ~win(t.started_at) & win(t.ended_at)
out = win(t.started_at) & ~win(t.ended_at) & (t.ended_at >= "2025-11-02 02:00")
across = (t.started_at < "2025-11-02 01:00") & (t.ended_at >= "2025-11-02 02:00")
ambiguous = both | into | out
print(f"start and end inside the repeated hour: {both.sum()}   end inside it: {into.sum()}   start inside it: {out.sum()}")
print(f"ambiguous by an hour: {ambiguous.sum()}   spanning the whole hour, so 60 min longer for certain: {across.sum()}")
start and end inside the repeated hour: 506   end inside it: 89   start inside it: 41
ambiguous by an hour: 636   spanning the whole hour, so 60 min longer for certain: 26

The negative trips are the only ones that announce themselves. 636 others started or ended inside the repeated hour and have two possible durations an hour apart β€” 506 entirely inside it, 89 ending in it, 41 starting in it. The file gives no way to choose between the two: a 10-minute trip recorded from 01:20 to 01:30 might have lasted 70 minutes. The quick answer takes the shorter reading for all of them, which is right for most short trips and wrong for some. 26 more trips spanned the whole hour, from before 01:00 to after 02:00; for them localizing adds exactly 60 minutes, and that correction is certain.

Two parallel timelines, UTC and the Washington clock, from 04:00 to 07:30 UTC. The clock runs 00:00 to 01:59 in daylight time and then repeats 01:00 to 01:59 in standard time. A real trip from 01:57:45 EDT to 01:03:43 EST is a 6-minute ride that the file records as minus 54 minutes. Two parallel timelines, UTC and the Washington clock, from 04:00 to 07:30 UTC. The clock runs 00:00 to 01:59 in daylight time and then repeats 01:00 to 01:59 in standard time. A real trip from 01:57:45 EDT to 01:03:43 EST is a 6-minute ride that the file records as minus 54 minutes.
One of the 35 negative trips on both clocks. The subtraction is done on the bottom line; the ride happened on the top one.

5. The spring night overstates instead

In March the error runs the other way. At 02:00 standard time on 9 March the clocks jumped to 03:00, so a trip across that moment is recorded an hour longer than it lasted:

spring = (t.started_at < "2025-03-09 02:00") & (t.ended_at >= "2025-03-09 03:00")
print(f"trips across the missing hour on 9 March: {spring.sum()}")
print(pd.DataFrame({"published": raw[spring], "true": minutes[spring]}).round(1).head(5).to_string(index=False))
trips across the missing hour on 9 March: 41
 published   true
      62.7    2.7
     112.6   52.6
    1455.7 1395.7
      77.1   17.1
    1559.9 1499.9

41 trips crossed the missing hour. A trip recorded from before 02:00 to after 03:00 as 62.7 minutes lasted 2.7. The last row is an unreturned bike: published at 1,559.9 minutes, it was closed after exactly 1,499.9 β€” the system's 25-hour cap is measured in elapsed time, and the published duration was an hour wrong.

Checking and flagging the repair

The data used on this page

Capital Bikeshare system data, the March and November 2025 trip files β€” the months in which daylight saving time began (9 March) and ended (2 November) in Washington, D.C. No rows are removed: the point is to see the published values.

from pathlib import Path
import zipfile

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
frames = []
for m in ("202503", "202511"):
    f = Path(f"data/{m}-capitalbikeshare-tripdata.zip")
    if not f.exists():
        f.write_bytes(requests.get(f"https://s3.amazonaws.com/capitalbikeshare-data/{f.name}", timeout=600).content)
    with zipfile.ZipFile(f) as z:
        frames.append(pd.read_csv(z.open(f"{m}-capitalbikeshare-tripdata.csv"), parse_dates=["started_at", "ended_at"],
                                  dtype={"start_station_id": "Int64", "end_station_id": "Int64"}))
t = pd.concat(frames, ignore_index=True)
print(f"{len(t):,} trips in March and November 2025")
1,073,051 trips in March and November 2025

Example 1: are the repaired trips plausible?

def km(a, b, c, d):
    a, b, c, d = map(np.radians, (a, b, c, d))
    return 2 * 6371.0088 * np.arcsin(np.sqrt(np.sin((c - a) / 2) ** 2 + np.cos(a) * np.cos(c) * np.sin((d - b) / 2) ** 2))


t["km"] = km(t.start_lat, t.start_lng, t.end_lat, t.end_lng)
t["kmh_fixed"] = t.km / (minutes / 60)
night = t.started_at.dt.hour.isin([0, 1, 2]) & (minutes >= 1) & (t.km > 0.2)
print(f"repaired trips, straight-line km/h: median {t.kmh_fixed[neg].median():.1f}, range "
      f"{t.kmh_fixed[neg].min():.1f}-{t.kmh_fixed[neg].max():.1f}")
print(f"all other trips starting midnight to 3 a.m.: median {t.kmh_fixed[night & ~neg].median():.1f}")
repaired trips, straight-line km/h: median 9.9, range 3.5-23.7
all other trips starting midnight to 3 a.m.: median 12.7

A repair is only believable if the repaired trips look like trips. Their straight-line speeds after the fix have a median of 9.9 km/h and range from 3.5 to 23.7 km/h β€” slower than the 12.7 km/h median of other trips at that hour, but within what a bike does.

Example 2: keep the repair honest

t["minutes"] = minutes
t["duration_flag"] = np.select([neg, ambiguous.values, (across | spring).values],
                               ["repaired: ended after the clocks went back", "could be 60 min longer",
                                "an hour corrected by localizing"], "")
print(t.duration_flag.value_counts().to_string())
duration_flag
                                              1072313
could be 60 min longer                            636
an hour corrected by localizing                    67
repaired: ended after the clocks went back         35

A flag column keeps the repair visible downstream. 67 trips were corrected by an hour with certainty, 35 repaired from negative, and 636 carry a flag saying their duration may be an hour longer than shown. Anyone computing durations for that night can drop or weight the flagged rows; anyone computing anything else can ignore them.

Dumbbell chart of the 41 trips that crossed 02:00 on 9 March 2025, each with its published duration and its true duration exactly 60 minutes shorter, on a log scale. Short trips published at just over an hour lasted a few minutes; the longest, published near 1,560 minutes, lasted 1,500. Dumbbell chart of the 41 trips that crossed 02:00 on 9 March 2025, each with its published duration and its true duration exactly 60 minutes shorter, on a log scale. Short trips published at just over an hour lasted a few minutes; the longest, published near 1,560 minutes, lasted 1,500.
The spring night: every trip across the missing hour is published an hour too long.

Local time is a label, not a measurement

A clock reading such as 2025-11-02 01:30 names a moment only together with a rule for which offset from UTC applied β€” and on the night the clocks go back, two moments share that name. A trip file that stores naive local times has thrown the offset away, so a subtraction that is exact on every other day of the year is wrong on two nights by whole hours.

tz_localize puts the rule back. Away from the change it is unambiguous and changes nothing. In the missing spring hour it can only raise or shift, because no clock read 02:30 that night; in the repeated autumn hour it needs to be told which of the two moments was meant, for every timestamp. That decision is the whole repair. For a trip whose naive duration is negative there is only one consistent answer: it started in the first pass of the hour and ended in the second. For a trip entirely inside the hour, both answers are consistent, and no amount of code can choose β€” only another clock, such as a GPS log, could.

The size of the problem is small: fewer than a thousand trips flagged or changed out of 1,073,051 in the two months. What makes it worth handling is that the errors are whole hours in a dataset whose median trip is nine minutes, and that the one group that can be detected without the time zone, the 35 negative trips, is a small part of the trips affected.

What else the clock change touches

  • Hourly counts on those nights. The autumn night has a 01:00 hour twice as long as any other; the spring night has no 02:00 hour.
  • Long rentals that span the change are an hour off too, including the 25-hour unreturned bikes.
  • Other cities in the same format store local times the same way; use each city's own time zone.
  • ambiguous="infer" does not help here. It infers from the order of a sorted series of timestamps, and trips are independent events, not one clock's readings.
  • Store UTC after localizing (tz_convert("UTC")) if the data will be written back out.

FAQ

Why do bike-share trips have negative durations?

Because the start and end times are naive local time. On the night daylight saving time ends, a trip that starts in the first 01:xx hour and ends in the second appears to end before it began. In Capital Bikeshare's 2025 data all 35 negative trips started between 01:00 and 02:00 on 2 November.

Should I drop the trips from the daylight-saving night?

Drop or flag the ambiguous ones if durations matter; repair the negative ones by localizing, since their true duration is certain. Counting trips is unaffected either way.