What Makes a Better Cup? Growing Altitude, Origin, and Coffee Quality

Author

Dev Narang

Published

Invalid Date

Introduction

Coffee professionals talk constantly about “high-grown” coffee as though altitude were a guarantee of quality. This project tests that claim against data, and asks a second question alongside it: once we account for altitude, does the country a coffee comes from still matter?

The dataset and its original source

The data comes from the Coffee Quality Institute (CQI), a nonprofit that trains and licenses the professional coffee graders known as Q Graders. Every row in this file is one coffee sample that a licensed Q Grader evaluated under CQI’s standardized cupping protocol, and the results are published in CQI’s Coffee Quality Program database.

TidyTuesday and the coffee-quality-database are repositories that pass the data along. CQI is the body that actually collected and published it.

Variables I use, defined for the reader

The file has 1,339 coffees and 43 variables. These are the ones this project relies on:

Quantitative variables

  • total_cup_points — the coffee’s overall quality score on a 0–100 scale. In specialty coffee, 80 is the threshold for “specialty grade,” and scores above 85 are rare and commercially valuable.
  • altitude_mean_meters — the midpoint of the altitude range where the coffee was grown, in meters above sea level.
  • aroma, flavor, aftertaste, acidity, body, balance, uniformity, clean_cup, sweetness — the individual attributes the grader scores from 0 to 10, which sum into the total. “Acidity” here is a positive term meaning brightness or liveliness in the cup, not sourness.

Categorical variables

  • country_of_origin — where the coffee was grown (36 countries).
  • processing_method — how the coffee cherry was pulped and dried: Washed/Wet, Natural/Dry, Semi-washed/Semi-pulped, Pulped natural/honey, or Other.
  • species — Arabica or Robusta.
  • region — a five-level grouping of countries that I construct during cleaning, since the raw file has no continent variable.

What I plan to explore

I want to know whether growing altitude predicts cupping score, and whether that relationship is strong enough to justify how much the industry leans on it. I expect a positive relationship, and I am specifically watching for countries that break the pattern, because those are more interesting than the ones that confirm it.

Setup

# tidyverse gives me readr for import, dplyr for cleaning, and ggplot2 for plots.
# ggrepel places the country labels on the final plot so they do not overlap each other.
library(tidyverse)
library(ggrepel)
# Import with readr::read_csv rather than base read.csv. read_csv returns a tibble,
# parses column types explicitly, and is considerably faster on files this size.
coffee_raw <- readr::read_csv("coffee_ratings.csv")

# Confirm the dimensions match what I expect before touching anything.
dim(coffee_raw)
[1] 1339   43

Data Cleaning

The raw file needs real work before it can be trusted. I handle each problem in its own step so the reasoning stays visible.

Step 1: Inspect the quality score for impossible values

# A cupping score is bounded between 0 and 100, and anything that reached this
# database should be at least in the 60s. Check the minimum for void records.
range(coffee_raw$total_cup_points)
[1]  0.00 90.58
# One row scores exactly 0, which is not a possible grade. Look at it directly.
coffee_raw |>
  filter(total_cup_points == 0) |>
  select(country_of_origin, total_cup_points, aroma, flavor, aftertaste)
# A tibble: 1 × 5
  country_of_origin total_cup_points aroma flavor aftertaste
  <chr>                        <dbl> <dbl>  <dbl>      <dbl>
1 Honduras                         0     0      0          0

That row has zeros across every attribute, so it is a void or failed record rather than a genuinely terrible coffee. It has to go, because leaving it in would drag any mean it touches downward by a meaningless amount.

Step 2: Diagnose the altitude column

# Coffee is not grown above roughly 2,500 m anywhere in the world, so check the
# upper tail of the altitude column for values that cannot be real.
coffee_raw |>
  filter(altitude_mean_meters > 3000) |>
  select(country_of_origin, altitude, unit_of_measurement, altitude_mean_meters) |>
  head(10)
# A tibble: 10 × 4
   country_of_origin altitude          unit_of_measurement altitude_mean_meters
   <chr>             <chr>             <chr>                              <dbl>
 1 Guatemala         3280              m                                   3280
 2 Brazil            11000 metros      m                                  11000
 3 Colombia          1800 meters (5900 m                                   3850
 4 Guatemala         3280              m                                   3280
 5 Myanmar           4001              m                                   4001
 6 Guatemala         190164            m                                 190164
 7 Guatemala         3280              m                                   3280
 8 Myanmar           3825              m                                   3825
 9 Nicaragua         1100.00 mosl      m                                 110000
10 Myanmar           3800              m                                   3800

This reveals three separate defects, all created upstream when free-text altitude entries were parsed into a numeric column:

  1. Lost decimal points. A Guatemalan coffee entered as 1901.64 became 190164, roughly 22 times the height of Everest. A Nicaraguan entry of 1100.00 mosl became 110000.
  2. Feet recorded as meters. Several Myanmar coffees sit at 3,800–4,287 “meters.” Myanmar’s coffee regions top out near 1,500 m, so these are plainly feet that were labelled meters. The repeated Guatemalan value of 3280 is the giveaway: 3,280 feet is exactly 1,000 m.
  3. Range text averaged badly. A Colombian entry reading 1800 meters (5900 was averaged into 3,850, mixing a metric figure with its own parenthetical conversion to feet.

I cannot recover the true value for each of these individually without guessing, so the defensible move is to mark implausible altitudes as missing rather than invent corrections.

Step 3: Apply the cleaning

coffee <- coffee_raw |>
  # Keep only Arabica. The 28 Robusta coffees are graded on a different scale
  # and would not be comparable to the Arabica scores.
  filter(species == "Arabica") |>
  # Drop the void record identified in Step 1 and the single row with no country.
  filter(total_cup_points > 0, !is.na(country_of_origin)) |>
  # Standardise country names: shorten one official long form, label the two US
  # entries readably, and repair a mojibake apostrophe in "Cote d?Ivoire" that
  # came from a character-encoding error in the source file.
  mutate(country = recode(country_of_origin,
                          "Tanzania, United Republic Of"  = "Tanzania",
                          "United States (Hawaii)"        = "Hawaii (USA)",
                          "United States (Puerto Rico)"   = "Puerto Rico (USA)",
                          "Cote d?Ivoire"                 = "Cote d'Ivoire")) |>
  # Recode implausible altitudes to NA rather than deleting the rows, so those
  # coffees still count in analyses that do not involve altitude.
  mutate(altitude_m = if_else(altitude_mean_meters > 3000 | altitude_mean_meters < 100,
                              NA_real_,
                              altitude_mean_meters)) |>
  # Build the region variable the raw file lacks, so countries can be coloured
  # by growing area on the final plot.
  mutate(region = case_when(
    country %in% c("Ethiopia", "Kenya", "Uganda", "Tanzania", "Malawi",
                   "Burundi", "Rwanda", "Zambia", "Mauritius",
                   "Cote d'Ivoire")                              ~ "Africa",
    country %in% c("Colombia", "Brazil", "Peru", "Ecuador")      ~ "South America",
    country %in% c("Mexico", "Guatemala", "Honduras", "Costa Rica",
                   "Nicaragua", "El Salvador", "Panama", "Haiti") ~ "Mexico & Central America",
    country %in% c("Hawaii (USA)", "Puerto Rico (USA)",
                   "United States")                              ~ "United States",
    TRUE                                                         ~ "Asia & Pacific"))

# Report what survived cleaning and how much altitude data was sacrificed.
cat("Coffees after cleaning:", nrow(coffee), "\n")
Coffees after cleaning: 1309 
cat("Rows with usable altitude:", sum(!is.na(coffee$altitude_m)), "\n")
Rows with usable altitude: 1051 
cat("Rows with altitude set to NA:", sum(is.na(coffee$altitude_m)), "\n")
Rows with altitude set to NA: 258 

Exploratory Analysis

Exploration 1: How are quality scores distributed?

# A histogram is the right first look at a single continuous variable. The dashed
# line marks 80 points, the industry threshold for "specialty grade" coffee.
ggplot(coffee, aes(x = total_cup_points)) +
  geom_histogram(binwidth = 0.5, fill = "#6F4E37", color = "white") +
  geom_vline(xintercept = 80, linetype = "dashed", color = "#D55E00", linewidth = 0.8) +
  annotate("text", x = 79.5, y = 60, label = "Specialty grade threshold",
           hjust = 1, size = 3.5, color = "#D55E00") +
  labs(title = "Almost every coffee in this database clears specialty grade",
       x = "Total Cupping Score (0-100 scale)",
       y = "Number of Coffees") +
  theme_minimal(base_size = 12)
Histogram of total cupping points showing a roughly symmetric distribution centred near 82 points, with a long thin left tail reaching down to about 60.
Figure 1: Distribution of overall cupping scores for Arabica coffees

Scores cluster tightly between 80 and 85. That is a selection effect worth naming early: producers submit coffees to CQI hoping for a good grade, so this is a sample of already-decent coffee, not a sample of all coffee.

Exploration 2: Do all ten attributes actually vary?

# Reshape the ten attribute columns into long format so I can compute, for each
# attribute, the proportion of coffees awarded the maximum score of 10.
ceiling_check <- coffee |>
  select(aroma, flavor, aftertaste, acidity, body, balance,
         uniformity, clean_cup, sweetness) |>
  pivot_longer(everything(), names_to = "attribute", values_to = "score") |>
  group_by(attribute) |>
  summarize(pct_perfect = 100 * mean(score == 10, na.rm = TRUE), .groups = "drop")

ggplot(ceiling_check, aes(x = pct_perfect, y = reorder(attribute, pct_perfect))) +
  geom_col(fill = "#0072B2") +
  labs(title = "Three of the ten scored attributes are almost always a perfect 10",
       subtitle = "These components cannot distinguish a good coffee from a great one",
       x = "Percent of Coffees Scored a Perfect 10",
       y = NULL) +
  theme_minimal(base_size = 12)
Bar chart showing that sweetness, clean cup, and uniformity are scored a perfect 10 for roughly 86 to 93 percent of coffees, while aroma, flavor, acidity, body, and balance are almost never given a 10.
Figure 2: Share of coffees receiving a perfect 10 on each scored attribute

This is the most useful thing exploration turned up. Sweetness, clean cup, and uniformity are scored a flawless 10 for 86–93% of coffees, so they are effectively constants. All the real variation in total_cup_points is carried by aroma, flavor, aftertaste, acidity, body, and balance.

Exploration 3: Does score vary by region?

# Side-by-side boxplots compare a continuous variable across a categorical one,
# showing medians, spread, and outliers together. reorder() sorts regions by
# median score so the comparison reads left to right.
ggplot(coffee, aes(x = reorder(region, total_cup_points, median),
                   y = total_cup_points)) +
  geom_boxplot(fill = "#009E73", alpha = 0.7, outlier.alpha = 0.4) +
  coord_flip() +
  labs(title = "African coffees score highest as a group",
       x = NULL,
       y = "Total Cupping Score (0-100 scale)") +
  theme_minimal(base_size = 12)
Boxplots showing African coffees with the highest median cupping score around 83.5, followed by South America, Asia and Pacific, the United States, and Mexico and Central America near 82.
Figure 3: Side-by-side boxplots of cupping score by growing region

African coffees lead. That points toward altitude, since East African coffee is grown very high, but region and altitude are tangled together and this plot cannot separate them. The final visualization puts both on the same axes.

Final Visualization

# Aggregate to one row per country: median altitude, median score, and the number
# of coffees behind each. Medians resist the influence of a few extreme coffees.
# Countries with fewer than 10 rated coffees are excluded, because a median built
# on three or four samples is too unstable to plot next to Mexico's 231.
country_summary <- coffee |>
  filter(!is.na(altitude_m)) |>
  group_by(country, region) |>
  summarize(n_coffees   = n(),
            med_alt     = median(altitude_m),
            med_score   = median(total_cup_points),
            .groups     = "drop") |>
  filter(n_coffees >= 10)

cat("Countries plotted:", nrow(country_summary),
    "| Coffees represented:", sum(country_summary$n_coffees), "\n")
Countries plotted: 17 | Coffees represented: 998 
# Correlation at the country level, for reference in the write-up.
cor(country_summary$med_alt, country_summary$med_score)
[1] 0.650944
# A deliberately chosen palette from the Okabe-Ito colourblind-safe set, one
# colour per growing region. These are intentional, non-default colours.
region_palette <- c("Africa"                   = "#D55E00",
                    "South America"            = "#0072B2",
                    "Mexico & Central America" = "#009E73",
                    "Asia & Pacific"           = "#CC79A7",
                    "United States"            = "#E69F00")

final_plot <- ggplot(country_summary, aes(x = med_alt, y = med_score)) +
  # Linear trend line first, so it sits behind the points.
  geom_smooth(method = "lm", se = TRUE,
              color = "grey35", fill = "grey85", linewidth = 0.7) +
  # Colour encodes region (categorical); size encodes sample size (quantitative).
  geom_point(aes(color = region, size = n_coffees), alpha = 0.9) +
  # Repelled labels so every country is identifiable without a lookup table.
  geom_text_repel(aes(label = country), size = 3.2, seed = 42,
                  box.padding = 0.5, max.overlaps = 20,
                  segment.color = "grey60") +
  scale_color_manual(name = "Growing Region", values = region_palette) +
  scale_size_continuous(name = "Coffees Rated", range = c(3, 10),
                        breaks = c(25, 100, 200)) +
  scale_x_continuous(breaks = seq(750, 1800, 250)) +
  labs(
    title    = "Higher-Grown Coffee Scores Better, and East Africa Sits at the Top",
    subtitle = "Each point is one country, positioned by its median growing altitude and median cupping score",
    x        = "Median Growing Altitude (meters above sea level)",
    y        = "Median Cupping Score (0-100 scale)",
    caption  = "Source: Coffee Quality Institute, Coffee Quality Program database (1,311 Arabica coffees graded by licensed Q Graders).\nCountries shown are those with at least 10 rated coffees and usable altitude data."
  ) +
  # A non-default theme, with further adjustments for readability.
  theme_minimal(base_size = 12) +
  theme(
    plot.title      = element_text(face = "bold", size = 15),
    plot.subtitle   = element_text(color = "grey30", margin = margin(b = 10)),
    plot.caption    = element_text(color = "grey40", hjust = 0, size = 8.5),
    legend.position = "right",
    panel.grid.minor = element_blank()
  )

final_plot
Scatterplot of 17 coffee-growing countries. Median growing altitude runs along the x-axis from about 750 to 1800 meters and median cupping score on the y-axis from 80.5 to 85.5. Points are coloured by growing region and sized by how many coffees each country contributed. A rising trend line shows higher-altitude countries scoring better, with Ethiopia, Kenya, and Uganda clustered at the top right and Nicaragua and Mexico at the lower left. Tanzania sits well below the trend line despite a high altitude.
Figure 4: Median cupping score against median growing altitude, by country

Essay

How I cleaned the dataset

Cleaning fell into four operations.

Subsetting for comparability. I filtered to the 1,311 Arabica coffees and excluded the 28 Robusta samples. Robusta is graded on a separate CQI scale, so mixing the two would compare scores that do not mean the same thing.

Removing a void record. Examining the range of total_cup_points surfaced one row scoring exactly 0, with zeros across every component attribute. A cupping score of 0 is not attainable, so this is a failed or voided grading rather than a real evaluation, and I dropped it. I also dropped the single row with a missing country_of_origin.

Recoding implausible altitudes as missing. This was the substantive decision. Inspecting the upper tail of altitude_mean_meters exposed three distinct upstream parsing defects: decimal points dropped during import, so 1901.64 became 190164; altitudes reported in feet but labelled meters, which is why several Myanmar coffees claim 3,800–4,287 m and why the value 3280 recurs, since 3,280 feet is exactly 1,000 m; and free-text ranges averaged incorrectly, as with the Colombian entry 1800 meters (5900 that became 3,850. Because I cannot recover any individual true value without guessing, I used if_else() to recode altitudes above 3,000 m or below 100 m to NA instead of imputing a substitute. I set them to missing rather than deleting the rows so those coffees still contribute to analyses that do not involve altitude. This cost me altitude data but preserved honesty about what the file can support.

Standardising labels and deriving a variable. I recoded four country names, including repairing Cote d?Ivoire, where a mojibake artifact had replaced an apostrophe during character encoding. The raw file has no continent or region field, so I derived a five-level region variable with case_when() to group the 36 countries by growing area, which is what lets the final plot use colour to carry a second dimension.

What the visualization represents, and what surprised me

The final plot places each coffee-growing country by two medians: growing altitude on the x-axis and cupping score on the y-axis. Colour distinguishes the growing region, point size shows how many rated coffees stand behind each median, and the grey line is a linear trend with its confidence band. Aggregating to the country level was deliberate — plotting 998 individual coffees produced a cloud too dense to read, while country medians make the structure legible and let each point be labelled.

The main pattern holds up. Higher-altitude countries score better, and the top-right corner belongs to East Africa: Ethiopia at 1,800 m and 85.2 points, Kenya at 1,754 m and 84.5, Uganda at 1,675 m and 83.9. The bottom left is Nicaragua at 1,100 m and 80.9. Ethiopia’s four-point lead over Nicaragua is large in this trade, where a single point can move a lot on price.

The first surprise was how much aggregation flattered the relationship. At the country level the correlation between median altitude and median score is 0.65, which looks impressive. But computed across individual coffees, the same correlation is only 0.24. Aggregating to medians averages away the within-country variation, so the country-level number describes a relationship between country averages, not between coffees. Reading 0.65 as the strength of the altitude effect on any particular coffee would be a mistake — it is close to an ecological correlation, and it made me much less confident in the industry’s “high-grown means good” shorthand than the plot alone suggests. The honest statement is that altitude explains some of the variation between countries and rather little within them.

The second surprise was Tanzania. It sits at 1,600 m, higher than Guatemala or Costa Rica, yet medians only 82.2 points, well below the trend line. Hawaii is a mirror image: Kona coffee grows around 500 m and still medians 82.8, above Mexico at 1,250 m. Two clear counterexamples in seventeen countries is a reminder that altitude is a proxy for the things that actually matter — cooler temperatures, slower cherry maturation, varietal choice, and processing care — and proxies fail when the underlying causes come apart.

The third surprise came from exploration rather than the final plot. Three of the ten scored attributes are nearly useless as discriminators: sweetness, clean cup, and uniformity are awarded a perfect 10 to between 86% and 93% of coffees. A third of the scoring rubric is doing almost no work, and the entire spread in total score is produced by the remaining attributes. Combined with the fact that nearly every coffee here clears the 80-point specialty threshold, this is a database of already-good coffee being separated by fine margins on a handful of dimensions.

What I wanted to show but could not

Hawaii had to leave the altitude analysis. Hawaii contributes 73 coffees, the sixth-largest group in the file, but 71 of them have no altitude recorded. With only two usable values it fell below my ten-coffee threshold and dropped out of the final plot. That is frustrating precisely because Hawaii is the most interesting counterexample I found: low-altitude coffee that scores well. I could describe it in prose but could not show it.

I could not separate altitude from region. These two variables are badly confounded. East African coffee is high-grown, so “Africa scores well” and “high altitude scores well” are nearly the same statement in this dataset, and nothing in the file lets me disentangle them. Doing so properly would need a model with both terms and an interaction, along with varietal and temperature data the file does not include, which is beyond what this project covers.

I wanted to use processing method as a third dimension — my original plan was to colour by processing method rather than region. I abandoned it after checking: median scores across the five methods span barely half a point, from 82.4 for Washed/Wet to 83.0 for Natural/Dry, and 170 coffees have no method recorded at all. Encoding a variable with that little signal would have added colour without adding information, so region earned the colour channel instead.

A time dimension was not available in usable form. harvest_year is free text with entries like 2013/2014, Sept 2009 - April 2010, and mmm, so building a trend over harvest years would have required a parsing effort larger than the rest of the cleaning combined.