NB-006tutorial·18 Oct 2026

What the Gini coefficient measures: a playground

Drag a slider and watch inequality change.

Simulated incomes, real method.

The quickest way to get a feel for the Gini coefficient is to build a small economy and stretch it.

Build a toy economy

  • 10,000 simulated incomes from a log-normal distribution.
  • One dial, the spread, sets how unequal the economy is.

Most people sit in the middle and a long tail earns a lot more. Turn the spread up and the tail gets longer.

Show code
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio

pio.renderers.default = "plotly_mimetype+notebook_connected"
GOLD, SLATE, GREY, INK = "#D99A1E", "#4F6D9A", "#8A8F98", "#4A4D55"

def toy_economy(spread, n=10_000, seed=42):
    """Log-normal incomes. A bigger spread means a longer tail and more inequality."""
    rng = np.random.default_rng(seed)
    return rng.lognormal(mean=10.8, sigma=spread, size=n)

def lorenz(incomes):
    """Cumulative share of people (poorest first) against their cumulative share of income."""
    x = np.sort(incomes)
    people = np.arange(len(x) + 1) / len(x)
    income = np.concatenate([[0], np.cumsum(x) / x.sum()])
    return people, income

def gini(incomes):
    """One minus twice the area under the Lorenz curve."""
    people, income = lorenz(incomes)
    return 1 - 2 * np.trapezoid(income, people)

The Lorenz curve

  • Line everyone up, poorest first, and add up their income as you go.
  • The shaded gap under the diagonal is what the Gini coefficient measures.
  • Zero means everyone earns the same; one means one person earns everything.

Drag the slider to stretch the economy. Hover the curve to read any point.

Show code
spreads = np.round(np.arange(0.2, 1.21, 0.1), 1)
grid = np.linspace(0, 1, 101)

fig = go.Figure()
fig.add_trace(go.Scatter(x=grid, y=grid, mode="lines", name="Perfect equality",
                         line=dict(color=GREY, dash="dash", width=1.5), hoverinfo="skip"))
for i, s in enumerate(spreads):
    people, income = lorenz(toy_economy(s))
    curve = np.interp(grid, people, income)
    fig.add_trace(go.Scatter(
        x=grid, y=curve, mode="lines", name=f"spread {s}", visible=(s == 0.6),
        line=dict(color=GOLD, width=3), fill="tonexty", fillcolor="rgba(217,154,30,0.18)",
        hovertemplate="The poorest %{x:.0%} earn %{y:.1%} of all income<extra></extra>"))

steps = []
for i, s in enumerate(spreads):
    g = gini(toy_economy(s))
    visible = [True] + [j == i for j in range(len(spreads))]
    steps.append(dict(method="update", label=f"{s}",
                      args=[{"visible": visible}, {"title.text": f"Spread {s}: Gini = {g:.2f}"}]))

start = int(np.where(spreads == 0.6)[0][0])
fig.update_layout(
    title=dict(text=steps[start]["args"][1]["title.text"], x=0, font=dict(size=16)),
    sliders=[dict(active=start, steps=steps, currentvalue=dict(prefix="Spread: "), pad=dict(t=40))],
    xaxis=dict(title="Share of people, poorest first", tickformat=".0%", range=[0, 1]),
    yaxis=dict(title="Share of income", tickformat=".0%", range=[0, 1]),
    showlegend=False, template="simple_white", height=520, margin=dict(l=60, r=20, t=60, b=40),
    font=dict(family="IBM Plex Sans, sans-serif"),
)
fig.show()

One number per economy

  • More spread, higher Gini, but not in a straight line.
  • Most rich countries report Gini coefficients for disposable income between about 0.25 and 0.40.

The first bit of stretching moves the Gini fast. After that it slows down.

Show code
table = pd.DataFrame({"spread": spreads, "gini": [gini(toy_economy(s)) for s in spreads]})

fig2 = go.Figure(go.Scatter(
    x=table["spread"], y=table["gini"], mode="lines+markers",
    line=dict(color=GOLD, width=3), marker=dict(size=8, color=GOLD),
    hovertemplate="Spread %{x}<br>Gini %{y:.2f}<extra></extra>"))
fig2.update_layout(
    xaxis=dict(title="Spread of the toy economy"), yaxis=dict(title="Gini coefficient", range=[0, 0.65]),
    template="simple_white", height=380, margin=dict(l=60, r=20, t=20, b=40),
    font=dict(family="IBM Plex Sans, sans-serif"),
)
fig2.show()

Who gets what

  • In the most stretched toy economy, the richest fifth takes over half of all income.
  • Two economies can share a Gini and still split income differently.

That’s why it pays to look at the pieces, not just the one number.

Show code
def quintile_shares(incomes):
    x = np.sort(incomes)
    return [chunk.sum() / x.sum() for chunk in np.array_split(x, 5)]

labels = ["Poorest 20%", "Second", "Middle", "Fourth", "Richest 20%"]
fig3 = go.Figure()
for s, color in [(0.3, GREY), (0.6, SLATE), (1.0, GOLD)]:
    fig3.add_trace(go.Bar(x=labels, y=quintile_shares(toy_economy(s)), name=f"Spread {s}",
                          marker_color=color, hovertemplate="%{x}: %{y:.1%} of income<extra></extra>"))
fig3.update_layout(barmode="group", yaxis=dict(title="Share of income", tickformat=".0%"),
                   template="simple_white", height=400, margin=dict(l=60, r=20, t=20, b=40),
                   legend=dict(orientation="h", y=1.08), font=dict(family="IBM Plex Sans, sans-serif"))
fig3.show()

What this doesn’t tell us

  • Real Gini numbers depend on choices this toy skips: income before or after taxes and transfers, household or person, and how you adjust for household size.
  • Statistical agencies publish all of these versions.

So always check which one you’re reading before you compare two countries.

Questions I still have

  • Does a log-normal fit the top of real income data, or does it miss the very rich?
  • How much do country rankings move once everyone uses the same definition?

If you know a good dataset for either, tell me.

Rebuild it in Excel

  • Data: 20 incomes in blue cells. Type your own over them.
  • Lorenz: sorts the incomes and builds the running shares, one column per step.
  • Gini: adds up the area under the curve and turns it into a Gini, with a Lorenz chart beside it.

With only 20 households the answer won’t match the 10,000-person version, and that’s part of the lesson: small samples are noisy.