Every baby tracker shows growth percentiles. “Your daughter is in the 72nd percentile for weight.” It looks like a lookup — find the row for her age, compare, print a number.
It isn’t. And the ways it goes wrong are interesting enough to be worth writing down.
I ended up implementing this properly for a baby journal app, and published the result as who-growth-standards — MIT, zero dependencies, all the WHO tables bundled. This post is the reasoning behind it.
The problem with “just look it up”
The WHO Child Growth Standards don’t publish percentiles directly. They publish three numbers per age per sex: L, M and S.
- M is the median — the 50th percentile value.
- S is the coefficient of variation.
- L is a Box-Cox power that handles skew.
That last one exists because growth data isn’t normally distributed. At three months a boy’s median weight is 6.37 kg, and the band around it isn’t symmetric: −2 SD sits 1.36 kg below the median, +2 SD sits 1.65 kg above it. The tail stretches further up than down, and L is what rescales it so a z-score means the same thing on both sides.
The z-score comes out as:
z = ((X / M)^L − 1) / (L × S)
Enter fullscreen mode Exit fullscreen mode
Then the percentile is just the normal CDF of that z-score. Two lines of code:
export function lmsToZScore(x: number, l: number, m: number, s: number): number {
return (Math.pow(x / m, l) - 1) / (l * s);
}
Enter fullscreen mode Exit fullscreen mode
Ship it, right?
The L = 0 problem
Look at the formula again. When L is zero, you divide by zero.
This isn’t hypothetical, though you have to look closely to see it. L drifts with age in only two of the six indicators — and those two are exactly the ones that cross zero. BMI-for-age crosses in the first days of life, going from −0.0681 on day 2 to +0.0505 on day 3, then crosses back around day 93. Weight-for-age for boys reaches L = 0.0001 on day 654 and −0.0001 on day 655.
The other four never come close: length/height-for-age and head-circumference-for-age publish L = 1 at every single row, and the weight-for-length pair holds a constant −0.35.
The Box-Cox transform has a defined limit there, and it’s the logarithm:
export function lmsToZScore(x: number, l: number, m: number, s: number): number {
return l === 0
? Math.log(x / m) / s
: (Math.pow(x / m, l) - 1) / (l * s);
}
Enter fullscreen mode Exit fullscreen mode
No published row has L exactly zero, so you might think the branch is decorative. It isn’t: interpolate weight-for-age at 654.5 days — a boy a few days short of twenty-two months — and L lands on exactly 0. Without the branch that’s a NaN, and it fails silently, because nobody validates a percentile that came back as NaN until a parent screenshots it.
There’s a related trap I hit while writing tests. My first test asserted continuity: that as L approaches zero, the power form converges to the logarithmic one. It failed at L = 1e-12.
That’s not a bug in the maths — it’s catastrophic cancellation. (X/M)^L for tiny L is 1 + L·ln(X/M) + …, a number extremely close to 1. Subtracting 1 destroys most of the significant digits, and the smaller L gets, the worse the result. Agreement is best around 1e-7 and degrades sharply below 1e-8.
The test now says so explicitly, because the naive version of that assertion looks correct and fails for reasons that take an hour to understand:
it("approaches the logarithmic form as L → 0", () => {
// Cannot be pushed arbitrarily close to zero: (X/M)^L − 1 loses significant
// digits catastrophically for tiny L, so agreement gets *worse* below ~1e-8.
const atZero = lmsToZScore(12, 0, 10, 0.12);
expect(lmsToZScore(12, 1e-6, 10, 0.12)).toBeCloseTo(atZero, 4);
});
Enter fullscreen mode Exit fullscreen mode
Getting the actual tables
The WHO publishes these as Excel files, one per indicator per sex, at cdn.who.int. Six indicators × two sexes = twelve files, roughly 17 000 rows of LMS triples in total.
My own app takes the lazy route: fourteen anchor points per table — months 0, 1, 2, 3, 4, 5, 6, 9, 12, 18, 24, 36, 48, 60 — with straight lines between them. It draws a perfectly convincing chart and it is quietly off by a percentile or two in the middle of each gap. Invisible in testing. Bundling the real table instead costs half a megabyte and removes the question entirely.
The other half of doing it properly is to generate rather than hand-copy. My repo has a script that downloads the source files and emits typed TypeScript modules:
SOURCES = {
"wfa": ("weight-for-age/expanded-tables/wfa-{sex}-zscore-expanded-tables.xlsx",
"day", "Weight-for-age"),
"lhfa": ("length-height-for-age/expandable-tables/lhfa-{sex}-zscore-expanded-tables.xlsx",
"day", "Length/height-for-age"),
# …
}
Enter fullscreen mode Exit fullscreen mode
Two practical notes. First, the URL patterns are inconsistent — some indicators live under expanded-tables, one under expandable-tables, and one file is -table.xlsx while its sibling is -tables.xlsx. Finding them took longer than parsing them.
Second: generated data files should be reproducible. Re-running my generator produces byte-identical output, which means the tables in the repo are verifiably the WHO’s numbers and not something that drifted through a manual edit three commits ago.
Age-based indicators come at daily resolution — 1857 rows covering 0 to 1856 days, the full 0–5 years. Weight-for-length and weight-for-height are indexed by centimetres in 0.1 cm steps.
Interpolation, and why it still matters
With daily tables you might think interpolation is unnecessary. It isn’t — real applications pass fractional values.
A measurement taken at 100.5 days. A length of 74.35 cm. Whether you floor, round, or interpolate changes the answer, and the difference is largest exactly where the curve is steepest — the first weeks of life, which is when parents check most obsessively.
Linear interpolation between adjacent grid points is enough here, because the grid is dense relative to how fast L, M and S change. But it should be a deliberate choice rather than an accident of Math.floor.
The part that actually matters: preterm infants
Here’s the case that convinced me this deserved to be a library rather than a file in one app.
The WHO standards describe children born at term. Apply them directly to a baby born at 32 weeks, and every comparison is against children who had eight extra weeks to grow.
const chronological = 120; // days since birth
const corrected = correctedAgeInDays(chronological, 32); // → 64
weightForAge(5.2, { sex: "male", ageDays: chronological }).zScore; // −2.53
weightForAge(5.2, { sex: "male", ageDays: corrected }).zScore; // −0.69
Enter fullscreen mode Exit fullscreen mode
Same baby. Same weight. Same day.
Uncorrected, that’s −2.53 — below the WHO cut-off, the range where a clinician starts investigating. Corrected, it’s −0.69 — unremarkable, middle of the normal band.
If your app skips this, you are showing parents of premature babies a red flag that shouldn’t be there. Given that these parents have usually just spent weeks in a NICU, that’s not a rounding error, it’s a cruelty.
The correction itself is trivial arithmetic:
export function correctedAgeInDays(ageDays: number, gestationalAgeWeeks: number): number {
if (gestationalAgeWeeks >= 37) return ageDays; // 37+ weeks is term
return Math.max(0, ageDays - (40 - gestationalAgeWeeks) * 7);
}
Enter fullscreen mode Exit fullscreen mode
What isn’t trivial is knowing it’s needed, and knowing when to stop — correction is conventionally applied until 2 years, or 3 for extreme prematurity. That’s a clinical judgement, so the library computes the corrected age and leaves the cut-off to the caller.
One more thing worth stating, because it’s a common mix-up: correction applies to growth and development, never to vaccination schedules. Those follow chronological age.
Why compute this locally
Two reasons, one obvious and one less so.
The first is proportionality. The input is a child’s weight, height and date of birth, and a round-trip to a server to divide two numbers is a strange trade for that. To be precise about it: the app this came from does sync measurements to its own backend, because parents change phones and two parents share one child — but nothing has to leave the device to answer where does she sit on the curve, and no third party is involved in that answer.
The less obvious one is that the network is the least reliable part of the stack, and parents log measurements in exactly the places where it fails: a paediatrician’s basement office, a hospital corridor, home at 3am with the wifi router two floors down. A percentile that needs a round-trip is a percentile that sometimes isn’t there.
The whole dataset is about 500 KB unminified — the size of a couple of photos. There’s no technical reason to put it behind an API.
What the library doesn’t do
It computes numbers. It does not interpret them.
There’s a classify() helper that reports where a z-score falls against WHO cut-offs, and it’s tempting to read that as a verdict. It isn’t. Those cut-offs come from children raised in conditions WHO defines as favourable — breastfed, non-smoking households, adequate nutrition. They describe how growth tends to look under good conditions, not a rule a particular child must obey. A child at the 3rd percentile can be perfectly healthy and simply small; a child at the 50th can have something going on. That judgement belongs to someone who has met the child.
Other limits worth knowing:
- 0–5 years only. The WHO 2007 reference covers 5–19 and is a different dataset — not included yet.
- Length and height aren’t interchangeable. Under 2 years children are measured lying down, from 2 years standing, and the difference is roughly 0.7 cm. That’s why weight-for-length and weight-for-height are separate tables rather than one.
- Units are taken on faith. Pass pounds where kilograms are expected and you’ll get a confident, wrong answer.
Using it
npm install who-growth-standards
Enter fullscreen mode Exit fullscreen mode
import { weightForAge, ageInDays, classify } from "who-growth-standards";
const age = ageInDays(new Date("2025-11-14")); // days since that birthday
const result = weightForAge(8.9, { sex: "female", ageDays: 279 });
result.zScore; // 0.5993
result.percentile; // 72.55
result.median; // 8.27 kg at this age
classify(result.zScore); // "normal"
Enter fullscreen mode Exit fullscreen mode
Six indicators, both sexes, out-of-range input throws by default with opt-in clamping, full TypeScript types, no runtime dependencies.
It came out of building Sunny Seed, a baby journal that computes these percentiles on the device. Writing the same maths a second time — properly, with the full tables, in the open — seemed more useful than leaving a fourteen-point approximation buried in an app. The formula is the same for everyone, and the failure modes above are worth not rediscovering one at a time.
If you spot something wrong in it, issues and PRs are welcome. Especially if you know the WHO 2007 reference well enough to add it.