[Python] Histogram to Visualize Distribution of Continuous Variables (matplotlib)
When you get a new dataset, one of the first things you probably do is calculate the mean. It’s quick, it’s familiar, and it gives you a number you can report. But a mean can be misleading if you don’t know what the underlying data actually looks like.

Imagine two agencies both report an average grant award of $200,000. At Agency A, most grants fall between $150,000 and $250,000. At Agency B, half the grants are under $50,000 and the other half are over $350,000. The average is the same, but the reality on the ground is completely different. If you only looked at the mean, you’d think these two agencies had similar funding patterns. They don’t.
This is the problem that histograms solve. A histogram shows you the distribution of your data: where values are concentrated, how spread out they are, and whether there are unusual patterns like gaps or multiple peaks. Knowing the shape of your data tells you which summary statistics to trust (mean vs. median), which statistical tests are appropriate, and whether there are subgroups hiding inside what looks like a single population. A histogram is the fastest way to get that information.
What Is a Histogram?

A histogram takes continuous data (numbers that can fall anywhere within a range, e.g., dollar amounts, age, or test scores), divides that range into equal-width intervals called “bins,” and shows how many data points fall into each bin as a bar. It looks similar to a bar chart, but there’s an important difference. Bar charts display categorical data (like the number of grants by region), while histograms display the distribution of continuous data. That’s also why the bars in a histogram touch each other with no gaps between them: the data is continuous.
Drawing Your First Histogram
Let’s plot the distribution of award_amount_clean from our grant data (you might have another variable to plot your variable distribution). If you already have it loaded as a DataFrame called df, this is all you need:
import matplotlib.pyplot as plt
plt.figure(figsize=(8, 5))
plt.hist(df['award_amount_clean'], bins=20, color='steelblue', edgecolor='white')
plt.xlabel('Award Amount ($)')
plt.ylabel('Number of Grants')
plt.title('Distribution of Grant Award Amounts')
plt.show()
The key line is plt.hist(). The first argument is the data column you want to visualize. bins=20 tells Python to split the data range into 20 intervals. color sets the bar color, and edgecolor='white' draws white borders between bars so you can distinguish the bins easily.

Take a look at the shape that comes out. Is it symmetric? Does it have a long tail on one side? Is there more than one peak? These are the questions a histogram helps you answer.
Why Bin Size Matters
The number of bins you choose can make the same data look very different. Too few bins and you lose detail. Too many bins and you see noise instead of patterns. As a rough guideline, 5-10 bins work well for datasets under 50 observations, and 10-30 bins for larger datasets. When you’re unsure, try a few different values and pick the one that best reveals the pattern. There’s no single right answer; it depends on the story you’re trying to tell.
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
axes[0].hist(df['award_amount_clean'], bins=5, color='steelblue', edgecolor='white')
axes[0].set_title('bins=5 (Too Few)')
axes[0].set_xlabel('Award Amount ($)')
axes[0].set_ylabel('Count')
axes[1].hist(df['award_amount_clean'], bins=20, color='steelblue', edgecolor='white')
axes[1].set_title('bins=20')
axes[1].set_xlabel('Award Amount ($)')
axes[2].hist(df['award_amount_clean'], bins=80, color='steelblue', edgecolor='white')
axes[2].set_title('bins=80 (Too Many)')
axes[2].set_xlabel('Award Amount ($)')
plt.tight_layout()
plt.show()

Adding a KDE Curve with seaborn
matplotlib’s plt.hist() gets the job done, but seaborn’s histplot() produces cleaner-looking histograms with less code. It also has a built-in option to overlay a KDE (Kernel Density Estimation) curve, which is essentially a smoothed version of the histogram. Instead of discrete bars, the KDE curve shows the overall shape of the distribution as a continuous line.
import seaborn as sns
plt.figure(figsize=(8, 5))
sns.histplot(data=df, x='award_amount_clean', bins=20, kde=True, color='steelblue')
plt.xlabel('Award Amount ($)')
plt.ylabel('Number of Grants')
plt.title('Distribution of Grant Award Amounts with KDE Curve')
plt.show()

Adding kde=True is all it takes. The curve makes it easier to see the overall shape at a glance. So what kinds of shapes should you be looking for?
Distribution Shapes You’ll See in Practice
Normal Distribution (Bell Curve)
The most well-known distribution is the normal distribution, also called the bell curve. Data clusters symmetrically around the mean, with values becoming less frequent the further they are from the center. Many natural and behavioral phenomena approximate this shape: people’s heights, blood pressure readings, standardized psychological test scores.
# Normal distribution example with mock data
import numpy as np
np.random.seed(10)
normal_scores = np.random.normal(loc=50, scale=10, size=500)
plt.figure(figsize=(8, 5))
sns.histplot(normal_scores, bins=25, kde=True, color='steelblue')
plt.axvline(x=50, color='red', linestyle='--', label='Mean = 50')
plt.xlabel('Standardized Score')
plt.ylabel('Count')
plt.title('Normal Distribution: Standardized Assessment Scores')
plt.legend()
plt.show()

In a normal distribution, about 68% of data falls within one standard deviation of the mean, and about 95% falls within two standard deviations. This is the “68-95-99.7 rule.” Many statistical methods assume that the data follows a normal distribution, so drawing a histogram to check whether your data is roughly bell-shaped is a good habit before running any analysis.
Our grant award data does not look like a bell curve. That’s completely expected, and it brings us to the next pattern.
Right Skew (Positive Skew)
A right-skewed distribution has a long tail stretching to the right. Most data points are concentrated on the lower end, with a few high values pulling the tail out. This is one of the most common patterns in social work data. Income, costs, grant amounts, and service utilization counts all tend to look like this: a large cluster of smaller values with a few very large ones stretching the right side.
Your grant data is very likely right-skewed. Let’s check by adding the mean and median lines:
plt.figure(figsize=(8, 5))
sns.histplot(data=df, x='award_amount_clean', bins=30, kde=True, color='coral')
mean_val = df['award_amount_clean'].mean()
median_val = df['award_amount_clean'].median()
plt.axvline(x=mean_val, color='red', linestyle='--', label=f'Mean = ${mean_val:,.0f}')
plt.axvline(x=median_val, color='green', linestyle='--', label=f'Median = ${median_val:,.0f}')
plt.xlabel('Award Amount ($)')
plt.ylabel('Number of Grants')
plt.title('Grant Award Amount Distribution (Mean vs Median)')
plt.legend()
plt.show()

In a right-skewed distribution, the mean gets pulled to the right of the median because those few very large grants drag it up. If you reported the “average grant amount,” you’d be overstating what most grants actually look like. The median is a more representative summary in cases like this. You wouldn’t know to make that call without first looking at the histogram.
Left Skew (Negative Skew)
A left-skewed distribution has a long tail stretching to the left. Most data points are concentrated on the higher end, with a few low values creating the tail. Program satisfaction surveys often produce this pattern: most clients report high satisfaction, and only a handful give low scores.
# Left-skewed example with mock data
np.random.seed(15)
satisfaction = 10 - np.random.exponential(scale=2, size=300).clip(0, 9)
plt.figure(figsize=(8, 5))
sns.histplot(satisfaction, bins=20, kde=True, color='mediumseagreen')
plt.xlabel('Program Satisfaction Score (1-10)')
plt.ylabel('Number of Clients')
plt.title('Left-Skewed Distribution: Program Satisfaction')
plt.show()

Bimodal Distribution (Two Peaks)
Sometimes a histogram shows two distinct peaks. This is called a bimodal distribution, and it’s often a signal that your data contains two different groups mixed together.
For example, if you plot session durations for an agency and see peaks around 30 minutes and 60 minutes, that could mean the data includes crisis intervention sessions (shorter) and regular counseling sessions (longer). A single average of 48 minutes wouldn’t accurately describe either type.
# Bimodal example with mock data
np.random.seed(20)
crisis_sessions = np.random.normal(loc=30, scale=5, size=100)
regular_sessions = np.random.normal(loc=60, scale=8, size=150)
all_sessions = np.concatenate([crisis_sessions, regular_sessions])
plt.figure(figsize=(8, 5))
sns.histplot(all_sessions, bins=25, kde=True, color='mediumpurple')
plt.xlabel('Session Duration (minutes)')
plt.ylabel('Number of Sessions')
plt.title('Bimodal Distribution: Session Durations')
plt.annotate('Crisis\nIntervention', xy=(30, 20), fontsize=10, ha='center', color='gray')
plt.annotate('Regular\nCounseling', xy=(60, 20), fontsize=10, ha='center', color='gray')
plt.show()

When you spot a bimodal distribution, ask yourself: “Should I really be summarizing this as one group?” Averaging across two distinct subgroups produces a number that describes neither group well. When you see something unexpected in a histogram, ask “why?” That question can redirect your entire analysis.
Comparing Groups: University vs. Non-University Grants
One of the most useful things you can do with histograms is compare distributions across groups. Instead of comparing single numbers (like group means), histograms let you compare entire distributions side by side.
Let’s use the is_university variable to see whether award amounts are distributed differently for university-based grants versus non-university grants. We’ll put them in two panels using plt.subplots().
fig, axes = plt.subplots(1, 2, figsize=(14, 5), sharey=True)
# Panel 1: University grants
uni_data = df[df['is_university'] == True]['award_amount_clean']
sns.histplot(uni_data, bins=20, kde=True, color='#0077BB', ax=axes[0])
axes[0].axvline(x=uni_data.median(), color='red', linestyle='--',
label=f'Median = ${uni_data.median():,.0f}')
axes[0].set_title(f'University Grants (n={len(uni_data)})')
axes[0].set_xlabel('Award Amount ($)')
axes[0].set_ylabel('Number of Grants')
axes[0].legend()
# Panel 2: Non-university grants
non_uni_data = df[df['is_university'] == False]['award_amount_clean']
sns.histplot(non_uni_data, bins=20, kde=True, color='#EE7733', ax=axes[1])
axes[1].axvline(x=non_uni_data.median(), color='red', linestyle='--',
label=f'Median = ${non_uni_data.median():,.0f}')
axes[1].set_title(f'Non-University Grants (n={len(non_uni_data)})')
axes[1].set_xlabel('Award Amount ($)')
axes[1].legend()
plt.suptitle('Grant Award Distribution: University vs Non-University', fontsize=13)
plt.tight_layout()
plt.show()
A few things to notice in this code. sharey=True makes both panels share the same y-axis scale, so you can compare the heights of the bars directly. We filter the DataFrame using df[df['is_university'] == True] to isolate each group before plotting. The median line in each panel gives you a quick reference point.

When you look at the two panels, pay attention to more than just where the center is. Does one group have a wider spread? Is one more skewed than the other? Are there outliers in one group but not the other? These are the kinds of questions that histograms are built to answer, and that a simple comparison of means would miss entirely.
If the two groups have very different sample sizes, the raw count comparison can be misleading. In that case, switch to density so each group’s histogram is normalized to its own total:
fig, axes = plt.subplots(1, 2, figsize=(14, 5), sharey=True)
sns.histplot(uni_data, bins=20, kde=True, stat='density', color='#0077BB', ax=axes[0])
axes[0].set_title(f'University Grants (n={len(uni_data)})')
axes[0].set_xlabel('Award Amount ($)')
axes[0].set_ylabel('Density')
sns.histplot(non_uni_data, bins=20, kde=True, stat='density', color='#EE7733', ax=axes[1])
axes[1].set_title(f'Non-University Grants (n={len(non_uni_data)})')
axes[1].set_xlabel('Award Amount ($)')
plt.suptitle('Grant Award Distribution: University vs Non-University (Density)', fontsize=13)
plt.tight_layout()
plt.show()

With stat='density', the y-axis shows proportions instead of raw counts, making the shapes directly comparable regardless of group size.
Common Mistakes to Watch For
Using a histogram for categorical data. If you want to show “number of grants by region,” that calls for a bar chart (plt.bar() or sns.countplot()), not a histogram. Histograms are strictly for continuous data.
Using the same bin count for every dataset. The right number of bins depends on the size and range of your data. Always adjust to fit.
Overlaying groups of different sizes without density. If you overlay histograms for two groups with very different sample sizes using raw counts, the larger group will dominate every bin simply because there are more observations. Use stat='density' or separate panels with sharey=True for a fair comparison.
You might call these the visualization equivalent of an “off-by-one error,” a classic programming bug. The code runs without errors, but the output leads you to the wrong conclusion. Programming education research (Guzdial, 2015) emphasizes that “the code runs” and “the code is correct” are two different things. Just because a histogram rendered doesn’t mean it’s telling the right story. Always take a second look.
What’s Next
Histograms are a starting point for understanding your data visually. The concept of distribution carries forward into other visualization methods like box plots and violin plots, which show distribution information in more compact forms. Understanding distribution shape also directly affects which statistical methods are appropriate for your data, since techniques designed for normally distributed data don’t always work well with heavily skewed data.
Resources
Official Python documentation:
Additional learning:
- Python Graph Gallery: Histogram – A collection of histogram examples with code
- seaborn Tutorial: Visualizing distributions
References cited in this post:
- Cairo, A. (2016). The Truthful Art: Data, Charts, and Maps for Communication. New Riders.
- Guzdial, M. (2015). Learner-Centered Design of Computing Education: Research on Computing for Everyone. Morgan & Claypool.
- Schwabish, J. (2021). Better Data Visualizations: A Guide for Scholars, Researchers, and Wonks. Columbia University Press.
- Tufte, E. (1983). The Visual Display of Quantitative Information. Graphics Press.
