Visualising income inequality social tables

At a glance:

Visualising income inequality social tables as a scatter plot rather than a dual axis bar/line plot. I use examples from eighteenth and nineteenth century France, England and Wales drawing on Branko Milanovic's Visions of Inequality 2023 publication.

08 Feb 2026


I recently read Branko Milanovic’s 2023 book, Visions of Inequality: from the French Revolution to the Cold War and gave it five stars. The bulk of the book is an excellent critical look at the views of six major economic thinkers—Quesnay, Smith, Ricardo, Marx, Pareto and Kuznets—on economic inequality. Even though the first four of these (yes, even Marx) wrote little directly on inequality narrowly defined, their class-based approach to understanding the economy has critical implications for the subject.

One of the things Milanovic does well is place each thinker in the context of economic inequality in the time they were writing—both as it was known to themselves, and as per our best modern estimates. There’s a lot of data and tools available to us that were not there for contemporary commentators, but each of these thinkers did exceptionally well with the information they had, and has been chosen because they combine narrative, theory, and empirical work in a way that earns our respect and is still fruitful.

After a chapter on the barren world of cold war economic inequality analysis, in his final chapter Milanovic identifies three factors that have made 21st century inequality studies take off. These are Piketty’s new, insightful and influential analysis on the implications of a rate of profit that is persistantly greater than economic growth; new data, tools and concepts relating to ‘global inequality’; and new historical empirical work including the increasing volume and quality of ‘social tables’ setting out income and population by class.

I’m using this blog post to explore an alternative visualisation for these social tables.

France in the time of Quesnay

Milanovic makes good use of charts like (but not exactly like) this one, and anyone wondering what a social table is can think about them as just the tabular version of the data on income and population by class shown here:

I’ve added the colour for the two different axes—I think this is super-helpful for dual axes plots to work, as I’ve written about in 2016, but obviously impossible in a grayscale publication—and used data-specific vertical axis labels rather than regular gridlines, in a nod to the sort of style Tufte might like. But otherwise this is pretty much the plot format used by Milanovic.

The estimates here are actually those of Quesnay himself, in Mirabeau’s La philosphie rurale, the physiocrat masterwork which really set out to be the definitive book of the French economy. Quesnay himself has a fair claim to being the world’s first modern economist. Some of his categories look a little odd to us, such as the very French category just for self-employed viticulturalists; the non-existence of capitalists other than tenant farmers; or perhaps most importantly, wrapping all the first and second “estates” (clergy and aristocrats) into one category along with their administrative support. This last has the effect of hiding some pretty material income disparities.

This plot type is ok but I definitely found a bit difficult to absorb. I found myself looking class by class at the numbers and effectively converting them to a table in my head, usually a sign that we’re not using the power of data visualisation to its best.

I thought the obvious alternative is a scatter plot so I drew this one:

Again, I’ve used data-specific labels on the axes—this only works when you’ve only got a small number of data points. I’ve reduced a lot of clutter (gridlines, etc.) and made the points’ labels a bit of a background colour relative to the points themselves. But it’s a pretty straightforward plot altogether. I think it will work for many audiences, and I like it.

Here’s the code to create the data (such a small number of points it’s ok to just hard-key it into an R script) and draw that first bar and line plot. It’s a bit complex because of the micro control I’m taking over things like where the breaks and labels go on the axes and the colours of the axes. But it’s all well within a regular approach to ggplot2 graphics, helped out just with ggtext to get italics for the references in the subtitle and caption.

#==================setup==================
library(tidyverse)
library(ggtext)
library(ggrepel)


# some general graphics parameters:
inc_col <- "red"
pop_col <- "blue"

theme_set(
  theme_minimal(base_family = "Roboto") +
    theme(
      panel.grid.minor = element_blank(),
      plot.subtitle = element_markdown(),
      plot.caption = element_markdown(colour = "grey50"),
      plot.title = element_markdown(family = "Sarala")
    )
)

#==============Quesnay France 1763=====================

d1 <- tribble(~population, ~income, ~class, ~class_detail,
              48, 0.5, "Workers",            "Agricultural labourers",
              22, 0.6, "Workers",            "Manufacturing low-skill workers",
              6, 0.8, "Self-employed",       "Self-employed in viticulture",
              4, 2.3, "Self-empoyed",        "Artisans and crafsmen in manufacturing",
              8, 2.7, "Capitalists",         "Tenant farmers",
              12, 2.3, "The Elite",          "Landlords, clergy, government administrators"
              ) |> 
  mutate(country = "France",
         period = "1763",
         class = factor(class,levels = unique(class)),
         class_detail = str_wrap(class_detail, 25),
         class_detail = factor(class_detail, levels = class_detail),
         pop_prop = population / max(population),
         inc_prop = income / max(income),
         pop_ratio = max(population),
         inc_ratio = max(income))


stopifnot(sum(d1$population) == 100)


#-------------dual axis bar and line chart-----------------
# as per Milanovic's style

d1 |> 
  ggplot(aes(x = class_detail)) +
  # we want the 'gridlines' to be coloured, match the values, and behind the columns:
  geom_hline(yintercept = c(0, 1/2.7, d1$inc_prop), colour = inc_col, alpha = 0.1) +
  geom_hline(yintercept = c(0, d1$pop_prop), colour = pop_col, alpha = 0.1) +
  # columns for population:
  geom_col(aes(y = pop_prop), fill = pop_col, alpha = 0.7) +
  # lines and points for income:
  geom_line(aes(x = as.numeric(class_detail), y = inc_prop), colour = inc_col) +
  geom_point(aes(x = as.numeric(class_detail), y = inc_prop), colour = inc_col) +
  # annotated labels, also colour coded:
  annotate("text", x = 4.2, y = 0.8, label = "Relative income (right axis)", colour = inc_col, hjust = 0) +
  annotate("text", x = 1.6, y = 0.6, label = "Population share (left axis)", colour = pop_col, hjust = 0) +
  # two different sets of labels for the different variables:
  scale_y_continuous(expand = c(0, 0),
                     limits = c(0, 1.1),
                     breaks = c(0, d1$pop_prop), labels = c(0, d1$population),
                     sec.axis = dup_axis(breaks = c(0, 1/2.7, d1$inc_prop), 
                                         labels = c(0, "1.0", d1$income), 
                                         name = "Income relative to the mean (1.0)")) +
  labs(x = "",
       y = "Percentage of poulation",
       title = "Contemporary understanding of income inequality in France in the time of Louis XV",
       subtitle = "Class-based income distribution in *La philosophie rurale* by Mirabeau and Quesnay, 1763. Gini estimated to be between 49 and 55.",
       caption = "Quesnay's original estimates, reproduced in Table 1.1 of Milanovic's *Visions of Inequality*, and plot style adapted from Milanovic's.") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1),
        panel.grid.major = element_blank(),
        axis.line.y.right = element_line(colour = inc_col),
        axis.line.y.left = element_line(colour = pop_col),
        axis.title.y.left = element_text(colour = pop_col),
        axis.title.y.right = element_text(colour = inc_col),
        axis.text.y.left = element_text(colour = pop_col),
        axis.text.y.right = element_text(colour = inc_col))

As an aside, relating to why I don’t use a large language model to help me write code for my blog: this sort of code is exactly the situation where I like writing code, not trying to explain in natural language to a computer what I want doing. I feel the ggplot2 syntax is exactly the right combination of precision, concision and legibility. Anything I said in English that was as precise about what I wanted to do would take longer to write (and definitely to polish) than the R code.

Next up is the code for the scatter plot version. This is a bit shorter, mostly because we are using the same data and setup as the last chart, but partly because there’s less fiddly customisation needed as I’m not having to specify the dual axis complications of the bar/line chart.

#---------------scatter plot--------------

d1 |> 
  ggplot(aes(x = population, y = income, label = class_detail)) +
  geom_hline(yintercept = 1, linetype = 2, colour = "grey80") +
  geom_point(size = 2) +
  geom_text_repel(colour = "steelblue", seed = 123, hjust = 0) +
  annotate("text", x = 30, y = 1.1, label = "Average income", colour = "grey80") +
  scale_x_continuous(breaks = c(0, d1$population), limits = c(0, 50), expand = c(0,0 )) +
  scale_y_continuous(breaks = c(1, d1$income), limits = c(0, 3), expand = c(0,0 )) +
  labs(y = "Income relative to the mean",
     x = "Percentage of population",
     title = "Contemporary understanding of income inequality in France in the time of Louis XV",
     subtitle = "Class-based income distribution in *La philosophie rurale* by Mirabeau and Quesnay, 1763. Gini estimated to be between 49 and 55.",
     caption = "Quesnay's original estimates, reproduced in Table 1.1 of Milanovic's *Visions of Inequality*.") +
  theme(panel.grid.major = element_blank(),
        axis.line = element_line(colour = "grey80"))

England and Wales in the time of Adam Smith

The second economist Milanovic considers in his book in chronological order is of course Adam Smith himself. Here we move to our contemporary (21st century) understanding of income inequality and make use of social tables reconstructed by Robert Allen from contemporary sources. Here’s my scatter plot version of some of the data used by Milanovic:

There’s white space at the top as a result of giving the vertical axis the same scale as the plot from the time of Ricardo (see a bit later in this post).

A thing that leaps out of course is the high income of England and Wales’ aristocratic landowners at the time relative to other groups, and the way the other groups are compressed vertically as a result. Sometimes we would use a logarithmic transform of the income variable to show the variation. This would give us a plot like this one:

But I don’t much like this for our purpose. After all we are reading a book about inequality. I think the original scale is better, and the way the landed aristocracy sit up at the top by themselves is the point!

Here’s the code for those two charts (and a full reference to Allen’s publication with the original social table in it):

#==============Adam Smith's time 1759==========

# Allen, Robert C. “Class Structure and Inequality during the Industrial
# Revolution: Lessons from England’s Social Tables, 1688-1867.” The Economic
# History Review 72, no. 1 (2019): 88–125.

#page 105 of Allen for % ofpopulation but I am using Milanovic's labels from his Figure 2.1
# page 106 for income in pounds

d2 <- tribble(~population, ~income, ~class,
              1.5, 452.78, "Landed aristocracy",
              4.2, 145.37,  "Capitalists",
              9.4, 27.17, "Shop owners",
              18.9,  21.57, "Peasants",
              56.4, 13.58, "Workers",
              9.6, 3.62  , "Paupers"
              ) |> 
  mutate(year = 1759)

bry <- round(d2$income)[c(1:3, 6)]
brx <- c(0, round(d2$population, 1))[c(1:6)]

# original scale:
d2 |> 
  ggplot(aes(x = population, y = income, label = class)) +
  geom_point(size = 2) +
  geom_text_repel(colour = "steelblue", seed = 123, hjust = 0) +
  scale_x_continuous(breaks = brx, limits = c(0, 65), expand = c(0, 0)) +
  scale_y_continuous(breaks = bry, limits = c(0, 800), expand = c(0, 0)) +
  labs(y = "Income in pounds",
       x = "Percentage of population",
       title = "Modern understanding of income inequality in England and Wales in 1759",
       subtitle = "Average income by earner in pounds per year, as estimated in 2019. Gini index between 45 and 51.",
       caption = "Robert Allen, “Class Structure and Inequality during the Industrial
Revolution: Lessons from England’s Social Tables, 1688-1867.”<br>*The Economic
History Review 72*, no. 1 (2019): 88–125, reproduced in Figure 2.1 of Milanovic's *Visions of Inequality*.") +
  theme(panel.grid.major = element_blank(),
        axis.line = element_line(colour = "grey80"))

# log scale:
d2 |> 
  ggplot(aes(x = population, y = income, label = class)) +
  geom_point(size = 2) +
  geom_text_repel(colour = "steelblue", seed = 123, hjust = 0) +
  scale_x_continuous(breaks = brx, limits = c(0, 65), expand = c(0, 0)) +
  scale_y_log10(breaks = round(d2$income),  limits = c(1, 800), expand = c(0, 0)) +
  labs(y = "Income in pounds (log scale)",
       x = "Percentage of population",
       title = "Modern understanding of income inequality in England and Wales in 1759",
       subtitle = "Average income by earner in pounds per year, as estimated in 2019. Gini index between 45 and 51.",
       caption = "Robert Allen, “Class Structure and Inequality during the Industrial
Revolution: Lessons from England’s Social Tables, 1688-1867.”<br>*The Economic
History Review 72*, no. 1 (2019): 88–125, reproduced in Figure 2.1 of Milanovic's *Visions of Inequality*.") +
  theme(panel.grid.major = element_blank(),
        axis.line = element_line(colour = "grey80"))

England and Wales in the time of David Ricardo

We’re on a familiar routine now. Of course, the next economist is David Ricardo. By the time he was at his peak, incomes had (for some) risen with the beginnings of the industrial revolution, and England was going through the existential traumas of the revolutionary and Napoleonic wars. Inequality, in the form of modern estimates of the Gini index, had grown.

Here’s my plot of the social table from 1801. Note that we have the same vertical axis scale (although different labels, of course) as the preceding chart from 1759 (not the log scale one, but the first 1759 chart shown above):

Shop-owners and capitalists had made significant income gains in England by this point, doubtless to Napoleon’s chagrin.

France in the time of Marx

The final plot I’ll show is of the social table for France in 1831, early in the productive life of Karl Marx. Income is back to relative terms, and the class categories are becoming another step more ‘modern’.

Here’s the code for the last two charts, from the time of Ricardo and Marx:

#--------------------time of ricardo-------------

d3 <- tribble(~population, ~income, ~class,
              1.3, 756, "Landed aristocracy",
              3.2, 525,  "Capitalists",
              8.6, 65, "Shop owners",
              10.8,  49, "Peasants",
              61.1, 23, "Workers",
              14.9, 4  , "Paupers"
) |> 
  mutate(year = 1801)

bry <- round(d3$income)[c(1:6)]
brx <- c(0, round(d3$population, 1))[c(1:7)]

d3 |> 
  ggplot(aes(x = population, y = income, label = class)) +
  geom_point(size = 2) +
  geom_text_repel(colour = "steelblue", seed = 123, hjust = 0) +
  scale_x_continuous(breaks = brx, limits = c(0, 65), expand = c(0, 0)) +
  scale_y_continuous(breaks = bry, limits = c(0, 800), expand = c(0, 0)) +
  labs(y = "Income in pounds",
       x = "Percentage of population",
       title = "Modern understanding of income inequality in England and Wales in 1801",
       subtitle = "Average income by earner in pounds per year, as estimated in 2019. Gini index of around 52.",
       caption = "Robert Allen, *Revising England’s Social Tables Once Again* 2016, reproduced in Table 3.1 of Milanovic's *Visions of Inequality*.") +
  theme(panel.grid.major = element_blank(),
        axis.line = element_line(colour = "grey80"))

svg_png(p5, "../img/0311-ricardo-scatter", w = 10, h = 6)


#-----------------------1831 France--------------
# From Milanovic's Marx chapter

d4 <- tribble(~employment, ~income, ~class,
              3.4, 8.6, "Employers",
              5.1, 3, "Large farmers",
              1.1, 1.8, "High-level civil servants",
              13.9, 1, "Blue-collar employees",
              2, 0.9, "White collar employees",
              13.4, 0.7, "Self-employed",
              1.1, 0.6, "Low-level civil servants",
              31.4, 0.5, "Small farmers",
              28.5, 0.45, "Agricultural workers and servants"
) |> 
  mutate(year = 1831)



bry <- sort(round(d4$income, 1))[c(1,3,6:9)]
brx <- sort(c(0, round(d4$employment, 1)))[c(1:2, 3,4,5,6,8,9, 10)]

d4 |> 
  ggplot(aes(x = employment, y = income, label = class)) +
  geom_hline(yintercept = 1, linetype = 2, colour = "grey80") +
  geom_point(size = 2) +
  geom_text_repel(colour = "steelblue", seed = 123, hjust = 0) +
  scale_x_continuous(breaks = brx, limits = c(0, 35), expand = c(0, 0)) +
  scale_y_continuous(breaks = bry, limits = c(0, 10), expand = c(0, 0)) +
  labs(y = "Relative income (average = 1.0)",
       x = "Percentage of employed persons",
       title = "Modern understanding of income inequality in France in 1831",
       subtitle = "Average income by earner relative to overall mean.",
       caption = "Christian Morrison, and Wayne Snyder. “The Income Inequality of France in Historical Perspective.” 
       <br>*European Review of Economic History* 4, no. 1 (2000): 59–83.
, reproduced in Table 4.4 of Milanovic's *Visions of Inequality*.") +
  theme(panel.grid.major = element_blank(),
        axis.line = element_line(colour = "grey80"))

That’s all for today. Really, this was just a blog post about scatter plots!

← Previous post