Pandas version checks
Reproducible Example
import pandas as pd
pi = pd.period_range("2000Q1", periods=12, freq="Q-FEB")
ser = pd.Series(range(12), index=pi)
# Slicing a single label by string returns two periods
ser.loc["2001Q1":"2001Q1"]
# 2000Q4 3
# 2001Q1 4
# Freq: Q-FEB, dtype: int64
# ...even though get_loc resolves the same label correctly
pi.get_loc("2001Q1") # 4 (correct)
# Period bounds give the right answer; only the string path is wrong
ser.loc[pd.Period("2001Q1", freq="Q-FEB"):pd.Period("2001Q1", freq="Q-FEB")]
# 2001Q1 4
Issue Description
Partial-string slicing on a PeriodIndex whose frequency is anchored to a month that
does not line up with calendar quarters resolves the string bounds one period too early,
so the slice is shifted and includes an extra label at the start.
It is not specific to quarterly frequencies — anchored annual frequencies are affected
too:
for anchor in ["Q-DEC", "Q-JAN", "Q-FEB", "Q-MAR", "Q-APR", "Q-NOV"]:
ser = pd.Series(range(12), index=pd.period_range("2000Q1", periods=12, freq=anchor))
print(anchor, list(ser.loc["2001Q1":"2001Q1"].index))
# Q-DEC ['2001Q1'] # OK
# Q-JAN ['2000Q4', '2001Q1'] # wrong
# Q-FEB ['2000Q4', '2001Q1'] # wrong
# Q-MAR ['2001Q1'] # OK
# Q-APR ['2000Q4', '2001Q1'] # wrong
# Q-NOV ['2000Q4', '2001Q1'] # wrong
for anchor in ["Y-DEC", "Y-JUN", "Y-MAR"]:
ser = pd.Series(range(8), index=pd.period_range("2000", periods=8, freq=anchor))
print(anchor, list(ser.loc["2002":"2002"].index))
# Y-DEC ['2002'] # OK
# Y-JUN ['2002', '2003'] # wrong
# Y-MAR ['2002', '2003'] # wrong
Q-DEC and Q-MAR are correct because their quarters coincide with calendar quarters.
The cause is in PeriodIndex._parsed_string_to_bounds
(pandas/core/indexes/period.py). The string is first parsed with the index frequency,
which correctly applies the anchor, but the result is then re-bucketed into a calendar
period before being converted back, which discards the anchor:
def _parsed_string_to_bounds(self, reso: Resolution, parsed: datetime):
freq = OFFSET_TO_PERIOD_FREQSTR.get(reso.attr_abbrev, reso.attr_abbrev)
iv = Period(parsed, freq=freq) # <- freq is "Q"/"Y", anchor lost here
return (iv.asfreq(self.freq, how="start"), iv.asfreq(self.freq, how="end"))
Traced for "2001Q1" on a Q-FEB index:
parse_datetime_string_with_reso("2001Q1", "Q-FEB") -> 2000-03-01, reso="quarter"
Period(2000-03-01, freq="Q") -> 2000Q1 # calendar, anchor lost
.asfreq("Q-FEB", how="start"/"end") -> 2000Q4 .. 2001Q1
so the bounds span two labels instead of resolving to Period("2001Q1", freq="Q-FEB").
This also reaches DataFrame.truncate/Series.truncate, which delegate to .loc.
Expected Behavior
The string bound should resolve to the same period the equivalent Period object does, so
that ser.loc["2001Q1":"2001Q1"] returns exactly the 2001Q1 row for every anchor:
ser.loc["2001Q1":"2001Q1"]
# 2001Q1 4
# Freq: Q-FEB, dtype: int64
i.e. the bounds should be derived using the index's own frequency
(Period("2001Q1", freq=self.freq)) rather than by re-bucketing the parsed datetime into a
calendar period.
Installed Versions
Details
pandas : 3.1.0.dev0+1434.gcbae8aea4a3
python : 3.13.11
OS : Darwin 23.3.0
numpy : 2.4.4
Pandas version checks
Reproducible Example
Issue Description
Partial-string slicing on a
PeriodIndexwhose frequency is anchored to a month thatdoes not line up with calendar quarters resolves the string bounds one period too early,
so the slice is shifted and includes an extra label at the start.
It is not specific to quarterly frequencies — anchored annual frequencies are affected
too:
Q-DECandQ-MARare correct because their quarters coincide with calendar quarters.The cause is in
PeriodIndex._parsed_string_to_bounds(
pandas/core/indexes/period.py). The string is first parsed with the index frequency,which correctly applies the anchor, but the result is then re-bucketed into a calendar
period before being converted back, which discards the anchor:
Traced for
"2001Q1"on aQ-FEBindex:so the bounds span two labels instead of resolving to
Period("2001Q1", freq="Q-FEB").This also reaches
DataFrame.truncate/Series.truncate, which delegate to.loc.Expected Behavior
The string bound should resolve to the same period the equivalent
Periodobject does, sothat
ser.loc["2001Q1":"2001Q1"]returns exactly the2001Q1row for every anchor:i.e. the bounds should be derived using the index's own frequency
(
Period("2001Q1", freq=self.freq)) rather than by re-bucketing the parsed datetime into acalendar period.
Installed Versions
Details