Back to all postsAn orange paper basketball with grey seams, on a tan paper card

CSV Import for Sports Statistics Software

A box score is written once, during the game, by a scorer sitting at the table. It goes into the statistics software the school runs, and it gets copied into a spreadsheet the moment anybody wants to look at a season instead of a night. A program holds one of those for every game it has played.

Box scores are printed for people to read. Shooting is written as makes and attempts inside one cell, minutes are written the way a clock reads, and the sheet carries rows for team totals that belong to no player.

A basketball program's box score archive arrives with seventeen columns. Your schema has a field for sixteen. Three of those columns carry two numbers in one cell, and one of them writes how long a player was on the court in five ways.

A sports statistics platform stores one row per player per game. Its customers export those rows out of the software their scorer runs during a game, or out of the spreadsheet an assistant keeps beside it. Taking a program's archive into a schema of your own is customer data onboarding.

The sports information director at the program is the one who uploads the archive, when that program goes live on your product. The schema it reaches is yours.

The archive below holds 2,443 rows over seventeen columns, 164 KB of .csv. Every count in this article came out of running that file through the importer.

How long a player was on the court

The NCAA publishes a statisticians' manual every season. Section 9 says that minutes played "may be kept and reported either to the second or to the nearest minute", and then names four symbols.

A player who was on the court for exactly zero seconds gets 0. A player who was on for less than half a minute gets 0+. A player who was on for at least 39 minutes and 30 seconds, without playing the whole 40, gets 40-. A player who never entered gets DNP.

FIBA's own manual, published for the 2024 season, uses DNP for the same case and writes minutes with seconds everywhere else. Two governing bodies, one symbol.

Two of those symbols name a range. 0+ covers everything between zero and 30 seconds, and 40- covers everything from 39 minutes 30 seconds up to the final horn. Neither one is a value your database can add up. Which number your platform stores in their place is a decision you make once, in code.

The archive a statistician keeps

The file is one row per player per game, over 174 games and six seasons. Its column names come from the box score the program's stats software printed, with a date and an opponent added so a season stays in one sheet.

box-scores-2020-2026.csv
ABCDEFGHIJKLMNOPQ
1DateOpponent##Player NameTOT-FG3-PTFTOFDETOTPFTPATOBLKSMIN
1022 rows not shown
102401/22/2023Underhill00Zeke Gillick7-137-80-1448521703331
102501/22/2023Underhill23Priit Vantrease2-32-30-317846633231
102601/22/2023Underhill20Dedrick Gillick5-70-21-6022411242228
102701/22/2023Underhill40Amare Quiroz1-101-72-3381135311333
102801/22/2023Underhill52Oisin Vantrease2-42-44-72810110421033
102901/22/2023Underhill31Solomon Janowski5-100-25-5358215211125
103001/22/2023Underhill25Rasheed Redfern2-21-10-201115000011
103101/22/2023Underhill33Yannick Ulmer0-00-00-00000000000
103201/22/2023Underhill10Malachi QuirozDNP
103301/22/2023Underhill05Dedrick DoranDNP
103401/22/2023Underhill45Lachlan Ostrander0-20-21-11120120009
103501/22/2023Underhill24Emeka Eklund0-00-00-00000000000+
103601/22/2023UnderhillTEAM4482
103701/22/2023UnderhillTotals24-5113-2913-28184058207426131111200
855 rows not shown
189302/07/2025Calderwood11Ferran Bosque6-112-53-4167217420140-
550 rows not shown
244403/07/2026RavensmoorTotals26-559-2415-191129401776151047200
1Date,Opponent,##,Player Name,TOT-FG,3-PT,FT,OF,DE,TOT,PF,TP,A,TO,BLK,S,MIN1022 rows not shown102401/22/2023,Underhill,00,Zeke Gillick,7-13,7-8,0-1,4,4,8,5,21,7,0,3,3,31102501/22/2023,Underhill,23,Priit Vantrease,2-3,2-3,0-3,1,7,8,4,6,6,3,3,2,31102601/22/2023,Underhill,20,Dedrick Gillick,5-7,0-2,1-6,0,2,2,4,11,2,4,2,2,28102701/22/2023,Underhill,40,Amare Quiroz,1-10,1-7,2-3,3,8,11,3,5,3,1,1,3,33102801/22/2023,Underhill,52,Oisin Vantrease,2-4,2-4,4-7,2,8,10,1,10,4,2,1,0,33102901/22/2023,Underhill,31,Solomon Janowski,5-10,0-2,5-5,3,5,8,2,15,2,1,1,1,25103001/22/2023,Underhill,25,Rasheed Redfern,2-2,1-1,0-2,0,1,1,1,5,0,0,0,0,11103101/22/2023,Underhill,33,Yannick Ulmer,0-0,0-0,0-0,0,0,0,0,0,0,0,0,0,0103201/22/2023,Underhill,10,Malachi Quiroz,,,,,,,,,,,,,DNP103301/22/2023,Underhill,05,Dedrick Doran,,,,,,,,,,,,,DNP103401/22/2023,Underhill,45,Lachlan Ostrander,0-2,0-2,1-1,1,1,2,0,1,2,0,0,0,9103501/22/2023,Underhill,24,Emeka Eklund,0-0,0-0,0-0,0,0,0,0,0,0,0,0,0,0+103601/22/2023,Underhill,,TEAM,,,,4,4,8,,,,2,,,103701/22/2023,Underhill,,Totals,24-51,13-29,13-28,18,40,58,20,74,26,13,11,11,200855 rows not shown189302/07/2025,Calderwood,11,Ferran Bosque,6-11,2-5,3-4,1,6,7,2,17,4,2,0,1,40-550 rows not shown244403/07/2026,Ravensmoor,,Totals,26-55,9-24,15-19,11,29,40,17,76,15,10,4,7,200

Fourteen rows of one game, a line from two seasons later, and the row the archive ends on. The player wearing 00 played 31 minutes. One player played 0+, one played 0, and two never left the bench. The row reading TEAM carries four rebounds each way and two turnovers with no jersey against it. The row reading Totals closes that game at 200 minutes, row 1893 is the 40- case, and row 2444 closes the last game the archive holds.

Updog opens that file in the tab the statistician is already sitting in. The reading, the matching and the checking run on their machine. Nothing about this game travels to your API until they press submit. Client-side and server-side import follows a file down each of the two routes.

The fields your platform stores

Sportradar publishes the field names its NBA box score endpoint answers with. Made and attempted are two fields each, being field_goals_made and field_goals_att. Minutes arrive as a string in "MM:SS", as in "30:44".

Minutes are stored in seconds, because seconds is the only unit that holds every shape the file writes. The date column writes 01/22/2023, and the first value with a part above 12 settles the whole file as month-first, so all 2,443 dates land as ISO.

import type {
DataEditorColumn,
DataEditorRow,
ValidationError,
} from "@updog/data-editor";
import { pointsAgree } from "./pointsAgree";
import { readSeconds } from "./readSeconds";
const printedPair = (value: unknown): ValidationError | null => {
const written = String(value ?? "").trim();
if (written === "") return null;
const pair = /^(\d+)-(\d+)$/.exec(written);
if (!pair) {
return { level: "error", message: "Write this as made-attempted" };
}
return Number(pair[1]) > Number(pair[2])
? { level: "error", message: "More made than attempted" }
: null;
};
const playerOrTeam = (
value: unknown,
row: DataEditorRow,
): ValidationError | null => {
if (String(value ?? "").trim() !== "") return null;
return row.playerName === "TEAM"
? null
: { level: "error", message: "Every line carries a jersey number" };
};
const shooting = (id: string, title: string): DataEditorColumn => {
return {
id,
title,
dependentFields: ["points"],
validators: [{ type: "function", fn: printedPair }],
};
};
const tally = (id: string, title: string): DataEditorColumn => {
return {
id,
title,
editor: { type: "number" },
validators: [{ type: "number", min: 0, decimalPlaces: 0 }],
};
};
export const columns: DataEditorColumn[] = [
{
id: "gameDate",
title: "Game date",
editor: { type: "date" },
validators: [{ type: "date" }],
},
{ id: "opponent", title: "Opponent" },
{
id: "jerseyNumber",
title: "Jersey number",
validators: [{ type: "function", fn: playerOrTeam }],
},
{
id: "playerName",
title: "Player name",
dependentFields: ["jerseyNumber"],
},
{
id: "secondsPlayed",
title: "Seconds played",
editor: { type: "number" },
transformer: readSeconds,
validators: [{ type: "number", min: 0, max: 3600, decimalPlaces: 0 }],
},
shooting("fieldGoals", "Field goals"),
shooting("threePointers", "Three pointers"),
shooting("freeThrows", "Free throws"),
tally("offensiveRebounds", "Offensive rebounds"),
tally("defensiveRebounds", "Defensive rebounds"),
tally("personalFouls", "Personal fouls"),
{
id: "points",
title: "Points",
editor: { type: "number" },
validators: [
{ type: "number", min: 0, decimalPlaces: 0 },
{ type: "function", fn: pointsAgree },
],
},
tally("assists", "Assists"),
tally("turnovers", "Turnovers"),
tally("blocks", "Blocks"),
tally("steals", "Steals"),
];

The three shooting columns keep the pair the file printed, and a rule checks its shape. Splitting them into six numbers is the last thing that happens, and it has a section below.

The headers that reach nothing

Three of the seventeen headers land with no configuration. Opponent is the field name. Player Name is the field name with a space in it. Date sits inside gamedate.

The other fourteen are abbreviations, and an abbreviation shares no word with a field name. Thirteen of them shrink to three characters or fewer once the punctuation comes off, and the matcher refuses to look inside a string that short. A, S, TO and PF reach nothing, and so do MIN and TOT-FG.

export const synonyms = {
columns: {
jerseyNumber: ["##", "no", "uni"],
secondsPlayed: ["min", "mp"],
fieldGoals: ["tot-fg", "fg-fga", "fg"],
threePointers: ["3-pt", "3pt", "3fg"],
freeThrows: ["ft", "ft-fta"],
offensiveRebounds: ["of", "oreb"],
defensiveRebounds: ["de", "dreb"],
personalFouls: ["pf"],
points: ["tp", "pts"],
assists: ["a", "ast"],
turnovers: ["to", "tov"],
blocks: ["blk"],
steals: ["s", "stl"],
},
};

Thirteen rows finish the mapping, and sixteen of the seventeen headers reach a field. TOT stays unmapped on purpose, because the total rebound column is the sum of the two beside it and your platform can add. Every string here is the vocabulary this whole sport writes in, so the next program's export lands on the same table. Remembering an import mapping covers carrying what one person fixed by hand into the next upload.

One unit for every shape

The minutes column arrives at a number field, so Updog reduces what it can. A currency symbol, a percent sign, accounting parentheses and the file's own grouping all come off. A clock does not, because no pattern in the numeric grammar reads one.

So 31:24 stays as written, and so do 0+, 40- and DNP. Against a schema with no transformer, 1,241 of the 2,269 filled cells in that column carry Invalid number.

export const readSeconds = (value: unknown): unknown => {
const written = String(value ?? "").trim();
if (written === "" || written === "DNP") return "";
const clock = /^(\d+):([0-5]\d)$/.exec(written);
if (clock) return String(Number(clock[1]) * 60 + Number(clock[2]));
if (written === "0+") return "0";
const nearly = /^(\d+)-$/.exec(written);
if (nearly) return String((Number(nearly[1]) - 1) * 60 + 30);
if (/^\d+$/.test(written)) return String(Number(written) * 60);
return value;
};

Every shape in the column reaches one unit, and every one of the 1,241 flags goes to zero.

Written Rows Becomes
a clock, as 31:24 896 1884
whole minutes, as 28 854 1680
DNP 289 an empty cell
200, on the totals line 174 12000, and out of range
empty, on the team line 174 stays empty
40- 31 2370
0+ 25 0

Two of those conversions are a judgement, and it is worth writing down what they cost. 0+ becomes zero seconds and 40- becomes 2370 seconds, so each one is short by at most 29 seconds. This archive holds 25 cells reading 0+ and 31 reading 40-, which keeps the whole rounding under half an hour across six seasons.

An empty cell and a zero mean different things here. Both manuals say so. The NCAA writes that a game played "would not be ruled out by the use of a '0' under minutes played". FIBA writes that a game with DNP does not count as a game played at all. A player given 0 was in the book for that game, and a player given DNP was never in it.

Folding DNP into zero seconds erases that line, and a games-played count goes up by one for every player who sat. The transformer above returns an empty string for DNP and the string "0" for a zero. The handler at the end of this article reads that empty cell as a played flag.

The cell that holds two numbers

7-13 is seven field goals made out of thirteen attempted. A published Stat Crew box score prints FG-FGA, FG-FGA and FT-FTA as its three shooting columns, and the NCAA points its own statisticians at that software.

One header reaches one field. Your schema holds fieldGoalsMade and fieldGoalsAtt apart, and the file offers TOT-FG as one column. The mapping step has nowhere to put the second number.

Keep the pair as the file wrote it and check its shape. printedPair lets an empty cell through and asks every other one for digits, a hyphen and digits. It also reports a cell whose first number is larger than its second, because nobody makes more shots than they take. Fourteen lines in this archive are written attempted-made, and all fourteen land in the person's list.

Where the second number goes

The grid ships a Split column move, and the person reaches it from the column header. It takes one source column, a separator, and two or more target columns, and it previews three rows before anything is written.

Point it at the field goals column, give it - as the separator, and choose fieldGoalsMade and fieldGoalsAtt as the targets. 2,443 rows fill in one step that undo reverses. An empty cell hands both targets an empty cell, so the 289 rows reading DNP stay empty.

The form also offers to delete the source column afterwards. That offer reaches only a column the person created themselves in the matching step. A column your schema declares survives the split and keeps what it had.

For an import that runs the same way every time, do the split in the handler. The four-line pair helper below reaches the same six numbers, and nobody has to remember a step.

The points column that disagrees

The points a line carries are decided by the three columns above it. Two points for every field goal, one more for every one of those that came from behind the arc, and one for every free throw.

import type { DataEditorRow, ValidationError } from "@updog/data-editor";
const made = (written: unknown): number | null => {
const pair = /^(\d+)-(\d+)$/.exec(String(written ?? "").trim());
return pair ? Number(pair[1]) : null;
};
export const pointsAgree = (
value: unknown,
row: DataEditorRow,
): ValidationError | null => {
const field = made(row.fieldGoals);
const three = made(row.threePointers);
const free = made(row.freeThrows);
if (field === null || three === null || free === null) return null;
const total = (field - three) * 2 + three * 3 + free;
return Number(value) === total
? null
: { level: "error", message: "The shooting columns add up to " + total };
};

The rule reads the whole row, so dependentFields: ["points"] sits on each shooting column and fires the check again when a person edits one. 82 rows in this archive report that the shooting columns add up to something else. Four more carry points below zero, which the min bound catches first, because a cell reports the first rule it fails.

34 of those 82 are Totals rows, and that is the rule working. A game total stops adding up the moment one line inside it is wrong, so a flagged totals row points at the game around it.

The jersey number that is not a number

0 and 00 are both legal. The NCAA approved uniform numbers "0 or 00 through 99" for the 2023-24 season, and before that the legal set already held both. Two players, two numbers, one character apart.

The canonical number form Updog stores rejects a leading zero, in the same way it rejects 007. Declare the jersey column as a number and 377 cells in this archive are flagged, being every 00, 01, 02, 04 and 05. The 58 rows reading 0 pass, so the one value that had to stay apart is the one that gets edited.

Declare it as text and nothing touches it. 00 arrives as 00 and 05 keeps its padding, and the only rule left on that column is that a line carries one at all.

The row with no player behind it

A rebound that no player earned still belongs to a team. The NCAA's manual defines the team rebound and the dead-ball rebound, and says that national rankings count team rebounds and leave dead-ball rebounds out. That is why every box score carries a TEAM line.

348 rows in this archive carry no jersey number, being 174 TEAM lines and 174 Totals lines. A plain required rule would flag both, and it would also stop the team lines from ever reaching your handler.

The TEAM line is the one row whose numbers sit nowhere else. A totals line can be rebuilt from the lines above it. Four team rebounds cannot, so playerOrTeam lets an empty jersey through when the name beside it reads TEAM, and the handler routes that line to your game record. The 174 totals lines have no such excuse, and every one of them lands in the person's list to be deleted.

dependentFields: ["jerseyNumber"] sits on the player name column, so correcting a name rechecks the jersey beside it. Common CSV import errors covers the failures any file can produce, and the ones above are the failures a box score produces.

Nothing here knows what a rebound is

Updog ships no sport, no stat vocabulary, no roster template and no connector to any scoring system. Updog Importer reads CSV, TSV, JSON, XML, XLSX, XLS, XLSB and ODS. An old workbook therefore opens beside this year's export, with no step in between. A box score that survives only as a PDF goes through a parser you supply and arrives as ordinary rows. Everything above that layer is yours, being the sixteen columns, the alias table, the transformer and the three rules.

The transformer carries one limit worth knowing. Its argument is a single cell, so anything that has to read a second column belongs in a validator or in your handler.

This archive arrives once, on the day a program moves in. No schedule, no queue and no server of ours sits between the statistician and your API.

What the handler writes

<DataEditor
columns={columns}
synonyms={synonyms}
primaryKey={["gameDate", "opponent", "jerseyNumber"]}
blockSubmitOnError
onComplete={async (result) => {
const rows = result.sources
.flatMap((source) => source.rows)
.filter((r) => r.isValid && !r.isDeleted)
.map((r) => r.row);
const pair = (written: unknown) => {
const [made, attempted] = String(written ?? "").split("-");
return [Number(made || 0), Number(attempted || 0)];
};
const lines = [];
const teamLines = [];
for (const row of rows) {
if (row.playerName === "Totals") continue;
if (row.playerName === "TEAM") {
teamLines.push({
gameDate: row.gameDate,
opponent: row.opponent,
teamRebounds:
Number(row.offensiveRebounds || 0) +
Number(row.defensiveRebounds || 0),
teamTurnovers: Number(row.turnovers || 0),
});
continue;
}
const [fgm, fga] = pair(row.fieldGoals);
const [tpm, tpa] = pair(row.threePointers);
const [ftm, fta] = pair(row.freeThrows);
lines.push({
gameDate: row.gameDate,
opponent: row.opponent,
jerseyNumber: row.jerseyNumber,
playerName: row.playerName,
played: row.secondsPlayed !== "",
seconds: row.secondsPlayed === "" ? null : Number(row.secondsPlayed),
fieldGoalsMade: fgm,
fieldGoalsAtt: fga,
threePointsMade: tpm,
threePointsAtt: tpa,
freeThrowsMade: ftm,
freeThrowsAtt: fta,
offensiveRebounds: Number(row.offensiveRebounds || 0),
defensiveRebounds: Number(row.defensiveRebounds || 0),
points: Number(row.points || 0),
});
}
await postArchive({ lines, teamLines });
}}
/>

onComplete hands back every row under the source it arrived in, each one tagged isNew, isChanged, isDeleted and isValid. The handler drops the totals lines, routes the team lines to your game record, and splits each printed pair on the way out. An empty seconds cell becomes played: false, which is the DNP line arriving as what it is.

primaryKey is required, and a box score line carries no identity of its own. The date, the opponent and the jersey together identify one, so all three go into the key. A team line carries an empty jersey, and a key with an empty part inside it matches no existing row, so that line arrives as new.

The list the statistician works down

Out of 2,443 rows, 226 carry something a rule caught. 174 of them are the Totals lines, which leaves 52 player lines out of 2,095. Fourteen are written attempted-made, and each of those fails the points check as well. The other 38 carry points that disagree with three columns written correctly.

The statistician works down that list against the book the game was scored in, which is the only place those answers live. Then 2,095 lines become six seasons of minutes in one unit, and a platform that could not add 0+ to 31:24 can add up a career.