Best Plot Type for Salary Distribution, Median, Quartiles, and Outliers: Histogram vs Box Plot vs Violin

โฑ๏ธ 12 min read ๐Ÿ“Š Visualization

For salary data where the question is the median, the quartiles, and who sits far above or below everyone else, the best default is a box plot: it draws exactly those numbers and stays readable across many departments. A box plot hides the shape of the distribution and how many people are behind it, though, so overlay the individual points when groups are small, and pair it with a histogram, violin, or ECDF when groups are large or the shape matters.

Quick answer: Use a box plot to show a salary distribution's median (the line), quartiles (the box), and outliers (points more than 1.5 ร— IQR beyond the box). With only a few dozen people per group or fewer, draw every salary as a point (strip or beeswarm plot) on top of the box or instead of it. With hundreds or thousands of people, use a histogram or density plot for one group, violins or raincloud plots to compare shapes across groups, and an ECDF when readers need exact percentiles. Always label how many people are in each group.

What is the best plot type for salary distribution, median, quartiles, and outliers?

A box plot. The box spans the first quartile (Q1, the 25th percentile) to the third quartile (Q3, the 75th percentile), the line inside it is the median, the whiskers reach the most extreme salaries within 1.5 ร— IQR of the box (IQR = Q3 โˆ’ Q1), and anything beyond the whiskers is drawn as an individual point. That is John Tukey's convention and the default in seaborn, matplotlib, and ggplot2. Because each group takes only a narrow column, you can line up a dozen departments side by side, sorted by median, and compare them on one axis. For a refresher on the anatomy, see box plots explained.

The box plot answers the question as asked, but it is rarely the whole answer. Add a second layer depending on your data:

What does each distribution plot show and hide?

Every distribution plot trades detail for readability. The table summarizes what each one puts on the page, what it leaves out or distorts, and where it earns its place. If your question is not about a distribution at all (a trend, a ranking, a part-to-whole), start from the general chart selection guide instead.

Plot Shows Hides or distorts Best for
Histogram Shape: skew, peaks, gaps, where most values sit Exact median and quartiles; shape shifts with bin width and bin start One distribution with enough values to fill the bins
Density (KDE) Smoothed shape; easy to overlay a few groups Sample size; bandwidth can hide or invent bumps; tails can run past the real min and max Comparing the shape of two to four large groups
Box plot Median, quartiles, IQR, whisker range, flagged outliers Multiple peaks, gaps, and sample size Comparing center and spread across many groups
Violin Mirrored density shape per group; quartiles if drawn inside Sample size (equal area by default); smoothing artifacts in small groups Comparing shapes across several large groups
Strip / jitter Every individual value Summary statistics unless added; points overplot at large n Small groups, and making a small n obvious
Beeswarm Every value, packed without overlap so width reads like a histogram Breaks down at large n when points no longer fit Small to moderate groups
Raincloud Half density, raw points, and usually a slim box plot Little, but it takes space and gets busy with many groups Moderate groups where shape, summary, and data all matter
ECDF Every value; any percentile read straight off the y-axis Peaks appear only as steep stretches; unfamiliar to many readers Precise group comparisons; "what share earns under $X"

When should I use a histogram or a density plot?

Use a histogram when you have one distribution with enough values to fill the bins and you want to see its shape: whether salaries are right-skewed, whether there are two pay bands, whether values pile up at round numbers. The catch is bin width. Too few bins hide structure and too many turn it into noise, so try two or three widths before you trust what you see; the histogram vs bar chart tip covers bin-width rules. seaborn's histplot picks bins with NumPy's "auto" rule by default, while ggplot2's geom_histogram uses 30 bins and prints a message asking you to choose a better value.

A kernel density estimate (KDE) replaces the bars with a smooth curve built by summing a small bump (the kernel) centered on each observation. Density curves are easier to overlay for two to four groups than overlapping histograms, but they trade bin width for bandwidth: seaborn defaults to Scott's rule and ggplot2 to bw = "nrd0" (Silverman's rule of thumb), and you scale the default with bw_adjust in seaborn or adjust in ggplot2. A density curve also hides sample size, since 15 salaries and 15,000 produce equally confident-looking curves, and its tails can spill past the lowest and highest real salary, even below zero.

Salaries within an organization are usually right-skewed, so a log-scaled x-axis often reveals more than a linear one: the bulk of the staff spreads out instead of being squeezed into the left third of the chart by a few very high earners.

When is a box plot better than a violin plot?

Choose a box plot when readers need the numbers (median, quartiles, outliers), when you are comparing many groups, or when the audience is not statistical, because far more people can read a box plot than a violin. Choose a violin when the shape is the finding, typically because a group contains two clusters, such as two pay grades mixed in one department. A box plot draws that bimodal group as one ordinary-looking box; a violin shows the narrow waist between the two bulges.

Violins have two defaults to watch. First, both seaborn (density_norm="area") and ggplot2 (scale = "area") give every violin the same area, so a department of 12 looks as substantial as one of 1,200. Switch to density_norm="count" or scale = "count" to make width reflect headcount, or label n under each group. Second, a violin is a density estimate, so everything above about bandwidth applies, and small groups get smooth, confident shapes they have not earned. Drawing quartile lines or a slim box plot inside each violin gives you both views in one mark.

When should I show every point with a strip plot or beeswarm?

Show the raw points whenever groups are small. With a few dozen values or fewer, summaries are fragile (adding or removing one person can move a quartile noticeably), and a box plot or violin makes five people look as well characterized as five hundred. A strip plot draws each value as a dot and adds random jitter along the category axis so identical salaries do not hide behind each other. A beeswarm places the points deterministically so that none overlap, which makes the width of the cloud act like a sideways histogram.

Both break down at scale. Strip plots turn into solid blobs once thousands of points overlap, even with transparency, and a beeswarm simply runs out of room: seaborn's swarmplot warns you when a percentage of the points cannot be placed. For small and moderate groups, the best general-purpose combination is a box plot with its outlier markers turned off and the jittered points drawn on top.

What is a raincloud plot?

A raincloud plot combines three layers: a half violin or density curve (the cloud), the raw data points beside it (the rain), and usually a slim box plot. Micah Allen and colleagues described it in a 2019 paper as a way to show distribution shape, summary statistics, and raw data at once. It is the most complete single view for moderate-sized groups, at the cost of space. Once you are comparing more than a handful of groups it gets busy, and a row of box plots with points is easier to scan.

When should I use an ECDF plot?

Use an empirical cumulative distribution function (ECDF) plot when you need precise comparisons or percentiles. For every salary on the x-axis, it plots the proportion of people earning that amount or less, so you read the median where the curve crosses 0.5, the quartiles at 0.25 and 0.75, and "what share earns under $80k" directly. There are no bins or bandwidth to tune, and every observation is represented. When you compare groups, a curve that sits entirely to the right of another means that group earns more at every percentile; curves that cross mean the groups differ in spread or shape, not just in level.

The drawback is familiarity. Peaks in the distribution show up only as steep stretches of the curve, and many business audiences have never seen an ECDF. It is an excellent analyst's plot and a good appendix plot; lead with a box plot or histogram for general readers. Box plots and ECDFs both encode values as positions along a common scale, which Cleveland and McGill's 1984 graphical perception experiments ranked as the most accurately judged encoding.

How does sample size change which plot to use?

Check sample size first, because it decides whether summaries and smooth curves can be trusted or whether you should simply show the data. Treat these as rough bands, not hard cutoffs:

What does a box plot show for a real salary example?

Take one team of 13 people with these annual salaries, in thousands of dollars, already sorted: 48, 52, 55, 57, 58, 60, 62, 65, 68, 72, 78, 95, 160. With the default quartile method in NumPy, pandas, and R, which is also what Excel's QUARTILE.INC uses (linear interpolation between sorted values), the box plot is built from these numbers:

Statistic Calculation Value ($k)
Median 7th of 13 sorted values 62
Q1 (25th percentile) Position 1 + 0.25 ร— 12 = 4th value 57
Q3 (75th percentile) Position 1 + 0.75 ร— 12 = 10th value 72
IQR 72 โˆ’ 57 15
Lower fence 57 โˆ’ 1.5 ร— 15 34.5
Upper fence 72 + 1.5 ร— 15 94.5
Whiskers Most extreme values inside the fences 48 to 78
Outliers Values beyond the fences 95 and 160
Mean (for comparison) 930 รท 13 71.5

The plot tells you three things a single average would not: the typical person earns $62k, the middle half earn between $57k and $72k, and two people sit well above everyone else. The mean of $71.5k is about $9.5k above the median because of those two salaries, which is why the median is the right center for pay data.

Now the caveats. The $95k salary clears the upper fence by only $500. Compute the quartiles with Excel's QUARTILE.EXC instead and you get Q1 = 56 and Q3 = 75, an IQR of 19, and an upper fence of 103.5, so $95k is no longer an outlier. Draw the same box plot on a log scale (seaborn's log_scale=True or ggplot2's scale_y_log10()) and the quartiles and fences are computed on the logged salaries, which puts the fences at roughly $40k and $102k, so again only $160k is flagged. Outlier status on a box plot depends on the method, and how quartiles are defined matters most in small samples. Finally, with only 13 values, the honest chart shows all 13: a box plot with the points on top, not a histogram or violin.

import numpy as np
import seaborn as sns

salaries = np.array([48, 52, 55, 57, 58, 60, 62, 65, 68, 72, 78, 95, 160])  # $k

q1, med, q3 = np.percentile(salaries, [25, 50, 75])   # 57.0, 62.0, 72.0
iqr = q3 - q1                                          # 15.0
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr                # 34.5, 94.5
print(salaries[(salaries < lo) | (salaries > hi)])     # [ 95 160]

# Box plot with every salary on top (fliers off so 95 and 160 are not drawn twice)
ax = sns.boxplot(x=salaries, showfliers=False, width=0.4)
sns.stripplot(x=salaries, color="black", size=5, ax=ax)

How do I make these distribution plots in seaborn?

Each plot is one or two calls in seaborn 0.13 or later, given a DataFrame df with one row per employee and columns dept and salary. Draw each block on its own figure, or pass ax= to place it in a subplot.

import seaborn as sns

# One distribution: histogram on a log axis (salaries are usually right-skewed)
sns.histplot(data=df, x="salary", bins="auto", log_scale=True)

# Shape per group: density curves, each normalized on its own, stopped at the data range
sns.kdeplot(data=df, x="salary", hue="dept", common_norm=False, cut=0)

# Median, quartiles, outliers, plus every point
ax = sns.boxplot(data=df, x="dept", y="salary", showfliers=False, width=0.5)
sns.stripplot(data=df, x="dept", y="salary", jitter=0.2, alpha=0.4,
              color="black", size=3, ax=ax)

# Violins with quartile lines, width proportional to headcount
sns.violinplot(data=df, x="dept", y="salary", inner="quart", cut=0,
               density_norm="count")

# Beeswarm: non-overlapping points (small to moderate groups only)
sns.swarmplot(data=df, x="dept", y="salary", size=3)

# ECDF: median where each curve crosses 0.5, quartiles at 0.25 and 0.75
sns.ecdfplot(data=df, x="salary", hue="dept")

# Raincloud approximation: half violin + slim box + jittered points
ax = sns.violinplot(data=df, x="salary", y="dept", split=True, inner=None, cut=0)
sns.boxplot(data=df, x="salary", y="dept", width=0.12, showfliers=False, ax=ax)
sns.stripplot(data=df, x="salary", y="dept", jitter=0.08, size=2,
              alpha=0.5, color="black", ax=ax)

The half violin comes from split=True without a hue, which seaborn supports from version 0.13; older versions require a hue variable with two levels. The points sit on top of the box rather than falling below the cloud, so treat it as a close approximation of a raincloud rather than the canonical layout.

How do I make these distribution plots in ggplot2?

The ggplot2 versions use the same df. Beeswarms need the ggbeeswarm package, and the density half of the raincloud comes from ggdist. The first two lines reproduce the worked example's quartiles.

library(ggplot2)

salaries <- c(48, 52, 55, 57, 58, 60, 62, 65, 68, 72, 78, 95, 160)
quantile(salaries, c(0.25, 0.5, 0.75))        # 57 62 72 (type 7, R's default)
quantile(salaries, c(0.25, 0.75), type = 6)   # 56 75, same as Excel QUARTILE.EXC

# Histogram on a log axis with dollar labels
ggplot(df, aes(x = salary)) +
  geom_histogram(bins = 40) +
  scale_x_log10(labels = scales::label_dollar())

# Density curves per group; adjust < 1 means less smoothing
ggplot(df, aes(x = salary, colour = dept)) +
  geom_density(adjust = 0.8)

# Box plot plus every point; jitter horizontally only so salaries are not altered
ggplot(df, aes(x = dept, y = salary)) +
  geom_boxplot(outlier.shape = NA, width = 0.5) +
  geom_jitter(width = 0.2, height = 0, alpha = 0.4)

# Violins sized by headcount, with a slim box plot inside
ggplot(df, aes(x = dept, y = salary)) +
  geom_violin(scale = "count") +
  geom_boxplot(width = 0.1, outlier.shape = NA)

# Beeswarm
ggplot(df, aes(x = dept, y = salary)) +
  ggbeeswarm::geom_beeswarm(size = 1)

# ECDF
ggplot(df, aes(x = salary, colour = dept)) +
  stat_ecdf()

# Raincloud: half-eye density, slim box, jittered points
ggplot(df, aes(x = dept, y = salary)) +
  ggdist::stat_halfeye(adjust = 0.5, width = 0.6, justification = -0.2,
                       .width = 0, point_colour = NA) +
  geom_boxplot(width = 0.12, outlier.shape = NA) +
  geom_jitter(width = 0.05, height = 0, alpha = 0.3)

What are the most common mistakes when plotting distributions?

Most distribution-plot errors come from trusting defaults or reading a convention as a finding. These are the ones that change conclusions:

Pro Tip: Put the group size in the category label, as in "Engineering (n=240)". It costs nothing, and it is the one fact that box plots, violins, and density curves all hide, so readers can decide for themselves how much weight an unusual shape or a stray outlier deserves.

โ† Back to Visualization Tips