I used the New York Times Books API to compare two Hardcover Fiction best-seller lists, 52 weeks apart. I wanted to know whether the latest list had more books that had stayed on the list for several weeks, or more recent entries.
Approach
The code requests both lists, saves the JSON responses, and turns the book records into a table. I compare the median weeks on the list, count recent and longer-running entries, and look at the top-ranked books. The saved responses let me render the report again without repeating the requests.
What it does: returns one best-seller list for one week. Each book comes with its rank, the previous rank, how many weeks it has been on the list, the title, author, publisher, a short description, and ISBNs.
Why this endpoint: it takes a date, so I can ask for the current week and for the same week a year ago, and it returns one list in full with the “weeks on list” number for every book, which is what my question needs.
The requests
Parameter
Value
Where it goes
date
current, then the same week one year earlier (YYYY-MM-DD)
in the URL path
list
hardcover-fiction
in the URL path
api-key
my key, read from the NYT_API_KEY environment variable
in the query string
I use the list date returned by the API and subtract 364 days, or 52 weeks, for the earlier comparison. This keeps the weekday the same, although the calendar date may differ. The publication date of a list is also different from the sales week it covers.
API key
The key is stored in .Renviron as NYT_API_KEY and read with Sys.getenv(). It is not included in this document. The .gitignore file excludes .Renviron from version control.
The responses are saved in data/, and later renders reuse those files. To update the comparison, I can set refresh_data: true at the top of the document, render, and change it back to false. Both JSON files should be included with the report so the results can be reproduced without a key.
Parsing the JSON and cleaning it
fromJSON() reads the response. The book records are in results$books; I convert these to a tibble and keep the columns used below.
# A tibble: 15 × 4
rank title author weeks_on_list
<int> <chr> <chr> <int>
1 1 THRESHING DAY Rebecca Yarros 4
2 2 MISS WOLCOTT'S GHOST Louise Penny 1
3 3 A MARRIED LITTLE CHRISTMAS Abby Jimenez 1
4 4 THE FAMILY CROSS James Patterson 1
5 5 CHAIN REACTION Lee Child and Andrew Ch… 1
6 6 THE FRENCH ILLUSION John Grisham 4
7 7 THE WITCH Freida McFadden 3
8 8 WITH HEARTS OF FLAME Briar Boleyn 1
9 9 THE CALAMITY CLUB Kathryn Stockett 25
10 10 PARTITA Barbara Kingsolver 3
11 11 HOLLOW BONES Jodi Picoult 6
12 12 PECK & PECK Bonnie Garmus 2
13 13 THE DUNGEON ANARCHIST'S COOKBOOK Matt Dinniman 32
14 14 THE GATE OF THE FERAL GODS Matt Dinniman 21
15 15 MERCY'S CHILD Richard Paul Evans 1
Cleaning decisions
I keep the rank, previous rank, weeks on the list, title, author, publisher, description, and primary ISBN, plus the list date.
I leave out nested fields such as isbns and buy_links because this comparison does not use them.
I remove extra spaces and replace empty text with NA.
I keep the original title capitalization and treat ISBNs as text.
The checks below catch empty lists, duplicate or missing ranks, and missing or negative week counts.
Code
stopifnot(nrow(books_now) >0, nrow(books_then) >0)stopifnot(!anyDuplicated(books_now$rank), !anyDuplicated(books_then$rank))stopifnot(is.numeric(books_now$weeks_on_list), is.numeric(books_then$weeks_on_list))stopifnot(!anyNA(books_now$rank), !anyNA(books_then$rank))stopifnot(!anyNA(books_now$weeks_on_list), !anyNA(books_then$weeks_on_list))stopifnot(all(books_now$weeks_on_list >=0), all(books_then$weeks_on_list >=0))both <-bind_rows( books_now |>mutate(week ="This week"), books_then |>mutate(week ="A year earlier")) |>mutate(week =factor(week, levels =c("This week", "A year earlier")))nrow(both)
[1] 30
Analysis
For this comparison, I call books with at least 10 weeks on the list long-running entries, and books with 2 weeks or fewer recent arrivals. Books with 3-9 weeks fall in between. These are working definitions for this report. They describe time on the list, not how recently a book was published.
books_now |>slice_min(rank, n =5) |>select(Rank = rank, Title = title, Author = author, `Weeks on list`= weeks_on_list) |> knitr::kable()
Rank
Title
Author
Weeks on list
1
THRESHING DAY
Rebecca Yarros
4
2
MISS WOLCOTT’S GHOST
Louise Penny
1
3
A MARRIED LITTLE CHRISTMAS
Abby Jimenez
1
4
THE FAMILY CROSS
James Patterson
1
5
CHAIN REACTION
Lee Child and Andrew Child
1
The table above shows the five highest-ranked books in the latest saved list.
Code
both |>group_by(week) |>slice_max(weeks_on_list, n =3, with_ties =FALSE) |>ungroup() |>select(Week = week, Title = title, Author = author, `Weeks on list`= weeks_on_list) |> knitr::kable()
Week
Title
Author
Weeks on list
This week
THE DUNGEON ANARCHIST’S COOKBOOK
Matt Dinniman
32
This week
THE CALAMITY CLUB
Kathryn Stockett
25
This week
THE GATE OF THE FERAL GODS
Matt Dinniman
21
A year earlier
FOURTH WING
Rebecca Yarros
98
A year earlier
IRON FLAME
Rebecca Yarros
82
A year earlier
ONYX STORM
Rebecca Yarros
38
The next chart shows weeks on the list for every book, ordered by rank within each week.
Code
plot_data <- both |>arrange(week, desc(rank)) |>mutate(plot_id =paste(week, rank, sep ="::"),plot_id =factor(plot_id, levels =unique(plot_id)),label =str_wrap(paste0(rank, ". ", title), width =30) )ggplot(plot_data, aes(x = weeks_on_list, y = plot_id)) +scale_y_discrete(labels =setNames(plot_data$label, plot_data$plot_id)) +geom_col(fill ="#1F4E79") +facet_wrap(~ week, scales ="free_y") +labs(title ="Weeks on the NYT Hardcover Fiction list, by book",subtitle ="Books ordered by rank (rank 1 at the top)",x ="Weeks on list", y =NULL) +theme_minimal()
Code
now_row <- compare |>filter(week =="This week")then_row <- compare |>filter(week =="A year earlier")top1 <- books_now |>slice_min(rank, n =1)
Conclusions
On the list dated 2026-11-08, the median book had been on the list for 3 weeks, and 3 of 15 books were long-running (10+ weeks), while 7 were recent arrivals (2 weeks or fewer). The longest-running title had been on the list for 32 weeks. The number-one book was THRESHING DAY by Rebecca Yarros.
A year earlier (2025-11-09) the median was 3 weeks, with 3 long-running books and 7 recent arrivals. The latest list therefore has a similar median time on the list than the same week a year ago.
In the latest list, recent arrivals account for 7 of 15 books, compared with 3 long-running entries. Recent arrivals therefore make up a larger group. They do not form a majority of the list. The two weeks have the same counts in these two categories. The longest time on the list is 32 weeks in the latest week, compared with 98 weeks a year earlier.
This comparison has a few limits:
Two weekly lists are not enough to establish a trend. A new release or holiday could change the mix.
The analysis uses the weeks reported by NYT. It does not establish whether those weeks were consecutive.
The findings apply to Hardcover Fiction. Other lists may look different.
A useful next step would be to collect several months of lists and see whether the same pattern holds.
I started with a draft from a collaborator that included Claude assistance with planning, code, and writing. ChatGPT helped revise the code and explanations. The tables and numerical results come from running the R code on the NYT responses.