Pull the same character positions out of every string in a column.
When is it safe to take characters three through five of every value in a column, sight unseen? Only when something outside your data frame guarantees that those positions mean the same thing in every row. That is a real category: codes with a published layout, where position carries meaning by design. A FIPS county code, an ISBN, an ISO timestamp, a license plate, an account number whose first two digits are the branch. For those, .str.slice() is not a shortcut; it is the correct reading of the format.
For anything a human typed, it is the wrong tool, and it fails quietly, on the rows you did not look at. This page gives that half as much space as the first.
Official documentation: Series.str.slice
The arguments that earn their keep
s.str.slice(start=None, # first position to keep; default the beginning
stop=None, # first position to drop; default the end
step=None) # take every nth character; -1 reverses
All three mean what [start:stop:step] means in Python, because that is what Pandas does with them. stop is exclusive, negative numbers count from the right, and an omitted argument runs to the end:
import pandas as pd
s = pd.Series(['ACW00011604', 'ACW00011647', 'AE000041196'])
s.str.slice(3).tolist() # ['00011604', '00011647', '00041196']
s.str.slice(-5).tolist() # ['11604', '11647', '41196']
s.str.slice(step=-1).tolist() # ['40611000WCA', '74611000WCA', '691140000EA']
The square-bracket form is exactly equivalent:
(s.str[0:2] == s.str.slice(0, 2)).all()
True
The brackets do double duty: an integer inside them is str.get, a slice is str.slice. Which would I write? The method, essentially always. Across the 455 Bamboo Weekly posts there are 43 .str.slice() call sites and not one .str[a:b]. In a chain a named method reads as a step where brackets read as indexing, and the method form says an omitted bound out loud — .str.slice(None, 40) — rather than hiding it in a bare colon inside a long assign.
Its rarely used sibling .str.slice_replace(start, stop, repl) substitutes a run of positions instead of extracting it, which masks a code without changing its shape:
s.str.slice_replace(3, 11, 'XXXXXXXX').tolist()
['ACWXXXXXXXX', 'ACWXXXXXXXX', 'AE0XXXXXXXX']
Pass the same value for start and stop and it inserts instead of replacing.
A worked example, on real data
NOAA's global weather-station inventory is a fixed-width file, which is to say a file where position is the entire point:
url = 'https://www.ncei.noaa.gov/pub/data/ghcn/daily/ghcnd-stations.txt'
df = pd.read_fwf(url, colspecs=[(0, 11), (38, 40), (41, 71)],
names=['station', 'state', 'name'])
df.head(4)
station state name
0 ACW00011604 NaN ST JOHNS COOLIDGE FLD
1 ACW00011647 NaN ST JOHNS
2 AE000041196 NaN SHARJAH INTER. AIRP
3 AEM00041194 NaN DUBAI INTL
The station ID has a layout, and NOAA publishes it: the first two characters are the FIPS country code, the third is a network code, the remaining eight are the station's own number. All 132,501 IDs are exactly eleven characters. That is the guarantee .str.slice() needs, so slicing the country out is just reading the format:
countries = pd.read_fwf(
'https://www.ncei.noaa.gov/pub/data/ghcn/daily/ghcnd-countries.txt',
colspecs=[(0, 2), (3, 70)], names=['code', 'country'])
(
df['station'].str.slice(0, 2)
.value_counts()
.head(5)
.rename(countries.set_index('code')['country'].to_dict())
)
station
United States 78567
Australia 17088
Canada 9362
Brazil 5989
Mexico 5249
Name: count, dtype: int64
Because the layout is real, you can check it. Every one of the 132,501 sliced codes appears in NOAA's country table:
df['station'].str.slice(0, 2).isin(countries['code']).all()
True
That check is the difference between slicing and guessing. The third character behaves the same way, naming the numbering system a station came from:
(
df
.loc[lambda df_: df_['station'].str.slice(0, 2) == 'US', 'station']
.str.slice(2, 3)
.value_counts()
.head(4)
)
station
1 51468
C 22818
W 1908
R 1509
Name: count, dtype: int64
Network 1 is CoCoRaHS, the volunteer rain-gauge network, which supplies most of the American stations.
Where it is the wrong tool
Now the same technique on the name column, which people typed. Those CoCoRaHS names follow an obvious pattern — town, distance, compass direction — so pulling the town out with a slice looks reasonable:
coop = df.loc[lambda df_: df_['station'].str.slice(0, 3) == 'US1']
(
coop
.assign(town=lambda df_: df_['name'].str.slice(0, 8).str.strip())
[['name', 'town']]
.head(8)
)
name town
53119 RMHS 1.6 SSW RMHS 1.6
53120 JUNIATA 1.5 S JUNIATA
53121 JUNIATA 6.0 SSW JUNIATA
53122 HOLSTEIN 0.1 NW HOLSTEIN
53123 AYR 3.5 NE AYR 3.5
53124 ROSELAND 2.8 SW ROSELAND
53125 HASTINGS 5.4 WSW HASTINGS
53126 GLENVIL 2.3 WSW GLENVIL
Six of eight, and 17 of the first 20 rows, come out right. Ship it and see what happens:
(coop['name'].str.slice(0, 8).str.strip() ==
coop['name'].str.split(r' \d+\.\d+ ', regex=True).str.get(0)).sum()
14082
14,082 correct out of 51,468. The slice is wrong on 37,386 rows, and 25,228 of those failures contain no digit to give them away: BLUE HILL became BLUE HIL, HARRISBURG became HARRISBU, SCOTTSBLUFF became SCOTTSBL. Group by the new column and you get 14,456 towns where there are 11,370. Nothing raised, nothing warned, and the chart looks fine.
The honest tools for human text are str.split when there is a delimiter, str.extract when there is a pattern, and str.strip with its removeprefix and removesuffix neighbors when there is a known piece to take off an end. All three describe the string's structure. A slice describes only the rows you happened to look at.
Four mistakes people make
Expecting a short string to announce itself. Ask for positions past the end and Pandas hands back whatever is there, which is often nothing at all:
df['station'].str.slice(11, 13).head(3)
0
1
2
Name: station, dtype: str
Three empty strings, no error. .str.get() is the opposite: an index past the end returns NaN, which shows up in isna() counts. Empty strings do not, and neither do half-length ones. A truncated value travels much further than a missing one before anybody notices.
Assuming a layout that is not as fixed as you think. Usually a leading zero was destroyed on import. The same NOAA file carries a five-digit WMO ID, documented as a character field, whose first two digits are the WMO block number:
raw = pd.read_fwf(url, colspecs=[(0, 11), (80, 85)], names=['station', 'wmo'])
(
raw
.loc[raw['station'].str.slice(0, 2) == 'BE']
.assign(block=lambda df_: df_['wmo'].astype('str').str.slice(0, 2))
)
station wmo block
17575 BE000006447 6447.0 64
Belgium is in block 06. Read the column as a number and it becomes 6447.0, so the slice returns 64 — itself a real block number, in central Africa. 339 of the file's 7,966 WMO IDs start with a zero. Read code columns as text with dtype='str' in read_csv or read_fwf and the problem never arises. The other half of this mistake is a code with two lengths in the wild: OECD time labels arrive as both 2013 and 2013-Q2, so .str.slice(-2) gives 13 for one and Q2 for the other.
Treating stop as inclusive. Documentation for fixed-layout files numbers columns from 1 and includes both ends — NOAA's readme says the ID is "columns 1-11", the state "39-40". Python means neither, so those become (0, 11) and (38, 40). Transcribe them as written and every field shifts by one:
df['station'].str.slice(1, 11).head(3)
0 CW00011604
1 CW00011647
2 E000041196
Name: station, dtype: str
Slicing a date out of a timestamp instead of parsing it. This is the most common misuse of the method, by a wide margin. Slicing an ISO date does give you the year, right up until one value is formatted differently:
ts = pd.Series(['2024-03-05T14:00:00Z', '2024-03-05 14:00', '2024-3-5T14:00:00Z'])
ts.str.slice(0, 7).tolist()
['2024-03', '2024-03', '2024-3-']
pd.to_datetime(ts, format='mixed', utc=True).dt.month.tolist()
[3, 3, 3]
to_datetime reads all three and hands back something you can sort, subtract and resample, rather than a string you will run through astype anyway.
Where it shows up in Bamboo Weekly
10 of the 455 Bamboo Weekly posts use .str.slice(), across 43 call sites, and every one of them is a coded field. Four worth reading, all free:
Bamboo Weekly #7: Bank failures is the plainest case in the archive. The FDIC's CITYST column ends in a two-letter state code, so df['CITYST'].str.slice(-2, None).value_counts() counts failures by state — Texas 910, California 263, Illinois 227. It also explains the None: s[-2:] really is a call to Python's slice builtin with (-2, None).
Bamboo Weekly #54: Household debt takes a two-digit year off the front of the New York Fed's quarterly labels with .str.slice(None, 2) and groups credit-card balances by it — the same shape as the OECD labels in #44: Global economics, which has ten call sites and is the densest .str.slice() post on the site.
Bamboo Weekly #78: Stock markets shows a layout fixed at only one end. Trading volumes arrive as 1.23M, so .str.slice(0, -1).astype(float) takes the number while .str.get(-1).map(factors) turns the suffix letter into a multiplier, the two halves recombined with map in one assign.
Bamboo Weekly #79: Cyber attacks is the one place I do slice human text, and worth seeing for why it is safe there: .str.slice(None, 40) truncates long actor names purely so a value_counts table fits on screen. Nothing downstream depends on the result. Truncating for display is fine; truncating to make a key is what the section above is about.
Practice it
Work through a .str.slice() exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/str-slice/
Go deeper
.str.slice() sits at one end of a spectrum of ways to take a string apart: str.split works from delimiters, str.replace and str.contains from patterns, and str.strip covers the small cleanup methods that run either side of a slice. A slice heading toward a number ends at astype; one heading toward a count ends at value_counts.
More Pandas videos on Python and Pandas with Reuven Lerner.
Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.
Related methods
.str.split()— when the field is separated by a delimiter rather than sitting at fixed positions.str.get()— when you want one element or character and NaN for the rows that are too short
See it on real data
Below are the 10 Bamboo Weekly exercises that use str.slice on real-world data — try each one, then study the worked solution.
- Bamboo Weekly #181: Housing costs
- Bamboo Weekly #108: Measles
- Bamboo Weekly #79: Cyber attacks
- Bamboo Weekly #78: Stock markets
- Bamboo Weekly #54: Household debt
- Bamboo Weekly #44: Global economics
- Bamboo Weekly #43: Financial protection
- Bamboo Weekly #31: Poverty
- Bamboo Weekly #10: Oil prices
- Bamboo Weekly #7: Bank failures
Part of the Pandas Methods Index. See also practice by skill.