Datetime Handling in Pandas
Datetime Handling in pandas
Core Principle: Temporal data in pandas is handled via
pd.to_datetime()for parsing, the.dtaccessor for timestamp component extraction, andDatetimeIndexfor temporal alignment and resampling.
Less central to bioinformatics than the other pandas notes, but it appears constantly in sample metadata, sequencing run tracking, clinical timelines and instrument QC.
Parsing
pd.to_datetime(df["date"])
pd.to_datetime(df["date"], format="%Y-%m-%d") # ← always specify; faster and safer
pd.to_datetime(df["date"], format="mixed") # heterogeneous formats
pd.to_datetime(df["date"], errors="coerce") # unparseable → NaT
pd.to_datetime(df["date"], dayfirst=True) # 01/02/2026 = 1 Feb
pd.to_datetime(df["epoch"], unit="s")
pd.read_csv(path, parse_dates=["collection_date"])
Always pass format=. Without it pandas infers from the first non-null value and applies that guess to everything, which silently mangles ambiguous dates. 01/02/2026 is 1 February in most of the world and 2 January in the US: and if your file mixes both conventions, inference will pick one and be wrong for half the rows with no warning.
errors="coerce" gives NaT (Not a Time) for failures. Count them:
parsed = pd.to_datetime(df["date"], format="%Y-%m-%d", errors="coerce")
print(f"{parsed.isna().sum()} unparseable dates")
pandas 3.0: microsecond default
Datetimes now default to microsecond resolution rather than nanosecond. The practical consequence is good: the old nanosecond backing gave a representable range of only 1677–2262, so historical dates overflowed. That limit is gone.
You can still request a specific unit:
pd.to_datetime(s).astype("datetime64[ns]")
pd.to_datetime(s).astype("datetime64[s]")
The .dt accessor
s.dt.year; s.dt.month; s.dt.day
s.dt.hour; s.dt.minute
s.dt.dayofweek # Monday = 0
s.dt.day_name(); s.dt.month_name()
s.dt.quarter
s.dt.dayofyear
s.dt.is_month_end
s.dt.date; s.dt.time
s.dt.to_period("M") # monthly period
s.dt.strftime("%Y-%m")
s.dt.tz_localize("UTC").dt.tz_convert("America/Los_Angeles")
s.dt.normalize() # midnight: strip the time component
Arithmetic and durations
df["days_since"] = (pd.Timestamp.today() - df["collection_date"]).dt.days
df["turnaround"] = (df["report_date"] - df["received_date"]).dt.days
df["date"] + pd.Timedelta(days=30)
df["date"] + pd.DateOffset(months=1) # calendar-aware, unlike Timedelta
pd.date_range("2026-01-01", periods=12, freq="MS") # month starts
pd.date_range("2026-01-01", "2026-12-31", freq="W")
Timedelta(days=30) is exactly 30 × 24 h. DateOffset(months=1) respects month lengths. For "one month later" you want the offset.
DatetimeIndex superpowers
df = df.set_index("date").sort_index()
df.loc["2026"] # whole year: partial string indexing
df.loc["2026-03"] # whole month
df.loc["2026-03-01":"2026-03-15"] # inclusive range
df.resample("D").mean() # downsample to daily
df.resample("W").agg({"reads": "sum", "q30": "mean"})
df.resample("ME").size() # month-end counts
df.asfreq("D") # reindex to a regular frequency, NaN for gaps
Partial-string indexing (df.loc["2026-03"]) only works on a DatetimeIndex, and it is the main reason to set one.
resample is groupby for time: same Split-Apply-Combine shape, with time bins as the groups.
Common frequency aliases
| Alias | Meaning |
|---|---|
D |
calendar day |
B |
business day |
W |
weekly (Sunday-ended) |
MS / ME |
month start / month end |
QS / QE |
quarter start / end |
YS / YE |
year start / end |
h, min, s |
hour, minute, second |
pandas 3.0 lowercased several of these (H → h, T → min); old uppercase forms warn or error.
Where this shows up in bioinformatics
# sequencing run QC over time: instrument drift
runs = pd.read_csv("runs.tsv", sep="\t", parse_dates=["run_date"])
monthly = runs.set_index("run_date").resample("ME").agg(
n_runs=("run_id", "size"),
mean_q30=("pct_q30", "mean"),
mean_yield=("total_gb", "mean"),
)
sns.lineplot(data=monthly.reset_index(), x="run_date", y="mean_q30")
# batch effects: is a batch confounded with collection date?
samples.groupby("batch")["collection_date"].agg(["min", "max", "size"])
# sample age at collection (careful: DOB is sensitive data)
samples["days_in_storage"] = (samples["extraction_date"] - samples["collection_date"]).dt.days
# clinical timeline
events["days_from_diagnosis"] = (events["event_date"] - events["diagnosis_date"]).dt.days
# turnaround time distribution
sns.histplot(data=cases, x="turnaround_days", bins=30)
The batch/date check is the useful one. If batch 1 was collected in January and batch 2 in June, any "batch effect" you find is confounded with season, storage time, protocol changes and staff turnover. Plotting collection date against batch is a two-line check that saves a retracted conclusion.
Time zones
s.dt.tz_localize("UTC") # naive → aware
s.dt.tz_convert("America/Los_Angeles") # aware → another zone
s.dt.tz_localize(None) # aware → naive
You cannot compare or subtract a tz-aware and a tz-naive datetime: pandas raises. Pick one convention (UTC internally is the usual advice) and apply it at load time.
Common mistakes
- No
format=, letting inference pick a convention. dayfirstambiguity in international data.- Not counting
NaTaftererrors="coerce". - Comparing tz-aware to tz-naive → TypeError.
Timedelta(days=30)for "a month". UseDateOffset.- Resampling without sorting the index first.
- Forgetting that partial-string indexing needs a DatetimeIndex.
- Storing dates of birth in an analysis table. It is identifiable information; store age at event instead.
See also
Series · Index Objects · groupby · Window Functions · pandas dtypes · Applied - Quality Control Plots
Exercise
Write storage_report(df) for a sample sheet with columns sample,
collected and extracted. Parse both date columns with the explicit format
%Y-%m-%d and errors="coerce", so that a date written in another convention
becomes NaT instead of being silently reinterpreted. Report the count of
rows where either date failed to parse, then drop those rows.
Return unparseable=<n> first. If no rows survive, return that line followed
by no complete rows. Otherwise append one line per calendar month of
collection, in ascending month order, formatted
<YYYY-MM> n=<rows> mean_days=<mean>, where the mean is the number of days
between collection and extraction averaged over that month's rows and written
to one decimal place.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | sample collected extracted S1 2026-01-05 2026-01-20 S2 2026-01-11 2026-02-01 S3 2026-02-28 2026-03-05 S4 2026-03-01 05/03/2026 S5 2026-02-02 2026-02-09 | unparseable=1 2026-01 n=2 mean_days=18.0 2026-02 n=2 mean_days=6.0 |
| 2 | stdin | sample collected extracted S1 2026-01-05 2026-01-06 | unparseable=0 2026-01 n=1 mean_days=1.0 |
| 3 | stdin | sample collected extracted S1 01/05/2026 2026-01-20 S2 2026-01-11 not_a_date | unparseable=2 no complete rows |
| 4 | stdin | sample collected extracted S1 2025-12-31 2026-01-10 S2 2025-12-01 2025-12-15 S3 2026-01-15 2026-01-15 | unparseable=0 2025-12 n=2 mean_days=12.0 2026-01 n=1 mean_days=0.0 |
Hints · 2
01Hint
pd.to_datetime(s, format="%Y-%m-%d", errors="coerce") gives NaT for anything that does not match the format.
02Hint
Subtracting two datetime Series gives a timedelta Series, whose .dt.days is the whole-day difference; .dt.strftime("%Y-%m") gives the month label.