A panda, reshaping swords into ploughshares.MidJourney 5
Friday’s exercise opened with a block of code containing the pd.concat() command, which we asked you to copy rather than write… along with a promise that you would get to write it yourself on Tuesday…
🎉 Today is Tuesday! 🎉
The copied block did two things. It stacked three files into one table, and it pulled an hour out of a timestamp. We will learn more about stacking this morning and we will come back to the 🦹♂️ timestamps 🦹♂️ after lunch.
Stacking three files gives you a long table, which is the layout that is easiest to build and the hardest one to compare across: 4,942 rows, each holding a single number, with the labels that explain that number spread across four other columns. Every comparison you made on Friday needed a .groupby() to reach it. That is what is known as a long table.
The same data can also be arranged wide, with one row per station and one column per pollutant. In that arrangement, most of Friday’s comparisons simplify into reading two numbers that are already sitting next to each other.
By the end of this session you will be able to:
write the stacking pattern, pd.concat([a, b, c]), and say what ignore_index=True fixes
describe the difference between a long table and a wide one, and say what each is good at
write the pivot pattern, pivot_table(index=, columns=, values=), and read its result
turn a .value_counts() result back into a table you can merge with, using .reset_index()
say which shape you want before you start writing code
Getting Started
Create the file. In the Explorer, hover over the EDS217 heading and click New File…, then type the name in full, extension included: Session_6B_Reshaping_Data.ipynb
Check the kernel. The Kernel Selector in the notebook’s action bar should read Python 3.11.15 (Conda: eds217). If it reads anything else, click it, choose Change Kernel, and pick the eds217 entry.
Add a title cell. Click + Markdown in the action bar, and give it this content, with today’s date:
# Day 6: Session 6B - Long and Wide[Session Webpage](https://eds-217-essential-python.github.io/course-materials/interactive-sessions/6b_reshaping_data.html)Date: 09/08/2026
Save with Ctrl + S (Cmd + S on macOS), and keep saving as you go.
Read the three station files. Nothing here is new:
Earlier this morning we joined two tables side by side. The tables had different columns, they shared a key, and the result was wider than either input.
Stacking goes the other direction. The three station files have the same fifteen columns and different rows, so there is no key to line them up on. We want to put one file underneath the other and end up with a taller table.
pd.merge() cannot stack tables. pd.concat() can, and it takes a list of DataFrames:
Code
aq = pd.concat([goleta, santa_barbara, cnsi])aq.shape
(4942, 15)
4,942 rows, which is 1,962 plus 2,266 plus 714. Nothing was matched and nothing was dropped, because stacking simply puts the three tables end to end in the order you listed them.
Label your pieces before you stack them
Look at the table you just built and try to answer a simple question: which of those 4,942 rows came from the CNSI monitor?
There is no way to tell. None of the three files has a column naming its station, because each file only ever described one station. Once the three files are stacked, the only record of where a row came from is the order of the rows, and the first sort or filter you run destroys even that.
So, just as we make sure to label columns before we merge dataframes, we must also add dataframe labels first and stack them second…because we can only be certain which station a row belongs to while its rows are still sitting in their own file.
The above code uses our derived-column patterns from Thursday, and the stacked table now records where every row came from. The four lines above are the block you copied on Friday, written out in full.
What ignore_index=True fixes
Stack the tables without it and look at the row labels at the join:
Each input table has its own index, numbered from zero, and stacking keeps all three of those indexes! The stacked table therefore has three rows “numbered” (it’s actually a label!) 0, three numbered 1, and so on, so .loc[0] no longer identifies a single row. ignore_index=True discards the old numbering and renumbers the result from 0 to 4,941.
Use it every time, unless you have a specific reason not to.
✏️ Test your knowledge
Two things, both of them the stacking pattern written from scratch:
Stack the three files again with ignore_index=True, in a different order: CNSI first, then Goleta, then Santa Barbara. Confirm the shape is identical. Then use .head() to show that the row order changed and .value_counts() on site to show that the contents did not.
Stack only two of them, Goleta and CNSI, into a table called two_sites. Predict the row count out loud before you run it, then check.
One number per row, plus four columns of labels explaining what the number is. As mentioned above, this layout has a name: it is long. Almost every instrument, database and API gives you data in this layout, because a long table can be appended to forever without ever changing its columns.
A long table is also awkward to compare across, because the numbers you want to put side by side are all stacked in the same column. To compare the mean PM2.5 across the three stations on Friday, you had to filter to one parameter and then group. To compare ozone and PM2.5 at Goleta earlier this morning, you had to split the table in two and join it back together.
The other layout is wide: one row per thing, one column per measurement, and the labels promoted out of the cells and up into the column headers. In a wide table you make a comparison by reading two numbers that are already sitting next to each other.
The pivot pattern
pivot_table() rotates a DataFrame from long to wide. It takes three arguments, and each one is the name of a column in your long table:
Code
means = aq.pivot_table(index='site', columns='parameter', values='value')means
parameter
o3
pm10
pm25
site
CNSI
NaN
NaN
6.083473
Goleta
0.022470
14.972921
6.480926
Santa Barbara
0.019822
17.563969
6.172324
df.pivot_table(index='rows', columns='columns', values='numbers')# ↑ ↑ ↑# what labels what labels what goes# the rows the columns in the cells
index= is the column whose values become the row labels.
columns= is the column whose values become the column headers.
values= is the column whose numbers fill the cells.
We have three stations, each with three parameters. This creates nine cells… and two of them empty!?
Much of Friday’s Part 1 and Part 2 is in that one small table, including a result that took several questions to reach on Friday: CNSI measures PM2.5 and nothing else.
What happens when a cell has more than one number
There are 714 CNSI PM2.5 readings and exactly one cell to put them in, so pivot_table has to reduce them to a single number. By default it takes the mean, which is why the cells above are averages rather than counts or sums.
The counts table has the same layout as the means table, and it is worth building first whenever you pivot, because it tells you how many numbers went into each cell. Two cells are empty in the means table and the same two are empty here: CNSI reports no ozone and no PM10 at all, so the gaps are real and not an accident of averaging bad data or some other issue.
Note
🐍 pivot_table with aggfunc= is the split-apply-combine pattern in a different arrangement!
aq.groupby(['site', 'parameter'])['value'].mean() computes exactly the same nine numbers; it just returns them stacked in a single column instead of laid out in a grid. Same calculation, different shape!! (another example of wide versus long, perhaps??)
Reading a wide table
The result of pivot_table is an ordinary DataFrame whose index is the index= column, so everything you know still works:
Code
means['pm25']
site
CNSI 6.083473
Goleta 6.480926
Santa Barbara 6.172324
Name: pm25, dtype: float64
Code
means.loc['Goleta', 'pm25']
np.float64(6.4809264305177114)
Watch out for one change, though: site is now the index rather than a column, so means['site'] raises a KeyError (site is not a column!).
When you want the station names back as a regular column, use .reset_index(), which you met on Friday:
Code
flat = means.reset_index()flat
parameter
site
o3
pm10
pm25
0
CNSI
NaN
NaN
6.083473
1
Goleta
0.022470
14.972921
6.480926
2
Santa Barbara
0.019822
17.563969
6.172324
Now site is a column again, and the flattened table can be merged, filtered and sorted like any other DataFrame.
✏️ Test your knowledge
Build a wide table with parameter down the rows and site across the columns, which is the transpose of the one above, by swapping the index= and columns= arguments. Then decide which of the two layouts you would put in a report for the Air Pollution Control District, and write a sentence saying why.
From a Series back to a table
One more small move, and it brings this morning’s two sessions together.
.value_counts() has been your counting tool since Day 2, and it returns a Series:
Code
aq['site'].value_counts()
site
Santa Barbara 2266
Goleta 1962
CNSI 714
Name: count, dtype: int64
A Series is easy enough to read, but you cannot merge one, because pd.merge() needs two DataFrames with columns to join on and a Series has an index where the column would be. So we promote it into a DataFrame:
One table, three rows, and the means and the counts for all three monitors side by side. On Friday, assembling the same summary took a whole series of separate .groupby() calls.
✏️ Test your knowledge
Build a count table of readings per parameter instead of per site, renaming the count column to n_readings. Then sort it so the best-measured parameter is first.
The shape that answers Friday’s best question… best?
On Friday you found the daily ozone cycle by grouping every ozone reading by hour, with all of the stations pooled together. Splitting that result out station by station was harder, because a grouped result comes back as one column of numbers and a station-by-station comparison needs one column per station.
A pivot does it in one line. The hour column comes from the second line we supplied on Friday, which cuts characters 11 and 12 out of each timestamp. We come back to it properly after lunch:
Twenty-four rows and three columns, laid out so we can ask a question that was awkward to ask on Friday: do the three stations rise and fall together?
Read down the columns. Goleta runs from 3.5 at five in the morning up to 9.1 at one in the morning, a range of 5.6. Santa Barbara does something similar over a narrower range, 4.0 to 7.7. CNSI runs from 5.5 to 7.6, and the only two hours above 7 are noon and one in the afternoon: for the other twenty-two hours it sits between 5.5 and 6.7 and barely moves. Three monitors, fifteen kilometres apart, and the CNSI monitor records a much flatter day than the other two do.
Nothing we just did was something that .groupby() could not have calculated. However, a grouped result would have come back as one long column, and you would have had to hold three separate results in your head to put them alongside each other. The pivot lays the same numbers out in a grid instead, so the comparison is cleaner.
Key points
pd.concat([a, b, c]) stacks tables with the same columns, top to bottom. It matches nothing and drops nothing.
Label the pieces before you stack them. Once the rows are combined, there is no way to recover which file a row came from.
Use ignore_index=True so the stacked table gets one clean set of row numbers.
A long table has one measurement per row and its labels in columns. It is easy to append to and hard to compare across.
A wide table has one row per thing and one column per measurement. It is easy to compare and awkward to extend.
The pivot pattern is df.pivot_table(index=, columns=, values=). It goes long to wide.
pivot_table averages by default. Pass aggfunc='count' and look at that version first.
The result is indexed by the index= column. .reset_index() turns it back into a plain table.
.value_counts().reset_index() promotes a count Series into a two-column table you can merge with. Rename the count column while you are there.