Kurtosis in Python: SciPy, pandas and NumPy Explained

Kurtosis measures how heavy a distribution's tails are, that is, how much of its variance comes from rare, extreme values. In Python, use scipy.stats.kurtosis(x) (excess kurtosis, biased estimator by default) or pandas.Series.kurt() (excess kurtosis, bias-corrected); the two give different numbers on the same data unless you pass bias=False to SciPy.
What kurtosis actually measures
Kurtosis is the standardized fourth moment: the average of ((x - mean) / std) ** 4. Because deviations are raised to the fourth power, values near the mean contribute almost nothing and values several standard deviations out dominate. That is why kurtosis is best read as a tail statistic. The older description as "peakedness" is misleading: you can construct distributions with a sharp peak and low kurtosis, or a flat top and high kurtosis. What drives the number is how often, and how far, observations land in the tails.
In practice, high kurtosis warns that outliers are more frequent than a normal model would predict, which matters for risk estimates, anomaly thresholds and any method that assumes normality.
Excess vs Pearson kurtosis
There are two conventions, and mixing them up is the most common source of confusion:
| Name | Definition | Normal distribution | Python default |
|---|---|---|---|
| Pearson kurtosis | m4 / m2² | 3 | scipy.stats.kurtosis(x, fisher=False) |
| Excess (Fisher) kurtosis | m4 / m2² − 3 | 0 | scipy.stats.kurtosis(x), Series.kurt() |
Excess kurtosis is centered on the normal distribution, so its sign is meaningful:
- Positive (leptokurtic): heavier tails than normal, e.g. Laplace (+3), Student's t with few degrees of freedom.
- Zero (mesokurtic): normal-like tails.
- Negative (platykurtic): lighter tails than normal, e.g. uniform (−1.2). The minimum possible excess kurtosis is −2.
scipy.stats.kurtosis: fisher and bias
scipy.stats.kurtosis takes two flags that change the result:
fisher=True(default) returns excess kurtosis;fisher=Falsereturns Pearson kurtosis.bias=True(default) returns the plain moment-based estimator g2;bias=Falseapplies the standard small-sample correction (often called G2), the same estimator used by Excel'sKURTand by pandas.
import numpy as np
from scipy import stats
import pandas as pd
data = np.array([2.1, 2.4, 2.2, 2.8, 2.3, 2.5, 2.2, 6.9, 2.4, 2.6])
print(stats.kurtosis(data)) # excess, biased
print(stats.kurtosis(data, fisher=False)) # Pearson, biased
print(stats.kurtosis(data, bias=False)) # excess, bias-corrected
print(pd.Series(data).kurt()) # pandas: excess, bias-corrected
4.789888358441371
7.789888358441371
9.432124062244567
9.432124062244565
With ten observations, the biased and corrected estimators differ by a factor of two. The single value 6.9 is responsible for almost all of it, which is exactly what a tail statistic should react to.
Other useful parameters: axis (default 0) to compute per column of a 2-D array, and nan_policy ('propagate', 'omit' or 'raise'). SciPy propagates NaN by default, while pandas skips NaN by default:
s = np.array([1.0, 2.0, np.nan, 4.0, 10.0])
print(stats.kurtosis(s)) # nan
print(stats.kurtosis(s, nan_policy="omit")) # biased estimator on the 4 valid values
print(pd.Series(s).kurt()) # corrected estimator on the 4 valid values
nan
-0.9292307692307693
2.0307692307692307
The last two disagree only because of bias; with four points, the correction is huge. The corrected estimator needs at least four non-missing values; pandas returns NaN below that.
pandas: Series.kurt and DataFrame.kurt
Series.kurt() (alias kurtosis()) always returns bias-corrected excess kurtosis; there is no flag to change that. On a DataFrame it computes one value per numeric column:
rng = np.random.default_rng(0)
df = pd.DataFrame({"a": rng.normal(size=1000), "b": rng.standard_t(4, size=1000)})
print(df.kurt())
Pandas also offers rolling and grouped versions, such as series.rolling(250).kurt() and df.groupby("key")["value"].apply(pd.Series.kurt), handy for monitoring how tail behavior changes over time or across segments. If you need pandas and SciPy to agree, call SciPy with bias=False.
Computing kurtosis by hand with NumPy
Writing the estimator yourself is the best way to understand what the libraries return. The biased version uses population moments (dividing by n); the corrected version rescales it:
def kurtosis_by_hand(x, excess=True):
x = np.asarray(x, dtype=float)
d = x - x.mean()
m2 = np.mean(d**2) # second central moment (divides by n)
m4 = np.mean(d**4) # fourth central moment
g2 = m4 / m2**2
return g2 - 3 if excess else g2
def kurtosis_unbiased(x):
x = np.asarray(x, dtype=float)
n = x.size
g2 = kurtosis_by_hand(x)
return ((n + 1) * g2 + 6) * (n - 1) / ((n - 2) * (n - 3))
print(kurtosis_by_hand(data), np.isclose(kurtosis_by_hand(data), stats.kurtosis(data)))
print(kurtosis_unbiased(data), np.isclose(kurtosis_unbiased(data), pd.Series(data).kurt()))
4.789888358441371 True
9.432124062244567 True
Note that the moments divide by n, not n - 1. Using np.std(x, ddof=1) inside a hand-rolled formula is a frequent cause of results that almost, but not quite, match SciPy. Despite the name, the "unbiased" estimator is exactly unbiased only for normal data; for other distributions it merely reduces the bias.
Examples with known distributions
Drawing large seeded samples from distributions with known theoretical kurtosis shows what to expect, and also where things get unstable:
rng = np.random.default_rng(42)
n = 100_000
samples = {
"normal": rng.normal(size=n),
"uniform": rng.uniform(-1, 1, size=n),
"laplace": rng.laplace(size=n),
"student_t(df=5)": rng.standard_t(5, size=n),
}
for name, s in samples.items():
print(f"{name:16s} {stats.kurtosis(s):7.3f}")
# theoretical excess kurtosis from scipy's distribution objects
print(stats.t.stats(5, moments="k"), stats.uniform.stats(moments="k"), stats.laplace.stats(moments="k"))
normal 0.023
uniform -1.202
laplace 2.864
student_t(df=5) 8.275
6.0 -1.2 3.0
Normal, uniform and Laplace land close to theory. The t-distribution with 5 degrees of freedom has a theoretical excess kurtosis of 6, yet 100,000 samples gave 8.3. That is not a bug: for t with df ≤ 8 the eighth moment is infinite, so the sample kurtosis has infinite variance and converges very slowly. A few extreme draws swing it wildly. For df ≤ 4 the population kurtosis itself is infinite, and any sample value is meaningless as an estimate of it.
Sample size and sensitivity
Kurtosis is one of the noisiest common statistics. Simulating 2,000 normal samples at each size shows how wide the spread is even when the true value is exactly 0:
rng = np.random.default_rng(0)
for size in (20, 100, 1000, 10000):
ks = [stats.kurtosis(rng.normal(size=size)) for _ in range(2000)]
print(f"n={size:6d} mean={np.mean(ks):+.3f} "
f"5%={np.percentile(ks, 5):+.3f} 95%={np.percentile(ks, 95):+.3f}")
n= 20 mean=-0.286 5%=-1.173 95%=+1.082
n= 100 mean=-0.058 5%=-0.649 95%=+0.803
n= 1000 mean=-0.005 5%=-0.239 95%=+0.267
n= 10000 mean=+0.001 5%=-0.071 95%=+0.080
With 20 points, a perfectly normal sample produces excess kurtosis anywhere from about −1.2 to +1.1 in 90% of cases, and the biased estimator is pulled negative on average. Below a few hundred observations, treat a single kurtosis value as a rough hint, not a measurement.
It is also extremely sensitive to individual points. Appending one value of 8.0 to 1,000 standard normal draws moved excess kurtosis from about −0.13 to about 3.4 in our run. Before concluding "heavy tails", check whether one or two data-entry errors explain the result.
If you need a formal answer, scipy.stats.kurtosistest(x) tests whether the kurtosis differs from that of a normal distribution (it requires at least 5 observations and warns below 20), and scipy.stats.normaltest combines skewness and kurtosis into one normality test.
Interpretation caveats
- Always state the convention. Report "excess kurtosis" or "Pearson kurtosis" explicitly; a value of 3 means "normal" under one and "heavy-tailed" under the other.
- Don't read it as peakedness. Describe it in terms of tail weight and outlier propensity.
- Look at the data. A histogram, a Q–Q plot (
scipy.stats.probplot) or a list of the largest absolute z-scores tells you far more than one number. - Kurtosis does not imply skew. Symmetric distributions can be very heavy-tailed; check
stats.skewseparately. - Consider robust alternatives. For small or contaminated samples, quantile-based tail measures (for example, comparing the 1st–99th percentile spread to the interquartile range) are far more stable.
Troubleshooting
- SciPy and pandas disagree: pass
bias=Falseto SciPy. - Your value is about 3 higher than expected: you are looking at Pearson kurtosis; subtract 3 or use
fisher=True. - Result is
nan: SciPy propagates missing values; usenan_policy="omit". A constant array also yieldsnanbecause the variance is zero. - Per-row instead of per-column: set
axis=1in SciPy ordf.kurt(axis=1)in pandas.
Summary
Kurtosis quantifies tail heaviness. Excess kurtosis (normal = 0) is the default in both scipy.stats.kurtosis and pandas.Series.kurt, but SciPy uses the biased estimator unless you pass bias=False, while pandas always applies the correction. The formula is a few lines of NumPy using moments that divide by n. Expect large sampling noise, especially for small samples and genuinely heavy-tailed data, and confirm what the number suggests with plots and an outlier check before acting on it.
DataSuppress scientific notation
Stop pandas and NumPy printing floats like 1.2e+09: set_printoptions(suppress=True), display.float_format, option_context, Styler and to_csv.
DataUnicodeDecodeError in read_csv
Why pd.read_csv raises UnicodeDecodeError: 'utf-8' codec can't decode byte, and how to fix it with the right encoding, BOM handling and UTF-8 conversion.
PythonRandom integers
Generate random integers in Python with random.randint, randrange, sample, secrets.randbelow and NumPy's rng.integers, plus seeding, uniqueness and speed.