10  Bar

How large is each category? bar draws a rectangle from the baseline (zero) to the value for each row. Bars are vertical when the categories are on x and horizontal when they are on y; see Horizontal bars.

The bar chart exists because William Playfair had too little data. His Commercial and Political Atlas of 1786 drew England’s imports and exports as two curves across thirty years (Playfair, 1786). For Scotland he had a single year, and no curve can show one year. So he drew a bar for each of Scotland’s trading partners instead, and wrote that the result was the weaker picture for having no time in it. The form he apologized for became the one everyone knows.

Two names for it are still in use, and they name opposite orientations. Much of the business world, Excel included, calls this a column chart when the bars are vertical, and keeps bar chart for when they are horizontal. gog has one bar, and its direction comes from the sentence rather than from a choice. Whichever axis carries the category decides it, which is what Horizontal bars is about. A histogram is a third name, and it is a genuinely different plot drawn with the same mark. Karl Pearson coined the word in the 1890s (Pearson, 1895) for the picture of a distribution, where the bars are cut from a continuous axis and touch; that is bar * bin, in Histogram below.

10.1 Basic usage

Which country won the most gold medals? Everything in this chapter is added to one sentence, the smallest that draws a bar: a source, the mark, and its two positions.

medals: all 5 rows
country gold silver bronze
USA 46 37 38
China 38 31 22
Great Britain 29 17 19
Russia 19 18 9
Germany 17 10 15
data(medals) + bar + x(country) + y(gold)
data(medals) + bar + x(col.country) + y(col.gold)
data(medals) + bar + x(:country) + y(:gold)
plot(data(medals), bar, x(col.country), y(col.gold))
USA China Great Britain Russia Germany 0 10 20 30 40 Gold Country

“Given the medals: bars, x is country, y is gold.”

The x axis places a tick at each bar position automatically. The y axis reaches zero unless you state your own limits, so a bar’s length is its value.

10.2 Color

Every bar is the same color until a column separates them. A color of its own lets a reader find one country quickly, and match it to the same country in another plot. Bind color to the column that already sits on x. color fills each bar with a distinct color:

data(medals) + bar + x(country) + y(gold) +
  color(country)
(data(medals) + bar + x(col.country) + y(col.gold) +
  color(col.country))
data(medals) + bar + x(:country) + y(:gold) + color(:country)
plot(data(medals), bar, x(col.country), y(col.gold), color(col.country))
USA China Great Britain Russia Germany 0 10 20 30 40 Gold Country Country USA China Great Britain Russia Germany

“Given the medals: bars, x is country, y is gold, color by country.”

10.3 Ordering bars

In what order should the bars appear? A reader looking for the largest value wants it first. A reader looking for one name wants the names in alphabetical order. order() controls the sequence of categories along whichever axis carries them: x on a vertical plot, y on a horizontal one. You can sort on any column in the table: the categorical column itself, the measured column, or a third column entirely. Naming the categorical column asks for that column’s own order. Here that is alphabetical, because country is plain text. A column with a declared order uses that order instead.

Default, data order (rows as they appear in the table):

data(medals) + bar + x(country) + y(gold) +
  title("Gold medals: data order")
(data(medals) + bar + x(col.country) + y(col.gold) +
  title("Gold medals: data order"))
data(medals) + bar + x(:country) + y(:gold) +
  title("Gold medals: data order")
plot(data(medals), bar, x(col.country), y(col.gold),
  title("Gold medals: data order"))
USA China Great Britain Russia Germany 0 10 20 30 40 Gold medals: data order Gold Country

By value, descending, tallest bar first, the most common pattern for ranked comparisons:

data(medals) + bar + x(country) + y(gold) +
  order(gold, desc = TRUE) +
  title("Gold medals: largest first")
(data(medals) + bar + x(col.country) + y(col.gold) +
  order(col.gold, desc = True) +
  title("Gold medals: largest first"))
data(medals) + bar + x(:country) + y(:gold) + order(:gold, desc = true) +
  title("Gold medals: largest first")
plot(data(medals), bar, x(col.country), y(col.gold),
  order(col.gold, { desc: true }), title("Gold medals: largest first"))
USA China Great Britain Russia Germany 0 10 20 30 40 Gold medals: largest first Gold Country

“Given the medals: bars, x is country, y is gold, ordered by gold, largest first.”

These bars are in the same order as the bars in the data order plot above. The medals arrive with the leader at the top, so ordering by gold changes nothing here. Writing the order anyway is still worth it. The plot then stays sorted even if next year’s rows arrive differently. The plots below each put the bars in a different order.

By value, ascending, smallest first, useful when scanning for the lowest values:

data(medals) + bar + x(country) + y(gold) +
  order(gold) +
  title("Gold medals: smallest first")
(data(medals) + bar + x(col.country) + y(col.gold) +
  order(col.gold) +
  title("Gold medals: smallest first"))
data(medals) + bar + x(:country) + y(:gold) + order(:gold) +
  title("Gold medals: smallest first")
plot(data(medals), bar, x(col.country), y(col.gold), order(col.gold),
  title("Gold medals: smallest first"))
Germany Russia Great Britain China USA 0 10 20 30 40 Gold medals: smallest first Gold Country

By category name, A → Z, natural for names and labels:

data(medals) + bar + x(country) + y(gold) +
  order(country) +
  title("Gold medals: A to Z")
(data(medals) + bar + x(col.country) + y(col.gold) +
  order(col.country) +
  title("Gold medals: A to Z"))
data(medals) + bar + x(:country) + y(:gold) + order(:country) +
  title("Gold medals: A to Z")
plot(data(medals), bar, x(col.country), y(col.gold), order(col.country),
  title("Gold medals: A to Z"))
China Germany Great Britain Russia USA 0 10 20 30 40 Gold medals: A to Z Gold Country

By category name, Z → A, the reverse:

data(medals) + bar + x(country) + y(gold) +
  order(country, desc = TRUE) +
  title("Gold medals: Z to A")
(data(medals) + bar + x(col.country) + y(col.gold) +
  order(col.country, desc = True) +
  title("Gold medals: Z to A"))
data(medals) + bar + x(:country) + y(:gold) +
  order(:country, desc = true) + title("Gold medals: Z to A")
plot(data(medals), bar, x(col.country), y(col.gold),
  order(col.country, { desc: true }), title("Gold medals: Z to A"))
USA Russia Great Britain Germany China 0 10 20 30 40 Gold medals: Z to A Gold Country

By a third column, order countries by their silver medal count, while plotting gold:

data(medals) + bar + x(country) + y(gold) +
  order(silver, desc = TRUE) +
  title("Gold medals: ordered by silver")
(data(medals) + bar + x(col.country) + y(col.gold) +
  order(col.silver, desc = True) +
  title("Gold medals: ordered by silver"))
data(medals) + bar + x(:country) + y(:gold) +
  order(:silver, desc = true) + title("Gold medals: ordered by silver")
plot(data(medals), bar, x(col.country), y(col.gold),
  order(col.silver, { desc: true }),
  title("Gold medals: ordered by silver"))
USA China Russia Great Britain Germany 0 10 20 30 40 Gold medals: ordered by silver Gold Country
Expression Order
(none) Data order (rows as they appear)
order(gold) Ascending by gold (smallest first)
order(gold, desc = TRUE) Descending by gold (largest first)
order(country) A → Z by country name
order(country, desc = TRUE) Z → A by country name
order(silver, desc = TRUE) By any other column in the table

10.4 Duplicate categories

A table often holds several rows for one category, one per event or one per year. The question is still how large each category is in total. Without a transform, bar draws one bar per row, and rows that share a category share a slot. This table has two rows each for USA and GBR:

medals_dup <- data.frame(
  country = c("USA", "USA", "GBR", "GBR", "JPN"),
  gold    = c(10, 5, 8, 4, 6)
)
data(medals_dup) + bar + x(country) + y(gold) +
  title("Duplicate rows: no transform")
medals_dup = {
  "country": ["USA", "USA", "GBR", "GBR", "JPN"],
  "gold": [10, 5, 8, 4, 6],
}
(data(medals_dup) + bar + x(col.country) + y(col.gold) +
  title("Duplicate rows: no transform"))
medals_dup = (
  country = ["USA", "USA", "GBR", "GBR", "JPN"],
  gold = [10, 5, 8, 4, 6],
)
data(medals_dup) + bar + x(:country) + y(:gold) +
  title("Duplicate rows: no transform")
const medals_dup = {
  country: ["USA", "USA", "GBR", "GBR", "JPN"],
  gold: [10, 5, 8, 4, 6],
};
plot(data(medals_dup), bar, x(col.country), y(col.gold),
  title("Duplicate rows: no transform"))
USA GBR JPN 0 2 4 6 8 10 Duplicate rows: no transform Gold Country

Every bar grows from zero, so the shorter ones sit inside the tallest. USA has rows of 10 and 5, and its bar reads 10 rather than 15. The darker band ends at 5, which is the row hidden inside. That band is the only sign that a category has extra rows.

Use the sum transform to total the values per category first:

data(medals_dup) + bar * sum + x(country) + y(gold) +
  order(gold, desc = TRUE) + title("Totals: bar * sum")
(data(medals_dup) + bar * sum + x(col.country) + y(col.gold) +
  order(col.gold, desc = True) + title("Totals: bar * sum"))
data(medals_dup) + bar * sum + x(:country) + y(:gold) +
  order(:gold, desc = true) + title("Totals: bar * sum")
plot(data(medals_dup), layer(bar, sum), x(col.country), y(col.gold),
  order(col.gold, { desc: true }), title("Totals: bar * sum"))
USA GBR JPN 0 5 10 15 Totals: bar * sum Gold Country

“Given the medals dup table: bars derived by sum, x is country, y is gold, ordered by gold, largest first.”

sum gives one number for each category. It reads every row that shares a category and totals them. USA’s 10 and 5 become 15, and GBR’s 8 and 4 become 12. Five rows become three bars, one for each country, and the darker band is gone.

A negative value needs no special treatment. The bar extends from zero in whichever direction the value lies, so it reaches down instead of up.

balance <- data.frame(quarter = c("Q1", "Q2", "Q3", "Q4"),
                      profit  = c(12.0, -5.0, 8.0, -2.0))
data(balance) + bar + x(quarter) + y(profit) +
  title("Negative values grow downward from zero")
balance = {
  "quarter": ["Q1", "Q2", "Q3", "Q4"],
  "profit": [12.0, -5.0, 8.0, -2.0],
}
(data(balance) + bar + x(col.quarter) + y(col.profit) +
  title("Negative values grow downward from zero"))
balance = (
  quarter = ["Q1", "Q2", "Q3", "Q4"],
  profit = [12, -5, 8, -2],
)
data(balance) + bar + x(:quarter) + y(:profit) +
  title("Negative values grow downward from zero")
const balance = {
  quarter: ["Q1", "Q2", "Q3", "Q4"],
  profit: [12, -5, 8, -2],
};
plot(data(balance), bar, x(col.quarter), y(col.profit),
  title("Negative values grow downward from zero"))
Q1 Q2 Q3 Q4 -5 0 5 10 Negative values grow downward from zero Profit Quarter

To color the losses and the gains differently, start from what color requires: it maps a column, so that column has to exist. “Is this negative” is a fact about the data, not about the rectangle. So put it in a column, map that column, and let palette() say which colors the mapping uses. The table below types the column’s values in by hand. In practice you compute it from profit with one condition. Above zero is a profit, and anything else is a loss.

balance <- data.frame(
  quarter = c("Q1", "Q2", "Q3", "Q4"),
  profit  = c(12.0, -5.0, 8.0, -2.0),
  result  = factor(c("profit", "loss", "profit", "loss"),
                   levels = c("loss", "profit"))
)
data(balance) + bar + x(quarter) + y(profit) + color(result) +
  palette(c("firebrick", "seagreen")) +
  title("Red below zero, green above")
balance = {
  "quarter": ["Q1", "Q2", "Q3", "Q4"],
  "profit": [12.0, -5.0, 8.0, -2.0],
  "result": ordered(["profit", "loss", "profit", "loss"], ["loss", "profit"]),
}
(data(balance) + bar + x(col.quarter) + y(col.profit) + color(col.result) +
  palette(["firebrick", "seagreen"]) +
  title("Red below zero, green above"))
balance = (
  quarter = ["Q1", "Q2", "Q3", "Q4"],
  profit = [12, -5, 8, -2],
  result = ordered(["profit", "loss", "profit", "loss"], ["loss", "profit"]),
)
data(balance) + bar + x(:quarter) + y(:profit) + color(:result) +
  palette(["firebrick", "seagreen"]) +
  title("Red below zero, green above")
const balance = {
  quarter: ["Q1", "Q2", "Q3", "Q4"],
  profit: [12, -5, 8, -2],
  result: ordered(["profit", "loss", "profit", "loss"], ["loss", "profit"]),
};
plot(data(balance), bar, x(col.quarter), y(col.profit), color(col.result),
  palette(["firebrick", "seagreen"]),
  title("Red below zero, green above"))
Q1 Q2 Q3 Q4 -5 0 5 10 Red below zero, green above Profit Quarter Result loss profit

“Given the balance table: bars, x is quarter, y is profit, color by result, with a palette of firebrick and seagreen.”

palette() assigns its colors in category order, and that is why result carries a declared order here instead of being plain text. A column of plain text takes the order its values first appear in. The first row here is a profit, so the palette would give firebrick to the gains. Declaring the order as loss, then profit, sets it, and the legend reads in it too. The plot then cannot silently invert if next quarter’s numbers start with a loss. R spells that with a factor’s levels, and the other three with ordered(). This is the same mechanism as Declaring the order of categories.

Red for loss and green for gain is one common convention. It is also the hardest pair to separate for red-green color blindness, the most common kind. Texture solves that and keeps the colors, by giving each result its own hatch as well as its own color: see Texture below, and Will it survive a photocopier? in the cookbook.

10.5 Horizontal bars

Sometimes the bars should be horizontal. The category names may be too long for the bottom axis. Or there may be many categories, and a list of rows reads more easily than a row of columns. Swap the bindings. If you know ggplot2’s coord_flip() or matplotlib’s barh(), there is no such word here, because it would be a second way to say one thing. Put the categories on y and the measure on x:

data(medals) + bar + x(gold) + y(country) +
  title("Categories on y: horizontal bars")
(data(medals) + bar + x(col.gold) + y(col.country) +
  title("Categories on y: horizontal bars"))
data(medals) + bar + x(:gold) + y(:country) +
  title("Categories on y: horizontal bars")
plot(data(medals), bar, x(col.gold), y(col.country),
  title("Categories on y: horizontal bars"))
0 10 20 30 40 Germany Russia Great Britain China USA Categories on y: horizontal bars Country Gold

gog takes the orientation from the bindings. The axis with the number is the one the bars grow along, so their length is read on it. The axis with the category is the one the bars are placed on, one slot each. Nothing else changes: order, transforms such as sum and bin, color and style() all behave identically.

This is what you want whenever the category names are long, because a vertical axis gives each label a whole line instead of a cramped slot:

departments: all 5 rows
department headcount
Research & Development 128
Sales and Marketing 96
Customer Operations 74
Finance 31
People & Culture 22
data(departments) + bar + x(headcount) + y(department) +
  order(headcount, desc = TRUE) +
  x_label("Headcount") +
  title("Long labels fit on a vertical axis")
(data(departments) + bar + x(col.headcount) + y(col.department) +
  order(col.headcount, desc = True) +
  x_label("Headcount") +
  title("Long labels fit on a vertical axis"))
data(departments) + bar + x(:headcount) + y(:department) +
  order(:headcount, desc = true) + x_label("Headcount") +
  title("Long labels fit on a vertical axis")
plot(data(departments), bar, x(col.headcount), y(col.department),
  order(col.headcount, { desc: true }), x_label("Headcount"),
  title("Long labels fit on a vertical axis"))
0 50 100 People & Culture Finance Customer Operations Sales and Marketing Research & Development Long labels fit on a vertical axis Department Headcount

order means the same thing in both orientations: the first category in sort order is drawn first. Descending puts the largest bar leftmost on a vertical plot and topmost on a horizontal one.

Orientation is only ambiguous if neither axis is categorical. In that case gog draws vertically, so x(year) + y(sales) is a column chart. To lay that out horizontally, name the measure on x.

A bar needs something to measure, so two categorical axes are refused. What it refuses shows that refusal.

10.6 Texture

A bar’s fill can carry a texture as well as a color: a hatch instead of a solid block of paint, set with style(pattern = ). A texture reads without color, so a hatched bar chart stays legible in grayscale or a black-and-white print:

data(medals) + bar + x(country) + y(gold) +
  style(pattern = "hatch") +
  title("A hatched bar reads without color")
(data(medals) + bar + x(col.country) + y(col.gold) +
  style(pattern = "hatch") +
  title("A hatched bar reads without color"))
data(medals) + bar + x(:country) + y(:gold) + style(pattern = "hatch") +
  title("A hatched bar reads without color")
plot(data(medals), bar, x(col.country), y(col.gold),
  style({ pattern: "hatch" }), title("A hatched bar reads without color"))
USA China Great Britain Russia Germany 0 10 20 30 40 A hatched bar reads without color Gold Country

“Given the medals: bars, x is country, y is gold, with pattern hatch.”

The five fill textures are "solid" (the default), "hatch", "crosshatch", "grid", and "dots"; the style chapter shows them beside the dashes a stroke takes, because style(pattern = ) is one name with one meaning per shape. One texture covers the whole layer; to give each color category its own hatch, map a column with the pattern() channel, which does for texture what shape() does for glyphs.

10.7 Histogram

How are the world’s life expectancies spread out, and where do most countries fall? That is a question about one continuous column, and a bar needs a category to stand on. Combine bar with the bin transform to create a histogram. bin groups continuous x values into equal-width bins and counts the rows in each:

gapminder_2007: first 5 of 142 rows
country continent year life population gdp
Afghanistan Asia 2007 43.828 31889923 974.5803
Albania Europe 2007 76.423 3600523 5937.0295
Algeria Africa 2007 72.301 33333216 6223.3675
Angola Africa 2007 42.731 12420476 4797.2313
Argentina Americas 2007 75.320 40301927 12779.3796
data(gapminder_2007) + bar * bin + x(life) + y(count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Histogram of life expectancy, 2007")
(data(gapminder_2007) + bar * bin + x(col.life) + y(col.count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Histogram of life expectancy, 2007"))
data(gapminder_2007) + bar * bin + x(:life) + y(:count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Histogram of life expectancy, 2007")
plot(data(gapminder_2007), layer(bar, bin), x(col.life), y(col.count),
  x_label("Life expectancy (years)"), y_label("Count"),
  title("Histogram of life expectancy, 2007"))
50 60 70 80 0 10 20 30 Histogram of life expectancy, 2007 Count Life expectancy (years)

“Given gapminder 2007: bars derived by bin, x is life, y is count.”

Thickness is automatic, and it is the clearest way a histogram and a bar chart differ on the page. A categorical bar fills 80% of the slot its category owns, and the empty fifth is what reads as “separate categories”. A histogram cuts a continuous axis into adjacent intervals, so nothing sits between one bin and the next and the bars touch, with a thin line in the panel’s background color to keep them readable. You set nothing in either plot. The bar chart at the top of this chapter and this histogram both take their thickness from the axis the bars sit on.

How fine the cut is decides what the histogram shows. Left alone, bin picks the number of bins from the number of rows, by Sturges’ rule. Name a width to cut the axis in the data’s own units, here five years:

data(gapminder_2007) + bar * bin(width = 5) + x(life) + y(count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Bins five years wide")
(data(gapminder_2007) + bar * bin(width = 5) + x(col.life) + y(col.count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Bins five years wide"))
data(gapminder_2007) + bar * bin(width = 5) + x(:life) + y(:count) +
  x_label("Life expectancy (years)") + y_label("Count") +
  title("Bins five years wide")
plot(data(gapminder_2007), layer(bar, bin({ width: 5 })), x(col.life),
  y(col.count), x_label("Life expectancy (years)"), y_label("Count"),
  title("Bins five years wide"))
50 60 70 80 0 10 20 30 Bins five years wide Count Life expectancy (years)

“Given gapminder 2007: bars derived by bin at width 5, x is life, y is count.”

bin(30) sets a count instead. Both belong to the transform, and Choosing the bin count shows what each does to the picture.

A histogram turns on its side by the same rule. Bind the measured column to y and the count is drawn along x:

data(gapminder_2007) + bar * bin + y(life) +
  y_label("Life expectancy (years)") +
  title("The same histogram, horizontal")
(data(gapminder_2007) + bar * bin + y(col.life) +
  y_label("Life expectancy (years)") +
  title("The same histogram, horizontal"))
data(gapminder_2007) + bar * bin + y(:life) +
  y_label("Life expectancy (years)") +
  title("The same histogram, horizontal")
plot(data(gapminder_2007), layer(bar, bin), y(col.life),
  y_label("Life expectancy (years)"),
  title("The same histogram, horizontal"))
0 10 20 30 50 60 70 80 The same histogram, horizontal Life expectancy (years) Count

10.7.1 Overlaid histograms: one distribution per group

Add color and the histogram splits: one histogram per category, cut on the same bins and drawn in the same panel. This answers the question a single combined histogram cannot: not “how is petal length distributed?” but “how is it distributed for each species?

iris_flowers: first 5 of 150 rows
sepal_length sepal_width petal_length species
5.1 3.5 1.4 setosa
4.9 3.0 1.4 setosa
4.7 3.2 1.3 setosa
4.6 3.1 1.5 setosa
5.0 3.6 1.4 setosa
data(iris_flowers) + bar * bin + x(petal_length) + color(species) +
  x_label("Petal length (cm)") + title("Petal length by species")
(data(iris_flowers) + bar * bin + x(col.petal_length) + color(col.species) +
  x_label("Petal length (cm)") + title("Petal length by species"))
data(iris_flowers) + bar * bin + x(:petal_length) + color(:species) +
  x_label("Petal length (cm)") + title("Petal length by species")
plot(data(iris_flowers), layer(bar, bin), x(col.petal_length),
  color(col.species), x_label("Petal length (cm)"),
  title("Petal length by species"))
2 4 6 0 10 20 30 40 Petal length by species Count Petal length (cm) Species setosa versicolor virginica

“Given the iris flowers: bars derived by bin, x is petal length, color by species.”

The three histograms overlay: each drawn in place, measured from the shared baseline. If the fills were opaque, the last species drawn would hide the others. So each bar is a translucent fill under a solid outline in its own color. The outline is what keeps the shape readable where the fills overlap. setosa sits alone on the left and reads clearly. versicolor and virginica overlap in the middle, and their outlines still separate them. The bins are shared across the groups, so the bars align and the overlap is real rather than an artifact of three different binnings.

The fill is faint by default so the series underneath show through. style(opacity = ) sets how faint; the outline stays solid whatever you choose:

data(iris_flowers) + bar * bin + x(petal_length) + color(species) +
  style(opacity = 0.6) + x_label("Petal length (cm)") +
  title("A heavier fill: style(opacity = 0.6)")
(data(iris_flowers) + bar * bin + x(col.petal_length) + color(col.species) +
  style(opacity = 0.6) + x_label("Petal length (cm)") +
  title("A heavier fill: style(opacity = 0.6)"))
data(iris_flowers) + bar * bin + x(:petal_length) + color(:species) +
  style(opacity = 0.6) + x_label("Petal length (cm)") +
  title("A heavier fill: style(opacity = 0.6)")
plot(data(iris_flowers), layer(bar, bin), x(col.petal_length),
  color(col.species), style({ opacity: 0.6 }),
  x_label("Petal length (cm)"),
  title("A heavier fill: style(opacity = 0.6)"))
2 4 6 0 10 20 30 40 A heavier fill: style(opacity = 0.6) Count Petal length (cm) Species setosa versicolor virginica

“Given the iris flowers: bars derived by bin, x is petal length, color by species, with opacity 0.6.”

The outline is the series color by default, but you can recolor it: style(border_color = ) sets the border, style(border_size = ) its width. A black border separates the overlapping fills, the way many overlaid histograms are drawn. It also stays visible against the light panel, where a white one fades:

data(iris_flowers) + bar * bin + x(petal_length) + color(species) +
  style(border_color = "black", border_size = 1.5) +
  x_label("Petal length (cm)") + title("Black borders between the fills")
(data(iris_flowers) + bar * bin + x(col.petal_length) + color(col.species) +
  style(border_color = "black", border_size = 1.5) +
  x_label("Petal length (cm)") + title("Black borders between the fills"))
data(iris_flowers) + bar * bin + x(:petal_length) + color(:species) +
  style(border_color = "black", border_size = 1.5) +
  x_label("Petal length (cm)") + title("Black borders between the fills")
plot(data(iris_flowers), layer(bar, bin), x(col.petal_length),
  color(col.species), style({ border_color: "black", border_size: 1.5 }),
  x_label("Petal length (cm)"), title("Black borders between the fills"))
2 4 6 0 10 20 30 40 Black borders between the fills Count Petal length (cm) Species setosa versicolor virginica

“Given the iris flowers: bars derived by bin, x is petal length, color by species, with border color black and border size 1.5.”

The fill and the outline are independent: a bar can be one color inside and another at its edge, whether the fill was mapped (as here) or set. See Setting vs mapping.

Or remove the outline entirely. style(border_size = 0) draws no border, so the translucent fills overlap with nothing between them. This is the plainest overlaid histogram:

data(iris_flowers) + bar * bin + x(petal_length) + color(species) +
  style(border_size = 0) +
  x_label("Petal length (cm)") + title("No border: just the fills")
(data(iris_flowers) + bar * bin + x(col.petal_length) + color(col.species) +
  style(border_size = 0) +
  x_label("Petal length (cm)") + title("No border: just the fills"))
data(iris_flowers) + bar * bin + x(:petal_length) + color(:species) +
  style(border_size = 0) + x_label("Petal length (cm)") +
  title("No border: just the fills")
plot(data(iris_flowers), layer(bar, bin), x(col.petal_length),
  color(col.species), style({ border_size: 0 }),
  x_label("Petal length (cm)"), title("No border: just the fills"))
2 4 6 0 10 20 30 40 No border: just the fills Count Petal length (cm) Species setosa versicolor virginica

“Given the iris flowers: bars derived by bin, x is petal length, color by species, with border size 0.”

When the groups overlap so much that the outlines run together, give each species its own panel instead. Adding | facet(species) to the sentence draws the same three histograms side by side, on one shared scale.

See Transforms for details on bin and other transforms.

10.8 Grouped bars: side by side

How did each continent’s life expectancy change between 1957 and 2007? That is a comparison inside each category, so each continent needs two bars, one per era. A color split alone draws both bars in one slot, the shorter inside the taller. To set them side by side as a grouped bar chart, add dodge. Each group’s bar narrows to share the slot, and the split reads as a comparison within each category. The gm_eras table holds two years, 1957 and 2007, with one row per country in each. mean averages those rows down to one value per continent, and era is the column that splits them.

gm_eras: first 5 of 284 rows
country continent year life population gdp era
Afghanistan Asia 1957 30.332 9240934 820.8530 1957
Afghanistan Asia 2007 43.828 31889923 974.5803 2007
Albania Europe 1957 59.280 1476505 1942.2842 1957
Albania Europe 2007 76.423 3600523 5937.0295 2007
Algeria Africa 1957 45.685 10270856 3013.9760 1957
data(gm_eras) + bar * mean * dodge + x(continent) + y(life) + color(era) +
  y_label("Mean life expectancy") +
  title("Mean life expectancy by continent, 1957 vs 2007")
(data(gm_eras) + bar * mean * dodge + x(col.continent) + y(col.life) + color(col.era) +
  y_label("Mean life expectancy") +
  title("Mean life expectancy by continent, 1957 vs 2007"))
data(gm_eras) + bar * mean * dodge + x(:continent) + y(:life) +
  color(:era) + y_label("Mean life expectancy") +
  title("Mean life expectancy by continent, 1957 vs 2007")
plot(data(gm_eras), layer(bar, mean, dodge), x(col.continent),
  y(col.life), color(col.era), y_label("Mean life expectancy"),
  title("Mean life expectancy by continent, 1957 vs 2007"))
Asia Europe Africa Americas Oceania 0 20 40 60 80 Mean life expectancy by continent, 1957 vs 2007 Mean life expectancy Continent Era 1957 2007

“Given the gapminder eras: bars derived by mean and dodge, x is continent, y is life, color by era.”

dodge is a collision modifier: it moves the bars rather than summarizing them, so it composes with the statistic (bar * mean * dodge here, bar * count * dodge for tallies). See Transforms for the whole family.

mean is the right statistic here, and gog will not choose it for you. Write sum in its place and the sentence still draws:

data(gm_eras) + bar * sum * dodge + x(continent) + y(life) + color(era) +
  y_label("Sum of life expectancies") +
  title("Sum in place of mean: legal, and meaningless")
(data(gm_eras) + bar * sum * dodge + x(col.continent) + y(col.life) + color(col.era) +
  y_label("Sum of life expectancies") +
  title("Sum in place of mean: legal, and meaningless"))
data(gm_eras) + bar * sum * dodge + x(:continent) + y(:life) +
  color(:era) + y_label("Sum of life expectancies") +
  title("Sum in place of mean: legal, and meaningless")
plot(data(gm_eras), layer(bar, sum, dodge), x(col.continent), y(col.life),
  color(col.era), y_label("Sum of life expectancies"),
  title("Sum in place of mean: legal, and meaningless"))
Asia Europe Africa Americas Oceania 0K 1K 2K Sum in place of mean: legal, and meaningless Sum of life expectancies Continent Era 1957 2007

“Given the gapminder eras: bars derived by sum and dodge, x is continent, y is life, color by era.”

Africa’s fifty-two life expectancies are added into one bar of 2,850 years. Life expectancies do not add. The grammar writes either; the data decides which is honest, and Law 8 says why gog refuses to decide for you.

10.9 Stacked bars: piled up

Sometimes the question is the total as well as the parts: how much is there altogether, and how much of it does each group hold? The other way to place a color split: instead of side by side, pile the groups on top of each other with stack. Each bar’s height becomes the category’s total across the groups, and the segments read as parts of a whole:

data(gm_eras) + bar * sum * stack + x(continent) + y(population) + color(era) +
  y_label("Population") +
  title("Population by continent, 1957 and 2007 stacked")
(data(gm_eras) + bar * sum * stack + x(col.continent) + y(col.population) + color(col.era) +
  y_label("Population") +
  title("Population by continent, 1957 and 2007 stacked"))
data(gm_eras) + bar * sum * stack + x(:continent) + y(:population) +
  color(:era) + y_label("Population") +
  title("Population by continent, 1957 and 2007 stacked")
plot(data(gm_eras), layer(bar, sum, stack), x(col.continent),
  y(col.population), color(col.era), y_label("Population"),
  title("Population by continent, 1957 and 2007 stacked"))
Asia Europe Africa Americas Oceania 0M 2000M 4000M Population by continent, 1957 and 2007 stacked Population Continent Era 1957 2007

“Given the gapminder eras: bars derived by sum and stack, x is continent, y is population, color by era.”

dodge compares the groups (equal baselines, side by side); stack sums them (one bar, segments piled). Use stack when the parts add to a meaningful total (populations, counts, revenue), and dodge when you are comparing the groups. Note which statistic each one takes: you stack sum of population, because quantities add, while the dodge example compared mean of life, because averages do not.

The grouped bars above have a mirror here. mean in place of sum draws too, and piles two averages into a bar that reads as their total:

data(gm_eras) + bar * mean * stack + x(continent) + y(life) + color(era) +
  y_label("Mean life expectancy, stacked") +
  title("Mean in place of sum: legal, and meaningless")
(data(gm_eras) + bar * mean * stack + x(col.continent) + y(col.life) + color(col.era) +
  y_label("Mean life expectancy, stacked") +
  title("Mean in place of sum: legal, and meaningless"))
data(gm_eras) + bar * mean * stack + x(:continent) + y(:life) +
  color(:era) + y_label("Mean life expectancy, stacked") +
  title("Mean in place of sum: legal, and meaningless")
plot(data(gm_eras), layer(bar, mean, stack), x(col.continent),
  y(col.life), color(col.era), y_label("Mean life expectancy, stacked"),
  title("Mean in place of sum: legal, and meaningless"))
Asia Europe Africa Americas Oceania 0 50 100 150 Mean in place of sum: legal, and meaningless Mean life expectancy, stacked Continent Era 1957 2007

“Given the gapminder eras: bars derived by mean and stack, x is continent, y is life, color by era.”

Oceania’s pile reaches 150 years. A stack reads as parts of a total, so it wants a quantity that has one, which is why this section sums population and the grouped bars averaged life.

10.9.1 The 100% stacked bar

The population stack answers two questions at once: how large each total is, and how it splits. The first answer makes the second too small to read. Asia’s two eras total 5.4 billion and Oceania’s total 0.04 billion, so the axis is scaled for Asia and the split inside the small continents cannot be read. When the split is the question and the total is not, ask for shares. stack(share = TRUE) divides each segment by its own bar’s total. A segment then reads as the share of its bar that its group holds, a number from 0 to 1. Every bar reaches 1, so all the splits sit on one scale and can be compared across continents:

data(gm_eras) + bar * sum * stack(share = TRUE) + x(continent) + y(population) +
  color(era) + title("Each continent's 1957/2007 split, filled to one")
(data(gm_eras) + bar * sum * stack(share = True) + x(col.continent) + y(col.population) +
  color(col.era) + title("Each continent's 1957/2007 split, filled to one"))
data(gm_eras) + bar * sum * stack(share = true) + x(:continent) +
  y(:population) + color(:era) +
  title("Each continent's 1957/2007 split, filled to one")
plot(data(gm_eras), layer(bar, sum, stack({ share: true })),
  x(col.continent), y(col.population), color(col.era),
  title("Each continent's 1957/2007 split, filled to one"))
Asia Europe Africa Americas Oceania 0.0 0.2 0.4 0.6 0.8 1.0 Each continent's 1957/2007 split, filled to one Share Continent Era 1957 2007

“Given the gapminder eras: bars derived by sum and stack as shares, x is continent, y is population, color by era.”

With every bar at 1, the question changes. The population stack compared the two eras inside one continent, and only for the large ones. This plot compares the splits across continents: which population grew the most between the two eras. Africa’s did: 78% of its two-era total falls in 2007, so its population more than tripled. Europe’s grew the least: 57%, the closest any continent comes to an even split. Neither number can be read from the population stack above. This plot, in turn, no longer shows the totals at all, which is why Oceania’s bar is now as tall as Asia’s. The two plots are complementary, each answering half of one question. stack says how much each continent has, and stack(share = TRUE) says how it splits. When you need both answers, draw both plots.

The axis says Share, because the numbers are fractions of one now, whatever they were before. That is true even though y(population) names a column: stack(share = TRUE) rescales an axis you already named. Share alone does not say a share of what, so name it yourself with y_label("Share of two-era population"). It is not proportion under another name. The two divide by different totals. proportion divides by the whole plot’s total, so its bars still show how large each category is. This one divides by the bar’s own total. And only this one works on a sum of a column, which proportion has no way to say.

10.10 What you can set

A setting changes how every bar looks without reading a column, and each mark takes its own. These are a bar’s, with the values each accepts:

Setting Value
style(color = ) any CSS color name or hex
style(opacity = ) 0 to 1
style(pattern = ) solid, hatch, crosshatch, grid, dots
style(border_color = ) any CSS color name or hex
style(border_size = ) pixels

A bar has no size, and that is deliberate. Its position and its value already fix how large it is, so a width setting would be a second, conflicting answer. What it does have, being a shape with an inside and an outline, is a border.

A fill, a texture and a border compose, so one layer can carry all three:

data(medals) + bar * sum + x(country) + y(gold) +
  style(color = "goldenrod", pattern = "crosshatch",
        border_color = "black", border_size = 1) +
  y_label("Gold medals") + title("A fill, a texture, and a border")
(data(medals) + bar * sum + x(col.country) + y(col.gold) +
  style(color = "goldenrod", pattern = "crosshatch",
        border_color = "black", border_size = 1) +
  y_label("Gold medals") + title("A fill, a texture, and a border"))
data(medals) + bar * sum + x(:country) + y(:gold) +
  style(color = "goldenrod", pattern = "crosshatch", border_color = "black", border_size = 1) +
  y_label("Gold medals") + title("A fill, a texture, and a border")
plot(data(medals), layer(bar, sum), x(col.country), y(col.gold),
  style({ color: "goldenrod", pattern: "crosshatch",
  border_color: "black", border_size: 1 }), y_label("Gold medals"),
  title("A fill, a texture, and a border"))
USA China Great Britain Russia Germany 0 10 20 30 40 A fill, a texture, and a border Gold medals Country

“Given the medals: bars derived by sum, x is country, y is gold, colored goldenrod, with pattern crosshatch, border color black and border size 1.”

The pattern values here are the five fill textures, not the three dashes a line takes. The setting has one name, and each mark draws it the way its shape allows. A fill takes a hatch, and a stroke takes a dash. Texture survives a grayscale printer, where color does not. A plot that will be printed in black and white can separate its bars by texture instead of color.

The histogram’s bins are not a setting. bin(30) and bin(width = 5) belong to the transform, because they change what is counted, not how a bar is drawn. Histogram shows both.

The grid of every mark and every setting shows which other marks share this list. What a mark maps rather than sets is its row on the companion grid.

10.11 What it refuses

The three refusals below come from different facts. The first belongs to the transform rather than the mark: bin needs a continuous axis to cut. The other two belong to the mark: a bar is a fill rather than a stroke, and a bar needs a number to measure.

Binning cuts a continuous axis into intervals, so there is nothing for it to cut on a categorical one:

data(gapminder_2007) + bar * bin + x(continent)
data(gapminder_2007) + bar * bin + x(col.continent)
data(gapminder_2007) + bar * bin + x(:continent)
plot(data(gapminder_2007), layer(bar, bin), x(col.continent))
Error:
! gog: `bin` cuts a continuous axis into intervals, and `x(continent)` is categorical — a category is one slot, with no width to cut. To tally rows per category, `count` is the transform that does it: `bar * count`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A category is already one slot per value, which is what count is for. The refusal says so rather than drawing empty bins.

Texture is the second refusal. A bar is a fill, so it takes a fill’s textures, and a dash is a stroke’s:

data(medals) + bar + x(country) + y(gold) + style(pattern = "dashed")
data(medals) + bar + x(col.country) + y(col.gold) + style(pattern = "dashed")
data(medals) + bar + x(:country) + y(:gold) + style(pattern = "dashed")
plot(data(medals), bar, x(col.country), y(col.gold),
  style({ pattern: "dashed" }))
Error:
! gog: `style(pattern = )` on a `bar`: `"dashed"` is a stroke's dash, not a fill texture. Use "solid" (the default), "hatch", "crosshatch", "grid", or "dots".
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

A bar’s outline takes a color and a width, never a dash. border_color and border_size are the only two settings the outline has.

Two categorical positions are the third refusal. The category decides which axis the bars stand on, and the number decides how far they reach, as Horizontal bars explained. With a category on both, there is no length left to draw:

data(medals) + bar + x(country) + y(country)
data(medals) + bar + x(col.country) + y(col.country)
data(medals) + bar + x(:country) + y(:country)
plot(data(medals), bar, x(col.country), y(col.country))
Error:
! gog: `bar` has categorical columns on both axes — `x(country)` and `y(country)` — so there is nothing for it to measure. One axis must be a number: that is the length of the bar. To count rows per category instead, use `bar * count`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message names both ways forward: put a number on one axis, or count the rows per category with bar * count.