Data through 2025–26 · updated June 2026
The data comes from the New Jersey Department of Education Fall Enrollment Reports, published at nj.gov/education/doedata/enr. It’s an annual fall headcount of every student in every public school and district in the state, broken out by grade, race/ethnicity, and economic and program status. The state posts one ZIP file per school year, and the series runs from 1998–99 through 2025–26 — 28 years.
Each ZIP holds a single file (CSV or Excel) whose name and shape depend on the year. The grain underneath is consistent in spirit — numbers for each school — but the container, the column names, and even which categories exist have all changed over the years. That’s the whole challenge.
The state rebuilt the report format twice. There are three distinct generations, and a parser has to detect which one it’s looking at before it can read a row.
| Era | Years | Container | Shape |
|---|---|---|---|
| A | 1998–99 → 2008–09 (11 yrs) | One CSV, STAT_ENR.CSV |
One row per school × program/grade. Race is split into sex-coded columns — WHM/WHF (white male/female), BLM/BLF, HIM/HIF, and so on. You sum male+female to get a race count. |
| B | 2009–10 → 2018–19 (10 yrs) | Excel "long" workbook. 2009–10 and 2010–11 are legacy binary .xls (must be converted, e.g. via LibreOffice, before a modern library will open them); 2011–12 onward are .xlsx. Filenames drift: enr.xls, enr.xlsx, EnrollmentReport.xlsx. |
A long "SQL Results"–style sheet: one row per school × grade × program, with race still as race × gender columns. Header naming shifts (WhiteM vs WHM), and some years mislabel the grade column "Total" on every row, keeping the real grade in a separate PRGCODE. |
| C | 2019–20 → 2025–26 (7 yrs) | Modern .xlsx with separate State / District / School sheets. |
One row per school, with plainly-named columns (White, Black, Hispanic…) and a percentage column next to each count. Read the count, skip the percent. |
Two practical notes that cost us time: header rows are not always the first row (scan the first several rows for a known label like a district-code column), and the way to tell Era B from Era C apart — both are .xlsx — is to look at the sheet names: if there are separate "School" and "District" sheets, it’s Era C; otherwise treat it as an Era B long workbook.
The single most important thing to understand: the race/ethnicity categories are not constant across the 28 years.
Four categories run the entire series unchanged in meaning: White, Black, Hispanic, and Native American. Asian spans the series but narrows when Pacific Islander becomes separate, and Two or More Races appears at the same break. Here’s exactly where:
| Category | Present | Note |
|---|---|---|
| White, Black, Hispanic, Native American | 1998–99 → 2025–26 (all 28 yrs) | Stable throughout. |
| Asian | 1998–99 → 2025–26 (all 28 yrs) | But its meaning narrows in 2006–07 — see below. |
| Native Hawaiian / Pacific Islander | 2006–07 → 2025–26 (20 yrs) | Split out as its own category. Before 2006–07 these students were counted inside Asian. |
| Two or More Races | 2006–07 → 2025–26 (20 yrs) | New multiracial category. Before 2006–07 there was no way to be multiracial; students were assigned a single race. |
| Dimension | Coverage |
|---|---|
| Free / reduced-price lunch (economic disadvantage) | Back to 1998–99, but blank ~2019–20 → 2021–22 in the source. |
| Multilingual / English learners (labeled LEP, then ELL, then ML over time — same dimension) | From ~2005–06. |
| Migrant | From ~2005–06. |
| Military-connected, Homeless | Modern only — 2022–23 onward. |
| Gender (male/female by race) | Exists in the legacy source files (1998–2018) but we do not load it. |
Because 2006–07 reshuffled the categories, the demographic-shift explorer does not put a single index definition across that line. A pre-2006–07 pair uses the five categories collected then; a pair wholly within 2006–07 onward uses all seven. A cross-boundary choice receives no shift and no rank. Folding later groups or setting Two or More Races aside would create a different measure under the same name.
Within either reporting scheme, the explorer’s Demographic Shift Index is ½ · Σ |Δ share| over that scheme’s complete group set: the share of students who would have to be in a different category to turn the earlier year’s mix into the later one’s.
In the CSV and long-workbook eras, each school has many rows: an authoritative "Total" row plus per-grade rows (and sometimes special-education-by-disability rows that are already inside the Total). Take a school’s total, race, and lunch counts from the Total row; take per-grade counts from the grade rows; never add the two together. Only fall back to summing grade rows when a Total row is genuinely absent.
A school’s per-grade columns can sum to less than its total, and that is not a parsing error. New Jersey’s headcount counts ungraded students — overwhelmingly special-education students who carry no grade level. They are in the total but in no grade. So at special-services schools (regional day schools, schools for the deaf, county educational-services commissions) the grade columns sit near zero against a real total, and at ordinary schools with a special-education program the grades fall short by exactly that ungraded population — Vineland Senior High lands around 89% of its total in graded rows, Newark’s Abington Avenue School around 85%. Across the series, roughly three-quarters to nine-tenths of schools have grades that sum to the full total in any given year; the rest carry ungraded students.
The general rule we use against that shortfall: trust per-grade detail for a year only when the grades sum to roughly the total (we require ≥ 90%); otherwise treat that year’s by-grade view as incomplete and fall back to the Total row.
This is not a gender artifact, which we checked directly: race in the legacy files is sex-split (WHM/WHF), so picking up only one sex would pull grade sums toward 50% of the total. Fewer than 1% of rows land near there — the male + female counts are being summed correctly.
For 2020–21 through 2022–23 the state’s School and District sheets misspelled the grade-8 header as Eight Grade (missing the "h"); it reads Eighth Grade in every other year. A column matcher looking for the correct spelling reads grade 8 as blank — for every school in New Jersey, those three years. It surfaces first at middle schools, where grade 8 is the terminal cohort, so a 1,700-student school looks several hundred short; the students were never missing, only their grade-8 cell. The fix is to accept both spellings. It’s a reminder that the column names are as much a moving target as the formats — match them loosely, and reconcile grade sums against the Total to catch a dropped column before it reaches a chart.
Shared-time students (those who split attendance between schools) are reported as halves. Counts are not always integers — there are roughly 4,400 fractional totals in the series. Preserve them as-is; don’t round on import.
The files embed aggregate rows. A school code of 999, or a school name containing "TOTAL," is the district total, not a building. A district code of 9999, or names like "County Total" / "State Total," are county/state rollups. Separate the district aggregates into their own table and drop the county/state rollups, or they’ll inflate everything.
Missing or suppressed values show up as ., *, N, -, or empty. Treat all of them as null, not zero.
Normalize the codes — district to 4 digits, school to 3 — and usually key schools on district_code + school_code. Do not assume that pair is infallibly unique: in 1999–00, 3530-050 labels both New Brunswick High and Rutgers-Douglas Center under different county codes. The explorer quarantines both conflicting rows for that school-year and fails closed on any new collision rather than guessing, summing, or overwriting. County code is not a universal repair because it is unreliable in some legacy files. School names, meanwhile, drift constantly and sometimes change outright. One building in South Orange-Maplewood (district 4900, school 090) reads:
JEFFERSON 1998-99 → 2009-10
Jefferson E.S. 2010-11
Jefferson Elementary School 2011-12 → 2021-22
Delia Bolden Elementary School 2022-23 → 2025-26
Same code throughout, four names. Track the history (we keep a school_name_history table) so a rename doesn’t read as a school appearing and disappearing.
All 28 years normalize into one SQLite file with three tables. Counts are stored as REAL (to preserve the fractional shared-time values), and missing values are NULL.
| Table | Grain | Rows |
|---|---|---|
enrollment | one row per school × year | ~69,300 |
district_enrollment | one row per district × year (authoritative district total; synthesized from member schools where the source omits it, and flagged) | ~18,400 |
school_name_history | one row per code × name, with first/last year — catches renames | — |
Coverage: 744 districts, ~3,038 schools, all 21 NJ counties (plus charter / state-operated groupings), 1998–99 → 2025–26.
The enrollment schema:
CREATE TABLE enrollment (
school_year TEXT, county_code TEXT, county_name TEXT,
district_code TEXT, district_name TEXT, school_code TEXT, school_name TEXT,
total REAL,
-- race / ethnicity (hawaiian_pi & two_or_more only populated from 2006-07)
white REAL, black REAL, hispanic REAL, asian REAL,
native_american REAL, hawaiian_pi REAL, two_or_more REAL,
-- grades
pk REAL, k REAL, g1 REAL, g2 REAL, g3 REAL, g4 REAL, g5 REAL, g6 REAL,
g7 REAL, g8 REAL, g9 REAL, g10 REAL, g11 REAL, g12 REAL,
-- economic / program
free_lunch REAL, reduced_lunch REAL, ml_learners REAL,
migrant REAL, military REAL, homeless REAL,
PRIMARY KEY (school_year, county_code, district_code, school_code)
);
-- district_enrollment mirrors these columns (no school_code/name; adds
-- synthesized INTEGER), keyed (school_year, county_code, district_code).
An example — every school’s economic-disadvantage rate for a given year:
SELECT school_year, district_name, school_name,
ROUND(100.0 * (free_lunch + reduced_lunch) / total, 1) AS pct_frl
FROM enrollment
WHERE school_year = '2024-25' AND total >= 100 AND free_lunch IS NOT NULL
ORDER BY pct_frl DESC;
A single build script reproduces the whole database from the public source. For each year it: downloads the ZIP from the state archive; detects the era (CSV → Era A; an Excel workbook with School + District sheets → Era C; any other workbook → the Era B long format, converting legacy .xls first); reads each row by matching column names against the known naming variants; applies the Total-row / grade-row / rollup rules above; normalizes codes; and writes the three tables. Everything in sections 1–4 is the spec it implements — given the raw ZIPs, that’s enough to rebuild the same schema from scratch.
The pipeline’s output reproduces figures the South Orange-Maplewood district published itself (from its Nov 2025 integration forum), which validates the parse end to end across eras:
| Figure | Our extract | District-published |
|---|---|---|
| SOMSD White %, 1998–99 | 43.8% | 44% |
| SOMSD % free/reduced lunch, 1998–99 | 17.0% | 16.9% |
| South Mountain White %, 1998–99 | 56.1% | 56% |
| SOMSD White %, 2019–20 | 55.4% | 55.4% |
| SOMSD % free/reduced lunch, 2024–25 | 14.1% | 14.1% |
Source data: New Jersey Department of Education, Fall Enrollment Reports, 1998–99 through 2025–26 (nj.gov/education/doedata/enr).
Every dataset behind this site keeps a structured registry of its known issues — the format breaks, suppression rules, entry errors, and definitional traps we hit while building on it — so that the next person (or agent) who works with this data doesn't rediscover them the hard way. Each issue has a stable id, a machine-readable scope (which years, columns, and tables it touches), and an effect: breaks stops a pipeline, corrupts silently wrongs the numbers, misleads invites a wrong reading of right numbers, context is background you must hold to use the data responsibly. Issues marked ★ are core: read them before any use of this data. The registry is maintained in the ergo format and served in machine-readable form alongside this page — links at the end of this section.
NJ Dept. of Education · 1998-99 → 2025-26 (28 yrs) · source confidence A · updated 2026-07-21 · 23 known issues (2 core)
The pitfall: 'Two or More Races' and 'Native Hawaiian/Pacific Islander' don't exist before 2006-07 — and the warehouse stores 0, not NULL, for them in every earlier year, so a naive trend line reads a definitional change as a demographic swing.
Type: format — applies to the whole dataset
How to spot it: wb.sheetnames / file extension differ across years: a bare .csv (Era A, 1998-99→2008-09), an Excel 'long' workbook (Era B, 2009-10→2018-19), or an .xlsx with separate School/District/State sheets (Era C, 2019-20→2025-26). parse_year() picks the reader by inspecting the zip contents and workbook shape, never the year label.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: 2009-10 → 2018-19 (binary .xls specifically 2009-10, 2010-11)
How to spot it: openpyxl.load_workbook raises on the 2009-10/2010-11 files unless converted first; the zip's single data file is named enr.xls, enr.xlsx, or EnrollmentReport.xlsx depending on the year, never a fixed name.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: 1998-99 → 2018-19 (Era A/B; Era C uses plain names like White, Black, Hispanic) · columns: white, black, hispanic, asian, native_american, hawaiian_pi, two_or_more
How to spot it: RACE_PATTERNS lists every observed header prefix per race×sex column, e.g. white: 'whm'/'whitem' (male), 'whf'/'whitef' (female); two_or_more: 'mum'/'2moreracesm'/'twoormorem'. A parser hard-coded to one spelling finds zero matching columns in the other era's file — a silent all-null column, not an error.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: some years within Era B (2009-10 → 2018-19); exact years not enumerated in the source or in the builder's comments · columns: Grade_Level, PRGCODE
How to spot it: In affected years, every row's grade-level column literally reads 'Total', with the real grade level carried in PRGCODE instead.
The misread: Trusting Grade_Level at face value in an affected year either collapses every grade into one bucket, or — because the accumulator treats a 'Total'-labeled row specially — makes every per-grade row look like the school's authoritative Total row.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: Era B and C (2009-10 → 2025-26)
How to spot it: parse_long_workbook scans rows 1-6, and parse_modern's hdr() scans rows 1-9, for a row containing a recognizable district-code-style label ('distid'/'districtcode'/'dist', or 'districtname') before treating anything as data.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: 2009-10 → 2025-26 (era boundary)
How to spot it: parse_year() checks wb.sheetnames for at least one sheet with 'school' in its name and one with 'district' in its name; only then does it call parse_modern (Era C). Otherwise the file is treated as an Era B long workbook.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: 2019-20 → 2025-26 (Era C) · columns: White, Black, Hispanic, Asian, *Percent*, *%*
How to spot it: hdr() explicitly skips any header cell whose raw value contains '%' when building the column index, so the first (count) column of each count/percent pair wins the name.
The misread: Reading the wrong member of a count/percent pair silently substitutes a 0-100 percentage for a headcount, or vice versa, with no type error to flag it.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: 2020-21, 2021-22, 2022-23 · columns: Eighth Grade, Eight Grade, g8
How to spot it: The School and District sheets header grade 8 as 'Eight Grade' in 2020-21, 2021-22, and 2022-23; every other year — including the correction in 2023-24 — spells it 'Eighth Grade.'
The misread: A column matcher looking only for the correct spelling reads every NJ school's grade-8 enrollment as blank for these three years — most visible at middle schools, where grade 8 is the terminal cohort.
Full entry, with the story and the numbers, in the served ergo doc.
Type: coding — applies to years: all (break at 2006-07) · columns: hawaiian_pi, two_or_more, asian
How to spot it: White, Black, Hispanic, and Native American run all 28 years unchanged. Native Hawaiian/Pacific Islander and Two or More Races both first appear in 2006-07; before that NJ used a 5-category scheme and Pacific Islander students were counted inside Asian.
The misread: Comparing a pre-2006-07 Asian share to a post-2006-07 Asian share as the same universe — pre-2006-07 Asian includes Pacific Islander, post-2006-07 doesn't. Comparing raw 'Two or More' shares across the 2006-07 line compares a real population to a structural zero. The demographic-shift explorer therefore calculates only pre/pre five-category pairs or post/post seven-category pairs; it does not assign a shift or rank across the reporting boundary.
Full entry, with the story and the numbers, in the served ergo doc.
Type: coding — applies to years: 1998-99 → 2005-06 (before Two or More / Pacific Islander existed in the source) · tables: enrollment, district_enrollment · columns: hawaiian_pi, two_or_more
How to spot it: The legacy producer emitted zero before the categories existed. The pinned Aquifer release and consumer adapter now emit NULL plus structural-absence measure states; the old builder remains rollback-only.
The misread: AVG(two_or_more / total) computed across the full 28-year series reads the pre-2006-07 years as '0% multiracial' rather than 'category did not exist' — silently dragging any long-run multiracial-share trend toward zero. A NULL-aware filter (WHERE two_or_more IS NOT NULL) does not catch these rows, because they aren't NULL.
Full entry, with the story and the numbers, in the served ergo doc.
Type: measurement — applies to years: 1998-99 → 2018-19 (Era A/B, row-per-grade files) · tables: enrollment, district_enrollment
How to spot it: Era A/B files have one Total-labeled row per school plus per-grade rows, and sometimes special-ed-by-disability rows that are already folded into Total.
The misread: Summing every row for a school (Total + grade rows + special-ed rows) roughly doubles or triples its reported enrollment.
Full entry, with the story and the numbers, in the served ergo doc.
Type: definitional — applies to years: all · columns: pk, k, g1, g2, g3, g4, g5, g6, g7, g8, g9, g10, g11, g12, total · entities: special-services schools, and any school with a sizable special-ed program
How to spot it: Roughly three-quarters to nine-tenths of schools have grades summing to the full total in a given year; special-services schools (regional day schools, schools for the deaf, county ed-services commissions) can sit near zero graded against a real total. Public explainer's examples: Vineland Senior High ~89% of total in graded rows, Newark's Abington Avenue School ~85%.
The misread: Treating (total - sum of grades) as a data-quality defect, or silently redistributing the gap across grades to make the columns add up. The public explainer's own working rule: trust per-grade detail for a year only when grades sum to >=90% of total; otherwise fall back to the Total row.
Full entry, with the story and the numbers, in the served ergo doc.
Type: measurement — applies to years: all · columns: total, white, black, hispanic, asian, native_american, hawaiian_pi, two_or_more, pk, k, g1, g2, g3, g4, g5, g6, g7, g8, g9, g10, g11, g12
How to spot it: num() parses to float and only rounds to a whole number when the value already is one (int(f) if f == int(f) else round(f, 1)); roughly 4,400 fractional totals exist across the series.
The misread: Casting a count column to int, or assuming COUNT-like integer semantics, truncates legitimate shared-time halves — a student split between two schools is reported as 0.5 at each.
Full entry, with the story and the numbers, in the served ergo doc.
Type: entry — applies to years: all · columns: school_code, school_name, district_code, district_name
How to spot it: A row with school_code '999', or a school_name containing 'TOTAL', is the district's own aggregate row, not a building. A row with district_code '9999', or a district_name of 'County Total'/'State Total'/'NJ Total', is a county/state rollup.
The misread: Loading these rows as ordinary schools or districts double-counts every student in that district/county/state on top of the real building- and district-level rows.
Full entry, with the story and the numbers, in the served ergo doc.
Type: suppression — applies to years: all · columns: *
How to spot it: num() treats '.', '*', 'N', '-', empty string, and any string made only of dots/spaces as missing, returning None rather than parsing it as zero or raising ValueError.
The misread: Coercing a suppressed cell to 0 (rather than NULL) understates a school's count in any SUM/AVG that doesn't explicitly filter nulls, and makes a genuinely-zero school indistinguishable from a suppressed one.
Full entry, with the story and the numbers, in the served ergo doc.
Type: linkage — applies to years: all · tables: enrollment, district_enrollment, school_name_history · columns: school_name, district_code, school_code, county_code
How to spot it: district_code (zfill 4) and school_code (zfill 3) are normalized in every parser and are normally stable across a school's history. SOMSD school 090 carries four names across one code. But 1999-00 has two semantically conflicting 3530-050 rows under different county codes: New Brunswick High and Rutgers-Douglas Center.
The misread: Joining by school_name reads a renamed school as several entities; treating (district_code, school_code, school_year) as infallibly unique silently overwrites one side of the 1999-00 3530-050 collision; including county_code in a general join can still drop matches where that field is unreliable. Quarantine the known collision and fail closed on any new one.
Full entry, with the story and the numbers, in the served ergo doc.
Type: measurement — applies to years: all · tables: district_enrollment · columns: synthesized
How to spot it: synth_missing_districts() sums NUMERIC columns across a district's member schools for any (county_code, district_code) with no district-level row already present that year, and writes synthesized=1; source-published district rows carry synthesized=0.
The misread: Treating every district_enrollment row as the state's own published district total. A synthesized row is our sum of that year's member schools and can disagree slightly with a source total that used its own rounding or suppression, in a year the source happens to also publish one.
Full entry, with the story and the numbers, in the served ergo doc.
Type: coverage — applies to years: varies by column — see detection · columns: ml_learners, migrant, military, homeless
How to spot it: free_lunch/reduced_lunch and ml_learners/migrant now run continuously from 1998-99; military appears only from 2019-20; homeless appears in 2009-10 and then continuously only from 2019-20.
The misread: Charting a district's military-connected or homeless counts back before their coverage began reads a source reporting gap as a demographic collapse to zero — though because these come out NULL rather than 0 (fall-enrollment/suppressed-blank-cells), a chart that doesn't skip nulls will show a gap, not a false zero, if it's built correctly.
Full entry, with the story and the numbers, in the served ergo doc.
Type: availability — applies to the whole dataset
How to spot it: The builder opened with `if DB.exists(): DB.unlink()` and then created three tables. data/njdoe_enrollment.sqlite holds 49 tables across nine families.
The misread: Running the documented rollback command on a populated warehouse silently discards budgets, School Performance Reports, state aid, accountability, IDEA, staffing, and the chronic-absence snapshot, then prints a normal success summary.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: 2019-20 → 2021-22 · columns: free_lunch, reduced_lunch, ml_learners, migrant, military, homeless
How to spot it: The School and District sheets for those three years carry %Free Lunch, %Reduced Lunch, %English Learners, %Migrant, %Military and %Homeless and no matching count column; from 2022-23 both a count and a percent column appear.
The misread: Reading the resulting NULLs as a reporting gap — the state never stopped collecting these — or treating a recovered count as an exact reported figure.
Full entry, with the story and the numbers, in the served ergo doc.
Type: coding — applies to years: 2010-11 → 2011-12 · columns: free_lunch, reduced_lunch, ml_learners, migrant
How to spot it: The 2010-11 and 2011-12 workbooks have PRGCODE but no GRADE_LEVEL column; from 2012-13 both are present and GRADE_LEVEL carries the literal 'Total'.
The misread: Treating the resulting NULL free/reduced-lunch columns as a source gap for those two years.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: 2009-10 · columns: reduced_lunch, homeless
How to spot it: The 2009-10 header row reads FREE_LUNCH, RED_LUNCH, LEP, MIG, HOMELESS where 2011-12 onward reads FREE_LUNCH, REDUCED_LUNCH / REDUCED_PRICE_LUNCH, LEP, MIGRANT.
The misread: Comparing a 2009-10 free/reduced-lunch rate against later years when only the free half was ever loaded — the 2009-10 rate reads about six points too low.
Full entry, with the story and the numbers, in the served ergo doc.
Type: format — applies to years: 2018-19, 2022-23 · columns: ml_learners
How to spot it: The 2022-23 School sheet header reads 'English Learners'; the 2023-24 sheet reads 'Multilingual Learners'. The same population, renamed.
The misread: Reading the NULL as a year in which no district enrolled a multilingual learner.
Full entry, with the story and the numbers, in the served ergo doc.
Machine-readable: this page as an ergo doc · all datasets (index.json) · format: ergo · implementation: source repo
The text on this page was generated by Claude Opus 4.8, working as part of a stack of tools created by Lyra Forge.