Data

Kurtosis in Python: SciPy, pandas and NumPy Explained

Data · Published 2026-10-24

A small brass hourglass with sand gathering in the lower bulb, on a wooden table
Sand piling up in the lower bulb of a small hourglass.

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:

NameDefinitionNormal distributionPython default
Pearson kurtosism4 / m2²3scipy.stats.kurtosis(x, fisher=False)
Excess (Fisher) kurtosism4 / m2² − 30scipy.stats.kurtosis(x), Series.kurt()

Excess kurtosis is centered on the normal distribution, so its sign is meaningful:

scipy.stats.kurtosis: fisher and bias

scipy.stats.kurtosis takes two flags that change the result:

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

Troubleshooting

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.

Read next

More in Data