Knowledge base / Python
pandas fillna in Python: filling missing values in pandas 3
I show how I fill missing values in pandas 3 with fillna, ffill and bfill, why old code with method= stopped working and when I deliberately leave NaN in the data.

fillna is the pandas method I use to put something concrete where values are missing: a number, a string or a separate value for each column. It looks like a small thing, yet every replacement is a decision about what the data means. An empty discount may mean an order without a discount, while an empty survey rating usually means only that the customer did not answer.
In pandas 3, fillna does one job: it inserts the value you give it. Copying neighbouring values moved to the separate ffill and bfill methods, and the old fillna(method="ffill") call now ends with an error. I ran every example below in Python 3.14 with pandas 3.0.6 on small, synthetic data. For the wider decisions about gaps, duplicates and outliers, see the article Data cleaning: five steps that preserve the meaning of your analysis.
fillna syntax in pandas 3
DataFrame.fillna(value, *, axis=None, inplace=False, limit=None)
Series.fillna(value, *, axis=None, inplace=False, limit=None)
value: what goes into the gaps. It can be a single value, a{column: value}dict, a Series or a DataFrame. pandas does not accept a list and raises a TypeError.axis: the axis along which filling works. For ordinary column filling I leave the default.inplace:Truemodifies the object in place. By default the method returns a new object and I assign the result.limit: the maximum number of missing values filled along the axis. In a DataFrame that means at most this many gaps in each column, counted from the top.
The method parameter is gone. It was deprecated in pandas 2.1 and removed in 3.0, and downcast is gone as well. Code from older tutorials fails like this:
# example: removed-method
import pandas as pd
prices = pd.Series([10.0, None, 12.0])
try:
prices.fillna(method="ffill")
except TypeError as error:
print(error)
print(prices.ffill().tolist())
NDFrame.fillna() got an unexpected keyword argument 'method'
[10.0, 10.0, 12.0]
The fix is mechanical: fillna(method="ffill") becomes ffill(), and fillna(method="bfill") becomes bfill(). While I am there, I check whether copying values makes sense in that place at all. More on that below.
Example: three kinds of gaps in one table
I work with five synthetic orders. First I write down the assumptions, because no filling makes sense without them: an empty channel means the system did not record it, an empty discount means the order had no discount, and an empty rating means the customer did not answer.
# example: orders
import pandas as pd
orders = pd.DataFrame({
"order_id": ["Z1", "Z2", "Z3", "Z4", "Z5"],
"channel": ["web", None, "store", "web", None],
"discount": [5, None, 10, None, None],
"rating": [5, None, 4, None, 2],
})
print(orders.isna().sum())
order_id 0
channel 2
discount 3
rating 2
dtype: int64
I start by counting the gaps with isna, because I want to know how many values I am about to change. Then I fill only two columns, each with a different value. A dict in fillna does it in one call, and columns outside the dict stay untouched:
# example: fill-dict
filled = orders.fillna({"channel": "unknown", "discount": 0})
filled["discount_was_missing"] = orders["discount"].isna()
print(filled)
order_id channel discount rating discount_was_missing
0 Z1 web 5.0 5.0 False
1 Z2 unknown 0.0 NaN True
2 Z3 store 10.0 4.0 False
3 Z4 web 0.0 NaN True
4 Z5 unknown 0.0 2.0 True
The channel got the label unknown, so those orders will not vanish when I group by channel. The discount got 0, as assumed. The rating stayed empty, because nobody gave a zero.
Before filling, I also store in a separate column which discounts were empty. After the change, a 0.0 from the system and a 0.0 added by me look exactly the same. The flag lets me tell them apart later, count them and show them in a report.
Column dtype after filling
I typed the discounts as integers, and yet I see 5.0 and 0.0. pandas stores gaps in an ordinary numeric column as NaN, which is a floating-point value, so the whole column became float64. A plain fillna(0) does not undo that:
# example: dtypes
print(filled["channel"].dtype)
print(filled["discount"].dtype)
print(filled["discount"].astype("int64").tolist())
print(orders["discount"].fillna("none").dtype)
discount = pd.Series([5, None, 10], dtype="Int64")
print(discount.fillna(0).dtype)
str
float64
[5, 0, 10, 0, 0]
object
Int64
Once the gaps are gone, I change the dtype explicitly with astype. Before filling, astype("int64") would raise an error, because NaN cannot be stored as an integer.
Filling a numeric column with a string such as "none" goes through without a warning, but turns the column into object. From then on, sums and averages cause trouble. If I need a description of the gap, I keep it in a separate column, like the flag above.
Int64 with a capital I is the pandas nullable integer dtype. It stores a gap as <NA> without switching to float, and after fillna(0) it stays an integer. Text columns use the str dtype by default in pandas 3, and filling them with a string keeps that dtype.
ffill and bfill instead of method=
ffill puts the last earlier value into a gap, so it copies values down the column. bfill takes the nearest next value and copies it up. Both make sense when a value stays valid until it changes: a list price, a customer status, the last known stock level.
The example is a synthetic daily stock level from an export that sometimes does not arrive. I accept a gap of two days, and a longer gap has to stay visible:
# example: ffill
stock = pd.Series(
[12.0, None, None, None, 9.0, None],
index=pd.date_range("2026-03-02", periods=6, freq="D"),
)
print(stock.ffill(limit=2))
print(stock.bfill(limit=1))
2026-03-02 12.0
2026-03-03 12.0
2026-03-04 12.0
2026-03-05 NaN
2026-03-06 9.0
2026-03-07 9.0
Freq: D, dtype: float64
2026-03-02 12.0
2026-03-03 NaN
2026-03-04 NaN
2026-03-05 9.0
2026-03-06 9.0
2026-03-07 NaN
Freq: D, dtype: float64

In ffill and bfill, limit is the maximum number of consecutive gaps filled within one run of missing values. The third day of the outage, 5 March, stayed empty, and it is immediately visible that the export failed for longer. The last day after bfill is empty too, because there is no later value to copy.
In fillna the same parameter counts something else: all filled gaps along the axis, regardless of runs. Same name, different meaning, so when I rewrite old code I compare the result before and after the change.
The second trap appears with several series in one table. ffill on the whole column does not know where one store ends and the next begins:
# example: groupby-ffill
shops = pd.DataFrame({
"store": ["A", "A", "B", "B"],
"stock": [7.0, None, None, 4.0],
})
print(shops["stock"].ffill().tolist())
print(shops.groupby("store")["stock"].ffill().tolist())
[7.0, 7.0, 7.0, 4.0]
[7.0, 7.0, nan, 4.0]
Without grouping, store B received the stock level of store A. With groupby, the gap in B stays a gap, because that store has no earlier value. I sort the data by store and date first, since ffill follows the row order. pandas 3 no longer has groupby(...).fillna(...), so for group-wise copying I use groupby(...).ffill() or .bfill().
When I choose interpolate and when I fill nothing
ffill repeats a value that really occurred. interpolate calculates new values between known points:
# example: interpolate
print(stock.interpolate().tolist())
print(stock.interpolate(limit_area="inside").tolist())
[12.0, 11.25, 10.5, 9.75, 9.0, 9.0]
[12.0, 11.25, 10.5, 9.75, 9.0, nan]
A stock level of 11.25 units never happened. For a temperature sensor, a linear change between readings can be a reasonable assumption; for units in a warehouse, usually not. By default interpolate also fills the end of the series with the last known value, and limit_area="inside" restricts it to gaps between known points. Interpolation is a separate analytical decision, so I do not treat it as one more fillna option.
Often the best choice is to fill nothing. The customer rating is a good example:
# example: rating
print(round(orders["rating"].mean(), 2), orders["rating"].fillna(0).mean())
print(orders["rating"].count(), orders["rating"].fillna(0).count())
3.67 2.2
3 5
The average of the three answers is 3.67. With zeros filled in it drops to 2.2, and the number of ratings grows from three to five, although nobody answered more often. Filling with the column mean keeps the average, but it still adds two answers that never existed and shrinks the spread of the data. pandas skips NaN in mean, sum and count anyway, so in many calculations the gap does no harm at all.
I keep the gap when I do not know why it is there, when the empty value is information in itself (no answer, no reading, no consent) or when filling would change the result I report. In that case the report says how many records went into the calculation. If a record without a value does not fit a particular analysis, I would rather filter it out deliberately with dropna than fill it with a made-up number.
Common mistakes: inplace, typos and empty strings
The first trap is inplace. pandas 3 uses copy-on-write: the column you get from orders["discount"] behaves as a copy, so changing it never reaches the table.
# example: copy-on-write
orders["discount"].fillna(0, inplace=True)
print(orders["discount"].isna().sum())
orders["discount"] = orders["discount"].fillna(0)
print(orders["discount"].isna().sum())
3
0
The first line emits a ChainedAssignmentError warning and changes nothing, which is why I still see three gaps. The second assigns the result to the column and works. orders.fillna({"discount": 0}, inplace=True) on the whole table works too, but I prefer the explicit assignment, because it shows right away what changes. A side note: in pandas 3 some methods called with inplace=True, including fillna, ffill, bfill and replace, return the object itself instead of None. Others, such as dropna and sort_values, still return None.
The second trap is dict keys. fillna ignores keys that are not among the columns and raises no error. The third is values that look missing, while pandas sees ordinary text:
# example: silent-misses
print(orders.fillna({"Channel": "unknown"})["channel"].isna().sum())
codes = pd.Series(["PL", "", None, "n/a"])
print(codes.fillna("unknown").tolist())
2
['PL', '', 'unknown', 'n/a']
Because of the capital letter in Channel, both channel gaps stayed in place. The empty string and "n/a" survived as well. I first turn such markers into real missing values with replace, and only then decide what to do with them. After every fillna I count the gaps again and compare the number with the one from before the change.
One last thing from older tutorials: df.fillna(df.mean()) on a table with a text column ends with a TypeError in pandas 3, because the mean of text cannot be computed. df.mean(numeric_only=True) helps, although I have already said what I think about the mean as a filler.
A short exercise
Recreate the orders table and add order Z6 with an empty string as the channel, no discount and a rating of 1. Turn the empty string into a real missing value, fill the channel and discount with a dict and add a flag for the discount. Check how many gaps remain in each column, what dtype the discount has and whether the average rating changed only by the new answer. Finally, sort the stock series by date in descending order and compare the result of ffill(limit=2) with the ascending version.
Key takeaways
Why does fillna(method='ffill') raise an error?
pandas 3.0 removed the method parameter from fillna, so Python raises a TypeError about an unexpected keyword argument. I use df.ffill() or df.bfill() instead, and both also accept limit.
Does fillna change the original DataFrame?
By default it returns a new object, so I assign the result back, for example df['discount'] = df['discount'].fillna(0). In pandas 3, df['discount'].fillna(0, inplace=True) leaves the table unchanged, because the selected column behaves as a copy.
Should I fill missing values with zero, the mean or the previous value?
It depends on what the gap means. Zero only when missing really means zero, the previous value when it stays valid until the next change, and the mean with care, because it shrinks the spread of the data. I usually keep a missing survey answer as missing.
