Georgia Senate Visualization Part 2: Runoff Edition


While the news was overshadowed by the attempted coup at The Capitol, history was made in Georgia earlier in January when Democrats Raphael Warnock and Jon Ossoff were elected to become Georgia’s first African-American and Jewish senators in what was also Georgia’s highest turnout runoff. As reported by FiveThirtyEight:

ver 4.4 million people voted in Tuesday’s election — more than double the number who voted in Georgia’s 2008 Senate runoff, which was previously the highest-turnout runoff in Georgia history. A full 60 percent of eligible voters (as estimated by Michael McDonald of the University of Florida) cast a ballot — higher than Georgia’s turnout rate in the 2016 presidential election!”

I personally was pessimistic about this kind of turnout, but I would be lying if I said I was displeased at being wrong in this instance. Their double victory is particularly significant given that it narrowly gives Democrats control of the senate; Democrats will now control 50 seats (two are Independents who caucus with Democrats) and have a tie-breaking vote in Vice President Harris.

In this article we’ll examine the results of the runoff and visualize it using R’s ggplot2 library, as well as examine some demographic and economic statistics to see if any trends emerge to explain the results. If you have not already had a chance to check it out, you can find my writeup of the general election here. As always, the code and source data used for this article can be found on my Github.

Read the Data

ga <- read.table('Georgia.csv',header = TRUE,sep=",")
ga$BIDEN.VOTES <- as.numeric(gsub(",", "",ga$BIDEN.VOTES))
ga$TRUMP.VOTES <- as.numeric(gsub(",", "",ga$TRUMP.VOTES))
ga$OSSOFF.VOTES <- as.numeric(gsub(",", "",ga$OSSOFF.VOTES))
ga$PERDUE.VOTES <- as.numeric(gsub(",", "",ga$PERDUE.VOTES))
ga$WARNOCK.VOTES <- as.numeric(gsub(",", "",ga$WARNOCK.VOTES))
ga$LOEFFLER.VOTES <- as.numeric(gsub(",", "",ga$LOEFFLER.VOTES))
ga$OSSOFF.VOTES.2 <- as.numeric(gsub(",", "",ga$OSSOFF.VOTES.2))
ga$PERDUE.VOTES.2 <- as.numeric(gsub(",", "",ga$PERDUE.VOTES.2))
ga$WARNOCK.VOTES.2 <- as.numeric(gsub(",", "",ga$WARNOCK.VOTES.2))
ga$LOEFFLER.VOTES.2 <- as.numeric(gsub(",", "",ga$LOEFFLER.VOTES.2))


The data includes election results for both the general and runoff elections, with runoff election results indicated by a “2”. Election data was pulled from Politico. The FIPS codes are used by ggplot2 for mapping purposes.

Overall Results

biden.sum <- sum(ga$BIDEN.VOTES)
trump.sum <- sum(ga$TRUMP.VOTES)
ossoff.sum <- sum(ga$OSSOFF.VOTES)
perdue.sum <- sum(ga$PERDUE.VOTES)
warnock.sum <- sum(ga$WARNOCK.VOTES,na.rm=T)
loeffler.sum <- sum(ga$LOEFFLER.VOTES,na.rm=T)
ossoff.sum.2 <- sum(ga$OSSOFF.VOTES.2)
perdue.sum.2 <- sum(ga$PERDUE.VOTES.2)
warnock.sum.2 <- sum(ga$WARNOCK.VOTES.2,na.rm=T)
loeffler.sum.2 <- sum(ga$LOEFFLER.VOTES.2,na.rm=T)
candidate <- c('Ossoff', 'Perdue','Warnock','Loeffler')
sum <- c(ossoff.sum.2, perdue.sum.2, warnock.sum.2, loeffler.sum.2)
party <- c('D','R','D','R')
sum.df <- data.frame(candidate,sum,party)
sum.df$candidate <- factor(sum.df$candidate, levels = c(sum.df[order(sum.df$sum,decreasing=T),]$candidate))
ggplot(data=sum.df, aes(x=candidate,y=sum,fill=party)) + geom_bar(stat="identity") + scale_fill_manual(values = c('#00BFC4','#F8766D')) + coord_cartesian(ylim = c(2000000,2300000))
Total votes per candidate

Results by County

We can also visualize the results by county, using a three-color scale to indicate the degree to which a candidate won a county. Here we will only do so for Ossoff and Perdue, not to exclude Warnock and Loeffler, but the results are nearly identical. If you’re curious what their maps look like, they are as math textbooks like to say, left as an exercise for the reader. We can however compare Warnock’s and Ossoff’s performances to see if anything noteworthy stands out (We’ll examine this further later in the article).

plot_usmap(include = c("GA"),regions="counties",data=ga,values="OSSOFF.PERDUE.PCT.DIFF") + scale_fill_gradient2(low="red",mid="white",high="blue",midpoint=0,name="Ossoff - Perdue Percent Votes") + theme(legend.position = "right")
Comparing Ossoff and Perdue Vote Share
plot_usmap(include = c("GA"),regions="counties",data=ga,values="OSSOFF.PERDUE.VOTE.DIFF") + scale_fill_gradient2(low="red",mid="white",high="blue",midpoint=0,name="Ossoff - Perdue Raw Votes") + theme(legend.position = "right")
Comparing Ossoff and Perdue Raw Votes
# Compare dem candidates to each other
plot_usmap(include = c("GA"),regions="counties",data=ga,values="OSSOFF.WARNOCK.RATIO") + scale_fill_gradient2(low="red",mid="white",high="blue",midpoint=1,name="Ossoff/Warnock Vote Ratio") + theme(legend.position = "right")
Ratio of Ossoff Votes to Warnock Votes (Blue indicates Ossoff overperformance, Red indicates Warnock Overperformance)


If you read my first article, you will not find these maps particularly surprising, as they’re nearly identical to the results from the general election at both the presidential and senatorial levels. Democrats ran up high margins in the Atlanta metro area s well as the Black Belt that cuts through mid Georgia (Which includes cities such as Columbus, Macon, and Augusta), and the counties containing Athens (home of UGA), and Savannah.

If we compare Ossoff’s and Warnock’s performances, we see that they track extremely closely, only differing by a few percent in either direction, although Warnock seems to have performed slightly better overall. Its possible that these small fluctuations are due to normal statistical variances that arise by chance, particularly in small counties where a three percent swing might only be a few votes.

Turnout Relative to General Election

We can also compare the candidates’ performances to those of the general election, both at the overall and county levels.

candidate <- c('Biden','Trump','Ossoff','Perdue','Warnock','Loeffler','Ossoff (r)', 'Perdue (r)','Warnock (r)','Loeffler (r)')
sum <- c(biden.sum, trump.sum, ossoff.sum, perdue.sum, warnock.sum, loeffler.sum, ossoff.sum.2, perdue.sum.2, warnock.sum.2, loeffler.sum.2)
party <- c('D','R','D','R','D','R','D','R','D','R')
sum.df <- data.frame(candidate,sum,party)
sum.df$candidate <- factor(sum.df$candidate, levels = c(sum.df[order(sum.df$sum,decreasing=T),]$candidate))
ggplot(data=sum.df, aes(x=candidate,y=sum,fill=party)) + geom_bar(stat="identity") + scale_fill_manual(values = c('#00BFC4','#F8766D')) + theme(axis.text.x = element_text(angle = 90, vjust = 0.5, hjust=1))
Total Votes for All Canddiates
ggplot( + geom_boxplot(aes(x=variable, y=value,fill=variable)) + xlab("Candidate") + ylab ("Percent change from general") + scale_x_discrete(labels=c("Ossoff","Perdue")) + scale_fill_manual(values=c('#00BFC4','#F8766D'))
Distribution of Runoff Votes as a Percentage of General Votes per Candidate
plot_usmap(include = c("GA"),regions="counties",data=ga,values="OSSOFF.PCT.CHANGE") +  scale_fill_gradient2(low="red",mid="white",high="blue",midpoint=1.0,name="Ossoff: Runoff votes as percent of General") + theme(legend.position = "right")
Ossoff Vote Retention Percentage per County (red indicates underperformance from General)
plot_usmap(include = c("GA"),regions="counties",data=ga,values="PERDUE.PCT.CHANGE") +  scale_fill_gradient2(low="red",mid="white",high="blue",midpoint=1.0,name="Perdue: Runoff votes as percent of General") + theme(legend.position = "right")
Perdue Vote Retention Percentage per County (red indicates underperformance from General)


The most noteworthy detail in my opinion is that Perdue had the votes to win, were it not for the rule mandating a majority rather than a plurality of votes. (The fact many historians argue this rule has its roots in black disenfranchisement makes it all the more poetic considering that a major contributor to their victory in both the general and runoff elections was black voter turnout.) Furthermore, if he had maintained his lead over Ossoff going into the runoff and had Ossoff’s voter retention rate (95% compared to 89%), he would have won. (however, a retention rate of 89% is still extremely high for a runoff, so credit where credit is due.)

It’s worth taking a moment to appreciate Ossoff’s absurdly high voter retention, including the fact that he managed to increase his vote share in some counties between November and January. This is a testament to the work of not only the Ossoff and Warnock campaigns, but also the work of grassroots organizations like the New Georgia Project and Black Voters Matter.

It’s also worth noting that Libertarian candidate Shane Hazel’s presence in the general election back in November is also a contributing factor, given that if he sat out the race or endorsed Perdue before November then Perdue would have won a majority outright. So thank you, Shane. Enjoy your $2000 stimulus check. If there are any left-leaning third party types reading this, take note of this as an example of why change is best enacted from within in my opinion (at least under the current winner-take-all system. Feel free to go wild if we get multi-member districts or proportional representation). If there are any right-leaning third party types reading this, y’all are doing great; keep doing what you’re doing.

Plotting the retention rate geographically, we don’t see any immediately obvious trends geographic trends. As before, were this not the case it might indicate some kind of demographic or strategy-based trend that might be worth further analysis.

Do Demographics Explain the Results?

Here we will shift gears and explore some basic demographic data for each county to see if there are any trends that might explain the election results. This is definitely no substitute for thorough academic research by actual political scientists, but it may reveal trends that guide hypotheses and future inquiry. Racial demographic data was pulled from the Georgia Secretary of State website, income pulled from Wikipedia, and poverty rate statistics from IndexMundi.

Going into the analysis I have two (and a half) questions:

1. What might explain Ossoff’s vote retention between November and January relative to Perdue (and vise versa)?

2. What might explain the difference between Ossoff’s and Warnock’s vote share in January?

We’ll perform some basic correlation and regression analysis to see to what extent these questions can be answered by some very basic demographic statistics.

dm <- read.table(‘Demographics.csv’,header=T,sep=’,’)
ga.OSSOFF.PCT.CHANGE 1.00 0.40 -0.30 -0.28
ga.PERDUE.PCT.CHANGE 0.40 1.00 -0.24 0.03
ga.OSSOFF.WARNOCK.RATIO -0.30 -0.24 1.00 -0.08
ga.TRUMP.PCT -0.28 0.03 -0.08 1.00 -0.05 0.01 -0.29 -0.28
dm.PCT.BLACK 0.30 -0.08 0.22 -0.87
dm.PCT.WHITE -0.23 0.14 -0.19 0.94
dm.PER.CAPITA.INCOME 0.11 0.38 -0.33 -0.07
dm.MEDIAN.FAMILY.INCOME 0.04 0.27 -0.31 -0.06
dm.MEDIAN.HOUSEHOLD.INCOME 0.03 0.20 -0.29 -0.05
dm.POVERTY.RATE 0.08 -0.19 0.30 -0.23
ggplot(tmp,aes(x=dm.PCT.WHITE ,y=ga.TRUMP.PCT))  + geom_point() + geom_smooth(method = "lm", fill = NA)
Percent Trump Vote as a function of Percent White Residents (per county)
ggplot(tmp,aes(x=dm.PCT.BLACK ,y=ga.OSSOFF.PCT.CHANGE))  + geom_point() + geom_smooth(method = "lm", fill = NA)
Ossoff Runoff Vote Retention as a function of Percent Black Residents (per County)
ggplot(tmp,aes(x=dm.PER.CAPITA.INCOME ,y=ga.PERDUE.PCT.CHANGE))  + geom_point() + geom_smooth(method = "lm", fill = NA)
Perdue Runoff Vote Retention as a function of Per Capita Income (per County)
ggplot(tmp,aes(x=dm.PER.CAPITA.INCOME ,y=ga.OSSOFF.WARNOCK.RATIO))  + geom_point() + geom_smooth(method = "lm", fill = NA)
Ossoff-Warnock Vote Ratio as a function of Per Capita Income (per County)


The most immediate trend is unrelated to my initial inquiries, and realistically should have been an analyzed in my first Georgia article, but that’s the extent to which white racial homogeneity predicts support for republican candidates (in this case Trump). I was initially curious to see if Trump’s unsubstantiated claims (read: blatant lies) about voter fraud and rigged elections in Georgia would discourage republican turnout, however this trend is worth discussing

Fitting a linear model of percent of county residents who identify as white to Trump’s percent vote share in November, we find that whiteness explains almost 90% of the variance in Trump’s percent vote share across all counties (contrasted with economic indicators such as poverty rate and per capita income which explain five and nearly zero percent variance respectively or population size which only explains seven percent. R² calculations left as an exercise for the reader). While further research is needed to draw any definitive conclusions, this makes for a compelling argument that Republican support is driven largely by white identarianism and racial grievance, consciously or otherwise.

Shifting back to the initial queries, we find much less compelling relationships, although there are some worth examining. If we look to predictors of Ossoff’s voter retention, we find a weak but nonnegligible relationship with the county percent black population. This could be due to the concerted effort of registering and mobilizing black voters both by the two campaigns and grassroots organizations.

Looking at Perdue’s retention, we see that per capita income for a county is the best predictor (although still quite noisy). While we can’t draw any immediate conclusions from this relationship, it seems reasonable to indulge the possibility that running on the platform of “I need to be in the senate in order to stop stimulus checks and a $15 minimum wage” doesn’t motivate people in poorer counties to come back to the polls.

Interestingly, per capita income is also the best predictor of counties in which Warnock outran Ossoff. On the flip side, poverty rate is the best predictor of counties in which Ossoff outran Warnock. I don’t have any immediate hypotheses as to why this might be the case, but further investigation might be warranted.


If you’re like me, exhausted as you are from the last year and a half of following campaigns (and I didn’t even work on a campaign), its impossible not to take this information and start thinking about what it means for 2022. Rev. Warnock will be up for reelection as his election was only to finish the term after the seat was vacated by Johnny Isakson due to ill health and temporarily filled by Kelly Loeffler. Additionally, in 2022 Gov. Brian Kemp will be up for reelection as well. Given that Trump has disowned Kemp for not refusing to hand him an election that he irrefutably lost (live by Trump, die by Trump, so the Faustian bargain goes), Kemp will no doubt be primaried from the right by a Trump acolyte and face an uphill battle for reelection. On the Democratic side, it remains to be seen whether or not Stacy Abrams will run again, although if she does choose to run it’s all but certain she will be the nominee. Will Democrats be able to build on the momentum and infrastructure they’ve established over the last several years or will things revert back to the mean? In my opinion it will come down to whether or not Democrats can maintain their massive voter turnout, and whether or not the suburbs around Atlanta continue to trend blue in a post-Trump world. Only time will tell.

I enjoy nice beverages, long walks on the beach, and thinking about how the world works (ideally at the same time)