I started this journey attempting to answer a question that weighs heavy on the minds of everyone these days: how does Paul Reed, a career backup center, rank 10th overall in Net Points for 2026 on espnanalytics.com? Surely there can be no greater pursuit.
I’m an avid hoops fan and, while not Kenny Atkinson-level data crazy, analytics definitely interest me. Even more so after I learned we (the Sixers) were unloading what was widely considered one of the worst contracts in the NBA, an aging Paul George, to pick up Jaylen Brown, who had just carried Boston for a season and finished 6th in MVP voting. Brad Stevens has shown often enough in the past that he’s a smart guy, and that gave me pause. Not only did Philly “win” the JB race, all we had to give up was an awful contract with an aging player attached, two first rounders and two seconds. How is it possible that that was the best Stevens could get for Brown? I quickly heard rumblings about analytics pointing to Brown’s value being grossly inflated versus his actual production.
It’s how I discovered ESPN’s Net Points. Net Points is Dean Oliver’s metric, and ESPN introduced it in March of 2025. The idea is simple to say and hard to do. A team’s net points for a game is just the final margin. The metric walks the play-by-play and splits the credit (and the blame) for every one of those points among the players on the floor. The shooter gets some, the passer gets some, the guys spacing the floor get some, and a tougher shot is worth more credit. On the other end, defenders get credit for stops and blame for buckets. Rebounds, turnovers, fouls and free throws all get divvied up the same way. Add it up and you get one number, in points, for how much a player moved the scoreboard. (ESPN’s explainer is careful to say it describes what happened and doesn’t predict what will.)
Which brought me to the espnanalytics.com Net Points leaderboard (per 100 possessions). The first nine names for 2026 are:
- Shai Gilgeous-Alexander
- Kawhi Leonard
- Nikola Jokic
- Victor Wembanyama
- Luka Doncic
- Jimmy Butler
- Jalen Duren
- Giannis Antetokounmpo
- Donovan Mitchell
The top four finishers in MVP voting are all in the first five. So far, so reasonable.
The tenth was Paul Reed. And I needed to understand why.
I Could Have Just Asked
Of course, I could have asked your favorite AI agent directly. It would have gone off, searched the web and come back with something plausible (and the next time I had a question like this, it would have gone off and done it all over again). I didn’t want an answer so much as I wanted the data, sitting on my own disk, so I could answer this question and the next fifty myself with the underlying stats in hand.
I envisioned a local warehouse of historical NBA data that could fit on my rather modest MacBook Air (an M2 with 16GB of RAM). Something similar to the nbadb project: a tool that fetches raw stats in a resumable way, stores them on disk and builds a local warehouse out of them. Getting the data was the first step. nbadb pulls from NBA.com, which actively blocks attempts to gather stats in bulk (and understandably so). ESPN, on the other hand, has an undocumented stats API that is incredibly permissive, which is awesome on their part. So ESPN it was.
I knew I wanted to use Python. That wasn’t a hard call. I’ve been writing Python for a very long time, I wrote a book about writing it idiomatically, and it’s most of what anyone knows me for. I also knew I wasn’t going to write a single line of it.
I wanted to understand how one would use tools like Claude Code on a non-trivial project. I wanted to know the strengths and weaknesses of these agents, and I wanted to learn every lesson the hard way, through work on an actual project. A todo app wasn’t going to teach me anything. It had to be something I’d actually use, built on real data that’s messy in all the ways real data is messy. I would direct, review, notice what was wrong and decide what mattered. The agent would type.
(As I write this the whole project is not quite four weeks old. Keep that in mind as this series goes on, because it’s going to sound like a lot longer.)
Here’s The Entire Spec
On Tuesday, August 25th, at 3:59 in the afternoon, I typed this into Claude Code. It’s reproduced here exactly, typos and the parenthesis I never closed included:
I want to create a dataset of NBA statistics similar to what the nbadb python package captures but using ESPN’s stats apis, starting with a runnable script written in python. All possible stats should be gathered for a given season or range of seasons, and interrupted processes should be resumable using whatever was already downloaded. Use a compact, flat file format to store results. Create a reasonable database structure tuned for data analytics usage (see nbadb schema pattern; don’t neccessarily need to use or reimplement, just as inspiration (use duckdb locally for storage).Come up with a design and implementation plan. Use
~/code/associationas your working directory .
That’s the whole thing. Notice what isn’t in it. There’s not a word about Paul Reed, or Net Points, or asking questions in plain English. (That turns out to matter. I’ll get to it.)
Eleven minutes later it came back with a plan. It hadn’t taken anyone’s word for what ESPN’s API looks like:
I probed ESPN’s live endpoints directly (via curl) to confirm real response shapes rather than trusting third-party docs, which turned out incomplete/stale.
Sixteen minutes after that, I got this:
Built and verified end-to-end.
Thirteen tables were loaded into DuckDB, with player game logs, standings, shot coordinates and career stats among them. It was resumable, as asked (”re-running after a full run completes in <1s”). It had even found and fixed a data bug on its own, where ESPN gives free throws a garbage shot coordinate instead of a null.
It also had some news about my “incredibly permissive” API:
One implementation surprise worth knowing about: ESPN’s CDN does TLS-fingerprint bot detection — plain Python
requestsgets a 403 even with a real browser User-Agent, whilecurlsucceeds. I confirmed this live and switched the HTTP layer tocurl_cffi(browser TLS impersonation), which works reliably.
So, fine, “incredibly permissive” gets an asterisk. There’s no API key and no login, but ESPN’s CDN turns away Python’s most popular HTTP library on sight. If you’ve ever chased a 403 that only happens from Python, you know that is not normally a twenty-seven minute problem.
It took twenty-seven minutes, start to finish. My entire contribution was one paragraph with a misspelled word in it.
“Intial Commit Of Association”
Then I did nothing for two days.
Thursday night I came back with a very different kind of question (hold that thought). Friday I tried to backfill 2020 through 2024 and got a traceback somewhere in the 2021 season, which we fixed. By Friday evening the pile of scripts was turning into a real package, because I asked for one:
let’s refactor all of this into a single executable, with a data sub command with pull (current fetch mechanics) and check (report on what data, of all the available data we know the ESPN API contains, is in the database).
I asked for pytest regression tests “for all of the genuine issues we’ve uncovered”. I asked for an on-disk cache so that finished seasons, which “won’t have data that changes”, would stop costing me network requests. And then, at 6:59 PM:
can we add a README.md describing what association does, light section on design, and commit this (commit message doesn’t need to be a wall of text explaining every step, just a “intial commit of association” type message is fine
(Yes, I misspelled “initial”, and yes, that’s another parenthesis I never closed. The agent, to its credit, fixed the first one. The commit message reads “Initial commit of association”.)
Two minutes later there was a repository, and it’s on GitHub if you want to follow along. The first commit was 32 files and 4,063 lines: a resumable fetch pipeline, Parquet files on disk, a DuckDB warehouse built from them, a data check command to tell me what I was missing, and about 1,300 lines of tests. Fifteen minutes later, ruff and mypy were running as pre-commit hooks. Ten minutes after that, the entire codebase was type annotated with mypy enforcing it.
Three sittings over four days, and the number of lines of code I had written was zero.
There was just one problem. The warehouse I had built in order to understand Paul Reed couldn’t tell me a single thing about Paul Reed. Net Points weren’t in it. ESPN’s stats API doesn’t serve them (espnanalytics.com does, and that turned out to be its own adventure).
And that question from Thursday night? I had asked whether I could put questions to my new warehouse in plain English, using a model small enough to run on the MacBook. Here is the first answer it ever gave me:
The player with athlete_id 3112335 had the most games with 20+ rebounds in the 2026 season, with a total of 5 such games.
Paul Reed was going to have to wait.
*Note: the project is called association and all of it is public at github.com/jeffknupp/association. Every commit I mention in this series is in there, so you can check my work (well, the agent’s work).*