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 WHO Child Growth Standards don't publish percentiles directly. They publish three numbers per age per sex: L, M and S.
That last one exists because growth data isn't normally distributed. A 3-month-old can weigh twice the median; none can weigh half of it and survive. The distribution has a long right tail, and L is what stretches the scale to make it symmetric.
The z-score comes out as:
z = ((X / M)^L − 1) / (L × S)
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);
}
Ship it, right?
Look at the formula again. When L
is zero, you divide by zero.
This isn't hypothetical. Across the WHO tables, L values drift with age and cross or approach zero in several indicators. Head-circumference-for-age publishes L = 1 exactly at birth; BMI-for-age starts negative and moves through zero territory as children grow.
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);
}
Miss this branch and you get Infinity
or NaN
for real children at real ages — usually 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-6
and degrades 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);
});
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.
I've seen implementations that hardcode a dozen anchor points and interpolate between them. That's how you end up several percentiles off in the middle of the range — invisible in testing, wrong in production.
The right move is to bundle all of it, and 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"),
}
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.
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
.
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
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 >= 40) return ageDays;
return Math.max(0, ageDays - (40 - gestationalAgeWeeks) * 7);
}
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.
Two reasons, one obvious and one less so.
The obvious one is privacy. The input is a child's weight, height and date of birth. Sending that to a server to divide two numbers is a strange trade. In the app this came from, the percentile calculation happens entirely on the device — the measurements never leave the phone for that purpose.
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.
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 describe a reference population. 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:
npm install who-growth-standards
js
import { weightForAge, ageInDays, classify } from "who-growth-standards";
const age = ageInDays(new Date("2025-11-14"));
const result = weightForAge(8.9, { sex: "female", ageDays: age });
result.zScore; // 0.5993
result.percentile; // 72.55
result.median; // 8.27 kg at this age
classify(result.zScore); // "normal"
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 does this calculation on the device. Extracting it seemed more useful than leaving it buried in an app — the maths 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.