Binned scales are silently incorrect in 4.0.0
Nobody has claimed this yet.
- Dominant language
- R
- Stars
- 7k
- Forks
- 2.1k
- Avg merge
- 59m
- Merged PRs (30d)
- 2
Description
I found a problem with using ggplot2::binned_scale(). When using 5 bins, it now requires 6 colors. This broke the stable version of ggredist on CRAN. It appears that this was not caught by revdep checks. Most importantly, this means that, if you provide a character vector of colors (instead of a vctrs vector), you will get an incorrect plot but no errors, as the bins are off. When using vctrs, the stricter behavior fails loudly.
I expected five colors to map onto five bins. I don't see this breaking change in the NEWS or in existing issues. I believe it is a subtle error in the subsetting logic and not intended.
Here is the code to reproduce the bug:
library(ggplot2)
library(ggredist)
data(oregon)
scale_fill_538 <- function(...) {
ggplot2::binned_scale(aesthetics = 'fill',
palette = function(x) ggredist$fivethirtyeight,
breaks = c(0, 0.35, 0.45, 0.55, 0.65, 1),
limits = c(.25, .75),
oob = scales::squish,
guide = 'colourbar',
...
)
}
oregon |>
ggplot(aes(fill = ndv / (ndv + nrv))) +
geom_sf() +
scale_fill_538()
#> Error in `vec_slice()`:
#> ! Can't subset elements past the end.
#> ℹ Location 6 doesn't exist.
#> ℹ There are only 5 elements.
What's going on here?
scale_fill_538() is a binned scale, based on 538's old electoral maps. It has 5 colors stored in a vctrs class from palette:
structure(c("#FA5A50", "#FF998A", "#EAE3EB", "#A1A9ED", "#5768AC"
), class = c("palette", "vctrs_vctr"))
If we update the scale to simply repeat the first color, it now fills in everything as expected.
library(ggplot2)
library(ggredist)
data(oregon)
scale_fill_538 <- function(...) {
ggplot2::binned_scale(aesthetics = 'fill',
palette = function(x) c(ggredist$fivethirtyeight[1], ggredist$fivethirtyeight),
breaks = c(0, 0.35, 0.45, 0.55, 0.65, 1),
limits = c(.25, .75),
oob = scales::squish,
guide = 'colourbar',
...
)
}
oregon |>
ggplot(aes(fill = ndv / (ndv + nrv))) +
geom_sf() +
scale_fill_538()

Created on 2025-09-15 with reprex v2.1.1
Digging a little deeper: When we don't use a vctrs class for the colors, we see the following:
library(ggplot2)
library(ggredist)
data(oregon)
scale_fill_538 <- function(...) {
ggplot2::binned_scale(aesthetics = 'fill',
palette = function(x) c("#FA5A50", "#FF998A", "#EAE3EB", "#A1A9ED", "#5768AC"
),
breaks = c(0, 0.35, 0.45, 0.55, 0.65, 1),
limits = c(.25, .75),
oob = scales::squish,
guide = 'colourbar',
...
)
}
oregon |>
ggplot(aes(fill = ndv / (ndv + nrv))) +
geom_sf() +
scale_fill_538()

Created on 2025-09-15 with reprex v2.1.1
ie, it produces an incorrect plot but doesn't error. It drops the first color silently, so that the 5 bins use 4 colors + white in the legend.
And again, we can correct the chart
library(ggplot2)
library(ggredist)
data(oregon)
scale_fill_538 <- function(...) {
ggplot2::binned_scale(aesthetics = 'fill',
palette = function(x) c("#FA5A50", "#FA5A50", "#FF998A", "#EAE3EB", "#A1A9ED", "#5768AC"
),
breaks = c(0, 0.35, 0.45, 0.55, 0.65, 1),
limits = c(.25, .75),
oob = scales::squish,
guide = 'colourbar',
...
)
}
oregon |>
ggplot(aes(fill = ndv / (ndv + nrv))) +
geom_sf() +
scale_fill_538()

Created on 2025-09-15 with reprex v2.1.1
Finally, this is an important issue as it silently produces incorrect plots. Here is a manually binned version, using dplyr to do the bins and adding a manual class with the names.
library(ggplot2)
library(ggredist)
data(oregon)
oregon |>
dplyr::mutate(class = dplyr::case_when(
ndv / (ndv + nrv) < 0.35 ~ "Strong R",
ndv / (ndv + nrv) < 0.45 ~ "Lean R",
ndv / (ndv + nrv) < 0.55 ~ "Tossup",
ndv / (ndv + nrv) < 0.65 ~ "Lean D",
TRUE ~ "Strong D"
)) |>
ggplot(aes(fill = class)) +
scale_fill_manual(
values = c(
"Strong R" = "#FA5A50",
"Lean R" = "#FF998A",
"Tossup" = "#EAE3EB",
"Lean D" = "#A1A9ED",
"Strong D" = "#5768AC"
)
) +
geom_sf()

Created on 2025-09-15 with reprex v2.1.1
I'm relying on data from ggredist because it allows me to directly link an example pkgdown generated under the old ggplot version with the expected map, here: https://alarm-redist.org/ggredist/reference/scale_538.html (Sorry, I know it's never optimal to introduce a second package into the mix while reporting issues, but I figure the reference is net helpful.)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reproducing the supplied R example and tracing ggplot2::binned_scale(), especially how break points and palette values are subset. Confirm the behavior for character and vctrs color vectors, then add regression coverage showing that five colors map to five bins without silent color shifts or indexing errors.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- r
- Domain
- data-visualization
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100