When to Pick: The Statistics of Grape Harvest Timing

Every winemaker faces the same question each autumn: when do I pick? Pick too early and you get thin, acidic wine. Too late and you lose freshness, structure, and risk rot. The decision usually comes down to a single number: Brix.
The problem is that measuring Brix from a handful of grapes at the edge of your vineyard will mislead you. Sampling a vineyard properly is a real statistical problem.
What Brix Actually Measures
Brix (symbol: °Bx) is the sugar content of a liquid, measured as grams of sucrose per 100 grams of solution. In grapes, this is mostly glucose and fructose rather than sucrose, but the scale still works.
A refractometer does the measurement optically: sugar changes the refractive index of juice, and the instrument converts that to °Bx. You crush a few berries, put a drop on the prism, and read the number. Simple enough. But interpreting that number is where it gets harder.
Why does sugar matter? Because yeast converts sugar to alcohol. The relationship is roughly:
Potential alcohol (% v/v) ≈ Brix × 0.55
So 24 °Bx grapes will produce wine around 13.2% alcohol. Most dry table wines are picked between 22–26 °Bx. Go below 20 and you'll struggle to make balanced wine. Go above 28 and you're in fortified wine territory, or trouble.
The Ripening Curve
After véraison, the moment red grapes turn from green to purple, or white grapes become translucent, sugar accumulates rapidly. Meanwhile, acids drop. The winemaker is watching two curves race in opposite directions.
fig, ax1 = plt.subplots(figsize=(8, 5))
days = np.linspace(0, 60, 200)
# Sugar accumulation (logistic curve)
brix = 8 + 18 / (1 + np.exp(-0.12 * (days - 25)))
# Total acidity decline (exponential decay)
ta = 15 * np.exp(-0.035 * days) + 3.5
# pH rise
ax2 = ax1.twinx()
ph = 2.8 + 0.7 / (1 + np.exp(-0.08 * (days - 30)))
ax1.plot(days, brix, '-', color='#5a3e1b', linewidth=2.5, label='Brix (°Bx)')
ax1.plot(days, ta, '--', color='#7a8c5e', linewidth=2.5, label='Total Acidity (g/L)')
ax2.plot(days, ph, ':', color='#c4985a', linewidth=2.5, label='pH')
ax1.axvspan(30, 45, alpha=0.12, color='#7a8c5e', label='Typical harvest window')
ax1.axvline(x=0, color='gray', linestyle='-', alpha=0.3)
ax1.annotate('Véraison', xy=(0, 27), fontsize=9, color='gray', style='italic')
ax1.set_xlabel('Days after Véraison', fontsize=11)
ax1.set_ylabel('Brix (°Bx) / Total Acidity (g/L)', fontsize=11)
ax2.set_ylabel('pH', fontsize=11, color='#c4985a')
ax2.tick_params(axis='y', labelcolor='#c4985a')
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='center right', fontsize=9)
ax1.set_title('Grape Ripening Dynamics', fontsize=12, fontweight='bold')
ax1.set_xlim(0, 60)
ax1.set_ylim(0, 30)
ax2.set_ylim(2.7, 3.8)
plt.tight_layout()
plt.savefig(_img('ripening-curve.png'), dpi=150, bbox_inches='tight',
facecolor='white', edgecolor='none')
plt.close()
_web('ripening-curve.png')
The ideal harvest window is where Brix is high enough for your target alcohol, acidity is still sufficient for freshness, and pH hasn't climbed so high that the wine becomes microbiologically unstable.
For most dry reds, this sweet spot lands around 23–25 °Bx, with total acidity above 6 g/L and pH below 3.6. For whites, you often pick earlier (21–23 °Bx) to preserve acidity and aromatics.
The Brix–Acid Ratio
Experienced winemakers don't look at Brix in isolation. The ratio of sugar to acid is what determines perceived balance. A wine with high sugar and high acid can taste balanced, while a wine with moderate sugar and low acid tastes flabby.
The Brix/TA ratio gives a crude but useful index:
| Ratio | Character |
|---|---|
| < 28 | Tart, underripe |
| 28–35 | Balanced, table wine |
| > 35 | Soft, potentially flat |
A grape sample at 24 °Bx with TA of 7.0 g/L gives a ratio of 34, right in the zone. The same 24 °Bx with TA of 5.0 g/L gives 48. You've waited too long.
Why a Single Measurement Lies
The common mistake: you walk to the nearest row, pick a few berries, crush them, and your refractometer says 23.5 °Bx. Great, time to pick? Not so fast.
That measurement is one sample from a spatially heterogeneous field. Brix varies, sometimes dramatically, across a vineyard because of:
- Sun exposure: South-facing rows (in the Northern Hemisphere) ripen faster. Grapes on the sun-exposed side of the canopy can be 2–3 °Bx ahead of shaded clusters on the same vine.
- Soil depth and drainage: Deeper soils hold more water, which delays sugar concentration. Rocky, shallow soils stress the vine and accelerate ripening.
- Elevation: Even 5 meters of slope changes drainage, cold air pooling, and sun angle.
- Vine age and vigor: Older vines with deeper roots produce more consistent fruit. Young, vigorous vines put energy into growth over fruit.
- Row position: Edge rows get more wind, light, and heat than interior rows. The end vines in a row behave differently from the middle.
np.random.seed(42)
fig, ax = plt.subplots(figsize=(8, 6))
# Simulate vineyard as 20x20 grid
rows, cols = 20, 20
x, y = np.meshgrid(np.arange(cols), np.arange(rows))
# Base brix with spatial gradients
# South-facing slope effect (higher brix at bottom)
slope_effect = 2.0 * (y / rows)
# Soil depth variation (patch of deeper soil in corner)
soil_effect = -1.5 * np.exp(-((x - 15)**2 + (y - 15)**2) / 30)
# Edge effect (more exposure on edges)
edge_mask = np.zeros_like(x, dtype=float)
edge_mask[0, :] = 0.8
edge_mask[-1, :] = 0.8
edge_mask[:, 0] = 0.8
edge_mask[:, -1] = 0.8
# Random vine-to-vine variation
noise = np.random.normal(0, 0.6, (rows, cols))
brix_field = 22.0 + slope_effect + soil_effect + edge_mask + noise
im = ax.imshow(brix_field, cmap='YlOrBr', origin='lower',
vmin=20.5, vmax=25.5, aspect='equal')
cbar = plt.colorbar(im, ax=ax, shrink=0.8, label='Brix (°Bx)')
# Mark a naive single sample point
ax.plot(2, 2, 'rx', markersize=15, markeredgewidth=3,
label=f'Naive sample: {brix_field[2, 2]:.1f} °Bx')
ax.annotate(f'{brix_field[2, 2]:.1f}', xy=(2, 2), xytext=(5, 0),
fontsize=10, color='red', fontweight='bold')
# Mark the true mean
true_mean = brix_field.mean()
ax.set_title(f'Brix Across a Vineyard Block (true mean: {true_mean:.1f} °Bx)',
fontsize=12, fontweight='bold')
ax.set_xlabel('Column (West → East)', fontsize=10)
ax.set_ylabel('Row (North → South)', fontsize=10)
ax.legend(loc='upper left', fontsize=9)
plt.tight_layout()
plt.savefig(_img('brix-heatmap.png'), dpi=150, bbox_inches='tight',
facecolor='white', edgecolor='none')
plt.close()
_web('brix-heatmap.png')
The heatmap above simulates a 20×20 vineyard block with realistic spatial effects: a slope gradient (south-facing rows ripen faster), a patch of deeper soil (delays ripening), edge effects, and random vine-to-vine noise. The naive single sample in the corner reads quite differently from the true block average.
How to Sample Properly
The goal is to estimate the population mean Brix (the average across all berries in the vineyard) with acceptable precision. This is a classic spatial sampling problem.
The Diagonal Walk
The standard protocol used by most viticulturists: walk a zigzag or diagonal path through the vineyard, picking berries at regular intervals. You want:
- 30–50 berries minimum from at least 15–20 vines
- Berries from both sides of the canopy (sun and shade)
- Berries from the top, middle, and bottom of clusters
- Vines from different rows and positions (edge, middle)
- Avoid the first and last vine in each row
Why berries and not whole clusters? Because individual berries are the fundamental unit of variation. Crushing 30 berries from one cluster tells you about that cluster. Crushing one berry from each of 30 vines tells you about the vineyard.
How Many Samples Do You Need?
You can estimate the required sample size from the variance in your vineyard.
The standard error of the mean is:
SE = σ / √n
where σ is the standard deviation of Brix across berries and n is the number of berries sampled. If you want your estimate to be within ±0.5 °Bx of the true mean with 95% confidence:
n = (1.96 × σ / 0.5)²
fig, ax = plt.subplots(figsize=(8, 5))
sigma_values = [0.8, 1.2, 1.8, 2.5]
target_errors = np.linspace(0.2, 1.5, 100)
colors = ['#7a8c5e', '#c4985a', '#d4856a', '#5a3e1b']
for sigma, color in zip(sigma_values, colors):
n_required = (1.96 * sigma / target_errors) ** 2
ax.plot(target_errors, n_required, '-', color=color, linewidth=2,
label=f'σ = {sigma} °Bx')
ax.axhline(y=30, color='gray', linestyle='--', alpha=0.5)
ax.annotate('Typical minimum (n=30)', xy=(1.3, 32), fontsize=9, color='gray')
ax.axhline(y=50, color='gray', linestyle=':', alpha=0.5)
ax.annotate('Better (n=50)', xy=(1.3, 52), fontsize=9, color='gray')
ax.set_xlabel('Target precision: ± °Bx (95% confidence)', fontsize=11)
ax.set_ylabel('Required sample size (n berries)', fontsize=11)
ax.set_title('Sample Size vs. Desired Precision', fontsize=12, fontweight='bold')
ax.legend(fontsize=9)
ax.set_ylim(0, 200)
ax.set_xlim(0.2, 1.5)
plt.tight_layout()
plt.savefig(_img('sample-size.png'), dpi=150, bbox_inches='tight',
facecolor='white', edgecolor='none')
plt.close()
_web('sample-size.png')
In a heterogeneous vineyard (σ ≈ 2.0 °Bx), you need roughly 60 berries for ±0.5 °Bx precision. In a uniform block (σ ≈ 1.0 °Bx), 15 berries might suffice. Most viticulturists settle on 30–50 as a practical compromise.
Stratified Sampling: Doing It Better
If you know your vineyard has distinct zones (a hilltop vs. a valley floor, or clay vs. gravel soils), you can do better than a random walk. Stratified sampling divides the vineyard into zones and samples each proportionally.
np.random.seed(42)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Reuse the brix field from before
rows, cols = 20, 20
x, y = np.meshgrid(np.arange(cols), np.arange(rows))
slope_effect = 2.0 * (y / rows)
soil_effect = -1.5 * np.exp(-((x - 15)**2 + (y - 15)**2) / 30)
edge_mask = np.zeros_like(x, dtype=float)
edge_mask[0, :] = 0.8; edge_mask[-1, :] = 0.8
edge_mask[:, 0] = 0.8; edge_mask[:, -1] = 0.8
noise = np.random.normal(0, 0.6, (rows, cols))
brix_field = 22.0 + slope_effect + soil_effect + edge_mask + noise
true_mean = brix_field.mean()
# Random sampling
n_samples = 20
rand_x = np.random.randint(0, cols, n_samples)
rand_y = np.random.randint(0, rows, n_samples)
rand_samples = brix_field[rand_y, rand_x]
rand_mean = rand_samples.mean()
axes[0].imshow(brix_field, cmap='YlOrBr', origin='lower',
vmin=20.5, vmax=25.5, aspect='equal')
axes[0].scatter(rand_x, rand_y, c='red', s=60, edgecolors='white',
linewidths=1.5, zorder=5)
axes[0].set_title(f'Random (n={n_samples}): est. {rand_mean:.1f} °Bx\n'
f'(true: {true_mean:.1f}, error: {abs(rand_mean - true_mean):.1f})',
fontsize=10, fontweight='bold')
# Stratified sampling (divide into 4 quadrants)
strat_x, strat_y, strat_samples = [], [], []
for qr in range(2):
for qc in range(2):
r_lo, r_hi = qr * 10, (qr + 1) * 10
c_lo, c_hi = qc * 10, (qc + 1) * 10
n_per = n_samples // 4
sx = np.random.randint(c_lo, c_hi, n_per)
sy = np.random.randint(r_lo, r_hi, n_per)
strat_x.extend(sx)
strat_y.extend(sy)
strat_samples.extend(brix_field[sy, sx])
strat_mean = np.mean(strat_samples)
axes[1].imshow(brix_field, cmap='YlOrBr', origin='lower',
vmin=20.5, vmax=25.5, aspect='equal')
axes[1].scatter(strat_x, strat_y, c='blue', s=60, edgecolors='white',
linewidths=1.5, zorder=5)
# Draw quadrant lines
axes[1].axhline(y=9.5, color='white', linestyle='--', alpha=0.7)
axes[1].axvline(x=9.5, color='white', linestyle='--', alpha=0.7)
axes[1].set_title(f'Stratified (n={n_samples}): est. {strat_mean:.1f} °Bx\n'
f'(true: {true_mean:.1f}, error: {abs(strat_mean - true_mean):.1f})',
fontsize=10, fontweight='bold')
for ax in axes:
ax.set_xlabel('Column', fontsize=9)
ax.set_ylabel('Row', fontsize=9)
plt.tight_layout()
plt.savefig(_img('sampling-comparison.png'), dpi=150, bbox_inches='tight',
facecolor='white', edgecolor='none')
plt.close()
_web('sampling-comparison.png')
Stratified sampling guarantees coverage of the entire vineyard. With the same 20 sample points, the stratified estimate is typically closer to the true mean because it doesn't accidentally over-sample one zone.
Beyond Brix: What Sugar Doesn't Tell You
Brix gets you in the right ballpark, but experienced winemakers know it's not the whole story. There are three types of ripeness, and they don't always align:
- Sugar ripeness (Brix): When sugar levels match your target alcohol.
- Acid ripeness (TA, pH): When the acid balance supports the wine style. High malic acid means the grapes taste green and tart. During ripening, malic acid is consumed by respiration. Warm nights accelerate this. Tartaric acid is more stable and provides the backbone of the wine.
- Phenolic ripeness: When the skins and seeds have developed enough color, tannin, and flavor compounds. You can have 24 °Bx grapes with green, astringent tannins that taste like chewing on a tea bag. The sugar is ready but the tannins aren't.
The nightmare scenario: a heat wave pushes Brix to 26 while tannins are still green and acids have crashed. Now you have to choose between over-alcoholic wine or underripe flavors. This is why climate change is the biggest challenge in modern viticulture. It decouples sugar ripeness from phenolic ripeness.

The Decision Framework
Our approach in practice:
- Start sampling 30 days after véraison, twice per week
- Track Brix, TA, and pH at each sampling. Plot the curves.
- Taste the berries: Chew the skins and seeds. Are the skins easy to separate from the pulp? Do the seeds crunch brown or are they still green and astringent?
- Check the weather: Rain forecast? Pick before. Heat wave? The window shrinks fast.
- Compute the Brix/TA ratio: Wait for the 28–35 zone.
- When in doubt, pick. You can add acid to wine. You can't add freshness.
fig, ax = plt.subplots(figsize=(8, 5))
# Simulate tracking data over 5 weeks
sample_days = np.array([0, 3, 7, 10, 14, 17, 21, 24, 28, 31, 35])
brix_track = np.array([18.2, 19.1, 20.0, 20.8, 21.5, 22.3, 23.1, 23.8, 24.2, 24.6, 24.9])
ta_track = np.array([12.1, 11.2, 10.0, 9.2, 8.3, 7.6, 7.0, 6.5, 6.2, 5.9, 5.6])
brix_std = np.array([1.8, 1.7, 1.6, 1.5, 1.4, 1.3, 1.2, 1.1, 1.0, 1.0, 0.9])
# Brix/TA ratio (TA converted from g/L to %)
ratio = brix_track / (ta_track / 10)
ax.errorbar(sample_days, brix_track, yerr=brix_std, fmt='o-',
color='#5a3e1b', linewidth=2, markersize=6, capsize=4,
label='Brix ± 1σ across vineyard')
ax2 = ax.twinx()
ax2.plot(sample_days, ratio, 's--', color='#7a8c5e', linewidth=2,
markersize=6, label='Brix/TA ratio')
# Harvest zone
ax2.axhspan(28, 35, alpha=0.1, color='#7a8c5e')
ax2.annotate('Target ratio zone', xy=(1, 31.5), fontsize=9,
color='#7a8c5e', style='italic')
ax.set_xlabel('Days from First Sample', fontsize=11)
ax.set_ylabel('Brix (°Bx)', fontsize=11, color='#5a3e1b')
ax2.set_ylabel('Brix/TA Ratio', fontsize=11, color='#7a8c5e')
ax2.tick_params(axis='y', labelcolor='#7a8c5e')
lines1, labels1 = ax.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax.legend(lines1 + lines2, labels1 + labels2, loc='upper left', fontsize=9)
ax.set_title('Tracking Toward Harvest', fontsize=12, fontweight='bold')
ax.set_xlim(-1, 37)
plt.tight_layout()
plt.savefig(_img('harvest-tracking.png'), dpi=150, bbox_inches='tight',
facecolor='white', edgecolor='none')
plt.close()
_web('harvest-tracking.png')![]()
Notice the error bars shrinking over time. As grapes ripen, the vineyard becomes more uniform as the lagging berries catch up. This convergence is itself a signal: when variance drops, the block is approaching readiness.
Brix is the most important number, but it's only meaningful when sampled properly across the whole vineyard and read alongside acidity and tannin development. The refractometer gives you a number. Whether you trust it depends on how you sampled.
Further Reading
- Phaos - Our olive oil and wine from Southern Greece
- Wine Science by Jamie Goode - Excellent overview of the biochemistry
- Coombe, B.G. (1995). "Growth Stages of the Grapevine." Australian Journal of Grape and Wine Research - The foundational paper on grape phenology
- Bramley, R.G.V. (2005). "Understanding variability in winegrape production systems." Australian Journal of Grape and Wine Research - Why spatial sampling matters