How do I stack two tables?

Glue DataFrames together with pd.concat, top to bottom by default and side by side with axis=1.

pd.concat takes a list of DataFrames and returns one DataFrame. By default it stacks them vertically: the rows of the second frame go under the rows of the first.

In this lesson I stack two small frames, fix the index that comes out of it, stack two months of prices into one table, and then put two frames side by side with axis=1.

Step 1. Two frames, one on top of the other

Both frames have the same two columns, so the result has those two columns and all four rows.

import pandas as pd

a = pd.DataFrame({"Ticker": ["AAA", "CCC"], "Close": [186.66, 416.98]})   # the first frame
b = pd.DataFrame({"Ticker": ["DDD", "FFF"], "Close": [125.18, 248.40]})   # the second, same columns

both = pd.concat([a, b])        # a list of frames goes in, one frame comes out

print(both)
# ->   Ticker   Close
# -> 0    AAA  186.66
# -> 1    CCC  416.98
# -> 0    DDD  125.18
# -> 1    FFF  248.40

print(both.shape)               # -> (4, 2)
print(both.index.tolist())      # -> [0, 1, 0, 1]   <- each frame kept its own labels
  Ticker   Close
0    AAA  186.66
1    CCC  416.98
0    DDD  125.18
1    FFF  248.40
(4, 2)
[0, 1, 0, 1]

concat kept each frame’s own index, so labels 0 and 1 appear twice. Asking for label 0 now returns two rows:

print(both.loc[0])              # one label, two rows come back
# ->   Ticker   Close
# -> 0    AAA  186.66
# -> 0    DDD  125.18
  Ticker   Close
0    AAA  186.66
0    DDD  125.18

ignore_index=True throws the old labels away and numbers the result 0 to 3.

both = pd.concat([a, b], ignore_index=True)   # drop the old labels, number the rows afresh

print(both)
# ->   Ticker   Close
# -> 0    AAA  186.66
# -> 1    CCC  416.98
# -> 2    DDD  125.18
# -> 3    FFF  248.40

print(both.index.tolist())      # -> [0, 1, 2, 3]
  Ticker   Close
0    AAA  186.66
1    CCC  416.98
2    DDD  125.18
3    FFF  248.40
[0, 1, 2, 3]

Step 2. Two months of prices

prices.csv holds simulated daily closes for three tickers. I split it into January and February so there are two frames to stack.

prices = pd.read_csv("prices.csv", parse_dates=["Date"])  # Date arrives as a real date

jan = prices[prices["Date"].dt.month == 1]                # January rows only
feb = prices[prices["Date"].dt.month == 2]                # February rows only

print(jan.shape, feb.shape)                               # -> (63, 4) (57, 4)
print(jan.tail(2))
# ->          Date Ticker   Close   Volume
# -> 61 2026-01-30    CCC  442.68  3378706
# -> 62 2026-01-30    DDD  113.41  7028914
(63, 4) (57, 4)
         Date Ticker   Close   Volume
61 2026-01-30    CCC  442.68  3378706
62 2026-01-30    DDD  113.41  7028914

Stack them and the row counts add up.

stacked = pd.concat([jan, feb], ignore_index=True)   # 63 + 57 rows back in one frame

print(stacked.shape)                                 # -> (120, 4)
print(stacked["Date"].min())                         # -> 2026-01-02 00:00:00
print(stacked["Date"].max())                         # -> 2026-02-26 00:00:00
(120, 4)
2026-01-02 00:00:00
2026-02-26 00:00:00

concat puts the frames down in the order I listed them and does no sorting. To get one ticker’s whole history together and in date order, sort afterwards.

srt = stacked.sort_values(["Ticker", "Date"], ignore_index=True)   # ticker first, date second

print(srt.head(2))
# ->         Date Ticker   Close   Volume
# -> 0 2026-01-02    AAA  186.66  2183494
# -> 1 2026-01-05    AAA  186.38  8463199

print(srt.tail(2))
# ->           Date Ticker   Close   Volume
# -> 118 2026-02-25    DDD  108.88  8875303
# -> 119 2026-02-26    DDD  107.57  7025675
        Date Ticker   Close   Volume
0 2026-01-02    AAA  186.66  2183494
1 2026-01-05    AAA  186.38  8463199
          Date Ticker   Close   Volume
118 2026-02-25    DDD  108.88  8875303
119 2026-02-26    DDD  107.57  7025675

Step 3. Side by side with axis=1

axis=1 glues frames left to right instead of top to bottom. The columns sit next to each other and the rows are matched on the index, not on position. Here AAA has four dates and CCC has three.

aapl = prices[prices["Ticker"] == "AAA"].set_index("Date")[["Close"]].head(4)   # four dates
msft = prices[prices["Ticker"] == "CCC"].set_index("Date")[["Close"]].head(3)   # three dates

aapl.columns = ["AAA"]         # rename so the two Close columns do not collide
msft.columns = ["CCC"]

side = pd.concat([aapl, msft], axis=1)   # left to right, rows matched on the Date index

print(side)
# ->               AAA     CCC
# -> Date
# -> 2026-01-02  186.66  416.98
# -> 2026-01-05  186.38  423.19
# -> 2026-01-06  188.51  423.95
# -> 2026-01-07  187.24     NaN
               AAA     CCC
Date                      
2026-01-02  186.66  416.98
2026-01-05  186.38  423.19
2026-01-06  188.51  423.95
2026-01-07  187.24     NaN

The result carries every date from either frame, and 2026-01-07 gets NaN for CCC because that frame has no such row.

Your turn

Three frames hold one day of closes each, for the same two tickers. Stack them into one frame of six rows numbered 0 to 5.

d1 = pd.DataFrame({"Ticker": ["AAA", "CCC"], "Close": [186.66, 416.98]})   # day one
d2 = pd.DataFrame({"Ticker": ["AAA", "CCC"], "Close": [190.10, 421.30]})   # day two
d3 = pd.DataFrame({"Ticker": ["AAA", "CCC"], "Close": [188.00, 430.00]})   # day three
days = pd.concat([d1, d2, d3], ignore_index=True)   # the list can hold any number of frames

print(days)
# ->   Ticker   Close
# -> 0   AAA  186.66
# -> 1   CCC  416.98
# -> 2   AAA  190.10
# -> 3   CCC  421.30
# -> 4   AAA  188.00
# -> 5   CCC  430.00

print(days.shape)       # -> (6, 2)

concat takes the union of the columns and fills the gaps with NaN.

one = pd.DataFrame({"Ticker": ["AAA"], "Close": [186.66]})                       # two columns
two = pd.DataFrame({"Ticker": ["DDD"], "Close": [125.18], "Volume": [7447373]})  # three columns

print(pd.concat([one, two], ignore_index=True))
# ->   Ticker   Close     Volume
# -> 0    AAA  186.66        NaN
# -> 1    DDD  125.18  7447373.0
  Ticker   Close     Volume
0    AAA  186.66        NaN
1    DDD  125.18  7447373.0

Volume became a float, because NaN is a float.

Older code adds a row with df.append(other). That method was removed in pandas 2.0.

print(hasattr(pd.DataFrame, "append"))     # -> False
False

pd.concat([df, other]) does the job. It builds a whole new frame every time, so calling it once per row inside a loop copies everything on each pass. Collect the frames in a list and pass the list to concat once.