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": ["AAPL", "MSFT"], "Close": [186.66, 416.98]})
b = pd.DataFrame({"Ticker": ["NVDA", "TSLA"], "Close": [125.18, 248.40]})

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

print(both)
# ->   Ticker   Close
# -> 0   AAPL  186.66
# -> 1   MSFT  416.98
# -> 0   NVDA  125.18
# -> 1   TSLA  248.40

print(both.shape)               # -> (4, 2)
print(both.index.tolist())      # -> [0, 1, 0, 1]
  Ticker   Close
0   AAPL  186.66
1   MSFT  416.98
0   NVDA  125.18
1   TSLA  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])
# ->   Ticker   Close
# -> 0   AAPL  186.66
# -> 0   NVDA  125.18
  Ticker   Close
0   AAPL  186.66
0   NVDA  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)

print(both)
# ->   Ticker   Close
# -> 0   AAPL  186.66
# -> 1   MSFT  416.98
# -> 2   NVDA  125.18
# -> 3   TSLA  248.40

print(both.index.tolist())      # -> [0, 1, 2, 3]
  Ticker   Close
0   AAPL  186.66
1   MSFT  416.98
2   NVDA  125.18
3   TSLA  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"])

jan = prices[prices["Date"].dt.month == 1]
feb = prices[prices["Date"].dt.month == 2]

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

Stack them and the row counts add up.

stacked = pd.concat([jan, feb], ignore_index=True)

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)

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

print(srt.tail(2))
# ->           Date Ticker   Close   Volume
# -> 118 2026-02-25   NVDA  108.88  8875303
# -> 119 2026-02-26   NVDA  107.57  7025675
        Date Ticker   Close   Volume
0 2026-01-02   AAPL  186.66  2183494
1 2026-01-05   AAPL  186.38  8463199
          Date Ticker   Close   Volume
118 2026-02-25   NVDA  108.88  8875303
119 2026-02-26   NVDA  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 AAPL has four dates and MSFT has three.

aapl = prices[prices["Ticker"] == "AAPL"].set_index("Date")[["Close"]].head(4)
msft = prices[prices["Ticker"] == "MSFT"].set_index("Date")[["Close"]].head(3)

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

side = pd.concat([aapl, msft], axis=1)

print(side)
# ->               AAPL    MSFT
# -> 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
              AAPL    MSFT
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 MSFT 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": ["AAPL", "MSFT"], "Close": [186.66, 416.98]})
d2 = pd.DataFrame({"Ticker": ["AAPL", "MSFT"], "Close": [190.10, 421.30]})
d3 = pd.DataFrame({"Ticker": ["AAPL", "MSFT"], "Close": [188.00, 430.00]})
days = pd.concat([d1, d2, d3], ignore_index=True)   # the list can hold any number of frames

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

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

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

one = pd.DataFrame({"Ticker": ["AAPL"], "Close": [186.66]})
two = pd.DataFrame({"Ticker": ["NVDA"], "Close": [125.18], "Volume": [7447373]})

print(pd.concat([one, two], ignore_index=True))
# ->   Ticker   Close     Volume
# -> 0   AAPL  186.66        NaN
# -> 1   NVDA  125.18  7447373.0
  Ticker   Close     Volume
0   AAPL  186.66        NaN
1   NVDA  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.