I'm an emergency physician, and I also write the code behind Growo, a set of child growth calculators in 17 languages and an MCP server that AI assistants can call. Parents kept asking me what the percentile on their child's chart meant. Answering properly meant computing it the way the WHO and the CDC do, then finding every place where a correct formula still gives the wrong number.
This post covers the formula, about 20 lines of TypeScript, and seven edge cases we hit. Every number below was recomputed with the production engine while writing.
The LMS method in one formula
The WHO and CDC growth charts are built with the LMS method (Cole and Green). For every age they publish three parameters:
- M: the median
- S: the coefficient of variation, which sets the spread
- L: a Box-Cox power that removes the skew. Weight is right-skewed, so its L is not 1
A measurement X becomes a z-score, and the z-score becomes a percentile through the standard normal CDF:
z = ((X / M)^L - 1) / (L * S) when L != 0
z = ln(X / M) / S when L == 0 (the limit)
percentile = Φ(z) * 100
JavaScript has no built-in Φ, so we use a polynomial approximation:
// Standard normal CDF: Zelen & Severo (Abramowitz & Stegun 26.2.17), |error| < 7.5e-8
const normalCdf = (x: number): number => {
const t = 1 / (1 + 0.2316419 * Math.abs(x));
const d = 0.3989423 * Math.exp((-x * x) / 2);
const p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
return x > 0 ? 1 - p : p;
};
// Measurement -> z-score, and back (the inverse draws the curves)
const lmsZ = (x: number, L: number, M: number, S: number): number =>
Math.abs(L) < 1e-7 ? Math.log(x / M) / S : (Math.pow(x / M, L) - 1) / (L * S);
const valueAtZ = (z: number, L: number, M: number, S: number): number =>
Math.abs(L) < 1e-7 ? M * Math.exp(S * z) : M * Math.pow(1 + L * S * z, 1 / L);
// 6-month-old girl, 7.2 kg: WHO weight-for-age, day 183
const z = lmsZ(7.2, -0.0759, 7.3016, 0.12204);
console.log(z.toFixed(3), (normalCdf(z) * 100).toFixed(1)); // -0.115 45.4
console.log(valueAtZ(-1.881, -0.0759, 7.3016, 0.12204).toFixed(2)); // 5.82 kg = 3rd percentile line
That girl is at the 45.4th percentile. The formula was the easy part.
Where L, M and S come from
| Reference | Ages | Grid |
|---|---|---|
| WHO Child Growth Standards (2006) | 0 to 1,856 days | One row per day |
| CDC growth charts (2000) | 0 to 36 months (lying length), 24 to 240 months (standing height) | Rows at 0, 0.5, 1.5, 2.5 … months |
| Olsen 2010 (size at birth) | 23 to 41 completed weeks of pregnancy | One row per week, weight in grams |
| Neyzi (Türkiye) | 0 to 18 years | Percentile curves only, no L, M, S |
The CDC recommends the WHO standards under 2 years in the US and its own charts from 2 to 20. Our percentile calculator opens on WHO in most languages and on the national reference in Turkish (Neyzi), Arabic (Saudi) and Korean (KDCA, which is WHO below 36 months). BMI under 24 months is always WHO, because the CDC's BMI chart starts at 2.
Edge case 1: a month is 30.4375 days
The WHO publishes one row per day and defines a month as 365.25 / 12 = 30.4375 days. When an API caller sends ageMonths: 1, you have to choose a row. Rounding to the nearest day turns one month into day 30, and a boy at exactly the WHO's published one-month median (4.4709 kg) then reads 51.2 instead of 50. The gap is about 1 point at one month and under half a point from two months on. So don't round: read L, M and S between the two bracketing days. Our engine does that now, and the same boy comes back at 50.0. The web calculator counts whole days from the birth date, so it reads the published row directly.
Edge case 2: CDC rows sit in the middle of the month
The CDC's infant rows are at 0, 0.5, 1.5, 2.5 … months. The 0.5 row stands for the first month of life. If you index the rows by whole months, a one-month-old gets the 0.5 row, and a boy exactly on the median reads the 78th percentile. He reads the 61st at 6 months and the 55th at 12. Interpolate L, M and S instead:
type Lms = { L: number; M: number; S: number };
const lerp = (a: number, b: number, f: number) => a + (b - a) * f;
// CDC rows sit at 0, 0.5, 1.5, 2.5 ... months. Interpolate; never index by whole months.
function cdcLmsAt(ages: number[], rows: Lms[], months: number): Lms {
const last = ages.length - 1;
if (months <= ages[0]) return rows[0];
if (months >= ages[last]) return rows[last]; // clamp, never extrapolate L/M/S
const hi = ages.findIndex((a) => a >= months);
const f = (months - ages[hi - 1]) / (ages[hi] - ages[hi - 1]);
const [a, b] = [rows[hi - 1], rows[hi]];
return { L: lerp(a.L, b.L, f), M: lerp(a.M, b.M, f), S: lerp(a.S, b.S, f) };
}
With interpolation, every published CDC median comes back as the 50th percentile.
Edge case 3: charts that publish percentiles, not LMS
Neyzi, like several national charts, publishes only the curves (3rd, 5th, 10th … 97th). Interpolate between the curves in z-space: map each curve to its z (the 3rd is -1.881), interpolate z linearly with the measurement, then apply Φ. Interpolating the percentile directly looks equivalent but fails in the tails. We tested both on the CDC's own birth-weight curves for boys, where the true answer is known:
| True position | Interpolated as a percentile | Interpolated as a z-score |
|---|---|---|
| 1st, extended from the 3rd/5th curves | -1.2 (below zero) | 0.9 |
| 10th, with only the 3rd/50th/97th curves | 19.7 | 11.3 |
| 25th, with only the 3rd/50th/97th curves | 34.8 | 27.2 |
Past the outermost curve, we continue the slope of the outermost pair in z-space.
Edge case 4: the tails
Rounded to one decimal, a percentile reads 0.0 or 100.0 once |z| passes about 3.3, so out there the z-score is the more useful number. Be careful which z-score you mean, though. For weight-based indicators beyond ±3 SD, the WHO switches to a restricted method that measures the distance past the 3 SD curve in units of the 2 SD to 3 SD gap. Our engine applies the same restricted method to the WHO weight and BMI tables, so beyond 3 SD its z-scores match the WHO software's. Length or height and head circumference have L = 1, a plain normal curve, and keep the ordinary formula, as in the WHO's own software. Either way, our reporting treats "3 SD or more" as a band of its own.
Edge case 5: lying length and standing height at 24 months
Babies are measured lying down and 2-year-olds standing up, and standing reads a little under a centimetre shorter. The WHO table switches between day 730 and day 731, and its median drops 0.67 cm there. The CDC publishes two tables that overlap from 24 to 36 months, with medians 0.80 cm apart at 24 months. If you read a 2-year-old's lying length on the standing chart, a median child moves to the 59th percentile. Choose the table by age (we switch at 24 months) and say which one you used.
Edge case 6: numbers typed in 17 languages
parseFloat("6,5"); // 6
A 6-month-old girl who weighs 6.5 kg is at the 16.9th percentile. Read as 6 kg, she drops to the 5.3rd, and nothing on the screen says so. The MCP server now accepts a decimal comma and refuses anything that is not entirely a number:
const asNumber = (text: string): number =>
/^-?\d+([.,]\d+)?$/.test(text.trim()) ? Number(text.trim().replace(",", ".")) : NaN;
asNumber("6,5"); // 6.5
asNumber("6 kg"); // NaN -> "must be a number"
asNumber("6,5,2"); // NaN
Units are a similar problem. The Olsen tables are in grams, so the birth-size percentile tool refuses 3.4 with "looks like kilograms, pass 3400" rather than computing a percentile for a 3.4 g baby.
Edge case 7: a baby born in the future
new Date("2026-10-04") is midnight UTC. In Türkiye (UTC+3) that is 03:00 local time, so between midnight and 3 a.m. a baby born today was "born in the future" and refused. At UTC+14 that window is 14 hours. Treat a birth date as a calendar date:
const [y, m, d] = dob.split("-").map(Number);
const birth = new Date(y, m - 1, d); // local midnight, not UTC
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const ageDays = Math.round((today.getTime() - birth.getTime()) / 86_400_000); // DST days are 23 or 25 h
Reporting without interpreting
The MCP server (Streamable HTTP, no key) returns the percentile, the z-score, the 3rd/15th/50th/85th/97th values at that age and the chart's name. It also returns interpretation: null and isDiagnostic: false. A deviationBand field takes one of three values:
-
within: between the 3rd and 97th percentiles -
outside: beyond the drawn 3rd to 97th band (|z| ≥ 1.881) -
marked: 3 SD or more from the median, which is the WHO's severe cut-off
The last two get a note suggesting a clinician look at the measurement. A position on a reference chart is a fact a tool can state. What it means for one child depends on that child's history and an examination, and no calculator has either.
How we checked it
- CDC: each CDC table lists its 3rd to 97th percentile values next to L, M and S. We fed all 9,882 of them (weight, length and head circumference from 0 to 36 months, weight and stature from 2 to 20 years, both sexes) back through the engine at their own ages. Every one came back as its own column, to one decimal. This tests the formula, the interpolation and Φ, not the data.
- WHO: the engine reads the WHO's own daily L, M and S tables (day 0 to 1,856) as published, with no fitting or smoothing, so a WHO percentile is the WHO's number by construction. Tests run over every WHO table: each published median returns the 50th percentile, each SD curve its textbook percentile, and a value computed at a z-score returns the same z, including beyond ±3 SD. As an outside check, Korea's official KDCA calculator, which uses WHO below 36 months, matched 12 readings within 0.1 percentile point.
- Neyzi: the z-space method returns each of the 658 published points as its own percentile.
- One engine: the MCP server imports the same module as the web calculator, so an assistant and a website visitor get the same answer.
If you build anything with growth charts, I'd like to hear which edge case bit you.
Sources: WHO Child Growth Standards · WHO methods and development (2006) · CDC growth chart data files · CDC 2000 growth charts: methods and development · Olsen et al., Pediatrics 2010 · Neyzi et al., J Clin Res Pediatr Endocrinol 2015











