Fix AttributeError: 'DataFrame' object has no attribute 'append' (pandas 2.0)

Fix AttributeError: 'DataFrame' object has no attribute 'append' (pandas 2.0)

You upgraded pandas — or deployed to an environment that already has pandas 2.x — and your script immediately dies with an AttributeError. The method DataFrame.append() that your data pipeline relied on simply does not exist anymore. This post shows exactly why it was removed, the four practical replacements, and how to choose between them based on your use case and performance requirements.

import pandas as pd

df = pd.DataFrame({"name": ["Alice", "Bob"], "score": [88, 92]})
new_row = {"name": "Charlie", "score": 95}

result = df.append(new_row, ignore_index=True)
Traceback (most recent call last):
  File "pipeline.py", line 6, in <module>
    result = df.append(new_row, ignore_index=True)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'DataFrame' object has no attribute 'append'

If you are on pandas 1.x you may have seen the deprecation warning first:

FutureWarning: The frame.append method is deprecated and will be removed from pandas in a future version.
Use pandas.concat instead.
  result = df.append(new_row, ignore_index=True)

The FutureWarning was added in pandas 1.4.0 (January 2022). The method was fully removed in pandas 2.0.0 (April 2023). Any code that ignored that warning now fails with the AttributeError above.


Why This Error Occurs

DataFrame.append() was a convenience wrapper that called pd.concat() internally. Every call to .append() created a brand-new DataFrame by copying both the original data and the new rows into fresh memory. This made it easy to write but extremely expensive to call in a loop — a pattern so widespread that the pandas maintainers identified it as the single most common source of quadratic-time data pipelines written by pandas users.

The decision to remove it entirely (rather than just keep it as a slow alias) was deliberate: keeping a convenience method that routinely caused O(n²) memory allocations was considered more harmful than the migration cost. The pandas 2.0 release notes state:

"DataFrame.append and Series.append have been removed. Use pd.concat instead. The removal was done because append created a new object at every call making loops with append very inefficient."

The three most common situations where you encounter this error after upgrading:

  • Accumulating rows in a loop — iterating over API responses, file chunks, or database cursor results and calling df = df.append(row) on each iteration.
  • Merging two DataFrames verticallycombined = df1.append(df2) used instead of a proper concat.
  • Adding a single computed row — appending a summary row (totals, averages) to the bottom of a report DataFrame.

Fix 1: Replace with pd.concat()

pd.concat() is the direct replacement for every use of .append(). It accepts a list of DataFrames (or Series) and concatenates them along an axis. The syntax is slightly more verbose but maps one-to-one with the old .append() behaviour.

Appending a dict (single row)

import pandas as pd

df = pd.DataFrame({"name": ["Alice", "Bob"], "score": [88, 92]})
new_row = {"name": "Charlie", "score": 95}

# Before (pandas < 2.0):
# result = df.append(new_row, ignore_index=True)

# After (pandas 2.0+):
result = pd.concat([df, pd.DataFrame([new_row])], ignore_index=True)
print(result)
#       name  score
# 0    Alice     88
# 1      Bob     92
# 2  Charlie     95

The key difference: you must wrap the dict in a list and pass it to pd.DataFrame() before concatenating. pd.concat() requires all arguments to be DataFrame or Series objects — it does not accept raw dicts directly.

Appending one DataFrame to another

import pandas as pd

df1 = pd.DataFrame({"name": ["Alice", "Bob"], "score": [88, 92]})
df2 = pd.DataFrame({"name": ["Charlie", "Diana"], "score": [95, 78]})

# Before:
# combined = df1.append(df2, ignore_index=True)

# After:
combined = pd.concat([df1, df2], ignore_index=True)
print(combined)
#       name  score
# 0    Alice     88
# 1      Bob     92
# 2  Charlie     95
# 3    Diana     78

Appending multiple DataFrames at once

One advantage of pd.concat() over the old .append(): you can pass a list of any length, making it easy to collapse a collection of DataFrames in a single call.

import pandas as pd

frames = [
    pd.DataFrame({"name": ["Alice"], "score": [88]}),
    pd.DataFrame({"name": ["Bob"], "score": [92]}),
    pd.DataFrame({"name": ["Charlie"], "score": [95]}),
]

# One call — no intermediate copies
combined = pd.concat(frames, ignore_index=True)
print(combined)
#       name  score
# 0    Alice     88
# 1      Bob     92
# 2  Charlie     95

Preserving the original index

import pandas as pd

df1 = pd.DataFrame({"score": [88, 92]}, index=["alice", "bob"])
df2 = pd.DataFrame({"score": [95, 78]}, index=["charlie", "diana"])

# Keep original index labels (omit ignore_index)
combined = pd.concat([df1, df2])
print(combined)
#         score
# alice      88
# bob        92
# charlie    95
# diana      78

# Or reset to a clean integer index
combined_reset = pd.concat([df1, df2], ignore_index=True)
print(combined_reset)
#    score
# 0     88
# 1     92
# 2     95
# 3     78

Fix 2: Build a list, then concat once

If you are accumulating rows inside a loop — the most common performance anti-pattern — the correct fix is not to call pd.concat() on each iteration. That would still produce O(n²) allocations. Instead, collect all rows into a plain Python list and call pd.concat() (or pd.DataFrame()) exactly once after the loop finishes.

Pattern: accumulate dicts, build DataFrame once

import pandas as pd
import requests  # example: accumulating API responses

# Before (pandas < 2.0) — slow, now also broken:
# result = pd.DataFrame()
# for page in range(1, 11):
#     data = fetch_page(page)
#     result = result.append(data, ignore_index=True)  # AttributeError

# After — correct and fast:
rows = []
for page in range(1, 11):
    # Simulate fetching a page of data
    page_rows = [
        {"id": page * 10 + i, "value": page * i}
        for i in range(5)
    ]
    rows.extend(page_rows)  # extend the list, not the DataFrame

# Single allocation at the end
result = pd.DataFrame(rows)
print(result.head())
#     id  value
# 0   10      0
# 1   11      1
# 2   12      2
# 3   13      3
# 4   14      4

Pattern: accumulate DataFrames, concat once

When each iteration produces a chunk DataFrame (e.g., reading CSV chunks or processing batches), collect the chunks and concat at the end:

import pandas as pd

# Simulated chunked processing
def process_chunk(chunk_df: pd.DataFrame) -> pd.DataFrame:
    """Apply some transformation to a chunk."""
    chunk_df = chunk_df.copy()
    chunk_df["value_squared"] = chunk_df["value"] ** 2
    return chunk_df

# Reading a large CSV in chunks
chunks = []
for chunk in pd.read_csv("large_file.csv", chunksize=10_000):
    processed = process_chunk(chunk)
    chunks.append(processed)  # append the chunk DataFrame to a list

# One concat — one memory allocation
result = pd.concat(chunks, ignore_index=True)
print(f"Total rows: {len(result):,}")

Pattern: using a generator with concat

For very large collections, you can pass a generator directly to pd.concat() to avoid materialising the entire list in memory at once:

import pandas as pd

def generate_dataframes():
    for i in range(100):
        yield pd.DataFrame({
            "batch": [i] * 5,
            "value": range(i * 5, i * 5 + 5)
        })

# pandas evaluates the generator lazily before allocating
result = pd.concat(generate_dataframes(), ignore_index=True)
print(result.shape)  # (500, 2)

Fix 3: Use loc or iloc for single-row appends

When you genuinely need to add one row to an existing DataFrame in-place — for example, appending a totals row to a report — .loc assignment is the most direct approach. It modifies the DataFrame without creating an intermediate copy.

Append a single row with loc

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "South", "East", "West"],
    "revenue": [120_000, 98_000, 145_000, 87_000],
    "units": [450, 380, 510, 320],
})

# Compute totals
totals = {
    "region": "TOTAL",
    "revenue": df["revenue"].sum(),
    "units": df["units"].sum(),
}

# Append totals row using loc with the next integer index
next_idx = len(df)
df.loc[next_idx] = totals

print(df)
#   region  revenue  units
# 0  North   120000    450
# 1  South    98000    380
# 2   East   145000    510
# 3   West    87000    320
# 4  TOTAL   450000   1660

Append a row using a named index

import pandas as pd

df = pd.DataFrame(
    {"q1": [10, 20, 30], "q2": [15, 25, 35], "q3": [12, 22, 32]},
    index=["product_A", "product_B", "product_C"]
)

# Add a row for a new product
df.loc["product_D"] = {"q1": 18, "q2": 28, "q3": 19}

print(df)
#            q1  q2  q3
# product_A  10  15  12
# product_B  20  25  22
# product_C  30  35  32
# product_D  18  28  19

Important caveat: .loc assignment mutates the DataFrame in place and can trigger a SettingWithCopyWarning if df is a slice of another DataFrame. If you see that warning, call .copy() first: df = original[mask].copy(). Additionally, .loc in a loop has the same O(n²) problem as .append() did — use it only for a small, fixed number of rows (like a single totals row), never inside a large loop.

Append a row with pd.DataFrame and concat — safe alternative

import pandas as pd

df = pd.DataFrame({
    "region": ["North", "South", "East", "West"],
    "revenue": [120_000, 98_000, 145_000, 87_000],
})

totals_row = pd.DataFrame([{
    "region": "TOTAL",
    "revenue": df["revenue"].sum(),
}])

# Clean, no mutation, no SettingWithCopyWarning
df_with_totals = pd.concat([df, totals_row], ignore_index=True)
print(df_with_totals)
#   region  revenue
# 0  North   120000
# 1  South    98000
# 2   East   145000
# 3   West    87000
# 4  TOTAL   450000

Fix 4: Other pandas 2.0 breaking changes to check

DataFrame.append() is the most widely encountered removal, but pandas 2.0 shipped a significant number of other breaking changes. If your codebase used .append(), it was likely written before 2023 and may be hitting several of these at once.

iteritems() removed

DataFrame.iteritems() and Series.iteritems() were removed. They were identical to items() — replace every occurrence with .items():

import pandas as pd

df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})

# Before (raises AttributeError in pandas 2.0):
# for col_name, col_data in df.iteritems():
#     print(col_name, col_data.sum())

# After:
for col_name, col_data in df.items():
    print(col_name, col_data.sum())
# a 3
# b 7

See also: Fix AttributeError: 'DataFrame' object has no attribute 'iteritems' for a full migration guide.

swaplevel() axis argument removed

The positional axis argument to DataFrame.swaplevel() was removed. Pass axis= as a keyword argument explicitly:

import pandas as pd

arrays = [["A", "A", "B", "B"], [1, 2, 1, 2]]
tuples = list(zip(*arrays))
idx = pd.MultiIndex.from_tuples(tuples, names=["letter", "number"])
df = pd.DataFrame({"value": [10, 20, 30, 40]}, index=idx)

# Before — positional axis:
# df.swaplevel(0, 1, 0)   # worked in pandas 1.x

# After — keyword only:
swapped = df.swaplevel(0, 1, axis=0)
print(swapped)

Numeric dtype inference changed

In pandas 2.0, integer columns no longer silently become float when a NaN is present, because pandas now uses nullable integer types (Int64, Int32, etc.) by default in many contexts. Code that relied on df["count"].dtype == float being True after introducing a NaN may break:

import pandas as pd
import numpy as np

# pandas 1.x: integers with NaN silently became float64
# pandas 2.0: use the nullable integer dtype explicitly

df = pd.DataFrame({"count": pd.array([1, 2, None, 4], dtype=pd.Int64Dtype())})
print(df.dtypes)
# count    Int64
# dtype: object

print(df["count"].sum())  # 7 (NaN skipped)

# If you need to fill NaN before converting back to plain int:
df["count_filled"] = df["count"].fillna(0).astype(int)

read_csv dtype_backend parameter

pandas 2.0 introduced the dtype_backend parameter to pd.read_csv(), pd.read_parquet(), and related readers. The default remains NumPy-backed dtypes, but you may see code that explicitly sets dtype_backend="numpy_nullable" or "pyarrow". If a dependency added this parameter against pandas 1.x, it will error:

import pandas as pd

# This parameter did not exist before pandas 2.0:
df = pd.read_csv("data.csv", dtype_backend="numpy_nullable")
# Works in pandas 2.0+; raises TypeError on pandas 1.x

copy_on_write behaviour preview

pandas 2.0 introduced Copy-on-Write (CoW) as an opt-in preview. In pandas 3.0, CoW will be the default. Code that mutates a slice and expects the parent DataFrame to change will silently stop working. Enable it now to surface issues early:

import pandas as pd

# Enable CoW preview in pandas 2.x
pd.options.mode.copy_on_write = True

df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
subset = df[["a"]]

# Under CoW, this modifies a copy — df["a"] is NOT changed
subset["a"] = 99

print(df["a"].tolist())  # [1, 2, 3] — original unchanged under CoW

Removed squeeze parameter from read_csv / groupby

The squeeze=True parameter in pd.read_csv() and DataFrameGroupBy.apply() was removed. If you used it to automatically return a Series when a single column was selected, replace it with an explicit .squeeze() call or index the column directly:

import pandas as pd

# Before (squeeze= removed in pandas 2.0):
# s = pd.read_csv("single_col.csv", squeeze=True)

# After:
df = pd.read_csv("single_col.csv")
s = df.squeeze()  # or df["column_name"] if you know the column name
print(type(s))  # <class 'pandas.core.series.Series'>

Performance note: why list-then-concat is fastest

Understanding the memory model makes it clear why the list-accumulate pattern is categorically faster than calling pd.concat() (or the old .append()) in a loop.

The quadratic cost of in-loop concatenation

Each call to pd.concat([existing_df, new_row]) inside a loop must:

  1. Allocate a new block of memory large enough to hold all existing rows plus the new row.
  2. Copy all existing data into the new block.
  3. Copy the new row into the new block.
  4. Release the old block to the garbage collector.

For n rows accumulated one at a time, this copies row 1 a total of n times, row 2 a total of n-1 times, and so on — giving O(n²) total copy operations. A 100,000-row accumulation via in-loop concat performs roughly 5 billion copy operations where only 100,000 are necessary.

import pandas as pd
import time

N = 5_000

# Approach A: in-loop pd.concat (quadratic)
start = time.perf_counter()
df_a = pd.DataFrame(columns=["x", "y"])
for i in range(N):
    df_a = pd.concat([df_a, pd.DataFrame([{"x": i, "y": i ** 2}])], ignore_index=True)
time_a = time.perf_counter() - start

# Approach B: list accumulation, single concat (linear)
start = time.perf_counter()
rows = []
for i in range(N):
    rows.append({"x": i, "y": i ** 2})
df_b = pd.DataFrame(rows)
time_b = time.perf_counter() - start

print(f"In-loop concat:     {time_a:.2f}s")
print(f"List then DataFrame:{time_b:.4f}s")
print(f"Speedup:            {time_a / time_b:.0f}x")

# Typical output on a modern machine for N=5000:
# In-loop concat:      8.34s
# List then DataFrame: 0.0021s
# Speedup:             3971x

The speedup is not marginal — it is typically three to four orders of magnitude for datasets in the tens of thousands of rows. The list-accumulate pattern keeps all intermediate data in Python's native list memory (which uses amortised O(1) appends) and performs a single vectorised copy at the end.

When the size of individual chunks matters

If each iteration produces a chunk DataFrame rather than a single row, collecting them in a Python list and calling pd.concat(chunks) once is still optimal. pd.concat() examines all input shapes upfront and allocates the exact output size in one shot — no intermediate copies.

import pandas as pd
import numpy as np
import time

N_CHUNKS = 200
CHUNK_SIZE = 500

# Approach A: concat inside the loop
start = time.perf_counter()
result_a = pd.DataFrame()
for i in range(N_CHUNKS):
    chunk = pd.DataFrame(np.random.randn(CHUNK_SIZE, 4), columns=list("abcd"))
    result_a = pd.concat([result_a, chunk], ignore_index=True)
time_a = time.perf_counter() - start

# Approach B: collect, concat once
start = time.perf_counter()
chunks = []
for i in range(N_CHUNKS):
    chunk = pd.DataFrame(np.random.randn(CHUNK_SIZE, 4), columns=list("abcd"))
    chunks.append(chunk)
result_b = pd.concat(chunks, ignore_index=True)
time_b = time.perf_counter() - start

print(f"In-loop concat:  {time_a:.3f}s  shape={result_a.shape}")
print(f"Collect + concat:{time_b:.3f}s  shape={result_b.shape}")
print(f"Speedup:         {time_a / time_b:.1f}x")

# Typical output for N_CHUNKS=200, CHUNK_SIZE=500:
# In-loop concat:   1.842s  shape=(100000, 4)
# Collect + concat: 0.021s  shape=(100000, 4)
# Speedup:          87.7x

Memory usage comparison

Peak memory also differs significantly. In-loop concatenation holds both the growing result DataFrame and the new chunk in memory simultaneously before freeing the old result. The peak memory usage is roughly 2x the final DataFrame size at the last iteration. The list-accumulate approach holds the list of chunks plus the final DataFrame simultaneously, but each chunk is small — peak usage is closer to 1.5x the final size, and the chunks can be garbage-collected as soon as the output is built.

import pandas as pd
import tracemalloc

N = 10_000

# Measure peak memory for list-then-DataFrame approach
tracemalloc.start()
rows = [{"x": i, "y": float(i) ** 0.5, "z": i % 7} for i in range(N)]
df = pd.DataFrame(rows)
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(f"DataFrame shape: {df.shape}")
print(f"Peak memory:     {peak / 1024:.1f} KB")
print(f"DataFrame memory:{df.memory_usage(deep=True).sum() / 1024:.1f} KB")

Summary

Quick reference for migrating away from DataFrame.append():

  • Root causeDataFrame.append() was deprecated in pandas 1.4 (2022) and fully removed in pandas 2.0 (April 2023). It was removed because it created a new DataFrame copy on every call, making loop-based accumulation quadratically slow.
  • Fix 1: pd.concat() — the direct replacement. Use pd.concat([df, pd.DataFrame([new_row])], ignore_index=True) to replace a single df.append(new_row). For two DataFrames use pd.concat([df1, df2], ignore_index=True).
  • Fix 2: list accumulation — the fastest approach for loops. Collect dicts or chunk DataFrames in a plain Python list, then call pd.DataFrame(rows) or pd.concat(chunks, ignore_index=True) exactly once after the loop. Delivers 100x–4000x speedups over in-loop concat.
  • Fix 3: loc assignment — use df.loc[len(df)] = row_dict for a small fixed number of appends (e.g., a single totals row). Never use this in a loop.
  • Fix 4: check other pandas 2.0 removals — also look for .iteritems() (replaced by .items()), squeeze=True in readers, positional axis in swaplevel(), and prepare for Copy-on-Write in pandas 3.0.
  • Performance rule — never call pd.concat() or any DataFrame constructor inside a loop that runs more than a few iterations. Accumulate in a Python list; allocate once.

Related articles