Blog / Coding tips

How UK Student Loan Repayments Are Calculated (With Code)

Cover image for How UK Student Loan Repayments Are Calculated (With Code)

UK student loan repayments are calculated from your income, not from how much you owe. For each pay period your employer takes 9% of your pay above your plan's threshold (6% for a Postgraduate Loan) and rounds it down to the whole pound. In the 2026/27 tax year the yearly thresholds are £26,900 for Plan 1, £29,385 for Plan 2, £33,795 for Plan 4, £25,000 for Plan 5 and £21,000 for the Postgraduate Loan. So on Plan 2 with a £35,000 salary, paid monthly, you repay £42 a month.

I turned these rules into a free tool, the UK Student Loan Repayment Calculator (source on GitHub). It's a small Next.js app that runs entirely in the browser. In this post I explain how the calculation works, with the TypeScript I wrote for it and the test output. Every number below was checked against the worked examples on gov.uk.

The 2026/27 figures, and where they come from

There are two official sources, and you need both. The gov.uk page "How much you repay" gives the yearly thresholds, the 9% and 6% rates, and worked examples. HMRC's rates and thresholds for employers 2026 to 2027 gives the exact per-period thresholds that payroll software uses. I put the yearly figures in one file, so next April only one file changes:

// plans.ts: 2026/27 figures from gov.uk and HMRC's employer guidance
export type PlanId = "plan1" | "plan2" | "plan4" | "plan5" | "pgl";

export const PLANS: Record<PlanId, { name: string; threshold: number; rate: number }> = {
  plan1: { name: "Plan 1", threshold: 26_900, rate: 0.09 },
  plan2: { name: "Plan 2", threshold: 29_385, rate: 0.09 },
  plan4: { name: "Plan 4", threshold: 33_795, rate: 0.09 },
  plan5: { name: "Plan 5", threshold: 25_000, rate: 0.09 },
  pgl: { name: "Postgraduate Loan", threshold: 21_000, rate: 0.06 },
};

export const PERIODS = { year: 1, month: 12, "four-weeks": 13, fortnight: 26, week: 52 } as const;
export type Period = keyof typeof PERIODS;

// Yearly threshold → one pay period, cut (not rounded) to whole pence
export function periodThreshold(yearly: number, period: Period): number {
  return Math.floor((yearly * 100) / PERIODS[period]) / 100;
}

The interesting part is periodThreshold(). The gov.uk page rounds the monthly Plan 1 threshold to £2,241, but HMRC's employer table says £2,241.66. That's £26,900 ÷ 12 = 2,241.666…, cut to whole pence, not rounded up to .67. The weekly figure works the same way: £26,900 ÷ 52 = 517.307…, which HMRC lists as £517.30. Multiplying by 100, flooring and dividing by 100 gives exactly HMRC's numbers:

import { PLANS, periodThreshold, type PlanId } from "./plans";

for (const id of Object.keys(PLANS) as PlanId[]) {
  const { name, threshold } = PLANS[id];
  console.log(name.padEnd(18), periodThreshold(threshold, "month"), periodThreshold(threshold, "week"));
}

// Output:
// Plan 1             2241.66 517.3
// Plan 2             2448.75 565.09
// Plan 4             2816.25 649.9
// Plan 5             2083.33 480.76
// Postgraduate Loan  1750 403.84

Every one of those matches the employer table. I check them in the app's unit tests too, so a typo in next year's thresholds will fail the build.

The repayment for one pay day

Your employer doesn't look at your yearly salary. They look at what you're paid this month (or week), compare it with that period's threshold, and take 9% of the difference. The full rules, including what happens when you have more than one plan, fit in one function:

// repay.ts: one pay period's student loan deduction
import { PLANS, periodThreshold, type Period, type PlanId } from "./plans";

// 9% of whole pence has at most 4 decimals: snap to that, then round down
const floorPounds = (n: number) => Math.floor(Math.round(n * 10_000) / 10_000);

export function repayment(pay: number, plans: PlanId[], period: Period = "month") {
  const ug = [...new Set(plans)]
    .filter((p) => p !== "pgl")
    .map((p) => ({ id: p, t: periodThreshold(PLANS[p].threshold, period) }))
    .sort((a, b) => a.t - b.t);

  // One 9% deduction over the LOWEST undergraduate threshold
  const undergraduate = ug.length ? floorPounds(Math.max(0, pay - ug[0].t) * 0.09) : 0;

  // Share it out: each lower plan gets at most 9% of the gap to the next threshold
  const split: Record<string, number> = {};
  let left = undergraduate;
  ug.forEach((plan, i) => {
    const next = ug[i + 1];
    const cap = next ? floorPounds(Math.max(0, Math.min(pay, next.t) - plan.t) * 0.09) : Infinity;
    split[plan.id] = Math.min(left, cap);
    left -= split[plan.id];
  });

  // A Postgraduate Loan is separate: 6% over its own threshold
  const postgraduate = plans.includes("pgl")
    ? floorPounds(Math.max(0, pay - periodThreshold(PLANS.pgl.threshold, period)) * 0.06)
    : 0;

  return { total: undergraduate + postgraduate, undergraduate, postgraduate, split };
}

There are four rules in there, and each one comes from a line on gov.uk:

  • Round down to the pound. 9% of £508.34 is £45.75, and £45 is taken. That's why floorPounds() uses Math.floor() and not Math.round().
  • Two undergraduate plans means one deduction. If you have Plan 1 and Plan 2, you don't pay twice. You pay 9% over the lowest of your thresholds, which is why the plans are sorted by threshold first.
  • That deduction is shared. The lower plan gets at most 9% of the gap between the two thresholds, and the rest goes to the higher plan. The forEach loop handles this for any number of plans.
  • A Postgraduate Loan is separate. It's 6% over £21,000, worked out on its own and added on top.

If you're less used to chains like filter().map().sort(), my post on JavaScript map, filter and reduce explains how each step returns a new array for the next one.

Testing it against gov.uk's own examples

The gov.uk page has four worked examples, which makes them perfect test cases. I also added edge cases: pay exactly at the threshold, and two weekly Plan 2 payments that broke my first two versions:

import { repayment } from "./repay";

// The worked examples on gov.uk/repaying-your-student-loan/what-you-pay
console.log("Plan 1, £2,750/month:", repayment(2750, ["plan1"]).total);
console.log("Plan 4, £3,000/month:", repayment(3000, ["plan4"]).total);
console.log("Plan 1 + 2, £3,200/month:", JSON.stringify(repayment(3200, ["plan1", "plan2"])));
console.log("PGL + Plan 2, £2,500/month:", JSON.stringify(repayment(2500, ["pgl", "plan2"])));

// Edge cases
console.log("Plan 5, at the threshold:", repayment(2083.33, ["plan5"]).total);
console.log("Plan 2 weekly, £1,065.09:", repayment(1065.09, ["plan2"], "week").total);
console.log("Plan 2 weekly, £1,065.04:", repayment(1065.04, ["plan2"], "week").total);
console.log("Naive maths:", (1065.09 - 565.09) * 0.09, "→", Math.floor((1065.09 - 565.09) * 0.09));

// Output:
// Plan 1, £2,750/month: 45
// Plan 4, £3,000/month: 16
// Plan 1 + 2, £3,200/month: {"total":86,"undergraduate":86,"postgraduate":0,"split":{"plan1":18,"plan2":68}}
// PGL + Plan 2, £2,500/month: {"total":49,"undergraduate":4,"postgraduate":45,"split":{"plan2":4}}
// Plan 5, at the threshold: 0
// Plan 2 weekly, £1,065.09: 45
// Plan 2 weekly, £1,065.04: 44
// Naive maths: 44.999999999999986 → 44

All four gov.uk examples come out right: £45 on Plan 1, £16 on Plan 4, £86 split into £18 and £68 for Plan 1 plus Plan 2, and £45 + £4 = £49 for a Postgraduate Loan with Plan 2.

The floating-point bug

The last lines are the reason floorPounds() doesn't just call Math.floor(). £1,065.09 a week is exactly £500 over the Plan 2 weekly threshold of £565.09, and 9% of £500 is £45. But computers store most decimals, like 1065.09, as a tiny bit off. The subtraction and multiplication give 44.999999999999986, and Math.floor() turns that into £44. Real payroll would take £45.

My first fix was to round to whole pence before flooring, and it was wrong too. On £1,065.04 the real answer is 9% of £499.95, which is £44.9955. Rounding that to pence gives £45.00, so I'd have taken a pound too much. The proper fix comes from the maths: pay and thresholds are whole pence, so 9% (or 6%) of the difference never has more than four decimal places. Snapping to four places with Math.round(n * 10_000) / 10_000 removes the float noise without changing any real value, and then Math.floor() is safe. Both cases are now unit tests in the repo.

Interest, and the "will I ever pay it off?" question

Repayments don't depend on your balance, but whether you clear the loan does. Interest keeps being added even when your income is under the threshold. When I checked on 4 October 2026, gov.uk listed 4.1% for Plans 1, 4 and 5 and 6% for the Postgraduate Loan. Plan 2 is different: after you leave your course, the rate depends on your income. It's 4.1% up to £29,385, rises on a sliding scale, and reaches 6% at £52,885:

// Plan 2 interest after you leave your course: RPI + up to 3%, capped at 6%
const RPI = 0.041;
const LOWER = 29_385;
const UPPER = 52_885;

export function plan2Interest(income: number): number {
  const share = Math.min(1, Math.max(0, (income - LOWER) / (UPPER - LOWER)));
  return Math.min(0.06, RPI + 0.03 * share);
}

for (const income of [25_000, 35_000, 41_135, 60_000]) {
  console.log(income, (plan2Interest(income) * 100).toFixed(2) + "%");
}

// Output:
// 25000 4.10%
// 35000 4.82%
// 41135 5.60%
// 60000 6.00%

The calculator uses this in a month-by-month loop. It adds a month of interest to the balance, takes off the monthly repayment, raises the salary once a year by the percentage you enter, and stops when the balance hits zero or the write-off date arrives. Plan 1 (for loans first paid from September 2006) is written off 25 years after the April you were first due to repay, Plans 2 and 4 and the Postgraduate Loan after 30 years, and Plan 5 after 40.

I was careful to call this an estimate, both in the tool and here. It keeps today's thresholds and interest rates fixed, while in reality both change most years. For a typical Plan 2 graduate on £35,000 with a £45,000 balance, it shows the loan being written off rather than paid off. That matches what most people on Plan 2 expect, but it isn't a forecast.

The form: small details that matter

The interface is one form, and the result updates as you type. A few choices made it nicer to use on a phone:

  • The salary field is a text input with inputMode="decimal", not type="number". Phones still show a number keypad, but people can type "£32,000" and the code strips the £ and commas. If you want to know where type="number" helps and where it gets in the way, see my guide to HTML form input types and validation.
  • The plans are real checkboxes inside a fieldset with a legend, and each one says who the plan is for ("England, courses starting on or after 1 August 2023"). Many people don't know which plan they're on.
  • The result box has aria-live="polite", so screen readers announce the new amount after a change.
  • The thresholds table on the page is made from the same PLANS object the maths uses, so the table and the results can't disagree.

Why it has no backend

Everything is plain arithmetic, so there's no reason to send anyone's salary to a server. Next.js pre-renders the site as static HTML, the maths runs in the browser, and there are no API keys or analytics scripts. It can't leak data it never receives.

FAQ

Why is my payslip different from the calculator?

Usually because your pay changed that month. Overtime or a bonus raises that month's deduction, because only that pay period counts. If your income for the whole tax year ends up below the yearly threshold, you can ask the Student Loans Company for a refund.

Does the amount I owe change my monthly repayment?

No. Only your income does. A £20,000 balance and a £60,000 balance cost the same each month on the same salary.

Conclusion

The student loan rules are simple once you write them down: per-period thresholds cut to the penny, 9% (or 6%) over the threshold, round down, one shared deduction for undergraduate plans, and a separate one for a Postgraduate Loan. The hard parts are the details: HMRC's exact thresholds, the split between plans, and a floating-point error that costs a pound. Try your own numbers in the student loan repayment calculator, or read the full code and tests on GitHub.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

How to Work Out Your UK Degree Classification
Coding tips

How to Work Out Your UK Degree Classification

Work out a First, 2:1 or 2:2 from module marks: credit-weighted averages, 30:70 or 1:2 year weightings, rounding at 69.5 and the mark you need.

October 4, 2026 · 10 min read