pandas tail() — audit the last rows before the last row fools you

head shows the front door; tail checks the back room where appends, footers, and stale readings hide.

head(5) shows you the front door of a dataset. tail(5) is the walk through the back room — where appends land, where TOTAL footers live, and where yesterday's bad merge is still smoldering.

What it does

tail(n=5) returns the last n rows of a DataFrame or Series, positionally — labels play no role, so nothing can fail to match. Three behaviors in one parameter: positive n takes from the back, an oversized n returns the whole frame, and negative n drops the FIRST |n| rows — tail(-2) is "all but the first two", the mirror of head(-2). The docstring spells out its own use case: verifying data after sorting or appending rows. On pandas 3.0.3 the implementation is two lines: return self.iloc[-n:].copy().

Why it matters

Bad data hides at the bottom: TOTAL footers from supplier exports, appends that double-counted a row, merges that shifted the index into the weeds, timestamp streams where the newest reading is the one that's broken. tail is the cheap audit — after any append, merge, or sort_values, look at the end before you aggregate. The same method hides two tools people go hunting for elsewhere: a header-rescuer for mis-read files, and groupby.tail(n) — the n most recent rows of every group in original frame order, no apply() required.

Examples

import pandas as pd

orders = pd.DataFrame({
    'order_id': [1001, 1002, 1003, 1004, 1005, 1006, 1007],
    'city': ['Berlin', 'Vienna', 'Berlin', 'Zurich', 'Vienna', 'Berlin', 'Zurich'],
    'units': [12, 7, 30, 4, 9, 21, 6],
    'revenue': [810.0, 55.3, 384.0, 122.0, 96.5, 640.5, 77.25],
})
late = pd.DataFrame({
    'order_id': [1008, 1009],
    'city': ['Prague', 'Munich'],
    'units': [15, 5],
    'revenue': [512.0, 44.0],
})
combined = pd.concat([orders, late], ignore_index=True)
combined.tail(4)
order_id    city  units  revenue
5      1006  Berlin     21   640.50
6      1007  Zurich      6    77.25
7      1008  Prague     15   512.00
8      1009  Munich      5    44.00

Verified on pandas 3.0.3. Exactly the case the docstring names — verify after appending rows: the two late orders sit at the bottom, original index labels intact (tail slices positionally, it never renumbers). I confirmed combined.tail(4).equals(combined.iloc[-4:]) is True.

# A report where the header landed as data rows (read_csv with header=None gone wrong).
messy = pd.DataFrame({
    'city': ['Report: Sales', 'generated 2026-09-23', 'Berlin', 'Vienna', 'Zurich', 'Munich'],
    'units': [-999, -999, 12, 7, 4, 5],
})
messy.tail(-2).reset_index(drop=True)
     city  units
0  Berlin     12
1  Vienna      7
2  Zurich      4
3  Munich      5

Verified on 3.0.3: tail(-2) means all rows except the first two — GNU coreutils convention mirrored, no length math needed. Verified tail(-2).equals(df.iloc[2:]) is True. The reset_index(drop=True) is cosmetic: tail keeps original labels (2, 3, 4, 5 here), it doesn't renumber.

# The two most recent readings per sensor, in one line:
readings = pd.DataFrame({
    'sensor': ['S1','S1','S1','S1','S2','S2','S2','S2','S3','S3','S3','S3'],
    'ts': ['09:00','09:15','09:30','09:45','09:00','09:15','09:30','09:45','09:00','09:15','09:30','09:45'],
    'temp': [21.4, 21.9, 22.3, 23.1, 18.2, 18.0, 17.8, 17.5, 24.0, 24.6, 25.1, 26.2],
})
readings.sort_values(['sensor', 'ts']).groupby('sensor').tail(2)
sensor     ts  temp
2      S1  09:30  22.3
3      S1  09:45  23.1
6      S2  09:30  17.8
7      S2  09:45  17.5
10     S3  09:30  25.1
11     S3  09:45  26.2

groupby.tail, not df.tail — the docstring promises "a subset of rows with original index and order preserved", as_index is ignored. This is the latest-reading-per-sensor report without a single line of apply(). For the oldest, groupby('sensor').head(2); for the newest single value per group, groupby('sensor').last().

prices = pd.Series(
    {'berlin': 810.0, 'vienna': 55.3, 'zurich': 122.0,
     'prague': 96.5, 'munich': 640.5, 'linz': 77.25},
    name='avg_price',
)
prices.tail(3)
prague     96.50
munich    640.50
linz       77.25
Name: avg_price, dtype: float64

Same one parameter on Series, name attribute intact. Verified on 3.0.3: tail(999999) on a 9-row frame returns all 9 rows, tail(0) is empty — it never raises. On pandas 3.0.3 the whole method is return self.iloc[-n:].copy().

Flags

FlagMeaning
n (int, default 5)How many rows to take from the bottom, positionally — the label at row 10,000 can't break it, unlike a loc-based peek.
n negativetail(-2) returns all rows except the FIRST two — the coreutils convention, mirrored by head(-n) for the bottom.
n oversizedtail(999999) on a 9-row frame returns all 9 rows; tail(0) is empty. Never raises — safe anywhere after any load.
groupby.tail(n)Last n rows of each group, original index and order preserved — a positional mask, not apply(lambda g: g.tail(n)).
Series.tail(n)Identical signature on DataFrame, Series, and groupby — the Series name attribute survives the slice.
df.head(n)The sibling: first n rows, same negative convention (head(-1) drops the last row). Reach for it after sorts and loads.

In with the slice syntax (2011)

I checked the source trees: tail exists in pandas v0.4.0 (tag committed September 12, 2011) as a two-liner in pandas/core/frame.py — def tail(self, n=5): return self[-n:] — and the v0.4.0 tag is the oldest release tag on the repo, so I can't push the claim earlier. It shipped beside head (return self[:n]) as raw Python slice syntax with a docstring, not indexing machinery.

Still df[-n:], just spelled out

Fifteen years later the docstring still promises "the same behavior as df[-n:]". The implementation moved from bare slicing to self.iloc[-n:].copy(), and the method is @final on the shared NDFrame base. One real semantic change since: pandas 2.1 (August 2023, issue #54011) made head and tail return deep copies under the new Copy-on-Write regime — previews stopped being writable views.

Under the hood: a mask, not a loop

DataFrame.tail is @final on the shared NDFrame base and literally returns self.iloc[-n:].copy() — the copy is why edits to a peek never leak into the parent, and since 2.1 that copy is deep under CoW (issue #54011). The interesting variant is groupby.tail: instead of iterating groups and calling apply, it builds one positional mask via _make_mask_from_positional_indexer(slice(-n, None)) and masks the original frame — which is why original index and order survive and why it stays cheap on wide frames. Same name, two mechanisms, both O(n) slices under the hood.

Fun facts

Pros

Cons

Takeaways