1Bar Charts and Histograms
▶
Bar charts and histograms are two of the most fundamental and widely used tools in data visualization, particularly in engineering and scientific contexts. Although they look superficially similar — both display rectangular bars — they serve entirely different purposes. A bar chart is used to compare discrete, categorical quantities: the tensile strength of five different alloys, the failure count across different machine types, or the average response time of several algorithms. A histogram, by contrast, is used to examine the distribution of a single continuous numerical variable: how are the measured diameters of a batch of machined parts spread across a range? Are they clustered tightly around the target value, or is there significant spread? Understanding which tool to use — and how to use it effectively — is essential for communicating data-driven findings clearly and accurately.
In Python, both chart types are produced using Matplotlib, specifically through the plt.bar() and plt.hist() functions. These functions are highly configurable, and mastering their parameters allows you to produce publication-quality, information-dense figures with relatively little code.
Creating a Bar Chart
The most basic bar chart requires only two arguments: a sequence of category labels (or positions) for the x-axis, and a corresponding sequence of numerical values for the heights of the bars. Consider a simple engineering scenario where you want to compare the average yield strength (in MPa) of four structural materials:
import matplotlib.pyplot as plt
import numpy as np
materials = ['Steel', 'Aluminum', 'Titanium', 'Copper']
yield_strength = [250, 95, 880, 70]
plt.bar(materials, yield_strength)
plt.xlabel('Material')
plt.ylabel('Yield Strength (MPa)')
plt.title('Average Yield Strength by Material')
plt.tight_layout()
plt.show()
This produces a clean, readable chart. However, Matplotlib's plt.bar() function accepts many additional keyword arguments that dramatically improve the chart's visual quality and informational content.
The width parameter controls how wide each bar is, expressed as a fraction of the unit interval between bar positions (the default is 0.8). Reducing it to, say, 0.5 creates gaps between bars and gives each bar a slimmer profile, which can be useful when you want to visually emphasize that the categories are discrete and unrelated. Increasing it closer to 1.0 makes the bars nearly touching, which can emphasize comparisons across categories. The color parameter accepts a single color string to apply uniformly, but more powerfully, it accepts a list of colors — one per bar — enabling you to visually distinguish individual categories without a legend:
colors = ['steelblue', 'darkorange', 'forestgreen', 'firebrick']
plt.bar(materials, yield_strength, width=0.6, color=colors)
This is a common technique in engineering dashboards where each category has a persistent color identity across multiple figures.
Error bars are a critical addition to bar charts in engineering, where measurements always carry uncertainty. The yerr parameter accepts either a scalar (the same error applied to all bars), a list (a different error per bar), or a 2×N array (asymmetric errors, where the first row is the lower error and the second is the upper error). Adding error bars transforms a bar chart from a simple comparison into a statistically honest one:
yield_strength = [250, 95, 880, 70]
errors = [12, 5, 35, 4] # one standard deviation for each material
plt.bar(materials, yield_strength, width=0.6, color=colors,
yerr=errors, capsize=6, error_kw={'elinewidth': 1.5, 'ecolor': 'black'})
Here, capsize controls the width of the horizontal cap at the end of each error bar, and error_kw is a dictionary of keyword arguments passed directly to the underlying error bar rendering. Without error bars, a reader might incorrectly conclude that the difference between two similar values is meaningful when it falls within measurement noise.
Grouped bar charts allow you to compare multiple subcategories side by side within each categorical group. Matplotlib does not have a single function call for grouped bars; instead, you construct them by making multiple calls to plt.bar(), each time offsetting the x-positions by the bar width. Suppose you want to compare yield strength and ultimate tensile strength (UTS) for the same four materials:
materials = ['Steel', 'Aluminum', 'Titanium', 'Copper']
yield_strength = [250, 95, 880, 70]
uts = [400, 140, 950, 220]
x = np.arange(len(materials)) # numerical positions: [0, 1, 2, 3]
bar_width = 0.35
fig, ax = plt.subplots()
bars1 = ax.bar(x - bar_width/2, yield_strength, width=bar_width,
label='Yield Strength', color='steelblue')
bars2 = ax.bar(x + bar_width/2, uts, width=bar_width,
label='UTS', color='darkorange')
ax.set_xticks(x)
ax.set_xticklabels(materials)
ax.set_ylabel('Stress (MPa)')
ax.set_title('Mechanical Properties by Material')
ax.legend()
plt.tight_layout()
plt.show()
The key idea is that x - bar_width/2 shifts the first group of bars to the left of each tick mark, and x + bar_width/2 shifts the second group to the right. For three groups, you would use offsets of -bar_width, 0, and +bar_width, and so on. This pattern scales predictably to any number of subgroups.
Annotating Bar Charts
Annotations transform a visual chart into a self-contained communication tool. The reader should not need to squint at the y-axis to determine a bar's value — important values can and should be printed directly on or above the bars. Matplotlib provides two main mechanisms for this: the high-level ax.bar_label() function (available since Matplotlib 3.4) and the more powerful, lower-level plt.annotate() function.
plt.annotate() is far more capable than simple text placement. It supports rich customization of the annotation text, its position, and the arrow or line connecting the annotation to the data point it describes. Its core signature is:
plt.annotate(text, xy=(x_point, y_point), xytext=(x_text, y_text),
arrowprops=dict(arrowstyle='->', color='black'))
Here, xy is the point being annotated (the tip of the arrow), and xytext is where the annotation text will be placed. The arrowprops dictionary controls the appearance of the connecting arrow. The arrowstyle key accepts strings like '->', 'fancy', 'wedge', and 'simple', while connectionstyle can be used to curve the connecting line:
plt.annotate('Highest Strength',
xy=(2, 880),
xytext=(2.5, 750),
fontsize=10,
ha='left',
arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0.2'))
This draws a curved arrow from the text label to the top of the Titanium bar, clearly identifying the highest value without cluttering the chart.
For programmatic placement of value labels on every bar, it is most efficient to iterate over the bar container object returned by plt.bar(). Each element of this container is a Rectangle patch, and you can query its height and position dynamically:
bars = plt.bar(materials, yield_strength, color=colors)
for bar in bars:
height = bar.get_height()
x_pos = bar.get_x() + bar.get_width() / 2 # center of the bar
plt.annotate(f'{height} MPa',
xy=(x_pos, height),
xytext=(0, 4), # 4 points vertical offset
textcoords='offset points',
ha='center', va='bottom',
fontsize=9)
Using textcoords='offset points' and xytext=(0, 4) places the label 4 typographic points above the top of the bar, regardless of the data scale. This is preferable to adding a fixed numerical offset in data coordinates, which would behave inconsistently across charts with different y-axis scales.
The fontsize keyword controls the size of annotation text and accepts either an integer (point size) or a string such as 'small', 'medium', or 'large'. The ha (horizontal alignment) keyword accepts 'left', 'center', or 'right', and correspondingly va (vertical alignment) accepts 'bottom', 'center', or 'top'. When placing labels above bars, ha='center' and va='bottom' is the conventional choice, as it aligns the bottom of the text with the annotation point.
Creating a Histogram
A histogram answers a fundamentally different question than a bar chart: how is a set of continuous measurements distributed? Rather than plotting one bar per category, a histogram divides a continuous range of values into equally spaced bins and plots the count (or density) of observations that fall into each bin. The shape of the resulting chart reveals whether the data is symmetric, skewed, multimodal, or contains outliers.
The plt.hist() function takes a 1D array-like of numerical values as its primary argument. The bins parameter controls how many bins to use (default is 10), and its choice critically affects how the histogram looks. Too few bins obscures the shape of the distribution; too many introduces noise that hides the underlying pattern. Common guidance suggests using the square root of the sample size as a starting point:
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
# Simulated shaft diameter measurements (mm), target = 50.00 mm
diameters = np.random.normal(loc=50.00, scale=0.05, size=500)
plt.hist(diameters, bins=30)
plt.xlabel('Diameter (mm)')
plt.ylabel('Frequency')
plt.title('Distribution of Shaft Diameters')
plt.show()
The density=True parameter is extremely important for statistical work. When set, it normalizes the histogram so that the total area of all bars equals 1.0, converting raw counts into a probability density function (PDF) estimate. The y-axis then represents probability per unit of x, not count. This is essential when you want to overlay a theoretical probability distribution (such as a normal curve) on top of the histogram, because the theoretical curve also integrates to 1:
from scipy.stats import norm
fig, ax = plt.subplots()
ax.hist(diameters, bins=30, density=True, color='steelblue', alpha=0.7)
# Overlay the theoretical normal distribution
x = np.linspace(diameters.min(), diameters.max(), 300)
ax.plot(x, norm.pdf(x, loc=50.00, scale=0.05), 'r-', linewidth=2,
label='Theoretical Normal PDF')
ax.set_xlabel('Diameter (mm)')
ax.set_ylabel('Probability Density')
ax.legend()
plt.show()
Without density=True, the y-axis shows raw counts and the overlaid PDF curve would appear as a nearly flat line, because its values are typically much smaller than the raw counts.
The edgecolor parameter draws visible borders between adjacent bars. By default, histogram bars are drawn without borders, which can make them blend together visually, especially when printed in grayscale. Adding edgecolor='black' or edgecolor='white' gives each bin a distinct boundary and substantially improves clarity:
plt.hist(diameters, bins=30, density=True,
color='steelblue', edgecolor='black', linewidth=0.5)
When you have overlapping histograms — for example, comparing the diameter measurements from two different manufacturing machines — the alpha parameter controls transparency, where 0.0 is fully transparent and 1.0 is fully opaque. Setting alpha to around 0.5 to 0.6 allows both histograms to be visible simultaneously, with the overlapping region showing a blended color that visually indicates where the distributions coincide:
machine_A = np.random.normal(loc=50.00, scale=0.04, size=500)
machine_B = np.random.normal(loc=50.02, scale=0.06, size=500)
plt.hist(machine_A, bins=30, alpha=0.6, color='steelblue', label='Machine A')
plt.hist(machine_B, bins=30, alpha=0.6, color='darkorange', label='Machine B')
plt.xlabel('Diameter (mm)')
plt.ylabel('Frequency')
plt.legend()
plt.show()
Without alpha, one histogram would completely obscure the other wherever they overlap, making the comparison uninformative.
Comparing Distributions with Histograms
One of the most common analytical tasks in engineering is comparing the distributions of two or more datasets: before vs. after a process change, two suppliers' parts, two sensor arrays measuring the same phenomenon. Matplotlib makes this straightforward because calling plt.hist() twice on the same axes overlays both histograms automatically. There is no need for any special "overlay" syntax — each successive call to plt.hist() draws on the currently active axes, stacking the visual layers in the order they are called:
fig, ax = plt.subplots(figsize=(8, 5))
ax.hist(machine_A, bins=40, density=True, alpha=0.55,
color='steelblue', edgecolor='white', linewidth=0.4,
label='Machine A (μ=50.00, σ=0.04)')
ax.hist(machine_B, bins=40, density=True, alpha=0.55,
color='darkorange', edgecolor='white', linewidth=0.4,
label='Machine B (μ=50.02, σ=0.06)')
ax.set_xlabel('Diameter (mm)', fontsize=12)
ax.set_ylabel('Probability Density', fontsize=12)
ax.set_title('Shaft Diameter Distributions: Machine A vs. Machine B')
ax.legend(fontsize=10)
plt.tight_layout()
plt.show()
When overlaying distributions, plt.legend() is not optional — it is essential. Without it, the reader has no way to determine which color corresponds to which dataset. The label parameter passed to each plt.hist() call is what populates the legend. Best practice is to include meaningful statistical information (mean, standard deviation, or sample size) in the label string itself, as shown above, so the chart is self-documenting.
A particularly powerful variant for engineering quality control is the cumulative histogram, enabled by setting cumulative=True. Rather than showing how many observations fall within each bin, a cumulative histogram shows how many observations fall at or below each value. When combined with density=True, the y-axis becomes a cumulative probability (ranging from 0 to 1), and the chart directly represents the empirical cumulative distribution function (ECDF). This is invaluable for reading off percentile thresholds: you can immediately see, for example, that 95% of Machine A's parts fall below a certain diameter, allowing direct comparison to engineering tolerances:
fig, ax = plt.subplots(figsize=(8, 5))
ax.hist(machine_A, bins=100, density=True, cumulative=True,
histtype='step', linewidth=2, color='steelblue', label='Machine A')
ax.hist(machine_B, bins=100, density=True, cumulative=True,
histtype='step', linewidth=2, color='darkorange', label='Machine B')
# Draw reference lines for the upper tolerance limit
ax.axvline(x=50.10, color='red', linestyle='--', linewidth=1.5,
label='Upper Tolerance Limit (50.10 mm)')
ax.axhline(y=0.95, color='gray', linestyle=':', linewidth=1.2,
label='95th Percentile')
ax.set_xlabel('Diameter (mm)')
ax.set_ylabel('Cumulative Probability')
ax.set_title('Cumulative Distribution of Shaft Diameters')
ax.legend()
plt.tight_layout()
plt.show()
Here, histtype='step' draws the histogram as an unfilled step curve rather than filled bars, which is a common choice for cumulative plots because it avoids the visual clutter of overlapping filled regions. The combination of density=True and cumulative=True produces a curve that rises from 0 at the minimum data value to 1.0 at the maximum. By adding reference lines with axvline() and axhline(), you can visually identify what fraction of parts from each machine fall within specification — a direct, actionable insight from a data visualization.
Together, bar charts and histograms form an indispensable pair. Whenever you encounter a dataset, asking "is this categorical or continuous?" immediately points you to the right tool. Bar charts compare distinct groups; histograms reveal the shape, spread, and central tendency of numerical measurements. With Matplotlib's extensive parameter set — controlling widths, colors, transparency, error bars, annotations, normalization, and cumulative representation — both chart types can be tailored precisely to the demands of rigorous engineering communication.