Every year the University Grants Commission (UGC) in Sri Lanka publishes a table of minimum Z-scores for university admission. Each row is a course at a university, each column is one of the 25 districts, and each cell is the lowest Z-score that won a place from that district. Many cells say "NQC" instead of a number.
I turned the 2025/2026 table into data for a university cut-off marks page. This post is about the part I think is reusable: how to model a table where a missing cell means something, and how to write queries that don't quietly lie about it.
The data shape
The table has 260 course-and-university rows. Times 25 districts, that is 6,500 cells. One row looks like this (the file is generated from the UGC PDF by a build script, and its header says not to edit it by hand):
export type CutoffCourse = { readonly n: string; // course name, official English readonly u: string; // institution readonly a: boolean; // 100% all-island merit, no district quota readonly t: boolean; // practical or aptitude test required /** Minimum selected Z-score per district, positional. null = NQC. */ readonly z: readonly (number | null)[]; }; The z array is positional. Index 0 is Colombo, index 1 is Gampaha, and so on, in the order the UGC prints the columns. The order lives in one constant, DISTRICT_KEYS, and the build script asserts that every row has exactly 25 values and that each page of the PDF yields all 25 districts, so a misaligned column fails there instead of showing up as a wrong number on the page.
I chose arrays over objects keyed by district to keep the generated file compact: 260 rows would otherwise repeat 25 district names each. The cost is that an index means nothing without DISTRICT_KEYS, so I never index into z by hand outside a loop that also reads the key list.
What NQC means, and why it is null
NQC stands for "No Qualified Candidates". The comment at the top of the data file records what the source allows it to mean: the district quota was already filled, the course filled from higher Z-scores in other districts, or nobody from that district met the requirements. The UGC table does not say which. So the cell is genuinely unknown, and it is not a Z-score of zero.
In the data it is null, and the formatter keeps it as text:
export function fmtZ(z: number | null): string { return z === null ? "NQC" : z.toFixed(4); } Using null instead of 0 or -1 is the whole point. A sentinel number will eventually be averaged, sorted or drawn on a chart. null forces every consumer to decide what to do with it. TypeScript's number | null type makes that decision a compile error if you skip it.
Here is how much it matters. Take Medicine at the University of Sri Jayewardenepura. I ran this against the real row:
const real = row.z.filter(z => z !== null); mean(real) // 2.0661 (17 districts) mean(row.z.map(z => z ?? 0)) // 1.4049 (NQC treated as 0) Math.min(...row.z.map(z => z ?? 0)) // 0 Eight of the 25 cells in that row are NQC. Treat them as zero and the average drops by 0.66 and the "lowest cut-off" becomes 0. Nobody was admitted with a Z-score of 0. The mistake is easy to make, and the output looks plausible enough that you might not notice.
Note that I write z !== null and not if (z). A truthiness check would be wrong the day a legitimate 0 shows up. The explicit comparison costs nothing.
Counting the missing cells
The share of NQC cells across the whole table is a one-liner over the same data:
export function nqcShare(year = LATEST_CUTOFF_YEAR) { const courses = coursesOf(year); const cells = courses.length * DISTRICT_KEYS.length; const nqc = courses.reduce((sum, c) => sum + c.z.filter((z) => z === null).length, 0); return { cells, nqc, share: nqc / cells, pct: pct1(nqc, cells) }; } Running it gives { cells: 6500, nqc: 1026, pct: '15.8' }. So about one cell in six has no number.
The denominator is rows × 25, not "cells that have a value". That is a deliberate choice: the page says "15.8% of cells are NQC", and the claim only makes sense against the full grid. Because the page text is built from this function, the figure on the page cannot drift from the data.
The same shape gives a per-district count. Mannar has the most NQC cells (107 of 260) and Colombo and Ratnapura the fewest (12 each). The table cannot tell me why; it only gives the count.
One real query: how wide is Medicine?
Medicine appears 12 times, once per university that offers it. "The range of Medicine cut-offs" is therefore a scan over 12 rows and 300 cells, skipping nulls:
export function courseRange(name: string, year = LATEST_CUTOFF_YEAR) { let rows = 0; let min: Located | null = null; let max: Located | null = null; for (const c of coursesOf(year)) { if (c.n !== name) continue; rows++; for (let i = 0; i < c.z.length; i++) { const z = c.z[i]; if (z === null) continue; const at = { z, university: c.u, district: DISTRICT_KEYS[i] }; if (!min || z < min.z) min = at; if (!max || z > max.z) max = at; } } if (!min || !max) return null; return { name, rows, min, max }; } Output for courseRange("MEDICINE"):
rows: 12 min: 1.4584 Eastern University, Sri Lanka, Mannar max: 2.4753 University of Colombo, Kandy Of the 300 Medicine cells, 77 are NQC. The lowest and highest minimums differ by almost a full point, but that comparison mixes universities and districts. The gap inside a single row is a fairer measure of how much the district quota matters. The widest one for Medicine is again Eastern University: 1.4584 for Mannar against 1.9151 for Hambantota, a spread of 0.4567. The table-wide median spread across all 260 rows is 0.3319, so that row is wider than usual.
Two details are worth stealing. The function returns null when a course has no numbers at all, rather than a made-up range, so callers must handle "nothing to say". And the scan keeps the university and district with each extreme, because "1.4584" alone is not a fact anyone can check against the PDF.
A per-course summary
The course pages use a bigger function, courseSummary, that computes everything a page states: min, median and max per university, the NQC count, districts where no university admitted anyone, and a "harder than N% of courses" figure. The last one uses the median of each course's non-null cells and ranks it against all courses. For Medicine it gives 97. It comes with a quality gate that counts the distinct numbers a page shows, so a thin page does not ship.
Two small design points there. Medians are rounded with Number(x.toFixed(4)) because the source has four decimals and floating-point noise should never reach a reader. And the median helper returns null for an empty list instead of 0, for the same reason as above.
Limits
- This is one year: the 2025/2026 intake, based on the G.C.E. (A/L) 2025 results after re-scrutiny. Cut-offs change every year, and a cut-off from last year does not predict next year's.
- A cell is the minimum selected Z-score. It is not a guarantee, and a lower score than the cell does not mean a place was impossible, only that none was offered to that district in this cycle.
- NQC has three possible causes and the table does not say which applied.
- The numbers come from the UGC's published PDF. The build script asserts column counts and row alignment, so a misread fails loudly, but the UGC document is the authority, not my page.
Takeaway
When a source table has a "no value" marker that carries meaning, keep it as null in the data and make every aggregation say what it does with it. Count against the full grid when you quote a share, and carry the location along with every extreme.
How do you model "missing but meaningful" values in your own datasets: a nullable type, a separate flag, or a sentinel? I would like to hear where each has bitten you.
This article was written with the help of AI. The code, numbers and sources come from my own project and were checked against it before publishing.


