
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.
| A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Date | Opponent | ## | Player Name | TOT-FG | 3-PT | FT | OF | DE | TOT | PF | TP | A | TO | BLK | S | MIN |
| 1022 rows not shown | |||||||||||||||||
| 1024 | 01/22/2023 | Underhill | 00 | Zeke Gillick | 7-13 | 7-8 | 0-1 | 4 | 4 | 8 | 5 | 21 | 7 | 0 | 3 | 3 | 31 |
| 1025 | 01/22/2023 | Underhill | 23 | Priit Vantrease | 2-3 | 2-3 | 0-3 | 1 | 7 | 8 | 4 | 6 | 6 | 3 | 3 | 2 | 31 |
| 1026 | 01/22/2023 | Underhill | 20 | Dedrick Gillick | 5-7 | 0-2 | 1-6 | 0 | 2 | 2 | 4 | 11 | 2 | 4 | 2 | 2 | 28 |
| 1027 | 01/22/2023 | Underhill | 40 | Amare Quiroz | 1-10 | 1-7 | 2-3 | 3 | 8 | 11 | 3 | 5 | 3 | 1 | 1 | 3 | 33 |
| 1028 | 01/22/2023 | Underhill | 52 | Oisin Vantrease | 2-4 | 2-4 | 4-7 | 2 | 8 | 10 | 1 | 10 | 4 | 2 | 1 | 0 | 33 |
| 1029 | 01/22/2023 | Underhill | 31 | Solomon Janowski | 5-10 | 0-2 | 5-5 | 3 | 5 | 8 | 2 | 15 | 2 | 1 | 1 | 1 | 25 |
| 1030 | 01/22/2023 | Underhill | 25 | Rasheed Redfern | 2-2 | 1-1 | 0-2 | 0 | 1 | 1 | 1 | 5 | 0 | 0 | 0 | 0 | 11 |
| 1031 | 01/22/2023 | Underhill | 33 | Yannick Ulmer | 0-0 | 0-0 | 0-0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1032 | 01/22/2023 | Underhill | 10 | Malachi Quiroz | DNP | ||||||||||||
| 1033 | 01/22/2023 | Underhill | 05 | Dedrick Doran | DNP | ||||||||||||
| 1034 | 01/22/2023 | Underhill | 45 | Lachlan Ostrander | 0-2 | 0-2 | 1-1 | 1 | 1 | 2 | 0 | 1 | 2 | 0 | 0 | 0 | 9 |
| 1035 | 01/22/2023 | Underhill | 24 | Emeka Eklund | 0-0 | 0-0 | 0-0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0+ |
| 1036 | 01/22/2023 | Underhill | TEAM | 4 | 4 | 8 | 2 | ||||||||||
| 1037 | 01/22/2023 | Underhill | Totals | 24-51 | 13-29 | 13-28 | 18 | 40 | 58 | 20 | 74 | 26 | 13 | 11 | 11 | 200 | |
| 855 rows not shown | |||||||||||||||||
| 1893 | 02/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 shown | |||||||||||||||||
| 2444 | 03/07/2026 | Ravensmoor | Totals | 26-55 | 9-24 | 15-19 | 11 | 29 | 40 | 17 | 76 | 15 | 10 | 4 | 7 | 200 | |
1Date,Opponent,##,Player Name,TOT-FG,3-PT,FT,OF,DE,TOT,PF,TP,A,TO,BLK,S,MIN⋮1022 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,200⋮855 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,200Fourteen 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.