How to Calculate Z-Scores in Python

Z-scores are one of the most fundamental concepts in statistics, yet many developers calculate them without fully understanding their power. A z-score tells you how many standard deviations a data...

Key Insights

  • Z-scores transform any dataset into a standardized scale where the mean is 0 and standard deviation is 1, making comparisons across different distributions straightforward
  • Python offers multiple approaches for z-score calculation: manual arithmetic for learning, NumPy for performance, SciPy for convenience, and Pandas for tabular data workflows
  • The most practical application of z-scores is outlier detection—data points with |z| > 3 occur less than 0.3% of the time in normal distributions and often warrant investigation

Introduction to Z-Scores

Z-scores are one of the most fundamental concepts in statistics, yet many developers calculate them without fully understanding their power. A z-score tells you how many standard deviations a data point sits from the mean. That’s it. Simple in concept, but remarkably useful in practice.

Why should you care? Three reasons dominate real-world applications.

First, standardization. When you’re comparing data from different scales—say, test scores ranging from 0-100 versus income measured in thousands—z-scores put everything on the same playing field. A z-score of 2 means the same thing regardless of the original units.

Second, outlier detection. In normally distributed data, approximately 99.7% of values fall within three standard deviations of the mean. Anything beyond that threshold deserves scrutiny. Z-scores give you an objective, quantifiable way to flag anomalies.

Third, probability calculations. Z-scores connect directly to the standard normal distribution, enabling you to calculate the probability of observing any given value. This underpins hypothesis testing, confidence intervals, and countless statistical procedures.

Let’s explore how to calculate z-scores in Python, starting from first principles and building toward production-ready approaches.

The Z-Score Formula

The z-score formula is straightforward:

z = (x - μ) / σ

Where:

  • x is the individual data point you’re standardizing
  • μ (mu) is the population mean
  • σ (sigma) is the population standard deviation
  • z is the resulting z-score

The numerator measures how far your data point deviates from the mean. The denominator scales that deviation by the typical spread of your data. Divide one by the other, and you get a dimensionless number representing relative position.

Consider a concrete example. Suppose you have exam scores with a mean of 75 and a standard deviation of 10. A student who scored 95 would have a z-score of:

z = (95 - 75) / 10 = 2.0

This student performed two standard deviations above average—a strong result that would place them roughly in the 97.7th percentile of a normal distribution.

Here’s how to implement this calculation in pure Python:

def calculate_zscore(x, mean, std):
    """Calculate z-score for a single value."""
    if std == 0:
        raise ValueError("Standard deviation cannot be zero")
    return (x - mean) / std

# Manual calculation
data = [65, 70, 75, 80, 85, 90, 95]
mean = sum(data) / len(data)
variance = sum((x - mean) ** 2 for x in data) / len(data)
std = variance ** 0.5

print(f"Mean: {mean}")
print(f"Standard Deviation: {std:.2f}")

# Calculate z-score for each value
for value in data:
    z = calculate_zscore(value, mean, std)
    print(f"Value: {value}, Z-score: {z:.2f}")

This approach works but becomes tedious with larger datasets. Let’s look at better options.

Calculating Z-Scores with NumPy

NumPy transforms z-score calculation from a loop-based operation into a vectorized one-liner. This matters when you’re processing thousands or millions of data points.

import numpy as np

data = np.array([65, 70, 75, 80, 85, 90, 95])

# Calculate mean and standard deviation
mean = np.mean(data)
std = np.std(data)  # Population std by default

# Vectorized z-score calculation
z_scores = (data - mean) / std

print("Original data:", data)
print("Z-scores:", np.round(z_scores, 2))

A critical detail: np.std() calculates the population standard deviation by default (dividing by N). If you’re working with a sample and want the sample standard deviation (dividing by N-1), pass ddof=1:

# Sample standard deviation
std_sample = np.std(data, ddof=1)
z_scores_sample = (data - mean) / std_sample

print(f"Population std: {np.std(data):.4f}")
print(f"Sample std: {std_sample:.4f}")

The difference between population and sample standard deviation shrinks as your dataset grows, but it’s worth knowing which you’re using. For most machine learning preprocessing tasks, population standard deviation is the convention.

Using SciPy’s Built-in Function

SciPy provides scipy.stats.zscore(), which handles the calculation in a single function call. This is my go-to for quick analyses.

from scipy import stats
import numpy as np

data = np.array([65, 70, 75, 80, 85, 90, 95])

# One-line z-score calculation
z_scores = stats.zscore(data)

print("Z-scores:", np.round(z_scores, 2))

The function shines when dealing with real-world data that contains missing values. The nan_policy parameter gives you control:

# Data with missing values
data_with_nan = np.array([65, 70, np.nan, 80, 85, 90, 95])

# Different strategies for handling NaN
z_propagate = stats.zscore(data_with_nan, nan_policy='propagate')  # Default: NaN in, NaN out
z_omit = stats.zscore(data_with_nan, nan_policy='omit')  # Ignore NaN in calculations

print("Propagate NaN:", z_propagate)
print("Omit NaN:", z_omit)

With nan_policy='omit', SciPy calculates the mean and standard deviation using only valid values, then returns NaN for the missing position. This preserves array length while giving you meaningful z-scores for the rest of your data.

For multidimensional arrays, the axis parameter specifies which dimension to standardize along:

# 2D array: rows are samples, columns are features
data_2d = np.array([
    [65, 150, 25],
    [70, 160, 30],
    [75, 170, 35],
    [80, 180, 40]
])

# Standardize each column (feature) independently
z_by_column = stats.zscore(data_2d, axis=0)
print("Z-scores by column:\n", np.round(z_by_column, 2))

Z-Scores with Pandas DataFrames

Most real-world data lives in DataFrames, not raw NumPy arrays. Pandas integrates naturally with both the manual approach and SciPy.

import pandas as pd
from scipy import stats

# Sample dataset
df = pd.DataFrame({
    'student': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
    'math_score': [85, 90, 78, 92, 88],
    'reading_score': [72, 85, 90, 78, 82],
    'income': [45000, 52000, 48000, 61000, 55000]
})

# Method 1: Manual calculation for a single column
df['math_zscore'] = (df['math_score'] - df['math_score'].mean()) / df['math_score'].std()

# Method 2: Using SciPy on a column
df['reading_zscore'] = stats.zscore(df['reading_score'])

print(df)

When you need to standardize multiple columns at once, .apply() keeps your code clean:

# Standardize all numeric columns
numeric_cols = ['math_score', 'reading_score', 'income']

# Create new columns with z-scores
for col in numeric_cols:
    df[f'{col}_z'] = stats.zscore(df[col])

# Or use apply for a more functional approach
df_zscores = df[numeric_cols].apply(stats.zscore)
df_zscores.columns = [f'{col}_z' for col in numeric_cols]

print(df_zscores)

One gotcha: Pandas’ .std() uses sample standard deviation (ddof=1) by default, while NumPy and SciPy use population standard deviation (ddof=0). For consistency, explicitly set ddof when mixing libraries.

Practical Application: Outlier Detection

Let’s put z-scores to work on a realistic problem: identifying anomalous transactions in a dataset.

import numpy as np
import pandas as pd
from scipy import stats
import matplotlib.pyplot as plt

# Simulate transaction data with some outliers
np.random.seed(42)
normal_transactions = np.random.normal(loc=100, scale=25, size=200)
outlier_transactions = np.array([250, 280, 15, 10, 320])  # Deliberate anomalies
all_transactions = np.concatenate([normal_transactions, outlier_transactions])

# Create DataFrame
df = pd.DataFrame({
    'transaction_id': range(len(all_transactions)),
    'amount': all_transactions
})

# Calculate z-scores
df['z_score'] = stats.zscore(df['amount'])
df['abs_z'] = np.abs(df['z_score'])

# Flag outliers (|z| > 3)
threshold = 3
df['is_outlier'] = df['abs_z'] > threshold

# Report findings
outliers = df[df['is_outlier']]
print(f"Found {len(outliers)} outliers out of {len(df)} transactions:\n")
print(outliers[['transaction_id', 'amount', 'z_score']].to_string(index=False))

Visualization makes the outliers immediately apparent:

# Visualize the distribution with outliers highlighted
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Histogram with outliers marked
axes[0].hist(df[~df['is_outlier']]['amount'], bins=30, alpha=0.7, label='Normal')
axes[0].hist(df[df['is_outlier']]['amount'], bins=10, alpha=0.7, color='red', label='Outliers')
axes[0].set_xlabel('Transaction Amount')
axes[0].set_ylabel('Frequency')
axes[0].set_title('Transaction Distribution')
axes[0].legend()

# Z-score plot
axes[1].scatter(df['transaction_id'], df['z_score'], c=df['is_outlier'], cmap='coolwarm', alpha=0.6)
axes[1].axhline(y=threshold, color='r', linestyle='--', label=f'Threshold (±{threshold})')
axes[1].axhline(y=-threshold, color='r', linestyle='--')
axes[1].set_xlabel('Transaction ID')
axes[1].set_ylabel('Z-Score')
axes[1].set_title('Z-Scores with Outlier Threshold')
axes[1].legend()

plt.tight_layout()
plt.savefig('outlier_detection.png', dpi=150)
plt.show()

The threshold of 3 is conventional but not sacred. Adjust based on your domain knowledge and tolerance for false positives. Financial fraud detection might use 2.5 to catch more edge cases; manufacturing quality control might use 4 to reduce unnecessary inspections.

Conclusion

You now have four solid approaches for calculating z-scores in Python:

Manual calculation teaches the concept and works in constrained environments without external dependencies. Use it for learning or when you can’t install packages.

NumPy provides vectorized performance for large arrays. Choose it when you’re already in a NumPy-heavy workflow or need maximum speed.

SciPy’s zscore() offers the cleanest syntax and handles edge cases like missing values gracefully. This is my default choice for exploratory analysis.

Pandas integration fits naturally into DataFrame workflows. Use it when your data is tabular and you’re doing broader data manipulation.

The method matters less than understanding what z-scores represent and when to apply them. Standardization for machine learning preprocessing, outlier detection for data quality, and probability calculations for statistical inference—these are the practical applications that justify the technique.

One final note: z-scores assume your data is roughly normally distributed. For heavily skewed data, consider robust alternatives like the modified z-score using median absolute deviation, or transform your data before standardizing. The tools covered here remain your foundation regardless of which direction you take.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.