How to Interpret a QQ Plot in Python
Before running a t-test, ANOVA, or linear regression, you need to know whether your data is normally distributed. Many statistical methods assume normality, and violating this assumption can...
Key Insights
- QQ plots compare your data’s quantiles against theoretical quantiles—points falling on the diagonal line indicate your data follows the theoretical distribution, while systematic deviations reveal skewness, heavy tails, or outliers.
- The shape of deviation tells you exactly what’s wrong: S-curves indicate heavy or light tails, consistent curvature suggests skewness, and scattered endpoints reveal outliers.
- QQ plots complement but don’t replace statistical tests—use them together with Shapiro-Wilk or Anderson-Darling for a complete picture, especially since visual inspection handles sample size limitations that plague formal tests.
Introduction to QQ Plots
Before running a t-test, ANOVA, or linear regression, you need to know whether your data is normally distributed. Many statistical methods assume normality, and violating this assumption can invalidate your results. The Quantile-Quantile plot (QQ plot) is your first line of defense.
A QQ plot is a graphical tool that compares the distribution of your data against a theoretical distribution—typically the normal distribution. Unlike histograms that can mislead you with bin choices, or statistical tests that become overly sensitive with large samples, QQ plots give you an immediate, interpretable picture of how your data behaves across its entire range.
The Theory Behind QQ Plots
The concept is straightforward: if your data follows a theoretical distribution, then the quantiles of your data should match the quantiles of that distribution.
Here’s how it works:
- Sort your data from smallest to largest
- Calculate the theoretical quantiles (where each point should fall if the data were perfectly normal)
- Plot your actual data values (y-axis) against these theoretical quantiles (x-axis)
If your data is perfectly normal, every point lands on a 45-degree reference line. The further points deviate from this line, the more your data departs from normality.
The key insight is that deviations aren’t random—they form patterns that tell you exactly how your distribution differs from normal. A curve means skewness. An S-shape means your tails are too heavy or too light. Scattered points at the extremes indicate outliers.
Creating Your First QQ Plot in Python
Python offers two primary tools for QQ plots: scipy.stats.probplot() and statsmodels.graphics.gofplots.qqplot(). Both work well, but they have different interfaces.
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
from statsmodels.graphics.gofplots import qqplot
# Generate normally distributed sample data
np.random.seed(42)
normal_data = np.random.normal(loc=50, scale=10, size=200)
# Method 1: Using scipy.stats.probplot
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# scipy approach
stats.probplot(normal_data, dist="norm", plot=axes[0])
axes[0].set_title("QQ Plot using scipy.stats.probplot")
axes[0].get_lines()[0].set_markerfacecolor('steelblue')
axes[0].get_lines()[0].set_markersize(6)
# Method 2: Using statsmodels
qqplot(normal_data, line='45', ax=axes[1], markerfacecolor='steelblue',
markeredgecolor='steelblue', alpha=0.6)
axes[1].set_title("QQ Plot using statsmodels.qqplot")
plt.tight_layout()
plt.show()
The scipy version returns the ordered data and theoretical quantiles, making it useful when you need those values for further analysis. The statsmodels version offers more customization options, including confidence bands. The line='45' parameter adds the reference line; you can also use line='s' for a standardized line fit to the data.
Interpreting Common Patterns
Understanding what different patterns mean is the core skill. Let’s generate four datasets that demonstrate the most common deviations from normality.
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42)
n_samples = 300
# Generate different distributions
normal_data = np.random.normal(0, 1, n_samples)
heavy_tails = np.random.standard_t(df=3, size=n_samples) # t-distribution
right_skewed = np.random.lognormal(0, 0.5, size=n_samples)
light_tails = np.random.uniform(-2, 2, size=n_samples)
datasets = [
(normal_data, "Normal Distribution\n(Points on line)"),
(heavy_tails, "Heavy Tails (t-dist, df=3)\n(S-curve pattern)"),
(right_skewed, "Right Skewed (Log-normal)\n(Curved upward)"),
(light_tails, "Light Tails (Uniform)\n(Inverted S-curve)")
]
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes = axes.flatten()
for ax, (data, title) in zip(axes, datasets):
stats.probplot(data, dist="norm", plot=ax)
ax.set_title(title, fontsize=11, fontweight='bold')
ax.get_lines()[0].set_markerfacecolor('steelblue')
ax.get_lines()[0].set_markersize(5)
ax.get_lines()[0].set_alpha(0.6)
plt.tight_layout()
plt.show()
Pattern interpretation guide:
-
Points on the line: Your data is approximately normal. Minor wobbles are expected with real data—perfection is suspicious.
-
S-curve (heavy tails): Points fall below the line on the left and above on the right. Your data has more extreme values than a normal distribution would predict. Common with financial returns, measurement errors, and natural phenomena.
-
Inverted S-curve (light tails): The opposite—points above the line on the left, below on the right. Your data is more concentrated around the mean than normal. Uniform distributions show this pattern.
-
Consistent upward curve (right skew): Points systematically curve above the line on the right side. Your data has a long right tail. Log-transformations often help.
-
Consistent downward curve (left skew): The mirror image—data has a long left tail.
-
Outliers at endpoints: A few points dramatically off the line at either end while the middle follows it. These are genuine outliers worth investigating.
Comparing Against Other Distributions
QQ plots aren’t limited to normality testing. You can compare your data against any theoretical distribution—exponential, uniform, gamma, or any distribution scipy.stats supports.
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42)
# Generate exponentially distributed data
exponential_data = np.random.exponential(scale=2.0, size=300)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Compare against normal distribution (wrong assumption)
stats.probplot(exponential_data, dist="norm", plot=axes[0])
axes[0].set_title("Exponential Data vs Normal Distribution\n(Wrong assumption)",
fontweight='bold')
axes[0].get_lines()[0].set_markerfacecolor('indianred')
axes[0].get_lines()[0].set_markersize(5)
# Compare against exponential distribution (correct assumption)
stats.probplot(exponential_data, dist="expon", plot=axes[1])
axes[1].set_title("Exponential Data vs Exponential Distribution\n(Correct assumption)",
fontweight='bold')
axes[1].get_lines()[0].set_markerfacecolor('seagreen')
axes[1].get_lines()[0].set_markersize(5)
plt.tight_layout()
plt.show()
The left plot shows severe deviation because we’re testing exponential data against a normal distribution. The right plot shows points falling on the line because we’re comparing against the correct distribution.
This technique is valuable when you suspect your data follows a specific distribution. Testing survival times? Compare against exponential or Weibull. Analyzing count data? Try Poisson. The QQ plot tells you whether your assumption holds.
QQ Plots vs. Other Normality Tests
QQ plots are visual; statistical tests give you p-values. Both have their place, and smart analysts use them together.
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42)
# Create two datasets: one normal, one slightly skewed
normal_sample = np.random.normal(0, 1, 150)
skewed_sample = np.random.exponential(1, 150)
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
for idx, (data, name) in enumerate([(normal_sample, "Normal Sample"),
(skewed_sample, "Skewed Sample")]):
# QQ Plot
ax_qq = axes[idx, 0]
stats.probplot(data, dist="norm", plot=ax_qq)
ax_qq.set_title(f"{name}: QQ Plot", fontweight='bold')
ax_qq.get_lines()[0].set_markerfacecolor('steelblue')
ax_qq.get_lines()[0].set_markersize(5)
# Histogram with statistical test results
ax_hist = axes[idx, 1]
ax_hist.hist(data, bins=25, density=True, alpha=0.7, color='steelblue',
edgecolor='white')
# Overlay normal curve
x_range = np.linspace(data.min(), data.max(), 100)
ax_hist.plot(x_range, stats.norm.pdf(x_range, data.mean(), data.std()),
'r-', linewidth=2, label='Normal fit')
# Statistical tests
shapiro_stat, shapiro_p = stats.shapiro(data)
dagostino_stat, dagostino_p = stats.normaltest(data)
test_text = (f"Shapiro-Wilk: W={shapiro_stat:.4f}, p={shapiro_p:.4f}\n"
f"D'Agostino-Pearson: p={dagostino_p:.4f}")
ax_hist.text(0.95, 0.95, test_text, transform=ax_hist.transAxes,
fontsize=9, verticalalignment='top', horizontalalignment='right',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
ax_hist.set_title(f"{name}: Histogram + Tests", fontweight='bold')
ax_hist.legend()
plt.tight_layout()
plt.show()
When to use each approach:
-
QQ plots: Best for understanding how your data deviates from normality. Essential for small samples where statistical tests lack power, and for large samples where tests become overly sensitive to trivial deviations.
-
Shapiro-Wilk test: Most powerful test for normality, but only reliable for samples between 20-5000 observations. Use for formal hypothesis testing.
-
Histograms/density plots: Good for overall shape, but sensitive to bin width choices. Use alongside QQ plots, never alone.
The practical approach: start with a QQ plot to understand your data visually, then confirm with Shapiro-Wilk if you need a formal test statistic.
Practical Tips and Common Pitfalls
Sample size matters. With fewer than 30 observations, QQ plots become noisy and hard to interpret. With thousands of observations, even tiny deviations become visible. Calibrate your expectations accordingly.
Don’t over-interpret minor deviations. Real data is never perfectly normal. A few points slightly off the line, especially at the extremes, is expected. Look for systematic patterns, not random scatter.
Use confidence bands when available. The statsmodels implementation supports confidence envelopes that help you judge whether deviations are statistically meaningful:
from statsmodels.graphics.gofplots import qqplot
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
data = np.random.normal(0, 1, 100)
fig, ax = plt.subplots(figsize=(8, 6))
qqplot(data, line='45', ax=ax, markerfacecolor='steelblue',
markeredgecolor='steelblue', alpha=0.6)
ax.set_title("QQ Plot with Reference Line", fontweight='bold')
plt.show()
Consider transformations. If your QQ plot shows right skewness, try log-transforming your data and re-plotting. If that straightens the line, you’ve found a useful transformation for your analysis.
Report what you see. When writing up results, describe the QQ plot pattern: “The QQ plot showed approximate normality with slight heavy tails at the extremes” is more informative than “we checked normality.”
QQ plots are a fundamental diagnostic tool. Master their interpretation, and you’ll catch distributional problems before they invalidate your statistical analyses.