kmail.at
← learning

pandas · difficulty ◆◆

pandas clip() — cap outliers and clamp values into a valid range

clip() does not find your outliers — it contains them, and it is the last line between a dirty export and a total you are willing to send.

One 99999 in a revenue column moved the monthly total by 10x, and the fix was not deleting the row — it was one call that keeps every row and puts a ceiling on all of them.

2026-10-11 · 5 min read

$ df.clip()

What it does

clip(lower=..., upper=...) replaces every value below lower with lower and every value above upper with upper, and touches nothing else. The bounds can be a scalar, a list or array, a {column: threshold} dict, or a Series; array-like bounds are applied element-wise along axis. One side is enough — clip(upper=5000) is a complete call, and None on a side means no bound there. The frame keeps its shape, index, column order and dtype: an int64 column stays int64, a float32 stays float32, NaN and NaT pass straight through. Two behaviours surprise people, both in the source. A scalar NaN bound is silently ignored, while a NaN inside a list-like bound means "no bound for this element" (GH 40420). And if you pass two scalars in the wrong order, pandas reorders them with min/max instead of raising — clip(10, 0) and clip(0, 10) both return the same thing on 3.0.3, verified.

Why it matters

Real columns are full of values that exist in the system and lie about the world: a credit note recorded as -80.50, a data-entry 99999, a sensor reading -40 degrees on a hot afternoon, a device whose clock is set to next week. Deleting those rows throws away the evidence that the source is broken; clip keeps the row, contains the damage and lets the rest of the pipeline stay honest, which is why it belongs right after your sentinel cleanup (replace) and before the first .sum() or .mean() you show anyone. The second reason is that it turns a spec into code: a table of per-column min/max becomes one dict argument that a reviewer can read. The one thing to be honest about is that clip is silent — it never tells you how many rows it rewrote, so count the out-of-range rows before you clamp them and put that number in the ticket.

Example

$ import pandas as pd

orders = pd.DataFrame({
    "order":   ["SO-1001", "SO-1002", "SO-1003", "SO-1004", "SO-1005"],
    "revenue": [1240.00, -80.50, 980.00, 99999.00, 310.75],
})
orders["billable"] = orders["revenue"].clip(lower=0, upper=5000)
print(orders.to_string(index=False))
print("gross:", orders["revenue"].sum(), "| billable:", orders["billable"].sum())
  order  revenue  billable
SO-1001  1240.00   1240.00
SO-1002   -80.50      0.00
SO-1003   980.00    980.00
SO-1004 99999.00   5000.00
SO-1005   310.75    310.75
gross: 102449.25 | billable: 7530.75

The bread-and-butter case: an ERP export where a credit note is stored as a negative and one typo added two digits. The frame keeps all five rows and the column keeps dtype float64 — only the totals move, from 102,449.25 to 7,530.75, which is the difference between a number you can send upstairs and one you cannot. Note what clip did NOT do: the -80.50 row still exists, it is just counted as 0.00, so a later count() or size() still sees the credit note and you can still investigate it. If you wanted the row gone you would be reaching for drop(), and you would be hiding the bug rather than containing it.

$ import pandas as pd

readings = pd.DataFrame({
    "station":   ["S-01", "S-02", "S-03", "S-04"],
    "temp_c":    [-40.0, 21.4, 88.0, 22.6],
    "press_kpa": [101.3, 990.0, 101.1, 100.9],
})
readings[["temp_c", "press_kpa"]] = readings[["temp_c", "press_kpa"]].clip(
    lower={"temp_c": -30.0, "press_kpa": 95.0},
    upper={"temp_c": 60.0, "press_kpa": 110.0},
)
print(readings.to_string(index=False))
station  temp_c  press_kpa
   S-01   -30.0      101.3
   S-02    21.4      110.0
   S-03    60.0      101.1
   S-04    22.6      100.9

This is the form I keep in production scripts: one dict per direction, keyed by column name, applied to a two-column slice. -40.0 and 88.0 are outside a temperature transmitter's own range, 990.0 kPa is a pressure spike, and all three get pulled to the spec limit while the other nine readings stay untouched — 101.3 is still 101.3, not rounded, not coerced. Two details worth noticing: the dict aligns on column names with no axis argument at all, and only the sliced columns are rewritten, so the station codes are physically outside the assignment. That is what makes this reviewable — the limits are data, not a chain of .loc masks.

$ import pandas as pd

tickets = pd.DataFrame({
    "ticket":          ["T-501", "T-502", "T-503", "T-504"],
    "first_reply_min": [-3, 95, 18, 240],
    "sla_min":         [15, 60, 30, 120],
})
tickets["counted_min"] = tickets["first_reply_min"].clip(lower=0, upper=tickets["sla_min"])
print(tickets.to_string(index=False))
print("SLA breaches after counting:", (tickets["first_reply_min"] > tickets["sla_min"]).sum())
ticket  first_reply_min  sla_min  counted_min
 T-501               -3       15            0
 T-502               95       60           60
 T-503               18       30           18
 T-504              240      120          120
SLA breaches after counting: 2

Here the ceiling is not a constant, it is another column, so each row gets its own limit: a 240-minute reply on a 120-minute SLA is counted as 120, and a reply before the clock started (-3) is counted as 0 instead of quietly crediting the agent. Series-against-Series needs no axis — alignment is on the index — so this composes with groupby and with merge results. The printed line underneath is the habit that keeps you honest: after clamping, `first_reply_min > sla_min` still finds the 2 rows that breached, because clip contained the duration without erasing the fact.

$ import pandas as pd

now = pd.Timestamp("2026-10-11 09:00:00")
log = pd.DataFrame({
    "meter": ["M-0412", "M-0413", "M-0414", "M-0415"],
    "last_seen": pd.to_datetime([
        "2026-10-11 08:12:00",
        "2026-10-12 14:03:00",
        None,
        "2026-10-10 23:40:00",
    ]),
})
log["last_seen_capped"] = log["last_seen"].clip(upper=now)
print(log.to_string(index=False))
print("capped rows:", (log["last_seen"] != log["last_seen_capped"]).sum())
 meter           last_seen    last_seen_capped
M-0412 2026-10-11 08:12:00 2026-10-11 08:12:00
M-0413 2026-10-12 14:03:00 2026-10-11 09:00:00
M-0414                 NaT                 NaT
M-0415 2026-10-10 23:40:00 2026-10-10 23:40:00
capped rows: 2

clip is not a numeric method, it is a comparison method, so datetimes work the same way: a meter whose clock ran into tomorrow gets pulled back to now, and rows that were already in the past are untouched. NaT stays NaT — clip never fills, so .dropna() and .fillna() downstream still see the two rows with no timestamp. Two notes for real logs: both bounds must be scalars or a single array, and if you swap them the min/max reorder quietly fixes it rather than complaining; and this is the line that makes "no future timestamps" enforceable in a test instead of a convention.

Common flags

lower= / upper=
None on a side means no bound on that side, so passing only upper=5000 is a complete, valid call. Values equal to the bound are kept — clip replaces strictly outside values.
lower={"temp_c": -30.0}
dict bounds: per-column thresholds in one argument, aligned on column names with no axis needed (verified on 3.0.3). This is the form that turns a spec table into code.
axis=0 or 1
required as soon as a bound is a Series or ndarray: a bare Series raises ValueError: Must specify axis=0 or 1. axis=1 aligns bounds on columns, axis=0 on rows. Dicts skip the requirement entirely, which is inconsistent but convenient.
bound = df["sla_min"]
on a Series, a Series bound needs no axis at all — alignment is on the index. That is how you give every row its own ceiling without a loop or an apply().
NaN bounds
a scalar NaN is ignored (no error, no clipping), and a NaN inside an array-like bound means no bound for that element (GH 40420). Use np.nan per column when one bound is a no-op.
inplace=True
returns self since 3.0.0 instead of None (GH 63207), and PDEP-8 is retiring the keyword. Under Copy-on-Write it saves nothing, so reassign the result and forget the argument.
**kwargs
numpy-compatibility names are swallowed (out=None is accepted), anything else raises TypeError: clip() got an unexpected keyword argument 'foo' — verified on 3.0.3. There is no method= or limit= to pass here.

History

September 12, 2011 — clip() ships with its arguments the wrong way round

The oldest tag pandas still carries, v0.4.0 (2011-09-12), declares Series.clip(self, upper=None, lower=None) in pandas/core/series.py — upper first — and DataFrame.clip(self, upper=None, lower=None) in pandas/core/frame.py. Series was corrected early: by v0.5.0 the signature reads clip(self, lower=None, upper=None, out=None). The DataFrame did not follow for two more years — v0.9.0 and v0.10.0 both still read def clip(self, upper=None, lower=None) in frame.py while their own series.py read (lower, upper). 0.11.0 (April 22, 2013) is the release that aligned the two, tracked as GH 2747, and the ghost of it is still in today's source as a comment, "# GH 2747 (arguments were reversed)", sitting directly above the line that quietly sorts two scalar bounds with min/max.

0.13.0 / 0.24.0 / 1.0.0 — three implementations become one, then the alias pair is deleted

clip was born as three methods on two classes: clip, clip_lower and clip_upper, each implemented once in series.py and once in frame.py. pandas 0.13.0 (January 3, 2014) collapsed them into NDFrame — the release notes list "Refactor clip methods to core/generic.py" (GH 4798) — which is the single code path that still runs today. The aliases lingered as legacy: clip_lower(0) and clip_upper(10) were deprecated in 0.24.0 (January 25, 2019, GH 24203) and removed in 1.0.0 (January 29, 2020), so a Stack Overflow answer from 2016 telling you to call df.clip_upper(10) is now an AttributeError. The signature kept moving afterwards: axis had appeared by 0.19.0, whose signature reads clip(lower, upper, axis=None, *args, **kwargs) while 0.16.0 still had only lower and upper; the *args catch-all was closed in 2.0.0 (April 3, 2023, GH 41511), making everything but lower and upper keyword-only; and 3.0.0 (January 21, 2026) made inplace=True return the object rather than None (GH 63207).

Fun facts

Pros & cons

pros

  • + Never changes shape, index, column order or dtype — int64 stays int64, float32 stays float32, NaN and NaT pass through (all verified) — so it can be inserted into an existing pipeline without repairing anything downstream
  • + Three levels of bound behind one signature: a scalar for a global rule, a dict for a per-column spec, a Series for a per-row rule. No loop, no apply(), no temporary column
  • + It is one comparison plus one masked write per bound, fully vectorized, so the code you write on a five-row test frame is the code that runs on five million rows

cons

  • − It contains outliers, it does not find them: nothing warns, nothing reports the count, and the number on the other side looks completely reasonable — a silent 13x correction of a total is one typo away
  • − Bound alignment is inconsistent: a dict lines up on column names for free, a Series demands an explicit axis (ValueError otherwise), and two swapped scalars are reordered instead of rejected (GH 2747)
  • − clip is a one-time intervention, not a rule. The next load of the same export is just as dirty, so the bounds belong in the loader function next to replace() and dropna(), not in the notebook cell where you noticed the problem

Takeaways

  1. 1Put clip() where the contract belongs: after sentinel cleanup (replace) and before the first aggregation, with the bounds in a named dict so a reviewer can diff the rule instead of reverse-engineering a chain of .loc masks.
  2. 2Count before you clamp. (~df["amount"].between(0, 5000)).sum() tells you how many rows you are about to rewrite, and that number — not a promise — is what goes in the change note.
  3. 3Any bound that comes from a column, a Series or an array needs axis= explicitly; a dict of per-column bounds does not. If you get ValueError: Must specify axis=0 or 1, that is pandas asking you a question, not complaining.
  4. 4clip() never fills and never converts: NaN in, NaN out, int64 in, int64 out (verified). Pair it with fillna() only if your policy really says a missing quantity is zero — clip is the wrong place to decide that.
  5. 5Delete clip_lower()/clip_upper() from anything you copy (removed in 1.0.0) and stop relying on inplace=True returning None — 3.0.0 returns the object itself (GH 63207). Reassign the result and the question disappears.

Related commands

← all learning