About 15 lakh students write JEE Main, India's engineering entrance exam, across 19 different shifts. Every shift has a different paper, so raw marks can't be compared across shifts. NTA, the testing agency, fixes that with a percentile score. When I built a rank predictor for students, most of the work turned out to be understanding that one number. Here's how it works, with the code.

The definition

For each shift, NTA computes:

percentile = 100 × (students in your shift with marks ≤ yours) / (students in your shift) 

It's calculated to 7 decimal places so ties are rare. A tough shift has fewer high scorers, so the same marks earn a higher percentile there. In 2026, 99 percentile needed about 153 marks in the toughest shift and about 200 in the easiest.

Percentile → rank

Because percentile is "share of people at or below you", the share above you is 100 − P. Multiply by the total number of candidates and you have the rank:

const N = 1538468; // unique candidates, JEE Main 2026 function rankFromPercentile(p) { return Math.max(1, Math.round(((100 - p) / 100) * N)); } rankFromPercentile(93.0); // 107693 rankFromPercentile(99.17); // 12769 

That's the whole trick, and it's why a 0.1 change in percentile at the top moves your rank by about 1,500 places.

Marks → percentile is the hard part

NTA publishes percentiles and ranks, but not a marks-to-percentile table. So you have to build one from published results: collect (marks, percentile) pairs, sort them, and interpolate between the nearest two.

// [marks, percentile] for an average shift, sorted by marks const curve = [ [0, 5.71], [50, 78.34], [100, 93.0], [150, 97.8], [200, 99.57], [250, 99.952], [300, 100], ]; function percentileFromMarks(m) { for (let i = 1; i < curve.length; i++) { const [m0, p0] = curve[i - 1], [m1, p1] = curve[i]; if (m <= m1) return p0 + ((m - m0) / (m1 - m0)) * (p1 - p0); } return 100; } 

The real table has a point every 10 marks, because the curve is steep at the bottom and flat at the top, and straight lines between far-apart points get it badly wrong around 30–60 marks.

Handling easy and tough shifts

A student who wrote a tough paper shouldn't see the same number as one who wrote an easy one. Cross-checking all 19 shifts of 2026, the same percentile sat within roughly ±10% marks either way. So the predictor shows a range: it scales the marks up and down by that amount and reads the curve at both ends.

const range = (m) => [percentileFromMarks(m * 0.9), percentileFromMarks(m * 1.1)]; 

With the full table, 100 marks comes out at about 91.3 in an easy shift and 94.4 in a tough one.

Details that matter

  • The same marks, different percentiles is correct, not a bug. It looks like one to a student comparing with a friend, so the page explains it in plain words right under the result.
  • Ranks use the total across all shifts, not your shift's size. Mixing the two gives ranks roughly 19 times too small.
  • Indian number formatting. toLocaleString('en-IN') gives 1,07,693, which is what students expect to see.
  • Honesty beats precision. A single number like "rank 1,07,693" looks authoritative but is an estimate. Showing a range sets the right expectations.

If you want to see it working, the predictor is free at Rankwise. Everything runs client-side, with no sign-up.