Split a stacked table into one group per ticker, run a calculation inside each group, and get the answers back.
df.groupby("Ticker") splits a table into one group per ticker, runs a calculation inside each group, and puts the answers back together. The calculation never reaches across from one ticker into the next.
In this lesson I read a stacked file of three tickers, take a few per-ticker summaries, then compute daily returns inside each ticker and show what happens at the row where one ticker ends and the next begins.
The prices are simulated, not real market data.
Step 1. Split, apply, combine
prices.csv holds 40 business days for three tickers, stacked on top of each other in one table.
import pandas as pddf = pd.read_csv("prices.csv", parse_dates=["Date"]) # Date read as timestamps, not textprint(df.shape) # -> (120, 4)print(df.head(3))# -> Date Ticker Close Volume# -> 0 2026-01-02 AAA 186.66 2183494# -> 1 2026-01-02 CCC 416.98 3117814# -> 2 2026-01-02 DDD 125.18 7447373
120 rows went in, three numbers came out. The tickers moved from a column into the index.
Here is the AAA answer worked by hand, the way you would do it without groupby.
aapl = df[df["Ticker"] =="AAA"] # the AAA group, filtered by handprint(round(aapl["Close"].mean(), 2)) # -> 183.69print(len(aapl)) # -> 40
183.69
40
groupby does that filter for every ticker in the column and stacks the results. Ask for two columns and you get a table back.
print(g[["Close", "Volume"]].mean().round(2)) # two columns at once comes back as a table# -> Close Volume# -> Ticker# -> AAA 183.69 5932120.05# -> CCC 437.15 5255052.88# -> DDD 112.36 6236481.92
Row 39 is AAA’s last day. Row 40 is CCC’s first day. Now compute returns twice: once on the whole column, once per ticker.
df["flat"] = df["Close"].pct_change() # one column, top to bottomdf["ret"] = df.groupby("Ticker")["Close"].pct_change() # restarts inside each tickerprint(df["flat"].isna().sum()) # -> 1print(df["ret"].isna().sum()) # -> 3
1
3
One NaN against three. ret starts fresh for every ticker, so each of the three loses its first day. flat only loses row 0.
The rows where the ticker changes show what flat put there instead.
edge = df["Ticker"] != df["Ticker"].shift(1) # True on each ticker's first rowprint(df.loc[edge, ["Date", "Ticker", "Close", "flat", "ret"]]) # first row of each ticker only# -> Date Ticker Close flat ret# -> 0 2026-01-02 AAA 186.66 NaN NaN# -> 40 2026-01-02 CCC 416.98 1.461221 NaN# -> 80 2026-01-02 DDD 125.18 -0.712850 NaN
Date Ticker Close flat ret
0 2026-01-02 AAA 186.66 NaN NaN
40 2026-01-02 CCC 416.98 1.461221 NaN
80 2026-01-02 DDD 125.18 -0.712850 NaN
flat reports a 146% gain on CCC’s first day and a 71% loss on DDD’s first day. Those are the prices of two different companies divided by each other:
print(416.98/169.42-1) # -> 1.461220635108016
1.461220635108016
AAA closed at 169.42 on its last row, CCC’s first row closed at 416.98, and pct_change divided one by the other. groupby puts NaN there because CCC has no day before its first day.
Side by side across the boundary:
print(df.loc[38:41, ["Date", "Ticker", "Close", "flat", "ret"]]) # the same boundary, both columns# -> Date Ticker Close flat ret# -> 38 2026-02-25 AAA 171.42 -0.007067 -0.007067# -> 39 2026-02-26 AAA 169.42 -0.011667 -0.011667# -> 40 2026-01-02 CCC 416.98 1.461221 NaN# -> 41 2026-01-05 CCC 423.19 0.014893 0.014893
Date Ticker Close flat ret
38 2026-02-25 AAA 171.42 -0.007067 -0.007067
39 2026-02-26 AAA 169.42 -0.011667 -0.011667
40 2026-01-02 CCC 416.98 1.461221 NaN
41 2026-01-05 CCC 423.19 0.014893 0.014893
Inside a ticker the two columns agree. They differ only on the row where the ticker changes.
Step 3. transform keeps every row
g["Close"].mean() gives one number per ticker: three rows out of 120. transform("mean") computes the same number and then copies it onto every row of that ticker, so it fits back into the table.
df["mean_close"] = df.groupby("Ticker")["Close"].transform("mean") # one mean per ticker, copied to all 40 rowsprint(df.loc[38:41, ["Date", "Ticker", "Close", "mean_close"]]) # the mean only moves when the ticker does# -> Date Ticker Close mean_close# -> 38 2026-02-25 AAA 171.42 183.68600# -> 39 2026-02-26 AAA 169.42 183.68600# -> 40 2026-01-02 CCC 416.98 437.15275# -> 41 2026-01-05 CCC 423.19 437.15275print(df.groupby("Ticker")["Close"].mean().shape) # -> (3,)print(df["mean_close"].shape) # -> (120,)
An average daily return is not the return over the whole stretch. Simple returns compound, they do not add.
NoteDoes the sort matter?
groupby finds the rows belonging to each ticker wherever they sit, so g["Close"].mean() gives the same answer on the unsorted file. pct_change is different: it takes each row against the row above it inside the group. If the dates are shuffled, the groups are still correct, but each return is then taken against whatever row happens to sit above it. Sort by ticker and date before anything that reads a neighbouring row.