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.
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:
df = df.append(row) on each iteration.combined = df1.append(df2) used instead of a proper 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.
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.
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
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
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
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.
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
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):,}")
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)
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.
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
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.
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
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.
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.
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)
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)
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
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
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'>
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.
Each call to pd.concat([existing_df, new_row]) inside a loop must:
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.
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
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")
Quick reference for migrating away from DataFrame.append():
DataFrame.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.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).pd.DataFrame(rows) or pd.concat(chunks, ignore_index=True) exactly once after the loop. Delivers 100x–4000x speedups over in-loop concat.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..iteritems() (replaced by .items()), squeeze=True in readers, positional axis in swaplevel(), and prepare for Copy-on-Write in pandas 3.0.pd.concat() or any DataFrame constructor inside a loop that runs more than a few iterations. Accumulate in a Python list; allocate once.